Compare commits

..
Author SHA1 Message Date
Paul Bottein 9ca5f1fa70 Add a checkbox to use the device name for entities 2026-09-07 11:32:03 +02:00
26 changed files with 838 additions and 3543 deletions
File diff suppressed because one or more lines are too long
-2
View File
@@ -81,8 +81,6 @@ 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) {
+18 -58
View File
@@ -19,64 +19,24 @@ 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) => {
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"]),
],
},
});
}),
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
),
});
+3 -1
View File
@@ -48,7 +48,7 @@
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.3",
"@codemirror/state": "6.7.2",
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
@@ -110,6 +110,7 @@
"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",
@@ -162,6 +163,7 @@
"@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);
+9 -11
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.
export const VECTOR_STYLES = {
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.
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
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.
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
const CONTEXT_RESTORE_GRACE = 2000;
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.
export const supportsWebGL2 = (): boolean => {
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;
};
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ export const loadStyle = async (url: string): Promise<StyleSpecification> => {
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
@@ -303,11 +303,9 @@ export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
token: string | undefined
): Promise<MapBaseLayer> => {
if (!rasterOnly && supportsWebGL2()) {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
-58
View File
@@ -1,58 +0,0 @@
/** 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);
}
`;
@@ -1,339 +0,0 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/** The Leaflet 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;
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive: options.interactive ?? true,
keyboard: options.interactive ?? true,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
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
@@ -1,267 +0,0 @@
/**
* 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],
];
};
-70
View File
@@ -1,70 +0,0 @@
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;
}
`;
+2 -11
View File
@@ -24,8 +24,6 @@ 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
@@ -78,20 +76,13 @@ 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: var(--ha-marker-border-width, 1px) solid
var(--ha-marker-color, var(--primary-color));
border: 1px solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(
--ha-marker-background,
var(--card-background-color)
);
background-color: var(--card-background-color);
}
.marker.picture {
overflow: hidden;
}
:host([selected]) .marker {
border-width: 3px;
}
.entity-picture {
background-size: cover;
height: 100%;
+227 -157
View File
@@ -1,24 +1,24 @@
import { consume } from "@lit/context";
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
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 { 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 type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaMap } 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,12 +43,6 @@ 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[];
@@ -65,19 +59,35 @@ 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;
@state() private _editingAvailable = true;
private Leaflet?: LeafletModuleType;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
// 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());
})
);
}
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
}
public fitBounds(
boundingbox: MapLatLng[],
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
@@ -87,46 +97,42 @@ export class HaLocationsEditor extends LitElement {
id: string,
options?: { zoom?: number }
): Promise<void> {
await this.updateComplete;
const location = this.locations?.find((loc) => loc.id === id);
if (!location) {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) {
return;
}
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,
});
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
if ("getBounds" in marker) {
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
} else {
this.map.setView(center, options?.zoom || this.zoom);
const circle = this._circles[id];
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
}
}
protected render(): TemplateResult {
return html`
<ha-map
.editableLocations=${this._editableLocations(this.locations)}
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.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>`
@@ -135,122 +141,63 @@ export class HaLocationsEditor extends LitElement {
`;
}
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);
}
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));
}
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,
}));
return layers;
}
);
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
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;
// Still loading.
if (!this.Leaflet) {
return;
}
// 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;
}
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;
if (changedProps.has("locations")) {
this._updateMarkers();
}
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 {
if (changedProps.has("locations")) {
fireEvent(this, "markers-updated");
// Still loading.
if (!this.Leaflet) {
return;
}
// Follow a location that was edited out of view
const oldLocations = changedProps.get("locations") as
MarkerLocation[] | undefined;
if (changedProps.has("locations")) {
const oldLocations = changedProps.get("locations");
const movedLocations = this.locations?.filter(
(loc, idx) =>
!oldLocations?.[idx] ||
!oldLocations[idx] ||
((loc.latitude !== oldLocations[idx].latitude ||
loc.longitude !== oldLocations[idx].longitude) &&
this.map.containsLocation([
oldLocations[idx].latitude,
oldLocations[idx].longitude,
]) &&
!this.map.containsLocation([loc.latitude, loc.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 }))
);
if (movedLocations?.length === 1) {
this.map.panTo([
movedLocations[0].latitude,
movedLocations[0].longitude,
]);
this.map.leafletMap?.panTo({
lat: movedLocations[0].latitude,
lng: 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.
@@ -259,37 +206,40 @@ export class HaLocationsEditor extends LitElement {
return longitude;
}
private _locationMoved(
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
) {
const [latitude, longitude] = ev.detail.location;
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
const location: [number, number] = [
latlng.lat,
this._normalizeLongitude(latlng.lng),
];
fireEvent(
this,
"location-updated",
{
id: ev.detail.id,
location: [latitude, this._normalizeLongitude(longitude)],
},
{ id: marker.id, location },
{ bubbles: false }
);
}
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
fireEvent(
this,
"radius-updated",
{ id: ev.detail.id, radius: ev.detail.radius },
{ id: marker.id, radius: circle.getRadius() },
{ bubbles: false }
);
}
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
}
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
if (this.pinOnClick && this.locations?.length) {
const id = this.locations[0].id;
private _mapClicked(ev) {
if (this.pinOnClick && this._locationMarkers) {
const id = Object.keys(this._locationMarkers)[0];
const location: [number, number] = [
ev.detail.location[0],
this._normalizeLongitude(ev.detail.location[1]),
@@ -298,11 +248,131 @@ 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.panTo(location);
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
}
}
}
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
@@ -1,5 +1,5 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiContentCopy, mdiRestore } from "@mdi/js";
import { mdiContentCopy, mdiPencil, 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,7 +8,10 @@ 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";
@@ -21,10 +24,13 @@ 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";
@@ -193,6 +199,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _name!: string;
@state() private _useDeviceName = false;
@state() private _icon!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@@ -254,6 +262,9 @@ 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
@@ -262,6 +273,8 @@ 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;
@@ -271,9 +284,6 @@ 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);
@@ -386,7 +396,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._name || null,
name: this._computeName(),
icon: this._icon || null,
entityId: this._entityId,
areaId: this._areaId ?? null,
@@ -464,41 +474,74 @@ 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`<ha-input
inset-label
class="name"
.value=${this._name}
.label=${this.hass.localize(
: html`<div
class="name-field"
role="group"
aria-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`<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
>`
? 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>`
: nothing
}
</ha-input>`
</div>`
}
${
this.hideIcon
@@ -1245,7 +1288,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
const params: Partial<EntityRegistryEntryUpdateParams> = {
name: this._name.trim() || null,
name: this._computeName(),
icon: this._icon.trim() || null,
area_id: this._areaId || null,
labels: this._labels || [],
@@ -1692,9 +1735,23 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
this._openDeviceSettings();
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 _openDeviceSettings() {
@@ -1798,7 +1855,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
ha-input.entityId,
ha-input.name {
ha-input.name,
.device-name {
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
}
@@ -1806,6 +1864,60 @@ 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;
}
+8 -17
View File
@@ -221,19 +221,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
public disconnectedCallback() {
super.disconnectedCallback();
// 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) {
if (this._unsubscribeEvents) {
this._unsubscribeEvents();
this._unsubscribeEvents = undefined;
}
@@ -923,14 +911,11 @@ 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" || this._unsubscribeEvents) {
if (!this.isConnected || this._mode !== "live") {
unsubscribe();
return;
}
@@ -1107,6 +1092,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
};
private _goBack(): void {
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
afterNextRender(() => goBack("/config/scene/dashboard"));
}
@@ -1131,6 +1119,9 @@ 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");
}
+17 -57
View File
@@ -23,10 +23,6 @@ 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,
@@ -73,20 +69,15 @@ 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[],
zoneEntityIds: Record<string, string>,
_colorVersion: number
): MarkerLocation[] => {
(storageItems: Zone[], stateItems: HassEntity[]): 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) => ({
@@ -96,11 +87,12 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
latitude: entityState.attributes.latitude,
longitude: entityState.attributes.longitude,
radius: entityState.attributes.radius,
radius_color: zoneColor(
entityState.entity_id,
!!entityState.attributes.passive,
computedStyles
),
radius_color:
entityState.entity_id === "zone.home"
? homeRadiusColor
: entityState.attributes.passive
? passiveRadiusColor
: zoneRadiusColor,
location_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
radius_editable:
@@ -109,11 +101,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
radius_color: zoneColor(
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
!!zone.passive,
computedStyles
),
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
location_editable: true,
radius_editable: true,
}));
@@ -123,21 +111,10 @@ 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();
}),
];
@@ -292,9 +269,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<ha-locations-editor
.locations=${this._getZones(
this._storageItems,
this._stateItems,
this._zoneEntityIds,
this._colorVersion
this._stateItems
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@@ -389,15 +364,6 @@ 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) {
@@ -443,10 +409,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private _itemClicked(ev: CustomEvent) {
if (
this._isProgrammaticSelection(ev) ||
!shouldHandleRequestSelectedEvent(ev)
) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
@@ -460,10 +423,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private _stateItemClicked(ev: CustomEvent) {
if (
this._isProgrammaticSelection(ev) ||
!shouldHandleRequestSelectedEvent(ev)
) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
+2 -2
View File
@@ -4,6 +4,7 @@ import {
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { LatLngTuple } from "leaflet";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -28,7 +29,6 @@ import type {
HaMapPaths,
MapCardMarkerLabelMode,
} from "../../../components/map/ha-map";
import type { MapLatLng } from "../../../common/map/map-engine";
import type { HistoryStates } from "../../../data/history";
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
import type { HomeAssistant } from "../../../types";
@@ -528,7 +528,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
continue;
}
const p = {} as HaMapPathPoint;
p.point = [latitude, longitude] as MapLatLng;
p.point = [latitude, longitude] as LatLngTuple;
p.timestamp = new Date(entityState.lu * 1000);
points.push(p);
}
+3 -4
View File
@@ -1149,10 +1149,7 @@
"category": "Category"
},
"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"
"error": "Unable to load map"
},
"statistics_charts": {
"loading_statistics": "Loading statistics…",
@@ -1942,6 +1939,8 @@
"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",
+2 -38
View File
@@ -12,16 +12,7 @@ const layer = (id, layout) => ({ id, type: "symbol", layout });
const STYLE = {
layers: [
{
...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-place-city", { "text-field": ["get", "name"] }),
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
@@ -110,33 +101,6 @@ describe("addLatinLabels", () => {
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
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();
});
expect(style.layers[3]).toEqual(STYLE.layers[3]);
});
});
@@ -8,6 +8,7 @@ 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,
@@ -132,6 +133,20 @@ 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,15 +167,6 @@ describe("createBaseLayer", () => {
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("renders raster tiles when asked to, even with WebGL2", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN, true);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
});
it("falls back to raster tiles when the style cannot be fetched", async () => {
const createBaseLayer = await setWebGL2(true);
vi.stubGlobal(
-203
View File
@@ -1,203 +0,0 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
MapEngine,
MapEngineOptions,
MapMarkerHandle,
} from "../../../src/common/map/map-engine";
import "../../../src/components/map/ha-map";
import type { HaMap } from "../../../src/components/map/ha-map";
// ha-map picks its engine at runtime: MapLibre GL where WebGL2 is available,
// Leaflet otherwise or after MapLibre fails. jsdom has no WebGL2 and cannot
// run MapLibre, so the probe is stubbed and the MapLibre engine is replaced
// by a fake that records what ha-map asks of it. The Leaflet fallback is
// real, as in the ha-map fit tests.
const webgl2 = vi.hoisted(() => ({ supported: true }));
vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
supportsWebGL2: () => webgl2.supported,
}));
const fakeEngine = vi.hoisted(() => {
class FakeMapLibreEngine implements MapEngine {
static instances: FakeMapLibreEngine[] = [];
static failInit = false;
options?: MapEngineOptions;
constructor() {
FakeMapLibreEngine.instances.push(this);
}
init = vi.fn(async (_container: HTMLElement, options: MapEngineOptions) => {
this.options = options;
if (FakeMapLibreEngine.failInit) {
throw new Error("WebGL context refused");
}
});
destroy = vi.fn();
invalidateSize = vi.fn();
hasUsableSize = () => true;
setDarkMode = vi.fn();
setZoomControlPosition = vi.fn();
setScaleRuler = vi.fn();
setView = vi.fn();
setZoom = vi.fn();
fitBounds = vi.fn();
panTo = vi.fn();
containsLocation = () => true;
addMarker = vi.fn((_element, location, options): MapMarkerHandle => ({
location,
clusterData: options.clusterData,
remove: vi.fn(),
}));
addCircle = vi.fn(() => ({ remove: vi.fn() }));
addPath = vi.fn(() => ({ remove: vi.fn() }));
setClustering = vi.fn();
refreshClusters = vi.fn();
}
return FakeMapLibreEngine;
});
vi.mock("../../../src/common/map/engines/maplibre-map-engine", () => ({
MapLibreMapEngine: fakeEngine,
}));
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
const STATES = {
"device_tracker.paulus": {
entity_id: "device_tracker.paulus",
state: "not_home",
attributes: {
friendly_name: "Paulus",
latitude: 52.372,
longitude: 4.89,
},
context: { id: "1", user_id: null, parent_id: null },
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
},
"device_tracker.anne_therese": {
entity_id: "device_tracker.anne_therese",
state: "not_home",
attributes: {
friendly_name: "Anne Therese",
latitude: 52.377,
longitude: 4.895,
},
context: { id: "2", user_id: null, parent_id: null },
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
},
} as unknown as HassEntities;
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
const isLoaded = (el: HaMap) => (el as any)._loaded as boolean;
const entityHandles = (el: HaMap) =>
(el as any)._entityHandles as MapMarkerHandle[];
const createMap = async (): Promise<HaMap> => {
const el = document.createElement("ha-map");
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
el.clusterMarkers = false;
(el as any)._states = STATES;
(el as any)._config = {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
document.body.appendChild(el);
await vi.waitUntil(() => isLoaded(el));
await el.updateComplete;
return el;
};
describe("ha-map engine selection", () => {
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
webgl2.supported = true;
fakeEngine.failInit = false;
fakeEngine.instances.length = 0;
});
afterEach(() => {
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("runs on the MapLibre engine when WebGL2 is available", async () => {
const el = await createMap();
expect(fakeEngine.instances).toHaveLength(1);
const engine = fakeEngine.instances[0];
expect(engine.init).toHaveBeenCalledOnce();
expect(engine.options?.rasterOnly).toBeFalsy();
expect(leafletMap(el)).toBeUndefined();
// Entities are drawn through the engine
expect(engine.addMarker).toHaveBeenCalledTimes(2);
expect(entityHandles(el)).toHaveLength(2);
});
it("runs on Leaflet when WebGL2 is not available", async () => {
webgl2.supported = false;
const el = await createMap();
expect(fakeEngine.instances).toHaveLength(0);
expect(leafletMap(el)).toBeDefined();
expect(entityHandles(el)).toHaveLength(2);
});
it("falls back to Leaflet when the MapLibre engine cannot start", async () => {
fakeEngine.failInit = true;
const el = await createMap();
// MapLibre was tried once, then abandoned
expect(fakeEngine.instances).toHaveLength(1);
expect(fakeEngine.instances[0].init).toHaveBeenCalledOnce();
expect(leafletMap(el)).toBeDefined();
// Entities are drawn on the fallback
expect(entityHandles(el)).toHaveLength(2);
});
it("rebuilds on Leaflet after a fatal engine failure", async () => {
const el = await createMap();
const engine = fakeEngine.instances[0];
const handlesBefore = entityHandles(el);
engine.options!.events.fatal!();
await vi.waitUntil(() => leafletMap(el) !== undefined && isLoaded(el));
await el.updateComplete;
expect(engine.destroy).toHaveBeenCalledOnce();
// Only one MapLibre attempt; the rebuild went straight to Leaflet
expect(fakeEngine.instances).toHaveLength(1);
// Entities are redrawn on the new engine, not carried over
expect(entityHandles(el)).toHaveLength(2);
expect(entityHandles(el)).not.toBe(handlesBefore);
});
});
+3 -7
View File
@@ -49,10 +49,6 @@ 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 = [
@@ -65,7 +61,7 @@ const createMap = async (): Promise<HaMap> => {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
document.body.appendChild(el);
await vi.waitUntil(() => leafletMap(el) !== undefined && (el as any)._loaded);
await vi.waitUntil(() => el.leafletMap !== undefined && (el as any)._loaded);
await el.updateComplete;
return el;
};
@@ -108,7 +104,7 @@ describe("ha-map", () => {
fireResizeObservers();
await el.updateComplete;
const map = leafletMap(el)!;
const map = el.leafletMap!;
// 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);
@@ -135,7 +131,7 @@ describe("ha-map", () => {
try {
const el = await createMap();
const map = leafletMap(el)!;
const map = el.leafletMap!;
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
expect(map.getCenter().lng).toBeCloseTo(4.8925, 2);
+30 -5
View File
@@ -2430,12 +2430,12 @@ __metadata:
languageName: node
linkType: hard
"@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"
"@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"
dependencies:
"@marijn/find-cluster-break": "npm:^1.0.0"
checksum: 10/04e6b758d5d45e6e6b343d9f1916d0d1f7577c7c5842a81354ebf2412f160235ee736d0d2258241e3a8ba2457276c5cc8793fa385cff0d6d7032d81991652eba
checksum: 10/363a6217e6d34ce406abca93ae7b87181e93e308be5ee037bec911b9d8c7bf6559de7e85620460bc1b7c1a590c05d75bc64e3b09d76fd82110ab829237e3fde2
languageName: node
linkType: hard
@@ -6022,6 +6022,15 @@ __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"
@@ -10341,7 +10350,7 @@ __metadata:
"@codemirror/language": "npm:6.12.4"
"@codemirror/lint": "npm:6.9.7"
"@codemirror/search": "npm:6.7.2"
"@codemirror/state": "npm:6.7.3"
"@codemirror/state": "npm:6.7.2"
"@codemirror/view": "npm:6.43.11"
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
@@ -10398,6 +10407,7 @@ __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"
@@ -10455,6 +10465,7 @@ __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"
@@ -11681,6 +11692,20 @@ __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"