Compare commits

...
Author SHA1 Message Date
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
20 changed files with 2822 additions and 495 deletions
File diff suppressed because one or more lines are too long
+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"]),
],
},
});
}),
});
-2
View File
@@ -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",
+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);
}
`;
+10 -10
View File
@@ -34,17 +34,9 @@ interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/**
* The Leaflet implementation of MapEngine. Renders vector tiles through the
* maplibre-gl-leaflet adapter when WebGL2 is available and raster tiles
* otherwise (see createBaseLayer), so it is both the non-WebGL2 fallback and
* the engine ha-locations-editor requires for leaflet-draw.
*/
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/**
* Escape hatch for ha-locations-editor, which manages its own Leaflet
* layers (leaflet-draw). Not for use anywhere else.
*/
/** For the ha-map jsdom tests only */
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
@@ -197,6 +189,14 @@ export class LeafletMapEngine implements MapEngine {
});
}
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,
+320 -4
View File
@@ -1,5 +1,6 @@
import type { Feature, FeatureCollection, Polygon } from "geojson";
import type {
GeoJSONSource,
IControl,
LayerSpecification,
Map as MapLibreMap,
@@ -29,16 +30,23 @@ import type {
MapCircleOptions,
MapClusterOptions,
MapControlPosition,
MapDraggableMarkerOptions,
MapEditableCircleHandle,
MapEditableCircleOptions,
MapEditableMarkerHandle,
MapEditingSupport,
MapEngine,
MapMarkerHandle,
MapEngineEvents,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { distanceMeters, pointEastOf } from "../map-engine";
import { createResizeHandleElement, RESIZE_KEY_STEP } from "../editable-circle";
type MapLibreModule = typeof maplibregl;
@@ -109,7 +117,12 @@ interface ManagedMarker {
element: HTMLElement;
location: MapLatLng;
options: MapMarkerOptions;
handle: MapMarkerHandle;
handle: MapEditableMarkerHandle;
draggable?: boolean;
onDragEnd?: (location: MapLatLng) => void;
dragging?: boolean;
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
staleLocation?: MapLatLng;
mlMarker?: MapLibreMarker;
decoration?: MapItemHandle;
removed?: boolean;
@@ -513,11 +526,35 @@ export class MapLibreMapEngine implements MapEngine {
);
}
public panTo(location: MapLatLng): void {
this._map?.panTo([location[1], location[0]]);
}
public containsLocation(location: MapLatLng): boolean {
return this._map?.getBounds().contains([location[1], location[0]]) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
return this._addMarker(element, location, options);
}
public editing: MapEditingSupport = {
addDraggableMarker: (element, location, options) =>
this._addMarker(element, location, options, true),
addEditableCircle: (center, options) =>
this._addEditableCircle(center, options),
};
private _addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions,
draggable = false
): MapEditableMarkerHandle {
element.style.width = `${options.size[0]}px`;
element.style.height = `${options.size[1]}px`;
if (options.title) {
@@ -535,11 +572,32 @@ export class MapLibreMapEngine implements MapEngine {
element,
location,
options,
handle: undefined as unknown as MapMarkerHandle,
draggable,
onDragEnd: options.onDragEnd,
handle: undefined as unknown as MapEditableMarkerHandle,
};
managed.handle = {
location,
get location() {
return managed.location;
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
if (managed.dragging) {
return;
}
// Skip one update echoing the pre-drag location (the save has not returned yet)
const stale = managed.staleLocation;
managed.staleLocation = undefined;
if (
stale &&
newLocation[0] === stale[0] &&
newLocation[1] === stale[1]
) {
return;
}
managed.location = newLocation;
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
},
remove: () => {
managed.removed = true;
this._hideMarker(managed);
@@ -566,6 +624,7 @@ export class MapLibreMapEngine implements MapEngine {
const { options } = managed;
managed.mlMarker = new this._maplibre.Marker({
element: managed.element,
draggable: managed.draggable ?? false,
...(options.anchor
? {
anchor: "top-left" as const,
@@ -578,6 +637,18 @@ export class MapLibreMapEngine implements MapEngine {
})
.setLngLat([managed.location[1], managed.location[0]])
.addTo(this._map);
if (managed.draggable) {
managed.mlMarker.on("dragstart", () => {
managed.dragging = true;
managed.staleLocation = managed.location;
});
managed.mlMarker.on("dragend", () => {
managed.dragging = false;
const lngLat = managed.mlMarker!.getLngLat();
managed.location = [lngLat.lat, lngLat.lng];
managed.onDragEnd?.(managed.location);
});
}
}
if (managed.options.decoration && !managed.decoration) {
managed.decoration = this.addCircle(
@@ -624,6 +695,240 @@ export class MapLibreMapEngine implements MapEngine {
};
}
private _addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle {
if (!this._map || !this._maplibre) {
return {
center,
radius: options.radius,
update: () => undefined,
remove: () => undefined,
};
}
const map = this._map;
const maplibre = this._maplibre;
let currentCenter = center;
let currentRadius = options.radius;
const id = `${CUSTOM_PREFIX}editable-${this._idCounter++}`;
this._addCustomSource(id, circlePolygon(center, options.radius));
this._addCustomLayer({
id: `${id}-fill`,
type: "fill",
source: id,
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
});
this._addCustomLayer({
id: `${id}-line`,
type: "line",
source: id,
paint: { "line-color": options.color, "line-width": 3 },
});
// Redraw at most once per frame while dragging
let frame: number | undefined;
const redraw = () => {
if (frame !== undefined) {
return;
}
frame = requestAnimationFrame(() => {
frame = undefined;
this._setCustomSourceData(
id,
circlePolygon(currentCenter, currentRadius)
);
});
};
const centerSize = options.centerSize ?? [16, 16];
const centerEl = options.centerElement ?? document.createElement("div");
if (!options.centerElement) {
centerEl.className = "editable-circle-center";
}
centerEl.style.width = `${centerSize[0]}px`;
centerEl.style.height = `${centerSize[1]}px`;
if (options.title) {
centerEl.title = options.title;
}
if (options.onClick) {
centerEl.tabIndex = 0;
}
setMarkerAccessibility(centerEl, options.title, !!options.onClick);
const centerMarker = new maplibre.Marker({
element: centerEl,
draggable: options.moveable ?? false,
})
.setLngLat([center[1], center[0]])
.addTo(map);
let resizeMarker: MapLibreMarker | undefined;
let resizeHandle: HTMLElement | undefined;
const placeResizeHandle = () => {
const east = pointEastOf(currentCenter, currentRadius);
resizeMarker?.setLngLat([east[1], east[0]]);
const radiusText = String(Math.round(currentRadius));
resizeHandle?.setAttribute("aria-valuenow", radiusText);
// Without a value text the value is read as a percentage of the range
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
if (options.resizable) {
const east = pointEastOf(center, options.radius);
resizeHandle = createResizeHandleElement(options.resizeLabel);
resizeMarker = new maplibre.Marker({
element: resizeHandle,
draggable: true,
})
.setLngLat([east[1], east[0]])
.addTo(map);
placeResizeHandle();
resizeMarker.on("drag", () => {
const lngLat = resizeMarker!.getLngLat();
currentRadius = Math.max(
1,
distanceMeters(currentCenter, [lngLat.lat, lngLat.lng])
);
redraw();
});
resizeMarker.on("dragend", () => {
// Snap the handle back onto the east edge
placeResizeHandle();
options.onResize?.(currentRadius);
});
// Arrow keys resize in steps, committed on key release
let keyboardRadius: number | undefined;
resizeHandle.addEventListener("keydown", (ev) => {
const direction =
ev.key === "ArrowRight" || ev.key === "ArrowUp"
? 1
: ev.key === "ArrowLeft" || ev.key === "ArrowDown"
? -1
: 0;
if (!direction) {
return;
}
ev.preventDefault();
currentRadius = Math.max(
1,
currentRadius * (1 + direction * RESIZE_KEY_STEP)
);
keyboardRadius = currentRadius;
placeResizeHandle();
redraw();
});
const commitKeyboardResize = () => {
if (keyboardRadius !== undefined) {
keyboardRadius = undefined;
options.onResize?.(currentRadius);
}
};
resizeHandle.addEventListener("keyup", commitKeyboardResize);
// Focus can leave while a key is still held
resizeHandle.addEventListener("blur", commitKeyboardResize);
}
// Skip one update echoing the pre-drag values (the save has not returned yet)
let dragging = false;
let staleCenter: MapLatLng | undefined;
let staleRadius: number | undefined;
[centerMarker, resizeMarker].forEach((handleMarker) => {
handleMarker?.on("dragstart", () => {
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
});
handleMarker?.on("dragend", () => {
dragging = false;
});
});
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
staleCenter !== undefined &&
staleRadius !== undefined &&
candidateCenter[0] === staleCenter[0] &&
candidateCenter[1] === staleCenter[1] &&
candidateRadius === staleRadius;
if (options.moveable) {
centerMarker.on("drag", () => {
const lngLat = centerMarker.getLngLat();
currentCenter = [lngLat.lat, lngLat.lng];
placeResizeHandle();
redraw();
});
centerMarker.on("dragend", () => options.onMove?.(currentCenter));
}
// The center element may be reused for a rebuilt circle; its listeners go with this one
let removeCenterListeners: (() => void) | undefined;
if (options.onClick) {
// A drag can end in a click; only one with its own pointer down counts
let dragged = false;
centerMarker.on("dragstart", () => {
dragged = true;
});
const onPointerDown = () => {
dragged = false;
};
const onClick = (ev: MouseEvent) => {
ev.stopPropagation();
if (dragged) {
return;
}
options.onClick!();
};
const onKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter") {
options.onClick!();
}
};
centerEl.addEventListener("pointerdown", onPointerDown);
centerEl.addEventListener("click", onClick);
centerEl.addEventListener("keydown", onKeydown);
removeCenterListeners = () => {
centerEl.removeEventListener("pointerdown", onPointerDown);
centerEl.removeEventListener("click", onClick);
centerEl.removeEventListener("keydown", onKeydown);
};
}
return {
get center() {
return currentCenter;
},
get radius() {
return currentRadius;
},
update: (newCenter, newRadius) => {
if (dragging) {
return;
}
const stale = isStale(newCenter, newRadius);
staleCenter = undefined;
staleRadius = undefined;
if (stale) {
return;
}
currentCenter = newCenter;
currentRadius = newRadius;
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
placeResizeHandle();
redraw();
},
remove: () => {
if (frame !== undefined) {
cancelAnimationFrame(frame);
}
removeCenterListeners?.();
centerMarker.remove();
resizeMarker?.remove();
this._removeCustomLayer(`${id}-fill`);
this._removeCustomLayer(`${id}-line`);
this._removeCustomSource(id);
},
};
}
public addPath(path: MapPath): MapItemHandle {
if (!this._map || !this._maplibre) {
return { remove: () => undefined };
@@ -767,6 +1072,17 @@ export class MapLibreMapEngine implements MapEngine {
});
}
private _setCustomSourceData(
id: string,
data: Feature<Polygon> | FeatureCollection
): void {
const source = this._customSources.get(id);
if (source) {
source.data = data;
}
(this._map?.getSource(id) as GeoJSONSource | undefined)?.setData(data);
}
private _addCustomLayer(layer: LayerSpecification): void {
this._whenStyleLoaded(() => {
// Under the labels, over the base cartography
+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);
};
+93 -33
View File
@@ -1,27 +1,14 @@
/**
* Engine abstraction for ha-map.
* 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`.
*
* ha-map keeps all Home Assistant semantics (entities, zones, cluster bubble
* DOM, history path math, fit policy) and delegates the primitive map
* operations to a MapEngine, so the engine can be selected at runtime:
* MapLibre GL native where WebGL2 is available, Leaflet otherwise (and always
* for ha-locations-editor, which edits with leaflet-draw).
*
* The interface exposes no engine types: positions are [latitude, longitude]
* tuples and marker content is caller-owned HTML elements.
*
* Zoom levels use Leaflet semantics (zoom 0 = one world tile), the historical
* convention across Home Assistant map configs. The MapLibre engine converts
* internally (MapLibre zoom = Leaflet zoom - 1).
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: number];
export interface MapPoint {
x: number;
y: number;
}
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
@@ -109,6 +96,58 @@ export interface MapMarkerHandle extends MapItemHandle {
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];
@@ -159,13 +198,13 @@ export interface MapEngine {
/** Fit the given points into view; a single point centers on it */
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
// Content ------------------------------------------------------------
/** Pan to the location, keeping the zoom */
panTo(location: MapLatLng): void;
/**
* Place an HTML element on the map. The element is owned by the caller;
* the engine positions it and, for interactive markers, makes it
* focusable.
*/
/** 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,
@@ -175,24 +214,45 @@ export interface MapEngine {
/** 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;
/**
* Enable or disable clustering of the markers added with cluster: true.
* Must be called after each batch of addMarker calls to place clusterable
* markers on the map; null places them unclustered.
*/
/** 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;
}
/**
* Bounding box corners of a circle, for fitting a radius into view without
* engine-specific circle bounds.
*/
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
+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;
}
`;
+14 -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}
>
${
@@ -94,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%;
+155 -226
View File
@@ -1,24 +1,24 @@
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
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 { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import type { ThemeMode } from "../../types";
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,28 +65,12 @@ 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);
@@ -97,43 +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
engine="leaflet"
.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>`
@@ -142,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.
@@ -207,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]),
@@ -249,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;
+590 -118
View File
@@ -1,9 +1,8 @@
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { Layer } from "leaflet";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
import { css, ReactiveElement, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { formatDateTime } from "../../common/datetime/format_date_time";
import {
@@ -17,9 +16,9 @@ import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsWebGL2 } from "../../common/map/base-layer";
import type { LeafletMapEngine } from "../../common/map/engines/leaflet-map-engine";
import type {
MapClusterIcon,
MapControlPosition,
MapEngine,
MapItemHandle,
MapLatLng,
@@ -27,8 +26,22 @@ import type {
MapPath,
MapPathMarker,
MapPathSegment,
MapEditableCircleHandle,
MapEditableMarkerHandle,
MapEditingSupport,
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { editableCircleStyles } from "../../common/map/editable-circle";
import {
entityMapColor,
subscribeEntityMapColors,
zoneColor,
} from "../../common/map/entity-map-colors";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
zoneMarkerStyles,
} from "../../common/map/zone-marker";
import { filterXSS } from "../../common/util/xss";
import {
configContext,
@@ -82,6 +95,112 @@ export const MAP_CARD_MARKER_LABEL_MODES = [
export type MapCardMarkerLabelMode =
(typeof MAP_CARD_MARKER_LABEL_MODES)[number];
/** A location drawn for editing: a draggable marker, or a circle with a moveable center and radius */
export interface HaMapEditableLocation {
id: string;
location: MapLatLng;
radius?: number;
/** Element shown at the location, e.g. a named icon */
element?: HTMLElement;
elementSize?: [width: number, height: number];
title?: string;
color?: string;
locationEditable?: boolean;
radiusEditable?: boolean;
}
// Geometry is updated in place; a change to anything else rebuilds the marker
const sameAppearance = (
a: HaMapEditableLocation,
b: HaMapEditableLocation
): boolean =>
a.element === b.element &&
a.title === b.title &&
a.color === b.color &&
a.locationEditable === b.locationEditable &&
a.radiusEditable === b.radiusEditable &&
a.elementSize?.[0] === b.elementSize?.[0] &&
a.elementSize?.[1] === b.elementSize?.[1];
// Without editing support (the Leaflet fallback) locations are static and redrawn on edits
const staticEditing = (engine: MapEngine): MapEditingSupport => ({
addDraggableMarker: (element, location, options) => {
let current = location;
let marker = engine.addMarker(element, current, options);
return {
get location() {
return current;
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
marker.remove();
current = newLocation;
marker = engine.addMarker(element, current, options);
},
remove: () => marker.remove(),
};
},
addEditableCircle: (center, options) => {
const centerEl = options.centerElement ?? document.createElement("div");
if (!options.centerElement) {
centerEl.className = "editable-circle-center";
}
// The center element may be reused for a rebuilt circle; the listener goes with this one
let removeClick: (() => void) | undefined;
if (options.onClick) {
const onClick = (ev: Event) => {
ev.stopPropagation();
options.onClick!();
};
centerEl.addEventListener("click", onClick);
removeClick = () => centerEl.removeEventListener("click", onClick);
}
let current = { center, radius: options.radius };
let items: MapItemHandle[] = [];
const draw = () => {
items = [
engine.addCircle(current.center, {
radius: current.radius,
color: options.color,
}),
engine.addMarker(centerEl, current.center, {
size: options.centerSize ?? [16, 16],
interactive: !!options.onClick,
title: options.title,
}),
];
};
draw();
return {
get center() {
return current.center;
},
get radius() {
return current.radius;
},
update: (newCenter, newRadius) => {
items.forEach((item) => item.remove());
current = { center: newCenter, radius: newRadius };
draw();
},
remove: () => {
removeClick?.();
items.forEach((item) => item.remove());
},
};
},
});
declare global {
interface HASSDomEvents {
"editable-location-moved": { id: string; location: MapLatLng };
"editable-location-resized": { id: string; radius: number };
"editable-location-clicked": { id: string };
/** Whether the loaded engine can edit; false on the Leaflet fallback */
"editing-available-changed": { available: boolean };
}
}
export interface HaMapEntity {
entity_id: string;
color: string;
@@ -90,9 +209,35 @@ export interface HaMapEntity {
unit?: string;
name?: string;
focus?: boolean;
hide_accuracy?: boolean;
hide_radius?: boolean;
selected?: boolean;
}
// Data carried by entity markers for rendering cluster bubbles
interface ClusterData {
entityId: string;
picture?: string;
label: string;
color?: string;
selected: boolean;
zoneId?: string;
}
const CLUSTER_AVATAR_SIZE = 32;
const CLUSTER_BUBBLE_PADDING = 6;
const CLUSTER_BUBBLE_GAP = 4;
const CLUSTER_MAX_AVATARS = 3;
const CLUSTER_MORE_WIDTH = 28;
const CLUSTER_MORE_MAX = 99;
const CLUSTER_TAIL_SIZE = 10;
// The tail is a rotated square on the bubble's bottom edge, reaching half its diagonal below
const CLUSTER_TAIL_HEIGHT = Math.round((CLUSTER_TAIL_SIZE * Math.SQRT2) / 2);
const CLUSTER_ZONE_SPACING = 2;
const CLUSTER_RADIUS = 40;
// Same-zone markers share the zone's bubble while they span at most this many
// pixels; further apart they show their actual positions
const ZONE_GROUP_RADIUS = 160;
@customElement("ha-map")
export class HaMap extends ReactiveElement {
@@ -127,17 +272,9 @@ export class HaMap extends ReactiveElement {
@property({ attribute: false }) public paths?: HaMapPaths[];
/**
* Raw Leaflet layers, for ha-locations-editor only. Requires
* engine="leaflet".
*/
@property({ attribute: false }) public layers?: Layer[];
/**
* Which map engine to use. "leaflet" is required by embedders that manage
* raw Leaflet layers (ha-locations-editor); "auto" picks the best engine.
*/
@property() public engine: "auto" | "leaflet" = "auto";
/** Locations drawn with drag handles for editing (ha-locations-editor) */
@property({ attribute: false })
public editableLocations?: HaMapEditableLocation[];
@property({ type: Boolean }) public clickable = false;
@@ -151,6 +288,11 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
@property({ attribute: "zoom-position" })
public zoomPosition: MapControlPosition = "topleft";
private _zonePositions: Record<string, MapLatLng> = {};
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@@ -168,14 +310,23 @@ export class HaMap extends ReactiveElement {
private _engine?: MapEngine;
/** Escape hatch for ha-locations-editor; only set with engine="leaflet" */
public get leafletMap() {
// Only the Leaflet engine has this property
return (this._engine as Partial<LeafletMapEngine> | undefined)?.leafletMap;
}
// Reconciled by id, so updates move handles instead of recreating them
private _editableHandles = new Map<
string,
(
| { kind: "circle"; handle: MapEditableCircleHandle }
| { kind: "marker"; handle: MapEditableMarkerHandle }
) & {
source: HaMapEditableLocation;
/** Detaches what ha-map itself put on the caller's element */
cleanup?: () => void;
}
>();
private _resizeObserver?: ResizeObserver;
private _unsubscribeColors?: () => void;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -201,6 +352,21 @@ export class HaMap extends ReactiveElement {
super.connectedCallback();
this._loadMap();
this._attachObserver();
this._subscribeColors();
}
private _subscribeColors(): void {
if (this._unsubscribeColors || !this._connection?.connection) {
return;
}
this._unsubscribeColors = subscribeEntityMapColors(
this._connection.connection,
() => {
if (this._loaded) {
this._drawEntities();
}
}
);
}
private _handleVisibilityChange = async () => {
@@ -217,6 +383,8 @@ export class HaMap extends ReactiveElement {
"visibilitychange",
this._handleVisibilityChange
);
this._unsubscribeColors?.();
this._unsubscribeColors = undefined;
this._engine?.destroy();
this._engine = undefined;
// An engine still setting up goes too; its setup notices and stops
@@ -227,6 +395,7 @@ export class HaMap extends ReactiveElement {
this._entityHandles = [];
this._zoneHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
this._focusZonePoints = [];
@@ -242,6 +411,10 @@ export class HaMap extends ReactiveElement {
protected update(changedProps: PropertyValues) {
super.update(changedProps);
if (changedProps.has("_connection")) {
this._subscribeColors();
}
if (!this._loaded) {
return;
}
@@ -268,6 +441,10 @@ export class HaMap extends ReactiveElement {
this._drawEntities();
}
if (changedProps.has("zoomPosition")) {
this._engine?.setZoomControlPosition(this.zoomPosition);
}
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
if (
changedProps.has("_loaded") ||
@@ -280,14 +457,27 @@ export class HaMap extends ReactiveElement {
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
// Cluster bubbles show entity colors only while trails are visible
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
if (!!oldPaths?.length !== !!this.paths?.length) {
this._engine?.refreshClusters();
}
}
if (changedProps.has("_loaded") || changedProps.has("layers")) {
this._drawLayers(changedProps.get("layers") as Layer[] | undefined);
autoFitRequired = true;
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
// Added or removed locations refit; edits keep the view
if (this._drawEditableLocations()) {
autoFitRequired = true;
}
}
if (changedProps.has("_loaded") || (this.autoFit && autoFitRequired)) {
if (changedProps.has("_loaded") && this._pendingFit) {
// A fit requested before the engine was ready wins over the default
this._runPendingFit();
} else if (
changedProps.has("_loaded") ||
(this.autoFit && autoFitRequired)
) {
this.fitMap();
}
@@ -336,7 +526,7 @@ export class HaMap extends ReactiveElement {
// Each engine is its own chunk; a map only downloads the one it uses
private async _createEngine(): Promise<MapEngine> {
if (this.engine === "leaflet" || this._forceLeaflet || !supportsWebGL2()) {
if (this._forceLeaflet || !supportsWebGL2()) {
const leaflet =
await import("../../common/map/engines/leaflet-map-engine");
return new leaflet.LeafletMapEngine();
@@ -395,7 +585,7 @@ export class HaMap extends ReactiveElement {
darkMode: this._darkMode,
token,
rasterOnly: this._forceLeaflet,
zoomControlPosition: "topleft",
zoomControlPosition: this.zoomPosition,
events: {
click: (location) => this._handleEngineClick(location),
zoomStart: () => {
@@ -422,6 +612,9 @@ export class HaMap extends ReactiveElement {
this._engine = engine;
this._updateMapStyle();
this._loaded = true;
fireEvent(this, "editing-available-changed", {
available: !!engine.editing,
});
} finally {
if (attempt === this._setupAttempt) {
this._loading = false;
@@ -451,6 +644,7 @@ export class HaMap extends ReactiveElement {
this._entityHandles = [];
this._zoneHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
this._focusZonePoints = [];
this._pendingFit = undefined;
@@ -502,7 +696,7 @@ export class HaMap extends ReactiveElement {
if (
!this._focusPoints.length &&
!this._focusZonePoints.length &&
!this.layers?.length
!this.editableLocations?.length
) {
this._withProgrammaticFit(() => {
this._engine!.setView(
@@ -516,22 +710,14 @@ export class HaMap extends ReactiveElement {
const points = [...this._focusPoints, ...this._focusZonePoints];
// Raw Leaflet layers (ha-locations-editor) contribute their bounds
const leafletMap = this.leafletMap;
if (this.layers?.length && leafletMap) {
this.layers.forEach((layer: any) => {
if ("getBounds" in layer) {
const layerBounds = layer.getBounds();
points.push(
[layerBounds.getSouth(), layerBounds.getWest()],
[layerBounds.getNorth(), layerBounds.getEast()]
);
} else if ("getLatLng" in layer) {
const latLng = layer.getLatLng();
points.push([latLng.lat, latLng.lng]);
}
});
}
// Editable locations contribute their bounds, radius included
this.editableLocations?.forEach((editable) => {
if (editable.radius) {
points.push(...circleBoundsPoints(editable.location, editable.radius));
} else {
points.push(editable.location);
}
});
this._withProgrammaticFit(() => {
this._engine!.fitBounds(points, {
@@ -565,16 +751,37 @@ export class HaMap extends ReactiveElement {
}
}
public panTo(location: MapLatLng): void {
this._engine?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this._engine?.containsLocation(location) ?? false;
}
public setView(center: MapLatLng, zoom?: number): void {
if (!this._engine) {
this._pendingFit = () => this.setView(center, zoom);
return;
}
this._pendingFit = undefined;
this._engine.setView(center, zoom);
}
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
) {
if (!this._engine) {
// Engine still loading (see _loadMap); runs once it is
this._pendingFit = () => this.fitBounds(boundingbox, options);
return;
}
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
return;
}
// An explicit fit is user intent; the reset focus control resumes auto-fit
this._pauseAutoFit = true;
this._withProgrammaticFit(() => {
this._engine!.fitBounds(boundingbox, {
maxZoom: options?.zoom || this.zoom,
@@ -585,20 +792,128 @@ export class HaMap extends ReactiveElement {
this._hasFitted = true;
}
private _drawLayers(prevLayers: Layer[] | undefined): void {
if (prevLayers) {
prevLayers.forEach((layer) => layer.remove());
// Returns whether locations were added or removed
private _drawEditableLocations(): boolean {
const engine = this._engine;
if (!engine) {
return false;
}
if (!this.layers) {
return;
const staticSupport = staticEditing(engine);
const editing = engine.editing ?? staticSupport;
const wanted = new Set((this.editableLocations ?? []).map((e) => e.id));
let changed = false;
for (const [id, entry] of this._editableHandles) {
if (!wanted.has(id)) {
entry.cleanup?.();
entry.handle.remove();
this._editableHandles.delete(id);
changed = true;
}
}
const leafletMap = this.leafletMap;
if (!leafletMap) {
return;
if (!this.editableLocations) {
return changed;
}
this.layers.forEach((layer) => {
leafletMap.addLayer(layer);
});
const defaultColor =
getComputedStyle(this).getPropertyValue("--accent-color");
for (const editable of this.editableLocations) {
const { id } = editable;
const existing = this._editableHandles.get(id);
const kind = editable.radius ? "circle" : "marker";
if (
existing &&
existing.kind === kind &&
sameAppearance(existing.source, editable)
) {
if (existing.kind === "circle") {
existing.handle.update(editable.location, editable.radius!);
} else {
existing.handle.setLocation(editable.location);
}
existing.source = editable;
continue;
}
if (existing) {
existing.cleanup?.();
existing.handle.remove();
} else {
changed = true;
}
if (kind === "circle") {
this._editableHandles.set(id, {
kind,
source: editable,
handle: editing.addEditableCircle(editable.location, {
radius: editable.radius!,
color: editable.color || defaultColor,
centerElement: editable.element,
centerSize: editable.elementSize,
title: editable.title,
moveable: editable.locationEditable,
resizable: editable.radiusEditable,
resizeLabel: editable.title
? this._i18n?.localize("ui.components.map.radius_of", {
name: editable.title,
})
: this._i18n?.localize("ui.components.map.radius"),
onMove: (location) =>
fireEvent(this, "editable-location-moved", { id, location }),
onResize: (radius) =>
fireEvent(this, "editable-location-resized", { id, radius }),
onClick: () => fireEvent(this, "editable-location-clicked", { id }),
}),
});
continue;
}
const element = editable.element ?? document.createElement("div");
if (!editable.element) {
element.className = "editable-circle-center";
}
// A drag can end in a click; only one with its own pointer down counts
let dragged = false;
const onPointerDown = () => {
dragged = false;
};
const onClick = (ev: Event) => {
ev.stopPropagation();
if (dragged) {
return;
}
fireEvent(this, "editable-location-clicked", { id });
};
element.addEventListener("pointerdown", onPointerDown);
element.addEventListener("click", onClick);
// A location that cannot be dragged is static on any engine
const support = editable.locationEditable ? editing : staticSupport;
this._editableHandles.set(id, {
kind,
source: editable,
cleanup: () => {
element.removeEventListener("pointerdown", onPointerDown);
element.removeEventListener("click", onClick);
},
handle: support.addDraggableMarker(element, editable.location, {
size: editable.elementSize ?? [16, 16],
interactive: true,
title: editable.title,
onDragEnd: (location) => {
dragged = true;
fireEvent(this, "editable-location-moved", { id, location });
},
}),
});
}
return changed;
}
// One by one, so the listeners on the caller's elements are detached too
private _removeEditableLocations(): void {
for (const entry of this._editableHandles.values()) {
entry.cleanup?.();
entry.handle.remove();
}
this._editableHandles.clear();
}
private _computePathTooltip(path: HaMapPaths, point: HaMapPathPoint): string {
@@ -759,14 +1074,28 @@ export class HaMap extends ReactiveElement {
}
const computedStyles = getComputedStyle(this);
const zoneColor = computedStyles.getPropertyValue("--accent-color");
const passiveZoneColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const darkPrimaryColor = computedStyles.getPropertyValue(
"--dark-primary-color"
);
// A person's state is "home" for the home zone, the zone name otherwise
const zoneByState: Record<string, string> = {};
this._zonePositions = {};
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
if (stateObj && computeStateDomain(stateObj) === "zone") {
zoneByState[
stateObj.entity_id === "zone.home"
? "home"
: computeStateName(stateObj)
] = stateObj.entity_id;
if (
typeof stateObj.attributes.latitude === "number" &&
typeof stateObj.attributes.longitude === "number"
) {
this._zonePositions[stateObj.entity_id] = [
stateObj.attributes.latitude,
stateObj.attributes.longitude,
];
}
}
}
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
@@ -795,26 +1124,24 @@ export class HaMap extends ReactiveElement {
continue;
}
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
const hideRadius = typeof entity !== "string" && entity.hide_radius;
// A host-set color wins, except passive zones are always muted
const markerColor =
!passive && typeof entity !== "string" && entity.color
? entity.color
: zoneColor(stateObj.entity_id, !!passive, computedStyles);
if (radius) {
if (!hideRadius && radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: zoneMarkerColor })
engine.addCircle(position, { radius, color: markerColor })
);
}
// create icon
const iconEl = document.createElement("div");
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
iconEl.appendChild(el);
} else {
const el = document.createElement("span");
el.textContent = title;
iconEl.appendChild(el);
}
const circleEl = createZoneMarkerElement({
color: markerColor,
icon,
name: title,
});
if (this.interactiveZones) {
const openMoreInfo = (ev: Event) => {
@@ -823,8 +1150,8 @@ export class HaMap extends ReactiveElement {
entityId: stateObj.entity_id,
});
};
iconEl.addEventListener("click", openMoreInfo);
iconEl.addEventListener("keydown", (ev) => {
circleEl.addEventListener("click", openMoreInfo);
circleEl.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
openMoreInfo(ev);
@@ -832,10 +1159,9 @@ export class HaMap extends ReactiveElement {
});
}
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
this._zoneHandles.push(
engine.addMarker(iconEl, position, {
size: [zoneIconSize, zoneIconSize],
engine.addMarker(circleEl, position, {
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
interactive: this.interactiveZones,
title,
})
@@ -845,7 +1171,7 @@ export class HaMap extends ReactiveElement {
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
if (radius) {
if (!hideRadius && radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
this._focusZonePoints.push(position);
@@ -889,19 +1215,40 @@ export class HaMap extends ReactiveElement {
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this._connection.hassUrl(entityPicture)
: "";
const entityColor =
typeof entity !== "string"
? entity.color
: entityMapColor(getEntityId(entity), computedStyles);
entityMarker.entityColor = entityColor;
if (typeof entity !== "string") {
entityMarker.entityColor = entity.color;
entityMarker.selected = entity.selected ?? false;
}
const clusterData: ClusterData = {
entityId: getEntityId(entity),
picture: entityMarker.entityPicture || undefined,
label: entityName,
color: entityColor,
selected: typeof entity !== "string" && (entity.selected ?? false),
zoneId: ["person", "device_tracker"].includes(
computeStateDomain(stateObj)
)
? zoneByState[stateObj.state]
: undefined,
};
const showAccuracy =
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
const markerSize = this._getMarkerSize(computedStyles);
this._entityHandles.push(
engine.addMarker(entityMarker, position, {
size: [markerSize, markerSize],
title,
cluster: true,
// create circle around if entity has accuracy
decoration: gpsAccuracy
? { radius: gpsAccuracy, color: darkPrimaryColor }
clusterData,
decoration: showAccuracy
? { radius: gpsAccuracy!, color: entityColor }
: undefined,
})
);
@@ -913,22 +1260,95 @@ export class HaMap extends ReactiveElement {
engine.setClustering(
this.clusterMarkers
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
? {
radius: CLUSTER_RADIUS,
iconBuilder: this._createClusterBubble,
// Everyone in a zone shares its bubble until zooming spreads them
groupKey: (marker) => (marker.clusterData as ClusterData)?.zoneId,
groupRadius: ZONE_GROUP_RADIUS,
}
: null
);
}
// Renders a marker cluster as a circle with the member count
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
const size = Math.round(
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
);
const element = document.createElement("div");
element.className = "marker-cluster";
const count = document.createElement("span");
count.textContent = String(members.length);
element.appendChild(count);
return { element, size: [size, size] };
// Renders a marker cluster as a bubble of its members' avatars
private _createClusterBubble = (
members: MapMarkerHandle[]
): MapClusterIcon => {
const data = members.map((member) => member.clusterData as ClusterData);
const shown = data.slice(0, CLUSTER_MAX_AVATARS);
const hidden = data.length - shown.length;
// With history trails shown, colored borders match avatars to trails
const showColors = !!this.paths?.length;
const bubble = document.createElement("div");
bubble.className = "cluster-bubble";
for (const member of shown) {
const avatar = document.createElement("ha-entity-marker");
avatar.entityId = member?.entityId;
avatar.entityName = member?.label ?? "";
avatar.entityPicture = member?.picture ?? "";
avatar.entityColor = member?.color;
if (showColors) {
avatar.style.setProperty(
"--ha-marker-color",
member?.color ?? "var(--primary-color)"
);
avatar.style.setProperty("--ha-marker-border-width", "2px");
}
if (member?.selected) {
avatar.selected = true;
}
bubble.appendChild(avatar);
}
let width =
shown.length * CLUSTER_AVATAR_SIZE +
(shown.length - 1) * CLUSTER_BUBBLE_GAP +
2 * CLUSTER_BUBBLE_PADDING;
if (hidden > 0) {
const more = document.createElement("span");
more.className = "more";
more.textContent =
hidden > CLUSTER_MORE_MAX ? `${CLUSTER_MORE_MAX}+` : `+${hidden}`;
bubble.appendChild(more);
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
}
// A cluster of one zone's occupants attaches to that zone's marker
const zoneId = data[0]?.zoneId;
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
const atZone =
!!zoneId &&
!!zonePosition &&
data.every((member) => member?.zoneId === zoneId);
let height = CLUSTER_AVATAR_SIZE + 2 * CLUSTER_BUBBLE_PADDING;
let root: HTMLElement = bubble;
if (atZone) {
root = document.createElement("div");
root.className = "cluster-marker";
const tail = document.createElement("div");
tail.className = "cluster-bubble-tail";
root.append(bubble, tail);
height += CLUSTER_TAIL_HEIGHT;
}
return {
element: root,
size: [width, height],
// Float above the zone circle, tail pointing at it
...(atZone && zonePosition
? {
location: zonePosition,
anchor: [
width / 2,
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
] as [number, number],
}
: {}),
};
};
private _drawScaleRuler(): void {
@@ -1015,8 +1435,9 @@ export class HaMap extends ReactiveElement {
.leaflet-tile-pane .leaflet-tile {
filter: var(--map-filter);
}
/* The adapter path (engine="leaflet" with WebGL2) loads no MapLibre
stylesheet; these are the only two rules its canvas needs. */
/* The Leaflet fallback with WebGL2 renders vectors through the adapter
without MapLibre's stylesheet; these are the only two rules its canvas
needs. */
.maplibregl-map {
position: relative;
overflow: hidden;
@@ -1026,6 +1447,11 @@ export class HaMap extends ReactiveElement {
top: 0;
left: 0;
}
.maplibregl-ctrl-bottom-left,
.maplibregl-ctrl-bottom-right {
/* Lets a card keep the attribution and scale clear of an overlay */
margin-bottom: var(--ha-map-bottom-inset, 0);
}
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
background-color: #1c1c1c;
}
@@ -1058,13 +1484,7 @@ export class HaMap extends ReactiveElement {
.dark .leaflet-bar a:hover {
background-color: #313131;
}
.leaflet-marker-draggable {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: var(--ha-border-radius-circle);
cursor: nesw-resize !important;
}
${unsafeCSS(editableCircleStyles)}
.named-icon {
display: flex;
align-items: center;
@@ -1073,19 +1493,67 @@ export class HaMap extends ReactiveElement {
text-align: center;
color: var(--primary-text-color);
}
.named-icon.draggable {
cursor: move;
}
.leaflet-pane {
z-index: 0 !important;
}
/* Zone icons, sized like the Leaflet divIcon they replaced */
.zone-icon {
.cluster-marker {
display: flex;
flex-direction: column;
align-items: center;
}
.cluster-bubble-tail {
width: ${CLUSTER_TAIL_SIZE}px;
height: ${CLUSTER_TAIL_SIZE}px;
margin-top: ${-CLUSTER_TAIL_SIZE / 2}px;
border-radius: 2px;
background: var(--card-background-color, #fff);
transform: rotate(45deg);
}
.cluster-bubble {
display: flex;
align-items: center;
gap: ${CLUSTER_BUBBLE_GAP}px;
padding: ${CLUSTER_BUBBLE_PADDING}px;
box-sizing: border-box;
background: var(--card-background-color, #fff);
border-radius: 14px;
box-shadow: var(--ha-box-shadow-s);
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
--ha-marker-color: transparent;
--ha-marker-border-width: 1px;
--ha-marker-shadow: none;
--ha-marker-font-size: var(--ha-font-size-s);
/* distinguish letter tiles from the bubble background */
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
}
.cluster-bubble .more {
flex: none;
width: ${CLUSTER_MORE_WIDTH}px;
height: ${CLUSTER_AVATAR_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--primary-text-color);
border-radius: 10px;
background: var(--ha-color-fill-neutral-quiet-resting, #f0f0f0);
color: var(--primary-text-color, #212121);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
}
.zone-icon.dark {
color: #ffffff;
/* Markers are rounded squares to match the cluster bubble avatars */
ha-entity-marker {
--ha-marker-border-radius: var(--ha-border-radius-lg);
}
.cluster-bubble ha-entity-marker {
flex: none;
--ha-marker-border-radius: 10px;
}
${unsafeCSS(zoneMarkerStyles)}
.leaflet-bottom {
/* Lets a card keep the attribution and scale clear of an overlay */
margin-bottom: var(--ha-map-bottom-inset, 0);
}
.leaflet-control,
.leaflet-top,
@@ -1137,18 +1605,22 @@ export class HaMap extends ReactiveElement {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster {
box-sizing: border-box;
.marker-cluster div {
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
width: calc(var(--ha-marker-size, 48px) * 0.667);
height: calc(var(--ha-marker-size, 48px) * 0.667);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
align-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}
.marker-cluster span {
line-height: var(--ha-line-height-expanded);
}
`;
}
+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[]
+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;
}
+227 -17
View File
@@ -3,14 +3,15 @@ import {
mdiGoogleCirclesCommunities,
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
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 +30,16 @@ import type {
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[] {
@@ -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 -2
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…",
@@ -9318,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();
});
});
});
+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);
-25
View File
@@ -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"
@@ -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"