Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld aab0644c6c 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-07 12:30:58 +02:00
Maarten Lakerveld d518a4102c 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-07 12:30:58 +02:00
Maarten Lakerveld cb56cf7154 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-07 12:30:58 +02:00
Maarten Lakerveld 2ed55ce7a5 Color activity timeline dots by zone
Zone changes in a person's or device's activity timeline show the dot in
that zone's color, the same color as its marker on the map and its row
in the zones tab; away and unknown stay grey. Arrivals and departures in
a zone's own timeline keep their green and grey.
2026-09-07 12:30:58 +02:00
Maarten Lakerveld 1f27c4f95e 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 12:30:58 +02:00
Maarten Lakerveld 11292ab98c 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 12:30:58 +02:00
17 changed files with 2139 additions and 227 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"]),
],
},
});
}),
});
+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);
};
+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;
}
`;
@@ -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,
+11 -2
View File
@@ -24,6 +24,8 @@ 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
@@ -76,13 +78,20 @@ 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, 1px) solid
var(--ha-marker-color, var(--primary-color));
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;
}
:host([selected]) .marker {
border-width: 3px;
}
.entity-picture {
background-size: cover;
height: 100%;
+27 -2
View File
@@ -15,6 +15,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
@@ -144,7 +148,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,
@@ -157,21 +163,40 @@ 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.radius_color,
location.location_editable,
]);
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 {
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;
+291 -57
View File
@@ -18,6 +18,7 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsWebGL2 } from "../../common/map/base-layer";
import type {
MapClusterIcon,
MapControlPosition,
MapEngine,
MapItemHandle,
MapLatLng,
@@ -31,6 +32,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,
@@ -198,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 {
@@ -251,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";
@@ -283,6 +325,8 @@ export class HaMap extends ReactiveElement {
private _resizeObserver?: ResizeObserver;
private _unsubscribeColors?: () => void;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -308,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 () => {
@@ -324,6 +383,8 @@ export class HaMap extends ReactiveElement {
"visibilitychange",
this._handleVisibilityChange
);
this._unsubscribeColors?.();
this._unsubscribeColors = undefined;
this._engine?.destroy();
this._engine = undefined;
this._entityHandles = [];
@@ -345,6 +406,10 @@ export class HaMap extends ReactiveElement {
protected update(changedProps: PropertyValues) {
super.update(changedProps);
if (changedProps.has("_connection")) {
this._subscribeColors();
}
if (!this._loaded) {
return;
}
@@ -371,6 +436,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") ||
@@ -383,6 +452,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")) {
@@ -493,7 +567,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: () => {
@@ -673,6 +747,8 @@ export class HaMap extends ReactiveElement {
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,
@@ -965,14 +1041,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)];
@@ -1001,26 +1091,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) => {
@@ -1029,8 +1117,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);
@@ -1038,10 +1126,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,
})
@@ -1051,7 +1138,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);
@@ -1095,19 +1182,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,
})
);
@@ -1119,22 +1227,96 @@ 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 ?? "";
if (showColors || member?.selected) {
avatar.entityColor = member?.color;
if (!member?.color) {
avatar.style.setProperty("--ha-marker-color", "var(--primary-color)");
}
}
if (showColors) {
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 {
@@ -1207,6 +1389,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;
}
@@ -1254,16 +1441,59 @@ 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-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,
@@ -1315,18 +1545,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[]
+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: {} } },
+214 -112
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,
@@ -69,15 +79,165 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
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}
?selected=${this._activeEntry === 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}
?selected=${this._activeEntry === stateObject.entity_id}
.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>,
_colorVersion: number
): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const stateLocations: MarkerLocation[] = stateItems.map(
(entityState) => ({
@@ -87,12 +247,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 +260,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 +274,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();
}),
];
@@ -142,102 +316,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}
?selected=${this._activeEntry === 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}
?selected=${this._activeEntry === stateObject.entity_id}
.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>
`;
@@ -269,7 +355,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}
@@ -551,6 +639,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)
@@ -578,6 +667,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>;
+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
+14 -1
View File
@@ -9321,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();
});
});
});