Compare commits

...
Author SHA1 Message Date
Maarten LakerveldandPetar Petrov b0ee0d811c Load MapLibre as a namespace and register its worker URL
MapLibre 6 has no default export, so the engine takes the module
namespace instead of .default and points MapLibre at the worker file
served from /static/map/. Both also work with 5.x.
2026-09-09 15:26:35 +03:00
Maarten LakerveldandPetar Petrov 0c15c9b953 Sort zones alphabetically with home first
The zone config page listed UI zones sorted and YAML zones after them in
state order; the map overview sorted all zones by name. Both now show the
home zone first and the rest alphabetically.
2026-09-09 15:26:35 +03:00
Maarten LakerveldandPetar Petrov 12f8ca0260 Show zone markers in the zone list
The zones list on the zone config page shows each zone as it looks on
the map: its color with its icon, or initials without one, instead of
a plain icon.
2026-09-09 15:26:35 +03:00
Maarten LakerveldandPetar Petrov 0ed04ab288 Show a zone's own icon and color on the zone dialog map
The location selector's icon option was never applied: the expression
parsed as (icon || radius) ? radius-icon : marker-icon, so the zone
dialog always showed the radius marker instead of the zone's icon. The
selector also gains a color option, and both zone dialogs pass the
zone's icon and its map color (the config panel hands the dialog the
zone's entity id for that), so the map in the dialog looks like the zone
does everywhere else, including the home zone and a live passive toggle.
2026-09-09 15:26:35 +03:00
Maarten LakerveldandPetar Petrov 0798eba0c7 Color activity timeline dots by zone
Zone changes in a person's or device's activity timeline show the dot in
that zone's color, the same color as its marker on the map and its row
in the zones tab; away and unknown stay grey. Arrivals and departures in
a zone's own timeline keep their green and grey.
2026-09-09 15:26:35 +03:00
Maarten LakerveldandPetar Petrov ed6a9eda7e Report activity that could not load, and list only events inside the window
A failed history request, or an instance without the history integration,
read as no recent activity; the panel now says the activity could not be
loaded. The first history sample is the state at the window's start, not
a change inside it, so a person who has not moved for days no longer shows
that old state as recent activity: samples before the window only seed
the previous state, for zones and people alike.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov cef275d4b6 Keep a focused marker clear of the overview
Focusing a person or zone fitted the full viewport, so the marker could
land under the overview: the start side on desktop, the bottom sheet on
phones. Fits now take viewport padding, both engines apply it per fit,
and the card passes the overview's size as padding on the side it covers,
honoring right-to-left layouts.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov a47fa8d0ef Reuse the cards' show-more-info label in the map overview
The overview added its own copy of a label the other cards already have
translated; it uses the shared key and the duplicate is gone.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov dba66b9bee Recolor history trails when entity colors change
Trail colors are computed inside the memoized history paths, keyed on the
config and the history, so an entity color update from the registry left
the trails in their old colors until new history arrived. A color version
bumped by the subscription is now part of the memo key.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 0ce163e0d4 Pause auto-fit before a deferred fit, and never select an empty tab
An explicit fit that had to wait for the engine or for a size did not
pause auto-fit until it ran, so an auto-fit queued in between could take
its place. The pause comes first now.

The overview's selected tab could be an empty, disabled one, showing an
empty list while another tab had content and leaving aria-selected on a
disabled button; the selection falls back to the first tab with items,
which the tab stop already did.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 4776fe55a2 Resubscribe the map card's colors when the connection changes
The card kept its entity color subscription on whichever connection it
first saw. It now remembers that connection and subscribes anew when
hass brings a different one, as ha-map already does.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 4ad3f4fac7 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-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov a47eee4357 Count entity color subscriptions from the listener set
The listener set deduplicated a reused callback while a separate counter
counted every call, so the two could disagree and the shared registry
stream could leak or be released early. Each subscription is now its own
listener, the count is the set's size, and unsubscribing twice is a no-op.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov b3f4bd6e8c Redraw markers on a theme change, and test the entity color assignment
Marker and trail colors are resolved from theme variables when drawn, but
a theme change only swapped the base style, so the old palette stayed on
the markers until something else redrew them. Any change to the themes
now redraws entities and trails.

The color assignment gets unit tests: creation order per domain, ties
broken by registry id, the home zone kept out of the palette, muted
passive zones, a stable fallback for entities outside the registry, and
one shared registry stream released with the last subscriber.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 9d04280a30 Fall back to the palette color for entities without one, recolor zones on theme change
An entity object without a color reached the accuracy circle's paint
with an undefined color; the map's own palette color fills in. The zone
page's markers are memoized on their data, while their colors come from
theme variables, so a theme change now bumps the color version and
recolors them at once.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 5802c00af1 Drop the numeric cluster badge styles, follow a replaced connection for colors
Clusters are avatar bubbles now, so the styles for the old count badge
had nothing left to style. The shared entity color subscription is a
module-level singleton keyed to whichever connection subscribed first; a
map arriving with a different connection now takes the stream over
instead of reading colors from the old one.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov a94860739f Tell the user when a dragged zone edit fails to save
A failed save dropped the pending edit and rethrew, which no caller
awaited, leaving an unhandled rejection and a marker that silently moved
back. The page now shows the error in a dialog instead.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 98e5944960 Address code review on the marker redesign
The selected ring's color came from an inline outline-color that beat the
rule's outline shorthand by precedence, which reads as if the shorthand
won; the inline style now sets a variable the rule consumes, with the
primary color as fallback. A map whose connection context changes
resubscribes to the entity colors instead of staying bound to the old
connection. The zone page's willUpdate called the wrong lifecycle hook on
its base class.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 9bb2cf9939 Subscribe to entity colors only for maps that draw entities
Every map with a connection subscribed to the entity registry for the
creation-order colors, including editor maps that never draw an entity.
Onboarding's location picker is one, and its mocked server rejects the
registry request, failing the onboarding end-to-end tests. The
subscription now starts when a map first draws entities.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 1db784bcf5 Keep dragged values on the zone page until they are saved
The engine used to guess at the host's save state: after a drag it
skipped one update that matched the pre-drag values, taking it for a
re-render with data the save had not yet replaced. That guess is wrong
when a save fails, since the old values are then the truth and the
marker stayed at an unsaved position. The engine now applies every host
update except while a drag is in progress.

The zone page, which knows when it is saving, keeps the dragged values
as pending edits and shows them until the saved data carries them; a
failed save drops them and the marker returns.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov e68392377d Drop the editor's drag classes
The engine sets the move cursor on the elements it actually drags, so the
zone marker and the named icon no longer carry a class for it, and the
editor's element cache no longer varies with editability.
2026-09-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov ec2e902a66 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-09 15:26:34 +03:00
Maarten LakerveldandPetar Petrov 72936f3566 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-09 15:26:34 +03:00
22 changed files with 2520 additions and 330 deletions
+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"]),
],
},
});
}),
});
@@ -186,6 +186,11 @@ export class LeafletMapEngine implements MapEngine {
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
paddingBottomRight: [
options?.padding?.right ?? 0,
options?.padding?.bottom ?? 0,
],
});
}
+26 -41
View File
@@ -9,7 +9,7 @@ import type {
Marker as MapLibreMarker,
StyleSpecification,
} from "maplibre-gl";
import type maplibregl from "maplibre-gl";
import type * as maplibregl from "maplibre-gl";
import {
clearMarkerAccessibility,
setMarkerAccessibility,
@@ -17,6 +17,7 @@ import {
import {
CONTEXT_RESTORE_GRACE,
ensureRTLTextPlugin,
ensureWorkerUrl,
loadStyle,
MAP_MAX_ZOOM,
MAP_MIN_ZOOM,
@@ -123,8 +124,6 @@ interface ManagedMarker {
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;
@@ -209,8 +208,9 @@ export class MapLibreMapEngine implements MapEngine {
if (options.rasterOnly) {
throw new Error("The MapLibre engine cannot render without WebGL");
}
const maplibre = (await import("maplibre-gl")).default;
const maplibre = await import("maplibre-gl");
this._maplibre = maplibre;
ensureWorkerUrl(maplibre.setWorkerUrl);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
// MapLibre's stylesheet for controls and popups; one link per root
@@ -518,13 +518,26 @@ export class MapLibreMapEngine implements MapEngine {
options?.maxZoom !== undefined
? options.maxZoom - ZOOM_OFFSET
: undefined;
// Passed per fit: easeTo's padding would stick to the map
const padding = {
top: options?.padding?.top ?? 0,
right: options?.padding?.right ?? 0,
bottom: options?.padding?.bottom ?? 0,
left: options?.padding?.left ?? 0,
};
if (minLat === maxLat && minLng === maxLng) {
// Zero-area bounds: center on the point
this._map.easeTo({
center: [minLng, minLat],
zoom: maxZoom ?? this._map.getZoom(),
animate: options?.animate,
});
// Zero-area bounds: center on the point, keeping the zoom unless given
this._map.fitBounds(
[
[minLng, minLat],
[minLng, minLat],
],
{
maxZoom: maxZoom ?? this._map.getZoom(),
animate: options?.animate,
padding,
}
);
return;
}
const pad = options?.pad ?? 0.5;
@@ -535,7 +548,7 @@ export class MapLibreMapEngine implements MapEngine {
[minLng - lngPad, minLat - latPad],
[maxLng + lngPad, maxLat + latPad],
],
{ maxZoom, animate: options?.animate }
{ maxZoom, animate: options?.animate, padding }
);
}
@@ -600,19 +613,10 @@ export class MapLibreMapEngine implements MapEngine {
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
// The user's hand wins while dragging; the host is the truth otherwise
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]]);
},
@@ -663,7 +667,6 @@ export class MapLibreMapEngine implements MapEngine {
if (managed.draggable) {
managed.mlMarker.on("dragstart", () => {
managed.dragging = true;
managed.staleLocation = managed.location;
});
managed.mlMarker.on("dragend", () => {
managed.dragging = false;
@@ -801,10 +804,8 @@ export class MapLibreMapEngine implements MapEngine {
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// Skip one update echoing the pre-edit values (the save has not returned yet)
// The user's hand wins while dragging; the host is the truth otherwise
let dragging = false;
let staleCenter: MapLatLng | undefined;
let staleRadius: number | undefined;
if (options.resizable) {
const east = pointEastOf(center, options.radius);
@@ -847,8 +848,6 @@ export class MapLibreMapEngine implements MapEngine {
if (keyboardRadius === undefined) {
// Host updates treat a key resize like a drag
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
}
currentRadius = Math.max(
1,
@@ -875,19 +874,11 @@ export class MapLibreMapEngine implements MapEngine {
[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", () => {
@@ -944,12 +935,6 @@ export class MapLibreMapEngine implements MapEngine {
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]]);
+105
View File
@@ -0,0 +1,105 @@
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 unsubscribe: UnsubscribeFunc | undefined;
let subscribedConnection: Connection | 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 => {
// Each subscription is its own entry, so a callback subscribed twice
// counts twice and an unsubscribe only ever removes itself
const listener = () => onChange();
listeners.add(listener);
// One registry stream, shared by every map; a replaced connection takes over
if (!unsubscribe || subscribedConnection !== connection) {
unsubscribe?.();
subscribedConnection = connection;
unsubscribe = subscribeEntityRegistry(connection, rebuildIndex);
}
return () => {
if (!listeners.delete(listener)) {
return;
}
if (listeners.size === 0 && unsubscribe) {
unsubscribe();
unsubscribe = undefined;
subscribedConnection = 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);
};
+9
View File
@@ -42,6 +42,15 @@ export interface MapFitOptions {
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
padding?: MapFitPadding;
}
export interface MapFitPadding {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface MapMarkerOptions {
+67
View File
@@ -0,0 +1,67 @@
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;
}
`;
@@ -127,11 +127,12 @@ export class HaLocationSelector extends LitElement {
? this.hass.config.longitude
: value.longitude,
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
radius_color: zoneRadiusColor,
radius_color: selector.location?.color || zoneRadiusColor,
icon:
selector.location?.icon || selector.location?.radius
selector.location?.icon ||
(selector.location?.radius
? "mdi:map-marker-radius"
: "mdi:map-marker",
: "mdi:map-marker"),
location_editable: true,
radius_editable:
!!selector.location?.radius && !selector.location?.radius_readonly,
+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({ "--ha-marker-selected-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(--ha-marker-selected-color, var(--primary-color));
}
.entity-picture {
background-size: cover;
height: 100%;
+26 -6
View File
@@ -16,6 +16,10 @@ import "./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
@@ -152,7 +156,9 @@ export class HaLocationsEditor extends LitElement {
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
elementSize: location.radius
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
@@ -166,29 +172,43 @@ export class HaLocationsEditor extends LitElement {
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const isZone = !!location.radius;
const key = JSON.stringify([
isZone,
location.icon,
location.iconPath,
location.name,
location.location_editable,
location.radius_color,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
const element = this._createIcon(location);
// The zone marker doubles as the circle's draggable center
const element = isZone
? this._createZoneMarker(location)
: this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
private _createZoneMarker(location: MarkerLocation): HTMLElement {
return createZoneMarkerElement({
color:
location.radius_color ||
getComputedStyle(this).getPropertyValue("--accent-color"),
icon: location.icon,
iconPath: location.iconPath,
name: location.name ?? "",
});
}
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" : ""
}`;
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
+301 -68
View File
@@ -18,7 +18,9 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsWebGL2 } from "../../common/map/base-layer";
import type {
MapClusterIcon,
MapControlPosition,
MapEngine,
MapFitPadding,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
@@ -31,6 +33,16 @@ import type {
} 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,
@@ -211,9 +223,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 {
@@ -264,6 +302,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";
@@ -296,6 +339,8 @@ export class HaMap extends ReactiveElement {
private _resizeObserver?: ResizeObserver;
private _unsubscribeColors?: () => void;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -321,6 +366,27 @@ export class HaMap extends ReactiveElement {
super.connectedCallback();
this._loadMap();
this._attachObserver();
this._subscribeColors();
}
// Only maps that draw entities need the registry's creation order; an
// editor's map, as in onboarding, never asks for it
private _subscribeColors(): void {
if (
this._unsubscribeColors ||
!this._connection?.connection ||
!this.entities?.length
) {
return;
}
this._unsubscribeColors = subscribeEntityMapColors(
this._connection.connection,
() => {
if (this._loaded) {
this._drawEntities();
}
}
);
}
private _handleVisibilityChange = async () => {
@@ -337,6 +403,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
@@ -363,6 +431,13 @@ export class HaMap extends ReactiveElement {
protected update(changedProps: PropertyValues) {
super.update(changedProps);
if (changedProps.has("_connection")) {
// A new connection needs its own subscription
this._unsubscribeColors?.();
this._unsubscribeColors = undefined;
this._subscribeColors();
}
if (!this._loaded) {
return;
}
@@ -389,6 +464,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") ||
@@ -401,6 +480,11 @@ 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("editableLocations")) {
@@ -433,13 +517,15 @@ export class HaMap extends ReactiveElement {
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") ||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
) {
return;
}
this._updateMapStyle();
// Marker and trail colors were resolved from the theme when drawn
this._drawEntities();
this._drawPaths();
}
private get _darkMode() {
@@ -528,7 +614,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: () => {
@@ -713,8 +799,11 @@ export class HaMap extends ReactiveElement {
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
) {
// An explicit fit is user intent, even while it waits for the engine or
// a size; an auto-fit must not take its place in the meantime
this._pauseAutoFit = true;
if (!this._engine) {
// Engine still loading (see _loadMap); runs once it is
this._pendingFit = () => this.fitBounds(boundingbox, options);
@@ -728,6 +817,7 @@ export class HaMap extends ReactiveElement {
maxZoom: options?.zoom || this.zoom,
pad: options?.pad ?? 0.5,
animate: this._hasFitted,
padding: options?.padding,
});
});
this._hasFitted = true;
@@ -1031,16 +1121,31 @@ export class HaMap extends ReactiveElement {
engine.setClustering(null);
return;
}
this._subscribeColors();
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)];
@@ -1069,26 +1174,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) => {
@@ -1097,8 +1200,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);
@@ -1106,10 +1209,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,
})
@@ -1119,7 +1221,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);
@@ -1163,19 +1265,40 @@ export class HaMap extends ReactiveElement {
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this._connection.hassUrl(entityPicture)
: "";
// A host may leave the color to the map
const entityColor =
(typeof entity !== "string" ? entity.color : undefined) ||
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,
})
);
@@ -1187,22 +1310,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 {
@@ -1301,6 +1497,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;
}
@@ -1346,16 +1547,61 @@ export class HaMap extends ReactiveElement {
.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,
@@ -1406,19 +1652,6 @@ export class HaMap extends ReactiveElement {
ha-icon {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster {
box-sizing: border-box;
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}
`;
}
+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[]
+2
View File
@@ -368,6 +368,8 @@ export interface LocationSelector {
radius?: boolean;
radius_readonly?: boolean;
icon?: string;
/** Marker and radius color; defaults to the theme's zone color */
color?: string;
} | null;
}
@@ -12,14 +12,21 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
import {
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../common/map/entity-map-colors";
const SCHEMA = [
{
name: "location",
required: true,
selector: { location: { radius: true } },
},
];
const SCHEMA = memoizeOne(
(icon: string, color: string) =>
[
{
name: "location",
required: true,
selector: { location: { radius: true, icon, color } },
},
] as const
);
@customElement("dialog-home-zone-detail")
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
@@ -81,7 +88,11 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
<ha-form
autofocus
.hass=${this.hass}
.schema=${SCHEMA}
.schema=${SCHEMA(
this.hass.states[HOME_ZONE_ENTITY_ID]?.attributes.icon ||
"mdi:home",
zoneColor(HOME_ZONE_ENTITY_ID, false, getComputedStyle(this))
)}
.data=${this._formData(this._data)}
.error=${this._error}
.computeLabel=${this._computeLabel}
+13 -3
View File
@@ -15,6 +15,7 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
import { zoneColor } from "../../../common/map/entity-map-colors";
@customElement("dialog-zone-detail")
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
@@ -105,7 +106,16 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
<ha-form
autofocus
.hass=${this.hass}
.schema=${this._schema(this._data.icon)}
.schema=${this._schema(
this._data.icon,
this._params?.entityId
? zoneColor(
this._params.entityId,
!!this._data.passive,
getComputedStyle(this)
)
: undefined
)}
.data=${this._formData(this._data)}
.error=${this._error}
.computeLabel=${this._computeLabel}
@@ -153,7 +163,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
}
private _schema = memoizeOne(
(icon?: string) =>
(icon?: string, color?: string) =>
[
{
name: "name",
@@ -172,7 +182,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
{
name: "location",
required: true,
selector: { location: { radius: true, icon } },
selector: { location: { radius: true, icon, color } },
},
{ name: "passive_note", type: "constant" },
{ name: "passive", selector: { boolean: {} } },
+296 -137
View File
@@ -3,6 +3,7 @@ import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
@@ -23,6 +24,15 @@ import type {
} from "../../../components/map/ha-locations-editor";
import { saveCoreConfig } from "../../../data/core";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import {
HOME_ZONE_ENTITY_ID,
subscribeEntityMapColors,
zoneColor,
} from "../../../common/map/entity-map-colors";
import {
contrastingZoneContent,
zoneInitials,
} from "../../../common/map/zone-marker";
import type {
HomeZoneMutableParams,
Zone,
@@ -47,6 +57,19 @@ import { configSections } from "../config-sections";
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
interface PendingEdit {
latitude?: number;
longitude?: number;
radius?: number;
}
// How close the saved value must come to a pending one to count as saved
const PENDING_TOLERANCE: Record<keyof PendingEdit, number> = {
latitude: 1e-7,
longitude: 1e-7,
radius: 0.5,
};
@customElement("ha-config-zone")
export class HaConfigZone extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -61,21 +84,175 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@state() private _stateItems?: HassEntity[];
// Values dragged on the map, shown until the saved data reflects them, so
// a re-render while the save is in flight does not move the marker back.
// A failed save drops them and the marker returns to the saved values.
@state() private _pendingEdits: Record<string, PendingEdit> = {};
@state() private _canEditCore = false;
@query("ha-locations-editor") private _map?: HaLocationsEditor;
private _regEntities: string[] = [];
private _getZones = memoizeOne(
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
// 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;
// Home first, then alphabetical, for UI and YAML zones alike
private _sortedItems = memoizeOne(
(
storageItems: Zone[],
stateItems: HassEntity[],
language: string
): ({ entry: Zone } | { stateObject: HassEntity })[] => {
const items = [
...storageItems.map((entry) => ({
entry,
name: entry.name,
home: false,
})),
...stateItems.map((stateObject) => ({
stateObject,
name: stateObject.attributes.friendly_name || stateObject.entity_id,
home: stateObject.entity_id === HOME_ZONE_ENTITY_ID,
})),
];
return items.sort((a, b) =>
a.home !== b.home
? a.home
? -1
: 1
: stringCompare(a.name, b.name, language)
);
const homeRadiusColor =
computedStyles.getPropertyValue("--primary-color");
}
);
private _renderStorageItem(entry: Zone) {
const hass = this.hass;
return html`
<ha-list-item
.entry=${entry}
.id=${this.narrow ? entry.id : ""}
graphic="avatar"
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
>
${this._renderZoneGraphic(
this._zoneEntityIds[entry.id] ?? `zone.${entry.id}`,
!!entry.passive,
entry.icon,
entry.name
)}
${entry.name}
${
!this.narrow
? html`
<div slot="meta">
<ha-icon-button
.id=${entry.id}
.entry=${entry}
@click=${this._openEditEntry}
.path=${mdiPencil}
.label=${hass.localize("ui.common.edit_item", {
name: entry.name,
})}
></ha-icon-button>
</div>
`
: ""
}
</ha-list-item>
`;
}
private _renderStateItem(stateObject: HassEntity) {
const hass = this.hass;
return html`
<ha-list-item
graphic="avatar"
.id=${this.narrow ? stateObject.entity_id : ""}
.hasMeta=${!this.narrow || stateObject.entity_id !== "zone.home"}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
.noEdit=${stateObject.entity_id !== "zone.home" || !this._canEditCore}
>
${this._renderZoneGraphic(
stateObject.entity_id,
!!stateObject.attributes.passive,
stateObject.attributes.icon,
stateObject.attributes.friendly_name || stateObject.entity_id
)}
${stateObject.attributes.friendly_name || stateObject.entity_id}
${
this.narrow &&
stateObject.entity_id === "zone.home" &&
!this._canEditCore
? nothing
: html`<ha-icon-button
.id="zone-${slugify(stateObject.entity_id)}"
.entityId=${stateObject.entity_id}
.noEdit=${
stateObject.entity_id !== "zone.home" || !this._canEditCore
}
.path=${
stateObject.entity_id === "zone.home" && this._canEditCore
? mdiPencil
: mdiPencilOff
}
.label=${hass.localize("ui.common.edit_item", {
name: hass.config.location_name,
})}
@click=${this._editHomeZone}
slot="meta"
></ha-icon-button>
<ha-tooltip
.for="zone-${slugify(stateObject.entity_id)}"
placement="left"
.disabled=${stateObject.entity_id === "zone.home"}
hoist
>
${hass.localize("ui.panel.config.zone.configured_in_yaml")}
</ha-tooltip>`
}
</ha-list-item>
`;
}
// The zone as it looks on the map: its color, with its icon or initials
private _renderZoneGraphic(
entityId: string,
passive: boolean,
icon: string | undefined,
name: string
) {
const color = zoneColor(entityId, passive, getComputedStyle(this));
return html`
<div
slot="graphic"
class="zone-avatar"
style=${styleMap({
background: color,
color: contrastingZoneContent(color),
})}
>
${icon ? html`<ha-icon .icon=${icon}></ha-icon>` : zoneInitials(name)}
</div>
`;
}
private _getZones = memoizeOne(
(
storageItems: Zone[],
stateItems: HassEntity[],
zoneEntityIds: Record<string, string>,
pendingEdits: Record<string, PendingEdit>,
_colorVersion: number
): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const stateLocations: MarkerLocation[] = stateItems.map(
(entityState) => ({
@@ -85,12 +262,12 @@ 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,
...pendingEdits[entityState.entity_id],
radius_color: zoneColor(
entityState.entity_id,
!!entityState.attributes.passive,
computedStyles
),
location_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
radius_editable:
@@ -99,7 +276,12 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
...pendingEdits[zone.id],
radius_color: zoneColor(
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
!!zone.passive,
computedStyles
),
location_editable: true,
radius_editable: true,
}));
@@ -109,10 +291,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();
}),
];
@@ -140,100 +333,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
`
: html`
<ha-list>
${this._storageItems.map(
(entry) => html`
<ha-list-item
.entry=${entry}
.id=${this.narrow ? entry.id : ""}
graphic="icon"
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
>
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
${entry.name}
${
!this.narrow
? html`
<div slot="meta">
<ha-icon-button
.id=${entry.id}
.entry=${entry}
@click=${this._openEditEntry}
.path=${mdiPencil}
.label=${hass.localize("ui.common.edit_item", {
name: entry.name,
})}
></ha-icon-button>
</div>
`
: ""
}
</ha-list-item>
`
)}
${this._stateItems.map(
(stateObject) => html`
<ha-list-item
graphic="icon"
.id=${this.narrow ? stateObject.entity_id : ""}
.hasMeta=${
!this.narrow || stateObject.entity_id !== "zone.home"
}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
.noEdit=${
stateObject.entity_id !== "zone.home" ||
!this._canEditCore
}
>
<ha-icon
.icon=${stateObject.attributes.icon}
slot="graphic"
>
</ha-icon>
${
stateObject.attributes.friendly_name ||
stateObject.entity_id
}
${
this.narrow &&
stateObject.entity_id === "zone.home" &&
!this._canEditCore
? nothing
: html`<ha-icon-button
.id="zone-${slugify(stateObject.entity_id)}"
.entityId=${stateObject.entity_id}
.noEdit=${
stateObject.entity_id !== "zone.home" ||
!this._canEditCore
}
.path=${
stateObject.entity_id === "zone.home" &&
this._canEditCore
? mdiPencil
: mdiPencilOff
}
.label=${hass.localize("ui.common.edit_item", {
name: hass.config.location_name,
})}
@click=${this._editHomeZone}
slot="meta"
></ha-icon-button>
<ha-tooltip
.for="zone-${slugify(stateObject.entity_id)}"
placement="left"
.disabled=${stateObject.entity_id === "zone.home"}
hoist
>
${hass.localize(
"ui.panel.config.zone.configured_in_yaml"
)}
</ha-tooltip>`
}
</ha-list-item>
`
${this._sortedItems(
this._storageItems,
this._stateItems,
hass.locale.language
).map((item) =>
"entry" in item
? this._renderStorageItem(item.entry)
: this._renderStateItem(item.stateObject)
)}
</ha-list>
`;
@@ -265,7 +372,10 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<ha-locations-editor
.locations=${this._getZones(
this._storageItems,
this._stateItems
this._stateItems,
this._zoneEntityIds,
this._pendingEdits,
this._colorVersion
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@@ -313,11 +423,64 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
public willUpdate(changedProps: PropertyValues<this>) {
super.updated(changedProps);
super.willUpdate(changedProps);
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && this._stateItems) {
this._getStates(oldHass);
}
// Zone colors come from theme variables
if (oldHass && oldHass.themes !== this.hass.themes) {
this._colorVersion++;
}
this._settlePendingEdits();
}
// A pending edit is done once the saved data carries its values
private _settlePendingEdits() {
for (const [id, pending] of Object.entries(this._pendingEdits)) {
const saved =
this._storageItems?.find((zone) => zone.id === id) ??
this.hass.states[id]?.attributes;
if (
saved &&
(Object.keys(pending) as (keyof PendingEdit)[]).every(
(key) =>
Math.abs((saved[key] as number) - pending[key]!) <
PENDING_TOLERANCE[key]
)
) {
this._dropPendingEdit(id);
}
}
}
private _dropPendingEdit(id: string) {
const { [id]: _done, ...rest } = this._pendingEdits;
this._pendingEdits = rest;
}
private async _saveEdit(id: string, pending: PendingEdit) {
this._pendingEdits = {
...this._pendingEdits,
[id]: { ...this._pendingEdits[id], ...pending },
};
try {
if (id === "zone.home") {
await saveCoreConfig(this.hass, pending);
return;
}
const entry = this._storageItems!.find((item) => item.id === id);
if (entry) {
await this._updateEntry(entry, pending);
}
} catch (err: any) {
// The saved values are the truth again; the marker moves back
this._dropPendingEdit(id);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.zone.can_not_edit"),
text: err.message,
});
}
}
private async _fetchData() {
@@ -359,37 +522,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
private async _locationUpdated(ev: CustomEvent) {
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
latitude: ev.detail.location[0],
longitude: ev.detail.location[1],
});
return;
}
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
if (!entry) {
return;
}
this._updateEntry(entry, {
private _locationUpdated(ev: CustomEvent) {
this._saveEdit(ev.detail.id, {
latitude: ev.detail.location[0],
longitude: ev.detail.location[1],
});
}
private async _radiusUpdated(ev: CustomEvent) {
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
radius: Math.round(ev.detail.radius),
});
return;
}
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
if (!entry) {
return;
}
this._updateEntry(entry, {
radius: ev.detail.radius,
private _radiusUpdated(ev: CustomEvent) {
this._saveEdit(ev.detail.id, {
radius:
ev.detail.id === "zone.home"
? Math.round(ev.detail.radius)
: ev.detail.radius,
});
}
@@ -522,6 +667,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
private async _openDialog(entry?: Zone) {
showZoneDetailDialog(this, {
entry,
entityId: entry ? this._zoneEntityIds[entry.id] : undefined,
createEntry: (values) => this._createEntry(values),
updateEntry: entry
? (values) => this._updateEntry(entry, values, true)
@@ -549,6 +695,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
ha-icon-button:not([disabled]) {
color: var(--secondary-text-color);
}
.zone-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--ha-font-weight-medium);
}
.zone-avatar ha-icon {
color: inherit;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
}
ha-icon-button {
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
}
@@ -3,6 +3,8 @@ import type { Zone, ZoneMutableParams } from "../../../data/zone";
export interface ZoneDetailDialogParams {
entry?: Zone;
/** The zone's entity, when it exists, for its map color */
entityId?: string;
createEntry: (values: ZoneMutableParams) => Promise<unknown>;
updateEntry?: (updates: Partial<ZoneMutableParams>) => Promise<unknown>;
removeEntry?: () => Promise<boolean>;
+277 -20
View File
@@ -3,14 +3,19 @@ import {
mdiGoogleCirclesCommunities,
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type {
Connection,
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";
@@ -28,10 +33,17 @@ import type {
HaMapPaths,
MapCardMarkerLabelMode,
} from "../../../components/map/ha-map";
import type { MapLatLng } from "../../../common/map/map-engine";
import type { MapFitPadding, 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 +60,15 @@ 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;
// Margin around the overview (--ha-space-3), in pixels
const OVERVIEW_GAP = 12;
const FOCUS_PERSON_ZOOM = 19;
const FOCUS_ZONE_MAX_ZOOM = 18;
interface GeoEntity {
entity_id: string;
label_mode?: MapCardMarkerLabelMode;
@@ -62,6 +83,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public layout?: string;
@property({ type: Boolean }) public preview = false;
@state() private _stateHistory?: HistoryStates;
@state()
@@ -74,16 +97,22 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@state() private _mapEntities: HaMapEntity[] = [];
// Bumped when entity colors change, so memoized trail colors recompute
@state() private _colorVersion = 0;
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 _overviewSize = { width: 0, height: 0 };
private _overviewLoaded = false;
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
private _getAllEntities(): string[] {
@@ -209,18 +238,39 @@ 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._overviewSize.height}px`,
})}
.entities=${this._filteredMapEntities}
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
.paths=${this._getHistoryPaths(
this._config,
this._stateHistory,
this._colorVersion
)}
.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 +302,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 +360,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 +398,80 @@ 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;
private _colorsConnection?: Connection;
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;
this._colorsConnection = undefined;
}
private _subscribeColors(): void {
const connection = this.hass?.connection;
if (!connection || this._colorsConnection === connection) {
return;
}
// A replaced connection needs its own subscription
this._unsubscribeColors?.();
this._colorsConnection = connection;
this._unsubscribeColors = subscribeEntityMapColors(connection, () => {
this._mapEntities = this._getMapEntities();
this._colorVersion++;
});
}
private _subscribeHistory() {
@@ -428,22 +554,117 @@ 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<{ width: number; height: number }>
) {
this._overviewSize = ev.detail;
}
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,
padding: this._overviewPadding(),
}
);
return;
}
const location = getEntityLocation(stateObj, this.hass.states);
if (location) {
this._map?.fitBounds([[location.latitude, location.longitude]], {
zoom: FOCUS_PERSON_ZOOM,
padding: this._overviewPadding(),
});
}
}
// The part of the map the overview covers, so a focused marker lands next
// to it rather than under it: the bottom sheet on phones, the start side
// otherwise (see the #overview styles)
private _overviewPadding(): MapFitPadding | undefined {
const { width, height } = this._overviewSize;
if (!width || !height) {
return undefined;
}
if (window.matchMedia("(max-width: 600px)").matches) {
return { bottom: height + OVERVIEW_GAP };
}
const side = width + 2 * OVERVIEW_GAP;
return computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? { right: side }
: { left: side };
}
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[] {
@@ -503,7 +724,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
private _getHistoryPaths = memoizeOne(
(
config: MapCardConfig,
history?: HistoryStates
history: HistoryStates | undefined,
_colorVersion: number
): HaMapPaths[] | undefined => {
if (!history || !(config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW)) {
return undefined;
@@ -595,10 +817,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
+14 -1
View File
@@ -9358,7 +9358,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",
"activity_unavailable": "Activity couldn't be loaded",
"person_arrived": "{name} arrived",
"person_left": "{name} left",
"show_list": "Show list",
"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();
});
});
});
+124
View File
@@ -0,0 +1,124 @@
import type { Connection } from "home-assistant-js-websocket";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
entityMapColor,
HOME_ZONE_ENTITY_ID,
subscribeEntityMapColors,
zoneColor,
} from "../../../src/common/map/entity-map-colors";
// The registry stream is driven by hand so creation order can be played out
const registry = vi.hoisted(() => ({
callback: undefined as ((entries: unknown[]) => void) | undefined,
unsubscribe: vi.fn(),
}));
vi.mock("../../../src/data/entity/entity_registry", () => ({
subscribeEntityRegistry: vi.fn((_conn, onChange) => {
registry.callback = onChange;
return registry.unsubscribe;
}),
}));
// Palette slot N reads back as "color-N", so an index is visible in the result
const styles = {
getPropertyValue: (name: string) => name.replace("--", ""),
} as unknown as CSSStyleDeclaration;
// One connection, as in the app; a different one would take the stream over
const connection = {} as Connection;
const entry = (entityId: string, createdAt: number, id = entityId) => ({
entity_id: entityId,
created_at: createdAt,
id,
});
describe("entity map colors", () => {
let unsubscribe: () => void;
beforeEach(() => {
registry.callback = undefined;
registry.unsubscribe.mockClear();
unsubscribe = subscribeEntityMapColors(connection, () => undefined);
});
afterEach(() => {
unsubscribe();
});
it("colors entities in creation order, per domain", () => {
registry.callback!([
entry("zone.work", 30),
entry("zone.school", 10),
entry("person.anne", 20),
entry("zone.gym", 20),
]);
expect(entityMapColor("zone.school", styles)).toBe("color-1");
expect(entityMapColor("zone.gym", styles)).toBe("color-2");
expect(entityMapColor("zone.work", styles)).toBe("color-3");
// Persons start their own sequence
expect(entityMapColor("person.anne", styles)).toBe("color-1");
});
it("breaks creation ties by registry id", () => {
registry.callback!([
entry("zone.b", 10, "id-b"),
entry("zone.a", 10, "id-a"),
]);
expect(entityMapColor("zone.a", styles)).toBe("color-1");
expect(entityMapColor("zone.b", styles)).toBe("color-2");
});
it("keeps the home zone out of the palette", () => {
registry.callback!([entry(HOME_ZONE_ENTITY_ID, 1), entry("zone.work", 2)]);
expect(entityMapColor("zone.work", styles)).toBe("color-1");
expect(zoneColor(HOME_ZONE_ENTITY_ID, false, styles)).toBe("primary-color");
});
it("mutes passive zones", () => {
registry.callback!([entry("zone.quiet", 1)]);
expect(zoneColor("zone.quiet", true, styles)).toBe("secondary-text-color");
expect(zoneColor("zone.quiet", false, styles)).toBe("color-1");
});
it("gives entities outside the registry a stable color", () => {
registry.callback!([]);
const color = entityMapColor("zone.yaml_zone", styles);
expect(color).toMatch(/^color-\d+$/);
expect(entityMapColor("zone.yaml_zone", styles)).toBe(color);
expect(entityMapColor("zone.other_yaml_zone", styles)).not.toBe(color);
});
it("counts a reused callback per subscription and ignores a repeated unsubscribe", () => {
const shared = vi.fn();
const first = subscribeEntityMapColors(connection, shared);
const second = subscribeEntityMapColors(connection, shared);
registry.callback!([entry("zone.work", 1)]);
expect(shared).toHaveBeenCalledTimes(2);
first();
first();
second();
// The subscription from beforeEach still holds the stream
expect(registry.unsubscribe).not.toHaveBeenCalled();
});
it("shares one registry stream and releases it with the last subscriber", () => {
const notified = vi.fn();
const second = subscribeEntityMapColors(connection, notified);
registry.callback!([entry("zone.work", 1)]);
expect(notified).toHaveBeenCalledOnce();
second();
expect(registry.unsubscribe).not.toHaveBeenCalled();
unsubscribe();
expect(registry.unsubscribe).toHaveBeenCalledOnce();
// Subscribe again in afterEach's stead so its unsubscribe stays balanced
unsubscribe = subscribeEntityMapColors(connection, () => undefined);
});
});
+35 -18
View File
@@ -319,14 +319,13 @@ const fakes = vi.hoisted(() => {
});
vi.mock("maplibre-gl", () => ({
default: {
Map: fakes.FakeMap,
Marker: fakes.FakeMarker,
Popup: fakes.FakePopup,
NavigationControl: vi.fn(),
ScaleControl: vi.fn(),
setRTLTextPlugin: vi.fn(),
},
Map: fakes.FakeMap,
Marker: fakes.FakeMarker,
Popup: fakes.FakePopup,
NavigationControl: vi.fn(),
ScaleControl: vi.fn(),
setRTLTextPlugin: vi.fn(),
setWorkerUrl: vi.fn(),
}));
const loadStyle = vi.hoisted(() => vi.fn());
@@ -334,6 +333,7 @@ vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadStyle,
ensureRTLTextPlugin: vi.fn(),
ensureWorkerUrl: vi.fn(),
}));
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
@@ -604,6 +604,27 @@ describe("MapLibreMapEngine", () => {
});
});
describe("fitting", () => {
it("keeps overlays clear of the fitted bounds", async () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.fitBounds([[52, 4]], { maxZoom: 15, padding: { bottom: 200 } });
expect(map.fitBounds).toHaveBeenCalledOnce();
const [bounds, options] = map.fitBounds.mock.calls[0];
// A single point centers on itself at the requested zoom
expect(bounds[0]).toEqual([4, 52]);
expect(bounds[1]).toEqual([4, 52]);
expect(options.maxZoom).toBe(14);
expect(options.padding).toEqual({
top: 0,
right: 0,
bottom: 200,
left: 0,
});
});
});
describe("markers", () => {
it("hands a removed element back without MapLibre's positioning", async () => {
const { engine, ready } = await createEngine();
@@ -1030,10 +1051,7 @@ describe("MapLibreMapEngine", () => {
expect(handle.radius).toBeCloseTo(110, 5);
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
// The host echoes the old radius once before its save returns
handle.update([52, 4], 100);
expect(handle.radius).toBeCloseTo(110, 5);
// A later identical update is a real change
// Once committed, the host is the truth again
handle.update([52, 4], 100);
expect(handle.radius).toBe(100);
});
@@ -1100,25 +1118,24 @@ describe("MapLibreMapEngine", () => {
expect(element.getAttribute("aria-valuenow")).toBe("150000");
});
it("ignores one update echoing the values from before a drag", async () => {
it("ignores host updates only while a drag is in progress", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, center } = addCircle(engine);
center.lngLat = [4.01, 52];
center.fire("dragstart");
// Nothing moves while a drag is in progress
// The user's hand wins while dragging
handle.update([53, 5], 500);
expect(handle.center).toEqual([52, 4]);
center.fire("drag");
center.fire("dragend");
// The host saves and echoes the old values once; that is not a move back
handle.update([52, 4], 100);
expect(handle.center).toEqual([52, 4.01]);
// A later identical update is a real change
// Afterwards the host is the truth, even when it moves the circle back
handle.update([52, 4], 100);
expect(handle.center).toEqual([52, 4]);
expect(handle.radius).toBe(100);
});
it("activates the center by click, Enter and Space, but not after a drag", async () => {