Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 3e001665d5 Color activity timeline dots by zone
Zone changes in a person's or device's activity timeline show the dot in
that zone's color, the same color as its marker on the map and its row
in the zones tab; away and unknown stay grey. Arrivals and departures in
a zone's own timeline keep their green and grey.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld c0f15212c9 Add people, devices, and zones overview to the map card in panel view
When the map card is the single card of a panel view it shows a
floating overview with People, Devices, and Zones tabs styled as an
animated segmented control; on mobile it becomes a bottom sheet drawer
that can be collapsed by tap or swipe. People list their zone or Away
state with the last update time, devices only appear when they have a
location, and zones show their occupant count while passive zones are
hidden in this view.

Selecting a row or map marker opens a detail view with a back button,
a name that opens the more info dialog, and a 24h activity timeline:
zone changes for a person or device, arrivals and departures per person
for a zone. Selecting focuses the map (street level for people, the
radius for zones, shown only while selected), marks the selected marker
with a thicker border, shows the accuracy circle when the position is
imprecise, and clicking the map deselects. In panel layout the overview
sits at the start side, so the zoom control and map buttons move to the
other side: ha-map gains a zoom-position property for that, and honours
--ha-map-bottom-inset so the attribution and scale stay clear of the
bottom sheet.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 27675126d0 Frame person and device markers, ring the selected one
Markers get a card-colored frame with a soft shadow instead of a thin
border in the entity color, and the selected marker gets a primary-color
ring outside that frame. With history trails shown the frame still takes
the entity color, so a marker can be matched to its trail, and the
markers redraw when trails appear or disappear.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 948c07203a Redesign map markers with zone circles and avatar cluster bubbles
Zones are drawn as colored circles containing the zone icon, using the
entity's color with the zone radius and GPS accuracy circles matching
it. Marker clusters render as a bubble of up to three rounded square
avatars with a +N chip; when all members are in the same zone and the
bubble would overlap that zone's circle, it floats above the circle,
pinned to the zone's coordinate, with a tail pointing at it. Individual
markers are rounded squares, and while history trails are shown bubble
avatars get borders in their entity color so trails can be matched.
Avatars in bubbles are clickable and open the entity, and the whole
bubble zooms in on its members.

ha-map gains per-entity hide_radius, hide_accuracy, and selected
options for hosts that manage a selection; ha-entity-marker exposes its
background and border width via CSS variables and a selected state with
a thicker border. The OSMF vector tiles carry some places twice (node
and boundary centroid); towns and larger now require a population,
keeping the node, and smaller places get a collision padding.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 3a87e7396c Keep the retained circle data current while resizing
The engine keeps its source specifications to rebuild them after a style
swap; the editable circle's redraw updates that copy along with the map.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 7e9dbcda14 Edit zones and locations on the map engine instead of leaflet-draw
ha-locations-editor (zone config, the location selector, onboarding)
used raw Leaflet layers and leaflet-draw, which pinned it to the Leaflet
engine. Its interaction surface is three gestures - drag a marker, drag
a zone center, drag a zone radius handle - so no editing library is
needed.

MapEngine gains an optional editing capability (draggable markers and an
addEditableCircle primitive) plus panTo and containsLocation. Only the
MapLibre engine implements editing: the circle geometry plus a draggable
center marker and a draggable radius handle on the east edge, the radius
derived from the great-circle distance between them, redrawn per frame
while dragging. The handle has a touch-sized hit target and is a
keyboard-operable slider with an accessible name. The Leaflet engine
stays a viewing fallback: without WebGL2 an editor shows its locations
as static markers and a notice that this browser cannot edit on the map.

ha-map exposes editableLocations and fires
editable-location-moved/resized/clicked and editing-available-changed.
Locations are reconciled by id: position and radius changes move the
existing handles, while a changed icon, name, color, or editability
rebuilds the marker, so edits made in the form fields show up on the
map on either engine. ha-locations-editor becomes a thin layer over
that with its public contract unchanged, so its consumers need no
changes; it reads localize from the internationalization context instead
of taking hass.

The zone panel no longer zooms the map when a selection comes from the
map itself. Clicking or dragging a marker selects its list item, and
mwc-list reports that programmatic selection with the same "property"
request-selected event a click ends with. The two are told apart by
timing: a selection from the map has already set the active entry when
its event arrives, while a click's event arrives before the handler
sets it. Skipping the programmatic event also keeps the item selected,
which the shared request-selected helper would otherwise reset.

The layers property and the engine selector are removed along with the
leaflet-draw dependency and its patch: Leaflet is now purely the viewing
fallback, selected by WebGL2 support alone. The Leaflet engine keeps a
leafletMap field for the ha-map fit tests, which run on it in jsdom.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 7b6dba7777 Tear down an engine that fails while still setting up
A fatal event during setup only flagged the fallback and waited for
init to settle, which relied on MapLibre still firing style.load. The
engine being set up is destroyed instead, which settles its init, so the
setup hands over to Leaflet without depending on the failed engine.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 7e2fd53b68 Address review: setup teardown, marker input, cluster focus
An engine still setting up could not be torn down: ha-map only held it
once init resolved, so removing the element mid-load kept its WebGL
context alive and left the loading guard set, ignoring a reconnect. The
engine being set up is now tracked and destroyed on disconnect, a stale
setup notices it was superseded, and the MapLibre engine settles a
pending init when destroyed.

Non-interactive markers let pointer input through, as they do on
Leaflet. Opening a cluster from the keyboard moves focus to its first
member, and a member that has focus when the bubble closes hands it to
the icon that replaces it.
2026-09-07 16:01:52 +02:00
Maarten Lakerveld ecb36c6144 Keep the engine's own record of its map sources and layers
Carrying zone circles and history paths over a style swap relied on
MapLibre serializing the outgoing style into transformStyle's previous
argument. The engine now keeps the source and layer specifications it
added and rebuilds them into the new style itself, so the carry-over no
longer depends on what MapLibre hands over, and a missing previous style
cannot drop them.
2026-09-07 16:01:52 +02:00
Maarten Lakerveld eea77277cd Address second code review round on the map engine
A cluster whose members share a spot, or that sits at maximum zoom,
could never be opened: fitting its bounds changed nothing and the next
regroup recreated it. Activating such a cluster now opens it: all its
members are shown in a bubble with a tail pointing at their spot, each
reachable on its own, until the map moves again.

An engine whose init failed, or was abandoned by a disconnect or a fatal
event during setup, was never destroyed and could keep a WebGL context;
setup now destroys any engine that did not become the active one.

The Leaflet engine sizes the cluster element like MapLibre does, so
cluster bubbles no longer shrink to their text.
2026-09-07 14:55:45 +02:00
Maarten Lakerveld 7494c2b917 Address code review on the map engine
A fatal engine event during setup was dropped because the in-flight
setup owned the loading flag; the fallback request is now recorded and
setup switches to Leaflet once init settles. Entity markers accept Enter
and Space, as both engines make them focusable buttons. History points
show their popup on tap as well as hover, since touch has no hover.

Tests cover the engine choice and fallback paths, including a fatal
event mid-setup, and the MapLibre engine itself against a fake map:
style swaps, queued layer work, custom layer carry-over, failed style
requests, token refusal recovery, context-loss grace, and clustering.
2026-09-07 14:42:37 +02:00
c94227e830 Fix dark mode style application logic
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 14:03:14 +02:00
d43178310b Enhance keyboard accessibility for icon click
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 14:03:14 +02:00
Maarten Lakerveld e8f7cc545e Run ha-map on a runtime-selected engine: MapLibre GL native or Leaflet
ha-map no longer drives Leaflet directly. All primitive operations -
camera, HTML element markers, meter-radius circles, history paths,
clustering with a pluggable icon builder, scale ruler, dark mode - go
through a MapEngine interface, and the engine is selected at runtime:

- MapLibreMapEngine renders the vector base map natively where WebGL2 is
  available, without Leaflet in the loop: continuous fractional zoom,
  DOM markers synced to the basemap every frame, GeoJSON zone and
  accuracy circles below the labels, GeoJSON history trails with hover
  popups, and a pixel-distance cluster grid recomputed when the camera
  settles. Dark mode swaps the style while carrying the custom layers
  over, and rotation and pitch stay disabled for north-up dashboards.
- LeafletMapEngine wraps the existing behavior unchanged (vector tiles
  through the maplibre adapter with the raster fallback, markercluster)
  for browsers without WebGL2 - the legacy floor includes iOS 12-14 and
  kiosk browsers without hardware acceleration - and for
  ha-locations-editor, which manages raw Leaflet layers with
  leaflet-draw and declares engine=leaflet.

A permanently lost WebGL context rebuilds the map on the Leaflet engine
instead of leaving a dead canvas. Zoom levels keep Leaflet semantics
across engines. MapLibre's stylesheet is shipped to /static/map for the
native engine's controls and popups.
2026-09-07 14:03:14 +02:00
9bffae3ee4 Exit scene live mode when leaving the editor (#53837)
* Exit scene live mode when leaving the editor

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

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

* Restore scene live states only on editor disconnect.

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-09-07 12:08:46 +02:00
renovate[bot]andGitHub a287aacd98 Update dependency @codemirror/state to v6.7.3 (#54020) 2026-09-07 11:05:14 +01:00
30 changed files with 5928 additions and 860 deletions
File diff suppressed because one or more lines are too long
+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) {
+58 -18
View File
@@ -19,24 +19,64 @@ const withEnglish = (placement) =>
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
// The OSMF Shortbread tiles carry some places twice: once as the place node
// and once as the centroid of its boundary area, tens of pixels apart. The
// style renders every feature of a kind, so those show as doubled labels.
// VersaTiles' own tiles dedupe at generation and are unaffected.
//
// A style expression only ever sees one feature, so the copies cannot be
// compared to each other; the population tag is the proxy. It lives on the
// place node, and towns and larger are tagged with one nearly without
// exception, so requiring it keeps the node and drops the centroid copy.
// Smaller places often lack the tag, so filtering them would erase real
// labels; they get a collision padding instead, which only ever hides a label
// that overlaps another one.
const POPULATED_PLACE_KINDS = ["town", "city", "state_capital", "capital"];
const SMALL_PLACE_PADDING = 24;
const isPlaceLabel = (layer) => layer["source-layer"] === "place_labels";
// The style filters places as ["==", ["get", "kind"], "<kind>"]
const placeKind = (layer) =>
Array.isArray(layer.filter) && layer.filter[0] === "=="
? layer.filter[2]
: undefined;
const dedupePlaceLabel = (layer) => {
if (!isPlaceLabel(layer)) {
return layer;
}
if (POPULATED_PLACE_KINDS.includes(placeKind(layer))) {
return {
...layer,
filter: ["all", layer.filter, ["has", "population"]],
};
}
return {
...layer,
layout: { ...layer.layout, "text-padding": SMALL_PLACE_PADDING },
};
};
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
layers: style.layers.map((layer) => {
if (!isNameLabel(layer)) {
return layer;
}
return dedupePlaceLabel({
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
});
}),
});
+1 -3
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",
@@ -110,7 +110,6 @@
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
@@ -163,7 +162,6 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.5",
+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([
+58
View File
@@ -0,0 +1,58 @@
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
/** Hit target for the radius handle; comfortably above touch minimums */
export const RESIZE_HANDLE_SIZE = 24;
/** The visible dot inside the hit target */
export const RESIZE_HANDLE_DOT_SIZE = 12;
/** Relative radius change per arrow key press on the handle */
export const RESIZE_KEY_STEP = 0.1;
export const createResizeHandleElement = (label?: string): HTMLElement => {
const element = document.createElement("div");
element.className = "editable-circle-resize";
element.tabIndex = 0;
element.setAttribute("role", "slider");
element.setAttribute("aria-valuemin", "1");
// A slider needs a maximum; no zone comes near 100 km
element.setAttribute("aria-valuemax", "100000");
if (label) {
element.setAttribute("aria-label", label);
}
const dot = document.createElement("div");
dot.className = "editable-circle-resize-dot";
element.appendChild(dot);
return element;
};
/** Styles for the handles, included by ha-map for both engines */
export const editableCircleStyles = `
.editable-circle-center {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
cursor: move;
}
.editable-circle-resize {
width: ${RESIZE_HANDLE_SIZE}px;
height: ${RESIZE_HANDLE_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
}
.editable-circle-resize-dot {
width: ${RESIZE_HANDLE_DOT_SIZE}px;
height: ${RESIZE_HANDLE_DOT_SIZE}px;
border-radius: 50%;
background: var(--card-background-color, #fff);
border: 2px solid var(--primary-color);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
`;
@@ -0,0 +1,350 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { setMarkerAccessibility } from "../marker-accessibility";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/** For the ha-map jsdom tests only */
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 panTo(location: MapLatLng): void {
this.leafletMap?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this.leafletMap?.getBounds().contains(location) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
// Leaflet's keyboard support focuses its own wrapper, where the element's
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
if (interactive) {
element.tabIndex = 0;
}
setMarkerAccessibility(element, options.title, interactive);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// The element fills the divIcon wrapper, which gets the size
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order per domain, so an
* entity has the same color on every map. Entities without a registry entry
* (e.g. YAML zones) get a color derived from their id.
*/
export const HOME_ZONE_ENTITY_ID = "zone.home";
/** Domains whose entities are colored by creation order */
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
let creationIndex: Record<string, number> = {};
let subscribers = 0;
let unsubscribe: UnsubscribeFunc | undefined;
const listeners = new Set<() => void>();
const rebuildIndex = (entries: EntityRegistryEntry[]) => {
const byDomain: Record<string, EntityRegistryEntry[]> = {};
for (const entry of entries) {
const domain = computeDomain(entry.entity_id);
if (ORDERED_DOMAINS.includes(domain)) {
(byDomain[domain] ??= []).push(entry);
}
}
const index: Record<string, number> = {};
for (const domainEntries of Object.values(byDomain)) {
domainEntries
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
// The home zone has a fixed color and does not take a palette slot
.filter((entry) => entry.entity_id !== HOME_ZONE_ENTITY_ID)
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
}
creationIndex = index;
listeners.forEach((listener) => listener());
};
/** Keeps the creation order current while a map is alive; onChange runs when colors may have changed */
export const subscribeEntityMapColors = (
connection: Connection,
onChange: () => void
): UnsubscribeFunc => {
listeners.add(onChange);
subscribers++;
if (!unsubscribe) {
unsubscribe = subscribeEntityRegistry(connection, rebuildIndex);
}
return () => {
listeners.delete(onChange);
subscribers--;
if (subscribers === 0 && unsubscribe) {
unsubscribe();
unsubscribe = undefined;
creationIndex = {};
}
};
};
// For entities without a registry entry
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
}
return hash;
};
/** The palette color of an entity on a map */
export const entityMapColor = (
entityId: string,
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex[entityId] ?? hashIndex(entityId),
computedStyles
);
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
export const zoneColor = (
entityId: string,
passive: boolean,
computedStyles: CSSStyleDeclaration
): string => {
if (entityId === HOME_ZONE_ENTITY_ID) {
return computedStyles.getPropertyValue("--primary-color");
}
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return entityMapColor(entityId, computedStyles);
};
+267
View File
@@ -0,0 +1,267 @@
/**
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
* contract; new capabilities go on MapLibre only, as optional members like
* `editing`.
*
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: 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 MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
export interface MapEditableCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
resizable?: boolean;
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
resizeLabel?: string;
onMove?(center: MapLatLng): void;
onResize?(radius: number): void;
onClick?(): void;
}
export interface MapEditingSupport {
/** Place a draggable HTML element marker */
addDraggableMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions
): MapEditableMarkerHandle;
/** Draw a circle whose center and radius can be dragged */
addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle;
}
/** A circle with drag handles for its center and radius */
export interface MapEditableCircleHandle extends MapItemHandle {
readonly center: MapLatLng;
readonly radius: number;
/** Move and resize without recreating (no-op mid-drag) */
update(center: MapLatLng, radius: number): void;
}
export interface MapEditableMarkerHandle extends MapMarkerHandle {
/** Move without recreating (no-op mid-drag) */
setLocation(location: MapLatLng): void;
}
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;
/** Pan to the location, keeping the zoom */
panTo(location: MapLatLng): void;
/** Whether the location is inside the current viewport */
containsLocation(location: MapLatLng): boolean;
/** Place a caller-owned element on the map */
addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
refreshClusters(): void;
}
const EARTH_RADIUS = 6371008.8;
/** Great-circle distance in meters */
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(b[0] - a[0]);
const dLng = toRad(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
};
/** The point the given distance due east of center, e.g. for a resize handle */
export const pointEastOf = (
center: MapLatLng,
distanceInMeters: number
): MapLatLng => {
const lngOffset =
(distanceInMeters /
(EARTH_RADIUS * Math.cos((center[0] * Math.PI) / 180))) *
(180 / Math.PI);
return [center[0], center[1] + lngOffset];
};
/** Bounding box corners of a circle, for fitting a radius into view */
export const circleBoundsPoints = (
center: MapLatLng,
radiusMeters: number
): MapLatLng[] => {
const latOffset = radiusMeters / 111320;
const lngOffset =
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
return [
[center[0] - latOffset, center[1] - lngOffset],
[center[0] + latOffset, center[1] + lngOffset],
];
};
+23
View File
@@ -0,0 +1,23 @@
/**
* Marker elements are focusable buttons on both engines, so the caller's
* element is the keyboard target and needs a name and a role. MapLibre would
* otherwise label it "Map marker".
*/
export const setMarkerAccessibility = (
element: HTMLElement,
title: string | undefined,
interactive: boolean
): void => {
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
}
if (!element.hasAttribute("role")) {
if (interactive) {
element.setAttribute("role", "button");
} else if (title) {
element.setAttribute("role", "img");
} else {
element.setAttribute("aria-hidden", "true");
}
}
};
+70
View File
@@ -0,0 +1,70 @@
import { getContrastedColorHex } from "../color/rgb";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Content color contrasting the fill; not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
} catch {
return "#ffffff";
}
};
export const zoneInitials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
export const createZoneMarkerElement = (options: {
color: string;
icon?: string;
/** Path for an ha-svg-icon, when there is no icon name */
iconPath?: string;
name: string;
}): HTMLElement => {
const element = document.createElement("div");
element.className = "zone-circle";
element.style.backgroundColor = options.color;
element.style.color = contrastingZoneContent(options.color);
if (options.icon) {
const icon = document.createElement("ha-icon");
icon.setAttribute("icon", options.icon);
element.appendChild(icon);
} else if (options.iconPath) {
const icon = document.createElement("ha-svg-icon");
icon.setAttribute("path", options.iconPath);
element.appendChild(icon);
} else {
const initials = document.createElement("span");
initials.textContent = zoneInitials(options.name);
element.appendChild(initials);
}
return element;
};
/** Styles for the zone marker, included by ha-map */
export const zoneMarkerStyles = `
.zone-circle {
width: ${ZONE_CIRCLE_SIZE}px;
height: ${ZONE_CIRCLE_SIZE}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
.zone-circle.draggable {
cursor: move;
}
`;
+32 -3
View File
@@ -24,11 +24,13 @@ class HaEntityMarker extends LitElement {
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
@property({ type: Boolean, reflect: true }) public selected = false;
protected render() {
return html`
<div
class="marker ${this.entityPicture ? "picture" : ""}"
style=${styleMap({ "border-color": this.entityColor })}
style=${styleMap({ "outline-color": this.entityColor })}
@click=${this._badgeTap}
>
${
@@ -58,6 +60,24 @@ class HaEntityMarker extends LitElement {
`;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("keydown", this._handleKeydown);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeydown);
}
// The map engines make the marker a focusable button
private _handleKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._badgeTap(ev);
}
};
private _badgeTap(ev: Event) {
ev.stopPropagation();
if (this.entityId) {
@@ -76,13 +96,22 @@ class HaEntityMarker extends LitElement {
height: var(--ha-marker-size, 48px);
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
border-radius: var(--ha-marker-border-radius, 50%);
border: 1px solid var(--ha-marker-color, var(--primary-color));
border: var(--ha-marker-border-width, 3px) solid
var(--ha-marker-color, var(--card-background-color, #fff));
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
color: var(--primary-text-color);
background-color: var(--card-background-color);
background-color: var(
--ha-marker-background,
var(--card-background-color)
);
}
.marker.picture {
overflow: hidden;
}
/* A ring in the entity color outside the frame marks the selected marker */
:host([selected]) .marker {
outline: 3px solid var(--primary-color);
}
.entity-picture {
background-size: cover;
height: 100%;
+157 -227
View File
@@ -1,24 +1,24 @@
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
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 { ThemeMode } from "../../types";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
} from "../../common/map/zone-marker";
declare global {
// for fire event
@@ -43,6 +43,12 @@ export interface MarkerLocation {
radius_editable?: boolean;
}
const ICON_SIZE = 24;
/**
* A map with draggable markers and zone circles, drawn by ha-map. Without
* editing support (the Leaflet fallback) they are static, with a notice.
*/
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public locations?: MarkerLocation[];
@@ -59,35 +65,19 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Boolean, attribute: "pin-on-click" })
public pinOnClick = false;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
private Leaflet?: LeafletModuleType;
@state() private _editingAvailable = true;
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
private _loadPromise: Promise<boolean | undefined | void>;
constructor() {
super();
this._loadPromise = import("leaflet").then((module) =>
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
return this.updateComplete.then(() => this.fitMap());
})
);
}
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
}
public fitBounds(
boundingbox: LatLngExpression[],
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
@@ -97,42 +87,46 @@ export class HaLocationsEditor extends LitElement {
id: string,
options?: { zoom?: number }
): Promise<void> {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) {
await this.updateComplete;
const location = this.locations?.find((loc) => loc.id === id);
if (!location) {
return;
}
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
if ("getBounds" in marker) {
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
const center: MapLatLng = [location.latitude, location.longitude];
if (location.radius) {
// Only the map's maximum caps the zoom, however small the zone
this.map.fitBounds(circleBoundsPoints(center, location.radius), {
pad: 0,
zoom: MAP_MAX_ZOOM,
});
} else {
const circle = this._circles[id];
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
this.map.setView(center, options?.zoom || this.zoom);
}
}
protected render(): TemplateResult {
return html`
<ha-map
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
@editable-location-moved=${this._locationMoved}
@editable-location-resized=${this._radiusChanged}
@editable-location-clicked=${this._markerClicked}
@editing-available-changed=${this._editingAvailableChanged}
></ha-map>
${
!this._editingAvailable && this._i18n
? html`<ha-input-helper-text
>${this._i18n.localize(
"ui.components.map.editing_unavailable"
)}</ha-input-helper-text
>`
: ""
}
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
@@ -141,63 +135,122 @@ export class HaLocationsEditor extends LitElement {
`;
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): (Marker | Circle)[] => {
const layers: (Marker | Circle)[] = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
private _editableLocations = memoizeOne(
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
this._elements.delete(id);
}
}
return layers;
return (locations ?? []).map((location) => ({
id: location.id,
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: location.radius
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
}));
}
);
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
// Still loading.
if (!this.Leaflet) {
return;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const isZone = !!location.radius;
const key = JSON.stringify([
isZone,
location.icon,
location.iconPath,
location.name,
location.radius_color,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
// The zone marker doubles as the circle's draggable center
const element = isZone
? this._createZoneMarker(location)
: this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
if (changedProps.has("locations")) {
this._updateMarkers();
private _createZoneMarker(location: MarkerLocation): HTMLElement {
const element = createZoneMarkerElement({
color:
location.radius_color ||
getComputedStyle(this).getPropertyValue("--accent-color"),
icon: location.icon,
iconPath: location.iconPath,
name: location.name ?? "",
});
element.classList.toggle("draggable", !!location.location_editable);
return element;
}
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
}
const el = document.createElement("div");
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
return el;
}
public updated(changedProps: PropertyValues): void {
// Still loading.
if (!this.Leaflet) {
return;
}
if (changedProps.has("locations")) {
const oldLocations = changedProps.get("locations");
fireEvent(this, "markers-updated");
// Follow a location that was edited out of view
const oldLocations = changedProps.get("locations") as
MarkerLocation[] | undefined;
const movedLocations = this.locations?.filter(
(loc, idx) =>
!oldLocations[idx] ||
!oldLocations?.[idx] ||
((loc.latitude !== oldLocations[idx].latitude ||
loc.longitude !== oldLocations[idx].longitude) &&
this.map.leafletMap?.getBounds().contains({
lat: oldLocations[idx].latitude,
lng: oldLocations[idx].longitude,
}) &&
!this.map.leafletMap
?.getBounds()
.contains({ lat: loc.latitude, lng: loc.longitude }))
this.map.containsLocation([
oldLocations[idx].latitude,
oldLocations[idx].longitude,
]) &&
!this.map.containsLocation([loc.latitude, loc.longitude]))
);
if (movedLocations?.length === 1) {
this.map.leafletMap?.panTo({
lat: movedLocations[0].latitude,
lng: movedLocations[0].longitude,
});
this.map.panTo([
movedLocations[0].latitude,
movedLocations[0].longitude,
]);
}
}
}
private _editingAvailableChanged(ev: HASSDomEvent<{ available: boolean }>) {
this._editingAvailable = ev.detail.available;
}
private _normalizeLongitude(longitude: number): number {
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
@@ -206,40 +259,37 @@ export class HaLocationsEditor extends LitElement {
return longitude;
}
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
const location: [number, number] = [
latlng.lat,
this._normalizeLongitude(latlng.lng),
];
private _locationMoved(
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
) {
const [latitude, longitude] = ev.detail.location;
fireEvent(
this,
"location-updated",
{ id: marker.id, location },
{
id: ev.detail.id,
location: [latitude, this._normalizeLongitude(longitude)],
},
{ bubbles: false }
);
}
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
fireEvent(
this,
"radius-updated",
{ id: marker.id, radius: circle.getRadius() },
{ id: ev.detail.id, radius: ev.detail.radius },
{ bubbles: false }
);
}
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
}
private _mapClicked(ev) {
if (this.pinOnClick && this._locationMarkers) {
const id = Object.keys(this._locationMarkers)[0];
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
if (this.pinOnClick && this.locations?.length) {
const id = this.locations[0].id;
const location: [number, number] = [
ev.detail.location[0],
this._normalizeLongitude(ev.detail.location[1]),
@@ -248,131 +298,11 @@ export class HaLocationsEditor extends LitElement {
// If the normalized longitude wraps around the globe, pan to the new location.
if (location[1] !== ev.detail.location[1]) {
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
this.map.panTo(location);
}
}
}
private _updateMarkers(): void {
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor =
getComputedStyle(this).getPropertyValue("--accent-color");
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
if (location.icon || location.iconPath) {
// create icon
const el = document.createElement("div");
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
icon = this.Leaflet!.divIcon({
html: el.outerHTML,
iconSize: [24, 24],
className: "light",
});
}
if (location.radius) {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
circle.addEventListener("add", () => {
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
circles[location.id] = circle;
}
}
if (
!location.radius ||
(!location.radius_editable && !location.location_editable)
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
options.icon = icon;
}
const marker = this.Leaflet!.marker(
[location.latitude, location.longitude],
options
)
.addEventListener("dragend", (ev: DragEndEvent) =>
this._updateLocation(ev)
)
.addEventListener(
// @ts-ignore
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
(marker as any).id = location.id;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
fireEvent(this, "markers-updated");
}
static styles = css`
ha-map {
display: block;
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -93,14 +93,14 @@ export interface HistoryStreamMessage {
}
export const entityIdHistoryNeedsAttributes = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "states">,
entityId: string
) =>
!hass.states[entityId] ||
NEED_ATTRIBUTE_DOMAINS.includes(computeDomain(entityId));
export const fetchDateWS = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "states" | "callWS">,
startTime: Date,
endTime: Date,
entityIds: string[]
@@ -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");
}
+57 -17
View File
@@ -23,6 +23,10 @@ import type {
} from "../../../components/map/ha-locations-editor";
import { saveCoreConfig } from "../../../data/core";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import {
subscribeEntityMapColors,
zoneColor,
} from "../../../common/map/entity-map-colors";
import type {
HomeZoneMutableParams,
Zone,
@@ -69,15 +73,20 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
private _regEntities: string[] = [];
// Storage zone id (its unique id) to entity id
@state() private _zoneEntityIds: Record<string, string> = {};
// Bumped when entity map colors change to recompute the memoized locations
@state() private _colorVersion = 0;
private _getZones = memoizeOne(
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
(
storageItems: Zone[],
stateItems: HassEntity[],
zoneEntityIds: Record<string, string>,
_colorVersion: number
): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const homeRadiusColor =
computedStyles.getPropertyValue("--primary-color");
const stateLocations: MarkerLocation[] = stateItems.map(
(entityState) => ({
@@ -87,12 +96,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
latitude: entityState.attributes.latitude,
longitude: entityState.attributes.longitude,
radius: entityState.attributes.radius,
radius_color:
entityState.entity_id === "zone.home"
? homeRadiusColor
: entityState.attributes.passive
? passiveRadiusColor
: zoneRadiusColor,
radius_color: zoneColor(
entityState.entity_id,
!!entityState.attributes.passive,
computedStyles
),
location_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
radius_editable:
@@ -101,7 +109,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
radius_color: zoneColor(
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
!!zone.passive,
computedStyles
),
location_editable: true,
radius_editable: true,
}));
@@ -111,10 +123,21 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityMapColors(this.hass.connection!, () => {
this._colorVersion++;
}),
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._regEntities = entities.map(
(registryEntry) => registryEntry.entity_id
);
this._zoneEntityIds = Object.fromEntries(
entities
.filter((registryEntry) => registryEntry.platform === "zone")
.map((registryEntry) => [
registryEntry.unique_id,
registryEntry.entity_id,
])
);
this._filterStates();
}),
];
@@ -269,7 +292,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<ha-locations-editor
.locations=${this._getZones(
this._storageItems,
this._stateItems
this._stateItems,
this._zoneEntityIds,
this._colorVersion
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@@ -364,6 +389,15 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
// Selecting an item in code (from the map) fires the same "property"
// request-selected event a click ends with, but with _activeEntry already set
private _isProgrammaticSelection(ev: CustomEvent): boolean {
return (
ev.detail.source === "property" &&
(ev.currentTarget! as any).value === this._activeEntry
);
}
private async _locationUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
if (ev.detail.id === "zone.home" && this._canEditCore) {
@@ -409,7 +443,10 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private _itemClicked(ev: CustomEvent) {
if (!shouldHandleRequestSelectedEvent(ev)) {
if (
this._isProgrammaticSelection(ev) ||
!shouldHandleRequestSelectedEvent(ev)
) {
return;
}
@@ -423,7 +460,10 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private _stateItemClicked(ev: CustomEvent) {
if (!shouldHandleRequestSelectedEvent(ev)) {
if (
this._isProgrammaticSelection(ev) ||
!shouldHandleRequestSelectedEvent(ev)
) {
return;
}
+229 -19
View File
@@ -3,15 +3,15 @@ import {
mdiGoogleCirclesCommunities,
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { LatLngTuple } from "leaflet";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../../../common/color/colors";
import { resolveThemeColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeRTL } from "../../../common/util/compute_rtl";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
@@ -29,9 +29,17 @@ import type {
HaMapPaths,
MapCardMarkerLabelMode,
} from "../../../components/map/ha-map";
import type { MapLatLng } from "../../../common/map/map-engine";
import {
entityMapColor,
subscribeEntityMapColors,
zoneColor,
} from "../../../common/map/entity-map-colors";
import type { HistoryStates } from "../../../data/history";
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
import type { HomeAssistant } from "../../../types";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { PANEL_VIEW_LAYOUT } from "../views/const";
import { findEntities } from "../common/find-entities";
import {
hasConfigChanged,
@@ -48,6 +56,12 @@ import {
export const DEFAULT_HOURS_TO_SHOW = 0;
export const DEFAULT_ZOOM = 14;
// GPS accuracy (meters) above which the selected person's circle is shown
const IMPRECISE_GPS_ACCURACY = 100;
const FOCUS_PERSON_ZOOM = 19;
const FOCUS_ZONE_MAX_ZOOM = 18;
interface GeoEntity {
entity_id: string;
label_mode?: MapCardMarkerLabelMode;
@@ -62,6 +76,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public layout?: string;
@property({ type: Boolean }) public preview = false;
@state() private _stateHistory?: HistoryStates;
@state()
@@ -76,14 +92,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
private _filteredMapEntities: HaMapEntity[] = [];
private _colorDict: Record<string, string> = {};
private _colorIndex = 0;
@state() private _error?: { code: string; message: string };
@state() private _clusterMarkers = true;
@state() private _overviewSelected?: string;
// Height of the overview drawer when it sits over the bottom of the map
@state() private _overviewHeight = 0;
private _overviewLoaded = false;
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
private _getAllEntities(): string[] {
@@ -209,18 +228,35 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return html`
<ha-card id="card" .header=${this._config.title}>
<div id="root">
<div
id="root"
class=${this.layout === PANEL_VIEW_LAYOUT ? "panel-layout" : ""}
@hass-more-info=${this._handleMapMoreInfo}
>
<ha-map
style=${styleMap({
"--overview-height": `${this._overviewHeight}px`,
})}
.entities=${this._filteredMapEntities}
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
.autoFit=${this._config.auto_fit || false}
.fitZones=${this._config.fit_zones || false}
.zoomPosition=${
this.layout === PANEL_VIEW_LAYOUT &&
!computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? "topright"
: "topleft"
}
.themeMode=${themeMode}
.clusterMarkers=${this._clusterMarkers}
.scaleRuler=${this._config.scale_ruler || false}
@map-clicked=${this._handleMapClicked}
interactive-zones
render-passive
.renderPassive=${this.layout !== PANEL_VIEW_LAYOUT}
></ha-map>
<div id="buttons">
${
@@ -252,6 +288,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
tabindex="0"
></ha-icon-button>
</div>
${
this.layout === PANEL_VIEW_LAYOUT
? html`<hui-map-overview
id="overview"
.entities=${this._filteredMapEntities}
.selected=${this._overviewSelected}
@map-overview-select=${this._handleOverviewSelect}
@map-overview-resize=${this._handleOverviewResize}
></hui-map-overview>`
: nothing
}
</div>
</ha-card>
`;
@@ -299,6 +346,9 @@ class HuiMapCard extends LitElement implements LovelaceCard {
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("hass")) {
this._subscribeColors();
}
if (
this._config?.show_all &&
!this._config?.entities &&
@@ -334,18 +384,75 @@ class HuiMapCard extends LitElement implements LovelaceCard {
} else {
this._filteredMapEntities = this._mapEntities;
}
if (this.layout === PANEL_VIEW_LAYOUT) {
if (!this._overviewLoaded) {
this._overviewLoaded = true;
void import("./map/hui-map-overview");
}
this._filteredMapEntities = this._decorateOverviewEntities(
this._filteredMapEntities,
this._overviewSelected,
this._overviewSelected
? this.hass.states[this._overviewSelected]
: undefined,
this.preview
);
}
}
// In panel layout, only the selected zone shows its radius (all of them
// while editing) and only an imprecise selected person its accuracy circle
private _decorateOverviewEntities = memoizeOne(
(
entities: HaMapEntity[],
selectedId: string | undefined,
selectedStateObj: HassEntity | undefined,
preview: boolean
): HaMapEntity[] => {
const selectedLocation = selectedStateObj
? getEntityLocation(selectedStateObj, this.hass.states)
: undefined;
const showSelectedAccuracy =
(selectedLocation?.gpsAccuracy ?? 0) > IMPRECISE_GPS_ACCURACY;
return entities.map((entity) => ({
...entity,
hide_accuracy: !(
showSelectedAccuracy && entity.entity_id === selectedId
),
hide_radius: !preview && entity.entity_id !== selectedId,
selected: entity.entity_id === selectedId,
}));
}
);
private _unsubscribeColors?: () => void;
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this._configEntities?.length) {
this._subscribeHistory();
}
this._subscribeColors();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeHistory();
this._unsubscribeColors?.();
this._unsubscribeColors = undefined;
}
private _subscribeColors(): void {
if (this._unsubscribeColors || !this.hass?.connection) {
return;
}
this._unsubscribeColors = subscribeEntityMapColors(
this.hass.connection,
() => {
this._mapEntities = this._getMapEntities();
}
);
}
private _subscribeHistory() {
@@ -428,22 +535,90 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._map?.fitMap({ unpause_autofit: true });
}
private _handleMapClicked() {
// A click on the map itself (not on a marker) deselects
if (this._overviewSelected) {
this._overviewSelected = undefined;
}
}
private _handleMapMoreInfo(ev: HASSDomEvent<{ entityId: string | null }>) {
if ((ev.target as HTMLElement)?.localName === "hui-map-overview") {
// The overview asks for the dialog itself, so let it through
return;
}
const entityId = ev.detail.entityId;
if (
this.layout !== PANEL_VIEW_LAYOUT ||
!entityId ||
!["person", "device_tracker", "zone"].includes(computeDomain(entityId)) ||
(computeDomain(entityId) !== "zone" &&
!this._filteredMapEntities.some(
(entity) => entity.entity_id === entityId
))
) {
return;
}
ev.stopPropagation();
this._overviewSelected = entityId;
this._focusEntity(entityId);
}
private _handleOverviewResize(ev: HASSDomEvent<{ height: number }>) {
this._overviewHeight = ev.detail.height;
}
private _handleOverviewSelect(ev: HASSDomEvent<{ entityId?: string }>) {
this._overviewSelected = ev.detail.entityId;
if (ev.detail.entityId) {
this._focusEntity(ev.detail.entityId);
}
}
private _focusEntity(entityId: string) {
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return;
}
if (computeStateDomain(stateObj) === "zone") {
const { latitude, longitude, radius } = stateObj.attributes;
// Convert the zone radius (meters) to a degree offset for a bounding box
const latOffset = (radius ?? 100) / 111320;
const lngOffset =
latOffset / Math.max(Math.cos((latitude * Math.PI) / 180), 0.01);
this._map?.fitBounds(
[
[latitude - latOffset, longitude - lngOffset],
[latitude + latOffset, longitude + lngOffset],
],
{ pad: 0.2, zoom: FOCUS_ZONE_MAX_ZOOM }
);
return;
}
const location = getEntityLocation(stateObj, this.hass.states);
if (location) {
this._map?.fitBounds([[location.latitude, location.longitude]], {
zoom: FOCUS_PERSON_ZOOM,
});
}
}
private _toggleClusterMarkers() {
this._clusterMarkers = !this._clusterMarkers;
}
// The same color for an entity on every map; a config color still wins
private _getColor(entityId: string): string {
let color = this._colorDict[entityId];
if (color) {
return color;
}
const computedStyles = getComputedStyle(this);
color = getColorByIndex(this._colorIndex, computedStyles);
if (color) {
this._colorIndex++;
this._colorDict[entityId] = color;
if (computeDomain(entityId) === "zone") {
const stateObj = this.hass?.states[entityId];
return zoneColor(
entityId,
!!stateObj?.attributes.passive,
computedStyles
);
}
return color;
return entityMapColor(entityId, computedStyles);
}
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
@@ -528,7 +703,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);
}
@@ -595,10 +770,45 @@ class HuiMapCard extends LitElement implements LovelaceCard {
flex-direction: column;
}
/* The overview panel covers the start side in panel layout */
#root.panel-layout #buttons {
left: auto;
inset-inline-start: auto;
inset-inline-end: 3px;
}
#root {
position: relative;
height: 100%;
}
#overview {
position: absolute;
top: var(--ha-space-3);
inset-inline-start: var(--ha-space-3);
width: min(360px, calc(100% - 2 * var(--ha-space-3)));
max-height: calc(100% - 2 * var(--ha-space-3));
display: flex;
z-index: 1;
}
@media (max-width: 600px) {
#overview {
top: auto;
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
width: auto;
max-height: 70%;
}
/* Keep the attribution and scale ruler above the drawer */
#root.panel-layout ha-map {
--ha-map-bottom-inset: calc(
var(--overview-height, 0px) + var(--ha-space-2)
);
}
}
`;
}
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -1149,7 +1149,10 @@
"category": "Category"
},
"map": {
"error": "Unable to load map"
"error": "Unable to load map",
"editing_unavailable": "This browser can't edit locations on the map.",
"radius": "Radius in meters",
"radius_of": "Radius of {name} in meters"
},
"statistics_charts": {
"loading_statistics": "Loading statistics…",
@@ -1939,8 +1942,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",
@@ -9320,7 +9321,20 @@
},
"map": {
"reset_focus": "Reset focus",
"toggle_grouping": "Toggle grouping"
"toggle_grouping": "Toggle grouping",
"overview": {
"people": "People",
"devices": "Devices",
"zones": "Zones",
"activity": "Activity",
"no_activity": "No recent activity",
"person_arrived": "{name} arrived",
"person_left": "{name} left",
"show_list": "Show list",
"show_more_info": "Show more info",
"hide_list": "Hide list",
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
}
},
"energy": {
"loading": "Loading…",
+38 -2
View File
@@ -12,7 +12,16 @@ const layer = (id, layout) => ({ id, type: "symbol", layout });
const STYLE = {
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
{
...layer("label-place-city", { "text-field": ["get", "name"] }),
"source-layer": "place_labels",
filter: ["==", ["get", "kind"], "city"],
},
{
...layer("label-place-suburb", { "text-field": ["get", "name"] }),
"source-layer": "place_labels",
filter: ["==", ["get", "kind"], "suburb"],
},
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
@@ -101,6 +110,33 @@ describe("addLatinLabels", () => {
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
expect(style.layers[3]).toEqual(STYLE.layers[3]);
expect(style.layers.at(-1)).toEqual(STYLE.layers.at(-1));
});
// The OSMF tiles carry some places twice (node and area centroid). Without
// these, doubled town labels come back and nothing else fails.
describe("deduplicates places", () => {
const byId = (id) => style.layers.find((l) => l.id === id);
it("requires a population on towns and larger, keeping the kind filter", () => {
expect(byId("label-place-city").filter).toEqual([
"all",
["==", ["get", "kind"], "city"],
["has", "population"],
]);
expect(byId("label-place-city").layout["text-padding"]).toBeUndefined();
});
it("pads smaller places instead, which often lack a population", () => {
const suburb = byId("label-place-suburb");
expect(suburb.filter).toEqual(["==", ["get", "kind"], "suburb"]);
expect(suburb.layout["text-padding"]).toBeGreaterThan(0);
});
it("leaves non-place labels alone", () => {
const streetLayer = byId("label-street-primary");
expect(streetLayer.filter).toBeUndefined();
expect(streetLayer.layout["text-padding"]).toBeUndefined();
});
});
});
@@ -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(
+789
View File
@@ -0,0 +1,789 @@
import type { LayerSpecification, StyleSpecification } from "maplibre-gl";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONTEXT_RESTORE_GRACE } from "../../../src/common/map/base-layer";
import { MapLibreMapEngine } from "../../../src/common/map/engines/maplibre-map-engine";
import type {
MapEngineEvents,
MapLatLng,
MapMarkerHandle,
} from "../../../src/common/map/map-engine";
// The MapLibre engine cannot run in jsdom (no WebGL2), so maplibre-gl is
// replaced by a fake map that models the parts the engine depends on: the
// style lifecycle (a swap leaves the style unloaded until it loads), sources
// and layers, screen projection for clustering, and WebGL context events.
type Listener = (ev?: any) => void;
const fakes = vi.hoisted(() => {
const baseStyle = (): StyleSpecification => ({
version: 8,
sources: {},
layers: [
{ id: "land", type: "background" },
{ id: "labels", type: "symbol", source: "osm", "source-layer": "place" },
],
});
class FakeMarker {
static all: FakeMarker[] = [];
lngLat?: [number, number];
options: any;
onMap = false;
constructor(options: any) {
this.options = options;
}
setLngLat(lngLat: [number, number]) {
this.lngLat = lngLat;
return this;
}
getLngLat() {
return { lng: this.lngLat![0], lat: this.lngLat![1] };
}
addTo() {
this.onMap = true;
FakeMarker.all.push(this);
document.body.appendChild(this.options.element);
return this;
}
remove() {
this.onMap = false;
const index = FakeMarker.all.indexOf(this);
if (index !== -1) {
FakeMarker.all.splice(index, 1);
}
(this.options.element as HTMLElement).remove();
return this;
}
getElement() {
return this.options.element as HTMLElement;
}
on() {
return this;
}
}
class FakePopup {
setLngLat() {
return this;
}
setHTML() {
return this;
}
addTo() {
return this;
}
remove() {
return this;
}
}
class FakeMap {
static instances: FakeMap[] = [];
/** Whether new maps start with their style loaded */
static startLoaded = true;
/** Makes the next setStyle call throw, like an invalid style would */
static failNextSetStyle = false;
style: StyleSpecification;
styleLoaded: boolean;
listeners: Record<string, { layer?: string; handler: Listener }[]> = {};
fitBounds = vi.fn();
easeTo = vi.fn();
jumpTo = vi.fn();
resize = vi.fn(() => {
this.fire("movestart");
this.fire("move");
this.fire("moveend");
});
remove = vi.fn();
addControl = vi.fn();
removeControl = vi.fn();
setStyle = vi.fn(
(
style: StyleSpecification,
options?: {
transformStyle?: (
previous: StyleSpecification | undefined,
next: StyleSpecification
) => StyleSpecification;
}
) => {
if (FakeMap.failNextSetStyle) {
FakeMap.failNextSetStyle = false;
throw new Error("Invalid style");
}
// MapLibre hands over the current style only once it has loaded
this.style = options?.transformStyle
? options.transformStyle(
this.styleLoaded ? this.style : undefined,
style
)
: style;
// A rebuilt style is unloaded until the next frame
this.styleLoaded = false;
}
);
touchZoomRotate = { disableRotation: vi.fn() };
keyboard = { disableRotation: vi.fn() };
scrollZoom = { setWheelZoomRate: vi.fn() };
private _container: HTMLElement;
constructor(options: {
container: HTMLElement;
style: StyleSpecification;
}) {
this._container = options.container;
this.style = options.style;
this.styleLoaded = FakeMap.startLoaded;
FakeMap.instances.push(this);
}
/** The style finished loading, as MapLibre reports a frame later */
loadStyle() {
this.styleLoaded = true;
this.fire("style.load");
this.fire("styledata");
}
on(type: string, layerOrHandler: string | Listener, handler?: Listener) {
const entry =
typeof layerOrHandler === "string"
? { layer: layerOrHandler, handler: handler! }
: { handler: layerOrHandler };
(this.listeners[type] ??= []).push(entry);
return this;
}
once(type: string, handler: Listener) {
const wrapped: Listener = (ev) => {
this.off(type, wrapped);
handler(ev);
};
return this.on(type, wrapped);
}
off(type: string, layerOrHandler: string | Listener, handler?: Listener) {
const target =
typeof layerOrHandler === "string" ? handler : layerOrHandler;
this.listeners[type] = (this.listeners[type] ?? []).filter(
(entry) => entry.handler !== target
);
return this;
}
fire(type: string, ev: any = {}) {
[...(this.listeners[type] ?? [])].forEach((entry) => entry.handler(ev));
}
isStyleLoaded() {
return this.styleLoaded;
}
// Like MapLibre: Style.serialize() returns undefined until the style has
// loaded, and getStyle() is that serialization
getStyle() {
return this.styleLoaded ? this.style : undefined;
}
private _assertLoaded() {
if (!this.styleLoaded) {
throw new Error("Style is not done loading");
}
}
addSource(id: string, source: any) {
this._assertLoaded();
this.style.sources[id] = source;
}
getSource(id: string) {
return this.style.sources[id];
}
removeSource(id: string) {
this._assertLoaded();
delete this.style.sources[id];
}
addLayer(layer: LayerSpecification, beforeId?: string) {
this._assertLoaded();
const index = beforeId
? this.style.layers.findIndex((candidate) => candidate.id === beforeId)
: -1;
this.style.layers.splice(
index === -1 ? this.style.layers.length : index,
0,
layer
);
}
getLayer(id: string) {
return this.style.layers.find((layer) => layer.id === id);
}
removeLayer(id: string) {
this._assertLoaded();
this.style.layers = this.style.layers.filter((layer) => layer.id !== id);
}
// 0.001 degrees is 10 screen pixels
project([lng, lat]: [number, number]) {
return { x: lng * 10000, y: -lat * 10000 };
}
zoom = 12;
getZoom() {
return this.zoom;
}
getMaxZoom() {
return 19;
}
getContainer() {
return this._container;
}
getCanvas() {
return { style: {} as CSSStyleDeclaration };
}
getBounds() {
return { contains: () => true };
}
queryRenderedFeatures() {
return [];
}
}
return { baseStyle, FakeMap, FakeMarker, FakePopup };
});
vi.mock("maplibre-gl", () => ({
default: {
Map: fakes.FakeMap,
Marker: fakes.FakeMarker,
Popup: fakes.FakePopup,
NavigationControl: vi.fn(),
ScaleControl: vi.fn(),
setRTLTextPlugin: vi.fn(),
},
}));
const loadStyle = vi.hoisted(() => vi.fn());
vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadStyle,
ensureRTLTextPlugin: vi.fn(),
}));
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
vi.mock("../../../src/data/map_tiles", () => ({
MAP_TILES_PATH: "/api/map_tiles",
mapTilesUrl: (path: string) => path,
withMapTilesToken: (url: string) => url,
refreshMapTilesToken,
subscribeMapTilesToken: (listener: (token: string) => void) => {
tokenListeners.add(listener);
return () => tokenListeners.delete(listener);
},
}));
const fakeMap = fakes.FakeMap;
const fakeMarker = fakes.FakeMarker;
const { baseStyle } = fakes;
const flush = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const createEngine = async (events: Partial<MapEngineEvents> = {}) => {
const container = document.createElement("div");
document.body.appendChild(container);
const engine = new MapLibreMapEngine();
const ready = engine.init(container, {
center: [52, 4],
zoom: 13,
darkMode: false,
zoomControlPosition: "topleft",
events,
});
await vi.waitUntil(() => fakeMap.instances.length > 0);
const map = fakeMap.instances[fakeMap.instances.length - 1];
return { engine, map, ready };
};
const customSourceIds = (map: InstanceType<typeof fakeMap>) =>
Object.keys(map.style.sources).filter((id) => id.startsWith("ha-"));
const layerIds = (map: InstanceType<typeof fakeMap>) =>
map.style.layers.map((layer) => layer.id);
describe("MapLibreMapEngine", () => {
beforeEach(() => {
fakeMap.instances.length = 0;
fakeMap.startLoaded = true;
fakeMap.failNextSetStyle = false;
fakeMarker.all.length = 0;
tokenListeners.clear();
refreshMapTilesToken.mockClear();
loadStyle.mockReset();
loadStyle.mockImplementation(async () => baseStyle());
});
afterEach(() => {
document.body.innerHTML = "";
vi.useRealTimers();
});
describe("style lifecycle", () => {
it("waits for the initial style before resolving init", async () => {
fakeMap.startLoaded = false;
const { map, ready } = await createEngine();
let resolved = false;
ready.then(() => {
resolved = true;
});
await flush();
expect(resolved).toBe(false);
map.loadStyle();
await ready;
expect(resolved).toBe(true);
});
it("settles a pending init when destroyed", async () => {
fakeMap.startLoaded = false;
const { engine, ready } = await createEngine();
let resolved = false;
ready.then(() => {
resolved = true;
});
await flush();
expect(resolved).toBe(false);
engine.destroy();
await ready;
expect(resolved).toBe(true);
});
it("queues sources and layers while a swapped style is loading", async () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.setDarkMode(true);
await flush();
expect(map.setStyle).toHaveBeenCalledOnce();
expect(map.isStyleLoaded()).toBe(false);
// Adding to an unloaded style would throw; the engine must hold it
const circle = engine.addCircle([52, 4], { radius: 100, color: "red" });
expect(customSourceIds(map)).toHaveLength(0);
map.loadStyle();
expect(customSourceIds(map)).toHaveLength(1);
expect(layerIds(map)).toEqual(
expect.arrayContaining([
expect.stringMatching(/-fill$/),
expect.stringMatching(/-line$/),
])
);
circle.remove();
expect(customSourceIds(map)).toHaveLength(0);
});
it("carries its own sources and layers over a style swap, under the labels", async () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.addCircle([52, 4], { radius: 100, color: "red" });
const customLayers = layerIds(map).filter((id) => id.startsWith("ha-"));
expect(customLayers).toHaveLength(2);
engine.setDarkMode(true);
await flush();
map.loadStyle();
// The new style is a fresh copy from loadStyle; the circle survived it
expect(customSourceIds(map)).toHaveLength(1);
const ids = layerIds(map);
for (const id of customLayers) {
expect(ids.indexOf(id)).toBeGreaterThan(ids.indexOf("land"));
expect(ids.indexOf(id)).toBeLessThan(ids.indexOf("labels"));
}
});
it("keeps its sources and layers when a swap arrives while another is loading", async () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.addCircle([52, 4], { radius: 100, color: "red" });
engine.setDarkMode(true);
await flush();
// The dark style has not loaded, so this swap sees no previous style
engine.setDarkMode(false);
await flush();
expect(map.setStyle).toHaveBeenCalledTimes(2);
map.loadStyle();
expect(customSourceIds(map)).toHaveLength(1);
expect(layerIds(map).filter((id) => id.startsWith("ha-"))).toHaveLength(
2
);
});
it("does not swap twice for the same mode, and retries after a failed swap", async () => {
const { engine, map, ready } = await createEngine();
await ready;
expect(loadStyle).toHaveBeenCalledTimes(1);
engine.setDarkMode(false);
await flush();
expect(loadStyle).toHaveBeenCalledTimes(1);
// The dark style fails to fetch: the map stays light and dark can be
// requested again
loadStyle.mockRejectedValueOnce(new Error("offline"));
engine.setDarkMode(true);
await flush();
expect(map.setStyle).not.toHaveBeenCalled();
engine.setDarkMode(true);
await flush();
expect(loadStyle).toHaveBeenCalledTimes(3);
expect(map.setStyle).toHaveBeenCalledOnce();
});
it("does not record a mode whose style could not be applied", async () => {
const { engine, map, ready } = await createEngine();
await ready;
fakeMap.failNextSetStyle = true;
engine.setDarkMode(true);
await flush();
expect(map.setStyle).toHaveBeenCalledOnce();
expect(map.isStyleLoaded()).toBe(true);
// Dark was not applied, so asking for it again applies it
engine.setDarkMode(true);
await flush();
expect(map.setStyle).toHaveBeenCalledTimes(2);
expect(map.isStyleLoaded()).toBe(false);
});
it("applies the style again once a refused token is replaced", async () => {
const { map, ready } = await createEngine();
await ready;
map.fire("error", { error: { status: 403 } });
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
expect(loadStyle).toHaveBeenCalledTimes(1);
tokenListeners.forEach((listener) => listener("new-token"));
await flush();
expect(loadStyle).toHaveBeenCalledTimes(2);
expect(map.setStyle).toHaveBeenCalledOnce();
});
});
describe("WebGL context loss", () => {
it("reports a fatal failure when a lost context is not restored in time", async () => {
vi.useFakeTimers();
const fatal = vi.fn();
const { map, ready } = await createEngine({ fatal });
await ready;
map.fire("webglcontextlost");
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE - 1);
expect(fatal).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fatal).toHaveBeenCalledOnce();
});
it("stays on the engine when the context comes back within the grace period", async () => {
vi.useFakeTimers();
const fatal = vi.fn();
const { map, ready } = await createEngine({ fatal });
await ready;
map.fire("webglcontextlost");
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE / 2);
map.fire("webglcontextrestored");
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE);
expect(fatal).not.toHaveBeenCalled();
});
it("does not report a failure after being destroyed", async () => {
vi.useFakeTimers();
const fatal = vi.fn();
const { engine, map, ready } = await createEngine({ fatal });
await ready;
map.fire("webglcontextlost");
engine.destroy();
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE);
expect(fatal).not.toHaveBeenCalled();
expect(map.remove).toHaveBeenCalledOnce();
});
});
describe("camera events", () => {
it("does not report a resize as the map moving", async () => {
const moveStart = vi.fn();
const { engine, map, ready } = await createEngine({ moveStart });
await ready;
engine.invalidateSize();
expect(map.resize).toHaveBeenCalledOnce();
expect(moveStart).not.toHaveBeenCalled();
map.fire("movestart");
expect(moveStart).toHaveBeenCalledOnce();
});
});
describe("markers", () => {
it("lets input through non-interactive markers", async () => {
const { engine, ready } = await createEngine();
await ready;
const interactive = document.createElement("div");
engine.addMarker(interactive, [52, 4], { size: [36, 36] });
const passive = document.createElement("div");
engine.addMarker(passive, [52, 4], {
size: [36, 36],
interactive: false,
});
expect(interactive.tabIndex).toBe(0);
expect(interactive.style.pointerEvents).toBe("");
expect(passive.style.pointerEvents).toBe("none");
});
});
describe("clustering", () => {
const addMarker = (
engine: MapLibreMapEngine,
location: MapLatLng,
clusterData?: unknown
) =>
engine.addMarker(document.createElement("div"), location, {
size: [48, 48],
cluster: true,
clusterData,
});
const iconBuilder = vi.fn((members: MapMarkerHandle[]) => ({
element: Object.assign(document.createElement("div"), {
textContent: String(members.length),
}),
size: [40, 40] as [number, number],
}));
beforeEach(() => {
iconBuilder.mockClear();
});
it("shows clusterable markers only once clustering is configured", async () => {
const { engine, ready } = await createEngine();
await ready;
addMarker(engine, [52, 4]);
expect(fakeMarker.all).toHaveLength(0);
engine.setClustering(null);
expect(fakeMarker.all).toHaveLength(1);
});
it("groups markers within the radius on screen and leaves the rest alone", async () => {
const { engine, ready } = await createEngine();
await ready;
// 20px apart, then 100px further
addMarker(engine, [52, 4.0]);
addMarker(engine, [52, 4.002]);
addMarker(engine, [52, 4.012]);
engine.setClustering({ radius: 40, iconBuilder });
expect(iconBuilder).toHaveBeenCalledOnce();
expect(iconBuilder.mock.calls[0][0]).toHaveLength(2);
// One cluster icon and one lone marker
expect(fakeMarker.all).toHaveLength(2);
});
it("groups markers sharing a key while they fit the group radius", async () => {
const { engine, ready } = await createEngine();
await ready;
// 100px apart: too far for proximity, close enough for the zone group
addMarker(engine, [52, 4.0], { zone: "home" });
addMarker(engine, [52, 4.01], { zone: "home" });
// Same distance, no key: clusters by proximity, so stays alone
addMarker(engine, [52.01, 4.0]);
engine.setClustering({
radius: 40,
groupRadius: 160,
groupKey: (marker) =>
(marker.clusterData as { zone?: string } | undefined)?.zone,
iconBuilder,
});
expect(iconBuilder).toHaveBeenCalledOnce();
expect(iconBuilder.mock.calls[0][0]).toHaveLength(2);
expect(fakeMarker.all).toHaveLength(2);
});
it("falls back to proximity when a keyed group is spread too wide", async () => {
const { engine, ready } = await createEngine();
await ready;
addMarker(engine, [52, 4.0], { zone: "home" });
addMarker(engine, [52, 4.05], { zone: "home" });
engine.setClustering({
radius: 40,
groupRadius: 160,
groupKey: (marker) =>
(marker.clusterData as { zone?: string } | undefined)?.zone,
iconBuilder,
});
expect(iconBuilder).not.toHaveBeenCalled();
expect(fakeMarker.all).toHaveLength(2);
});
it("drops removed markers from their cluster on refresh", async () => {
const { engine, ready } = await createEngine();
await ready;
addMarker(engine, [52, 4.0]);
const second = addMarker(engine, [52, 4.002]);
engine.setClustering({ radius: 40, iconBuilder });
expect(iconBuilder.mock.calls[0][0]).toHaveLength(2);
second.remove();
engine.refreshClusters();
// A cluster of one is the marker itself
expect(iconBuilder).toHaveBeenCalledOnce();
expect(fakeMarker.all).toHaveLength(1);
});
const openBubble = () => {
expect(fakeMarker.all).toHaveLength(1);
const [bubble] = fakeMarker.all;
expect(bubble.options.anchor).toBe("bottom");
return (bubble.options.element as HTMLElement).querySelectorAll(
".cluster-open-members > *"
);
};
it("opens a cluster whose members share a spot in a bubble", async () => {
const { engine, map, ready } = await createEngine();
await ready;
const elements = [
addMarker(engine, [52, 4]),
addMarker(engine, [52, 4]),
addMarker(engine, [52, 4]),
];
engine.setClustering({ radius: 40, iconBuilder });
expect(fakeMarker.all).toHaveLength(1);
(iconBuilder.mock.results[0].value.element as HTMLElement).click();
// Zooming would change nothing; the members themselves sit in a
// bubble pointing at the spot
expect(map.fitBounds).not.toHaveBeenCalled();
expect(openBubble()).toHaveLength(elements.length);
// A refresh keeps it open; regrouping after the map moves closes it
engine.refreshClusters();
expect(openBubble()).toHaveLength(elements.length);
engine.setClustering({ radius: 40, iconBuilder });
expect(iconBuilder).toHaveBeenCalledTimes(2);
expect(fakeMarker.all).toHaveLength(1);
expect(fakeMarker.all[0].options.anchor).toBeUndefined();
});
it("moves keyboard focus into an opened cluster and back to its icon", async () => {
const { engine, ready } = await createEngine();
await ready;
const first = document.createElement("div");
engine.addMarker(first, [52, 4], { size: [48, 48], cluster: true });
addMarker(engine, [52, 4]);
engine.setClustering({ radius: 40, iconBuilder });
const icon = iconBuilder.mock.results[0].value.element as HTMLElement;
icon.focus();
icon.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
expect(document.activeElement).toBe(first);
// The bubble closes on the next regroup; focus lands on the new icon
engine.setClustering({ radius: 40, iconBuilder });
const reopened = iconBuilder.mock.results[1].value.element as HTMLElement;
expect(document.activeElement).toBe(reopened);
});
it("opens a cluster at maximum zoom in a bubble", async () => {
const { engine, map, ready } = await createEngine();
await ready;
map.zoom = map.getMaxZoom();
addMarker(engine, [52, 4.0]);
addMarker(engine, [52, 4.002]);
engine.setClustering({ radius: 40, iconBuilder });
(iconBuilder.mock.results[0].value.element as HTMLElement).click();
expect(map.fitBounds).not.toHaveBeenCalled();
expect(openBubble()).toHaveLength(2);
});
it("zooms in on a cluster's members when it is activated", async () => {
const { engine, map, ready } = await createEngine();
await ready;
addMarker(engine, [52, 4.0]);
addMarker(engine, [52, 4.002]);
engine.setClustering({ radius: 40, iconBuilder });
const icon = iconBuilder.mock.results[0].value.element as HTMLElement;
icon.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
expect(map.fitBounds).toHaveBeenCalledOnce();
icon.click();
expect(map.fitBounds).toHaveBeenCalledTimes(2);
});
});
});
+277
View File
@@ -0,0 +1,277 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
MapEngine,
MapEngineOptions,
MapMarkerHandle,
} from "../../../src/common/map/map-engine";
import "../../../src/components/map/ha-map";
import type { HaMap } from "../../../src/components/map/ha-map";
// ha-map picks its engine at runtime: MapLibre GL where WebGL2 is available,
// Leaflet otherwise or after MapLibre fails. jsdom has no WebGL2 and cannot
// run MapLibre, so the probe is stubbed and the MapLibre engine is replaced
// by a fake that records what ha-map asks of it. The Leaflet fallback is
// real, as in the ha-map fit tests.
const webgl2 = vi.hoisted(() => ({ supported: true }));
vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
supportsWebGL2: () => webgl2.supported,
}));
const fakeEngine = vi.hoisted(() => {
class FakeMapLibreEngine implements MapEngine {
static instances: FakeMapLibreEngine[] = [];
static failInit = false;
/** When set, init waits for it, so events can be fired mid-setup */
static initGate?: Promise<void>;
options?: MapEngineOptions;
constructor() {
FakeMapLibreEngine.instances.push(this);
}
/** Settles a pending init, as the real engine's destroy does */
private _settleInit?: () => void;
init = vi.fn(async (_container: HTMLElement, options: MapEngineOptions) => {
this.options = options;
await Promise.race([
FakeMapLibreEngine.initGate,
new Promise<void>((resolve) => {
this._settleInit = resolve;
}),
]);
if (FakeMapLibreEngine.failInit) {
throw new Error("WebGL context refused");
}
});
destroy = vi.fn(() => {
this._settleInit?.();
});
invalidateSize = vi.fn();
hasUsableSize = () => true;
setDarkMode = vi.fn();
setZoomControlPosition = vi.fn();
setScaleRuler = vi.fn();
setView = vi.fn();
setZoom = vi.fn();
fitBounds = vi.fn();
panTo = vi.fn();
containsLocation = () => true;
addMarker = vi.fn((_element, location, options): MapMarkerHandle => ({
location,
clusterData: options.clusterData,
remove: vi.fn(),
}));
addCircle = vi.fn(() => ({ remove: vi.fn() }));
addPath = vi.fn(() => ({ remove: vi.fn() }));
setClustering = vi.fn();
refreshClusters = vi.fn();
}
return FakeMapLibreEngine;
});
vi.mock("../../../src/common/map/engines/maplibre-map-engine", () => ({
MapLibreMapEngine: fakeEngine,
}));
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
const STATES = {
"device_tracker.paulus": {
entity_id: "device_tracker.paulus",
state: "not_home",
attributes: {
friendly_name: "Paulus",
latitude: 52.372,
longitude: 4.89,
},
context: { id: "1", user_id: null, parent_id: null },
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
},
"device_tracker.anne_therese": {
entity_id: "device_tracker.anne_therese",
state: "not_home",
attributes: {
friendly_name: "Anne Therese",
latitude: 52.377,
longitude: 4.895,
},
context: { id: "2", user_id: null, parent_id: null },
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
},
} as unknown as HassEntities;
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
const isLoaded = (el: HaMap) => (el as any)._loaded as boolean;
const entityHandles = (el: HaMap) =>
(el as any)._entityHandles as MapMarkerHandle[];
const createMap = async (): Promise<HaMap> => {
const el = document.createElement("ha-map");
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
el.clusterMarkers = false;
(el as any)._states = STATES;
(el as any)._config = {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
document.body.appendChild(el);
await vi.waitUntil(() => isLoaded(el));
await el.updateComplete;
return el;
};
describe("ha-map engine selection", () => {
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
webgl2.supported = true;
fakeEngine.failInit = false;
fakeEngine.initGate = undefined;
fakeEngine.instances.length = 0;
});
afterEach(() => {
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("runs on the MapLibre engine when WebGL2 is available", async () => {
const el = await createMap();
expect(fakeEngine.instances).toHaveLength(1);
const engine = fakeEngine.instances[0];
expect(engine.init).toHaveBeenCalledOnce();
expect(engine.options?.rasterOnly).toBeFalsy();
expect(leafletMap(el)).toBeUndefined();
// Entities are drawn through the engine
expect(engine.addMarker).toHaveBeenCalledTimes(2);
expect(entityHandles(el)).toHaveLength(2);
});
it("runs on Leaflet when WebGL2 is not available", async () => {
webgl2.supported = false;
const el = await createMap();
expect(fakeEngine.instances).toHaveLength(0);
expect(leafletMap(el)).toBeDefined();
expect(entityHandles(el)).toHaveLength(2);
});
it("falls back to Leaflet when the MapLibre engine cannot start", async () => {
fakeEngine.failInit = true;
const el = await createMap();
// MapLibre was tried once, torn down, then abandoned
expect(fakeEngine.instances).toHaveLength(1);
expect(fakeEngine.instances[0].init).toHaveBeenCalledOnce();
expect(fakeEngine.instances[0].destroy).toHaveBeenCalledOnce();
expect(leafletMap(el)).toBeDefined();
// Entities are drawn on the fallback
expect(entityHandles(el)).toHaveLength(2);
});
it("falls back to Leaflet when the engine fails while still setting up", async () => {
let openGate!: () => void;
fakeEngine.initGate = new Promise<void>((resolve) => {
openGate = resolve;
});
const el = document.createElement("ha-map");
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
el.clusterMarkers = false;
(el as any)._states = STATES;
(el as any)._config = {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
document.body.appendChild(el);
await vi.waitUntil(() => fakeEngine.instances[0]?.options);
const engine = fakeEngine.instances[0];
// The context is lost before init has resolved, and init never would
// resolve on its own: tearing the engine down is what settles it
engine.options!.events.fatal!();
expect(isLoaded(el)).toBe(false);
await vi.waitUntil(() => leafletMap(el) !== undefined && isLoaded(el));
await el.updateComplete;
// The failed engine was never installed, and the fallback drew the map
expect(engine.destroy).toHaveBeenCalled();
expect(fakeEngine.instances).toHaveLength(1);
expect(entityHandles(el)).toHaveLength(2);
openGate();
});
it("tears down an engine still setting up when disconnected, and sets up again on reconnect", async () => {
let openGate!: () => void;
fakeEngine.initGate = new Promise<void>((resolve) => {
openGate = resolve;
});
const el = document.createElement("ha-map");
el.entities = ["device_tracker.paulus"];
el.clusterMarkers = false;
(el as any)._states = STATES;
(el as any)._config = {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
document.body.appendChild(el);
await vi.waitUntil(() => fakeEngine.instances[0]?.options);
el.remove();
expect(fakeEngine.instances[0].destroy).toHaveBeenCalledOnce();
openGate();
fakeEngine.initGate = undefined;
document.body.appendChild(el);
await vi.waitUntil(() => isLoaded(el));
// The abandoned engine was not installed; a fresh one was set up
expect(fakeEngine.instances).toHaveLength(2);
expect(fakeEngine.instances[1].init).toHaveBeenCalledOnce();
expect(fakeEngine.instances[1].destroy).not.toHaveBeenCalled();
});
it("rebuilds on Leaflet after a fatal engine failure", async () => {
const el = await createMap();
const engine = fakeEngine.instances[0];
const handlesBefore = entityHandles(el);
engine.options!.events.fatal!();
await vi.waitUntil(() => leafletMap(el) !== undefined && isLoaded(el));
await el.updateComplete;
expect(engine.destroy).toHaveBeenCalledOnce();
// Only one MapLibre attempt; the rebuild went straight to Leaflet
expect(fakeEngine.instances).toHaveLength(1);
// Entities are redrawn on the new engine, not carried over
expect(entityHandles(el)).toHaveLength(2);
expect(entityHandles(el)).not.toBe(handlesBefore);
});
});
+7 -3
View File
@@ -49,6 +49,10 @@ const STATES = {
},
} as unknown as HassEntities;
// jsdom has no WebGL2, so ha-map runs its Leaflet fallback engine here; the
// Leaflet map underneath is what the fit assertions read.
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
const createMap = async (): Promise<HaMap> => {
const el = document.createElement("ha-map");
el.entities = [
@@ -61,7 +65,7 @@ const createMap = async (): Promise<HaMap> => {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
document.body.appendChild(el);
await vi.waitUntil(() => el.leafletMap !== undefined && (el as any)._loaded);
await vi.waitUntil(() => leafletMap(el) !== undefined && (el as any)._loaded);
await el.updateComplete;
return el;
};
@@ -104,7 +108,7 @@ describe("ha-map", () => {
fireResizeObservers();
await el.updateComplete;
const map = el.leafletMap!;
const map = leafletMap(el)!;
// The map should be fitted to the markers, not zoomed out to the world.
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
@@ -131,7 +135,7 @@ describe("ha-map", () => {
try {
const el = await createMap();
const map = el.leafletMap!;
const map = leafletMap(el)!;
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
expect(map.getCenter().lng).toBeCloseTo(4.8925, 2);
+5 -30
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
@@ -6022,15 +6022,6 @@ __metadata:
languageName: node
linkType: hard
"@types/leaflet-draw@npm:1.0.13":
version: 1.0.13
resolution: "@types/leaflet-draw@npm:1.0.13"
dependencies:
"@types/leaflet": "npm:^1.9"
checksum: 10/1a6c3a8b3011f15362108b522fa6d17cca888a7f866e2e582dac921e2c748d9e24685d83b2a5ed1d97e3a1448b0f5b1879a42f1143ffdeb36b71c7fb9b94e9f5
languageName: node
linkType: hard
"@types/leaflet.markercluster@npm:1.5.6":
version: 1.5.6
resolution: "@types/leaflet.markercluster@npm:1.5.6"
@@ -10350,7 +10341,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"
@@ -10407,7 +10398,6 @@ __metadata:
"@types/culori": "npm:4.0.1"
"@types/html-minifier-terser": "npm:7.0.2"
"@types/leaflet": "npm:1.9.22"
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.5"
@@ -10465,7 +10455,6 @@ __metadata:
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lightningcss: "npm:1.33.0"
@@ -11692,20 +11681,6 @@ __metadata:
languageName: node
linkType: hard
"leaflet-draw@npm:1.0.4":
version: 1.0.4
resolution: "leaflet-draw@npm:1.0.4"
checksum: 10/180cc222a2d63de9d1ea197bca107ba5b977e1ce323dd714028be0ba30d0b8a2963619b2555bff5b2efb7befd240a45ad973c0e1947ca355ee4e7e2d11231cbe
languageName: node
linkType: hard
"leaflet-draw@patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch::locator=home-assistant-frontend%40workspace%3A.":
version: 1.0.4
resolution: "leaflet-draw@patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch::version=1.0.4&hash=790842&locator=home-assistant-frontend%40workspace%3A."
checksum: 10/70e43ef0307dd3995dc392609a627b349070f25a57b908a4ab07e17b579878fdd0ff090847ca42826b933c547e324a4e560f35a8a1417f8209e702c3512e4d8a
languageName: node
linkType: hard
"leaflet.markercluster@npm:1.5.3":
version: 1.5.3
resolution: "leaflet.markercluster@npm:1.5.3"