Compare commits

..
2 Commits
18 changed files with 225 additions and 914 deletions
+37 -12
View File
@@ -123,6 +123,8 @@ 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;
@@ -598,10 +600,19 @@ 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]]);
},
@@ -652,6 +663,7 @@ 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;
@@ -789,8 +801,10 @@ export class MapLibreMapEngine implements MapEngine {
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// The user's hand wins while dragging; the host is the truth otherwise
// Skip one update echoing the pre-edit values (the save has not returned yet)
let dragging = false;
let staleCenter: MapLatLng | undefined;
let staleRadius: number | undefined;
if (options.resizable) {
const east = pointEastOf(center, options.radius);
@@ -833,6 +847,8 @@ 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,
@@ -859,11 +875,19 @@ 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", () => {
@@ -920,6 +944,12 @@ 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]]);
@@ -1189,16 +1219,13 @@ export class MapLibreMapEngine implements MapEngine {
const clusterable = this._markers.filter(
(managed) => managed.options.cluster && !managed.removed
);
// A member or bubble that had focus hands it to the icon replacing it;
// read before the bubble holding it is removed
// A member that had focus hands it to the icon replacing it; read before
// the open bubble holding it is removed
const active = (
this._map.getContainer().getRootNode() as Document | ShadowRoot
).activeElement;
const focusedMember = active
? (clusterable.find((managed) => managed.element.contains(active)) ??
this._clusterGroups.find((group) =>
group.iconMarker?.getElement().contains(active)
)?.members[0])
? clusterable.find((managed) => managed.element.contains(active))
: undefined;
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
@@ -1352,10 +1379,8 @@ export class MapLibreMapEngine implements MapEngine {
const group = this._clusterGroups.find((candidate) =>
candidate.members.includes(focusedMember)
);
if (group && !group.open) {
// A singleton group has no bubble icon; the member shows on its own,
// so send focus back to that member rather than to the document.
(group.iconMarker?.getElement() ?? focusedMember.element).focus();
if (group?.iconMarker && !group.open) {
group.iconMarker.getElement().focus();
}
}
}
-73
View File
@@ -1,73 +0,0 @@
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order, so an entity has the
* same color on every map. The registry comes from the fullEntitiesContext;
* entities without an 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"];
// One index per registry update, shared by every map on the page. Zones,
// persons and trackers share one sequence, so a person never has the color
// of the zone it is in.
const creationIndex = memoizeOne(
(entries: EntityRegistryEntry[]): Record<string, number> => {
const index: Record<string, number> = {};
entries
.filter(
(entry) =>
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
// The home zone has a fixed color and does not take a palette slot
entry.entity_id !== HOME_ZONE_ENTITY_ID
)
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
return index;
}
);
// 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,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex(entries)[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,
entries: EntityRegistryEntry[],
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, entries, computedStyles);
};
+1 -5
View File
@@ -16,11 +16,7 @@ export const setMarkerAccessibility = (
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (
title &&
!element.hasAttribute("aria-label") &&
!element.hasAttribute("aria-labelledby")
) {
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
-67
View File
@@ -1,67 +0,0 @@
import { getContrastedColorHex } from "../color/rgb";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Content color contrasting the fill; not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
} catch {
return "#ffffff";
}
};
export const zoneInitials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
export const createZoneMarkerElement = (options: {
color: string;
icon?: string;
/** Path for an ha-svg-icon, when there is no icon name */
iconPath?: string;
name: string;
}): HTMLElement => {
const element = document.createElement("div");
element.className = "zone-circle";
element.style.backgroundColor = options.color;
element.style.color = contrastingZoneContent(options.color);
if (options.icon) {
const icon = document.createElement("ha-icon");
icon.setAttribute("icon", options.icon);
element.appendChild(icon);
} else if (options.iconPath) {
const icon = document.createElement("ha-svg-icon");
icon.setAttribute("path", options.iconPath);
element.appendChild(icon);
} else {
const initials = document.createElement("span");
initials.textContent = zoneInitials(options.name);
element.appendChild(initials);
}
return element;
};
/** Styles for the zone marker, included by ha-map */
export const zoneMarkerStyles = `
.zone-circle {
width: ${ZONE_CIRCLE_SIZE}px;
height: ${ZONE_CIRCLE_SIZE}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
+3 -14
View File
@@ -24,13 +24,11 @@ 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({ "--ha-marker-selected-color": this.entityColor })}
style=${styleMap({ "border-color": this.entityColor })}
@click=${this._badgeTap}
>
${
@@ -96,22 +94,13 @@ class HaEntityMarker extends LitElement {
height: var(--ha-marker-size, 48px);
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
border-radius: var(--ha-marker-border-radius, 50%);
border: var(--ha-marker-border-width, 3px) solid
var(--ha-marker-color, var(--card-background-color, #fff));
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
border: 1px solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(
--ha-marker-background,
var(--card-background-color)
);
background-color: var(--card-background-color);
}
.marker.picture {
overflow: hidden;
}
/* 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%;
+12 -57
View File
@@ -1,17 +1,14 @@
import type { ContextType } from "@lit/context";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext, uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import "../ha-input-helper-text";
@@ -19,10 +16,6 @@ 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
@@ -75,15 +68,6 @@ export class HaLocationsEditor extends LitElement {
@state() private _editingAvailable = true;
// Marker elements bake in theme colors, so they are rebuilt on a theme
// change; narrow the UI context to themes to avoid unrelated rerenders
@state()
@consume({ context: uiContext, subscribe: true })
@transform<ContextType<typeof uiContext>, Themes>({
transformer: ({ themes }) => themes,
})
private _themes?: Themes;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -124,10 +108,7 @@ export class HaLocationsEditor extends LitElement {
return html`
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(
this.locations,
this._themes
)}
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
@@ -159,14 +140,7 @@ export class HaLocationsEditor extends LitElement {
}
private _editableLocations = memoizeOne(
(
locations: MarkerLocation[] | undefined,
themes: Themes | undefined
): HaMapEditableLocation[] => {
if (themes !== this._elementsThemes) {
this._elementsThemes = themes;
this._elements.clear();
}
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
@@ -178,9 +152,7 @@ export class HaLocationsEditor extends LitElement {
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: location.radius
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
: [ICON_SIZE, ICON_SIZE],
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
@@ -193,46 +165,30 @@ export class HaLocationsEditor extends LitElement {
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
private _elementsThemes?: Themes;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const isZone = !!location.radius;
const key = JSON.stringify([
isZone,
location.icon,
location.iconPath,
location.name,
location.radius_color,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
// The zone marker doubles as the circle's draggable center
const element = isZone
? this._createZoneMarker(location)
: this._createIcon(location);
const element = 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";
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
@@ -340,13 +296,12 @@ export class HaLocationsEditor extends LitElement {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
The control sits at the physical top-left in either direction. */
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
ha-alert {
position: absolute;
top: var(--ha-space-2);
left: 56px;
right: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
z-index: 1;
}
`;
+68 -294
View File
@@ -1,4 +1,4 @@
import { consume, ContextConsumer } from "@lit/context";
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -31,23 +31,15 @@ import type {
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, 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,
connectionContext,
formattersContext,
fullEntitiesContext,
internationalizationContext,
statesContext,
uiContext,
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import type {
HomeAssistantConfig,
@@ -219,37 +211,9 @@ 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;
showIcon: boolean;
unit: 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 {
@@ -300,8 +264,6 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
private _zonePositions: Record<string, MapLatLng> = {};
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@@ -334,11 +296,6 @@ export class HaMap extends ReactiveElement {
private _resizeObserver?: ResizeObserver;
// Registry creation order decides the palette colors
@state() private _entityReg: EntityRegistryEntry[] = [];
private _registryConsumer?: ContextConsumer<typeof fullEntitiesContext, this>;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -366,21 +323,6 @@ export class HaMap extends ReactiveElement {
this._attachObserver();
}
// Only maps that draw entities ask for the registry; an editor's map, as in
// onboarding, never does. The context provider shares one subscription.
private _watchRegistry(): void {
if (this._registryConsumer || !this.entities?.length) {
return;
}
this._registryConsumer = new ContextConsumer(this, {
context: fullEntitiesContext,
subscribe: true,
callback: (entries) => {
this._entityReg = entries;
},
});
}
private _handleVisibilityChange = async () => {
if (!document.hidden) {
setTimeout(() => {
@@ -443,7 +385,7 @@ export class HaMap extends ReactiveElement {
}
}
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
if (changedProps.has("clusterMarkers")) {
this._drawEntities();
}
@@ -459,11 +401,6 @@ 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")) {
@@ -496,19 +433,13 @@ export class HaMap extends ReactiveElement {
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
(!changedProps.has("_ui") ||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
) {
return;
}
this._updateMapStyle();
// Marker, trail and circle colors were resolved from the theme when drawn
this._drawEntities();
this._drawPaths();
if (this._editableHandles.size) {
this._removeEditableLocations();
this._drawEditableLocations();
}
}
private get _darkMode() {
@@ -1100,36 +1031,16 @@ export class HaMap extends ReactiveElement {
engine.setClustering(null);
return;
}
this._watchRegistry();
const computedStyles = getComputedStyle(this);
// 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)];
// A zone that is not drawn cannot anchor a bubble either
if (
stateObj &&
computeStateDomain(stateObj) === "zone" &&
(this.renderPassive || !stateObj.attributes.passive)
) {
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,
];
}
}
}
const zoneColor = computedStyles.getPropertyValue("--accent-color");
const passiveZoneColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const darkPrimaryColor = computedStyles.getPropertyValue(
"--dark-primary-color"
);
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
@@ -1158,29 +1069,26 @@ export class HaMap extends ReactiveElement {
continue;
}
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,
this._entityReg,
computedStyles
);
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
if (!hideRadius && radius) {
if (radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: markerColor })
engine.addCircle(position, { radius, color: zoneMarkerColor })
);
}
const circleEl = createZoneMarkerElement({
color: markerColor,
icon,
name: title,
});
// 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);
}
if (this.interactiveZones) {
const openMoreInfo = (ev: Event) => {
@@ -1189,8 +1097,8 @@ export class HaMap extends ReactiveElement {
entityId: stateObj.entity_id,
});
};
circleEl.addEventListener("click", openMoreInfo);
circleEl.addEventListener("keydown", (ev) => {
iconEl.addEventListener("click", openMoreInfo);
iconEl.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
openMoreInfo(ev);
@@ -1198,9 +1106,10 @@ export class HaMap extends ReactiveElement {
});
}
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
this._zoneHandles.push(
engine.addMarker(circleEl, position, {
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
engine.addMarker(iconEl, position, {
size: [zoneIconSize, zoneIconSize],
interactive: this.interactiveZones,
title,
})
@@ -1210,7 +1119,7 @@ export class HaMap extends ReactiveElement {
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
if (!hideRadius && radius) {
if (radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
this._focusZonePoints.push(position);
@@ -1254,42 +1163,19 @@ 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), this._entityReg, computedStyles);
entityMarker.entityColor = entityColor;
if (typeof entity !== "string") {
entityMarker.selected = entity.selected ?? false;
entityMarker.entityColor = entity.color;
}
const clusterData: ClusterData = {
entityId: getEntityId(entity),
picture: entityMarker.entityPicture || undefined,
label: entityName,
showIcon: entityMarker.showIcon,
unit: entityMarker.entityUnit ?? "",
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,
clusterData,
decoration: showAccuracy
? { radius: gpsAccuracy!, color: entityColor }
// create circle around if entity has accuracy
decoration: gpsAccuracy
? { radius: gpsAccuracy, color: darkPrimaryColor }
: undefined,
})
);
@@ -1301,97 +1187,22 @@ export class HaMap extends ReactiveElement {
engine.setClustering(
this.clusterMarkers
? {
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,
}
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
: null
);
}
// 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.entityUnit = member?.unit ?? "";
avatar.showIcon = member?.showIcon ?? false;
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],
}
: {}),
};
// 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] };
};
private _drawScaleRuler(): void {
@@ -1452,7 +1263,6 @@ export class HaMap extends ReactiveElement {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
}
.cluster-open-members {
display: flex;
@@ -1466,13 +1276,6 @@ export class HaMap extends ReactiveElement {
border-radius: 14px;
box-shadow: var(--ha-box-shadow-s);
}
/* Both tails are a rotated square whose upper half sits under the bubble;
drawn behind it, so it never covers a member's frame or selected ring */
.cluster-open-tail,
.cluster-bubble-tail {
position: relative;
z-index: -1;
}
.cluster-open-tail {
width: 10px;
height: 10px;
@@ -1546,59 +1349,17 @@ export class HaMap extends ReactiveElement {
.leaflet-pane {
z-index: 0 !important;
}
.cluster-marker {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
}
.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;
/* Zone icons, sized like the Leaflet divIcon they replaced */
.zone-icon {
display: flex;
align-items: center;
justify-content: center;
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);
text-align: center;
color: var(--primary-text-color);
}
/* Markers are rounded squares to match the cluster bubble avatars */
ha-entity-marker {
--ha-marker-border-radius: var(--ha-border-radius-lg);
.zone-icon.dark {
color: #ffffff;
}
.cluster-bubble ha-entity-marker {
flex: none;
--ha-marker-border-radius: 10px;
}
${unsafeCSS(zoneMarkerStyles)}
.leaflet-control,
.leaflet-top,
.leaflet-bottom {
@@ -1649,6 +1410,19 @@ 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);
}
`;
}
+3 -17
View File
@@ -761,9 +761,9 @@ export class HatScriptGraph extends LitElement {
static get styles() {
return css`
:host {
display: grid;
display: flex;
flex-direction: row;
overflow: hidden;
position: relative;
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
--active-clr: var(--active-color, var(--primary-color));
--track-clr: var(--track-color, var(--accent-color));
@@ -782,10 +782,9 @@ export class HatScriptGraph extends LitElement {
--hat-graph-branch-height: ${BRANCH_HEIGHT}px;
}
.graph-scroll {
grid-area: 1 / 1;
flex: 1;
overflow: auto;
min-width: 0;
min-height: 0;
}
.graph-container {
display: flex;
@@ -796,19 +795,6 @@ export class HatScriptGraph extends LitElement {
.actions {
display: flex;
flex-direction: column;
grid-area: 1 / 1;
justify-self: end;
align-self: start;
margin-top: var(--ha-space-2);
margin-inline-end: var(--ha-space-5);
z-index: 1;
background-color: color-mix(
in srgb,
var(--card-background-color) 70%,
transparent
);
backdrop-filter: blur(8px);
border-radius: var(--ha-border-radius-pill);
}
.parent {
margin-left: 8px;
@@ -21,10 +21,6 @@ import { computeRTLDirection } from "../../common/util/compute_rtl";
export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
// Reflected so the styles can apply the right safe-area inset whenever the
// drawer spans the full viewport width, not only below a fixed breakpoint.
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _notifications: PersistentNotification[] = [];
@state() public _open = false;
@@ -52,7 +48,6 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
}
showDialog({ narrow }) {
this.narrow = narrow;
this._unsubNotifications = subscribeNotifications(
this.hass.connection,
(notifications) => {
@@ -223,9 +218,11 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
display: block;
}
:host([narrow]) ha-header-bar {
--header-bar-padding: var(--safe-area-inset-top, 0px)
var(--safe-area-inset-right, 0px) 0 var(--safe-area-inset-left, 0px);
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-header-bar {
--header-bar-padding: var(--safe-area-inset-top, 0px)
var(--safe-area-inset-right, 0px) 0 var(--safe-area-inset-left, 0px);
}
}
.list-container {
@@ -249,9 +246,11 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
color: var(--primary-text-color);
}
:host([narrow]) .notifications {
padding-right: var(--safe-area-inset-right, 0px);
padding-inline-end: var(--safe-area-inset-right, 0px);
@media all and (max-width: 450px), all and (max-height: 500px) {
.notifications {
padding-right: var(--safe-area-inset-right, 0px);
padding-inline-end: var(--safe-area-inset-right, 0px);
}
}
.notification {
+2 -4
View File
@@ -26,10 +26,7 @@
width: 100%;
max-width: 400px;
margin: 0 auto;
padding: var(--safe-area-inset-top, 0px)
calc(16px + var(--safe-area-inset-right, 0px))
var(--safe-area-inset-bottom, 0px)
calc(16px + var(--safe-area-inset-left, 0px));
padding: 0 16px;
box-sizing: content-box;
}
@@ -38,6 +35,7 @@
align-items: center;
justify-content: center;
margin-bottom: 32px;
padding-top: var(--safe-area-inset-top);
}
.header img {
+5 -5
View File
@@ -109,11 +109,11 @@ export const rowStyles = css`
text-box-edge: cap alphabetic;
overflow: hidden;
transition:
opacity var(--ha-animation-duration-fast) ease-out,
transform var(--ha-animation-duration-fast) ease-out,
width var(--ha-animation-duration-fast) ease-out,
margin-inline-end var(--ha-animation-duration-fast) ease-out,
border-width var(--ha-animation-duration-fast) ease-out;
opacity 180ms ease-out,
transform 180ms ease-out,
width 180ms ease-out,
margin-inline-end 180ms ease-out,
border-width 180ms ease-out;
}
.trigger-index-badge.hidden {
@@ -1,11 +1,10 @@
import { consume } from "@lit/context";
import { mdiLinkVariantOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../../../common/array/ensure-array";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-trigger-icon";
import type { TriggerCondition } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
@@ -33,7 +32,6 @@ export class HaAutomationTriggerReferences extends LitElement {
protected render() {
const options = this._triggers?.options ?? [];
const showIndices = this._triggers?.showIndices ?? false;
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
const selectedTriggers = options.filter((option) =>
selectedIds.includes(option.id)
@@ -54,28 +52,7 @@ export class HaAutomationTriggerReferences extends LitElement {
selectedTriggers.map(
(option) => html`
<span class="trigger-reference">
<span
id="trigger-index-badge-${option.index}"
tabindex=${showIndices ? "0" : "-1"}
class="trigger-index-badge ${showIndices ? "" : "hidden"}"
aria-label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.trigger_index_aria_label",
{ number: option.index + 1 }
)}
aria-hidden=${showIndices ? "false" : "true"}
>${option.index + 1}</span
>
${
showIndices
? html`<ha-tooltip for="trigger-index-badge-${option.index}"
><p>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.trigger_index_tooltip"
)}
</p></ha-tooltip
>`
: nothing
}
<span class="trigger-index-badge">${option.index + 1}</span>
<ha-trigger-icon
.trigger=${"trigger" in option.trigger ? option.trigger.trigger : ""}
></ha-trigger-icon>
@@ -155,22 +132,6 @@ export class HaAutomationTriggerReferences extends LitElement {
line-height: 1;
text-box-trim: both;
text-box-edge: cap alphabetic;
overflow: hidden;
transition:
opacity var(--ha-animation-duration-fast) ease-out,
transform var(--ha-animation-duration-fast) ease-out,
width var(--ha-animation-duration-fast) ease-out,
margin-inline-end var(--ha-animation-duration-fast) ease-out,
border-width var(--ha-animation-duration-fast) ease-out;
}
.trigger-index-badge.hidden {
opacity: 0;
transform: translateX(calc(-8px * var(--scale-direction)));
width: 0;
margin-inline-end: calc(var(--ha-space-2) * -1);
border-width: 0;
pointer-events: none;
}
`;
}
@@ -1338,7 +1338,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
const configEntries = this._domainConfigEntries(
this.domain,
this._extraConfigEntries || this.configEntries
).filter((entry) => entry.source !== "ignore");
).filter(
(entry) =>
entry.source !== "ignore" &&
Object.prototype.hasOwnProperty.call(
entry.supported_subentry_types,
flowType
)
);
if (!configEntries.length) {
return;
+47 -171
View File
@@ -22,12 +22,7 @@ import type {
MarkerLocation,
} from "../../../components/map/ha-locations-editor";
import { saveCoreConfig } from "../../../data/core";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import {
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../common/map/entity-map-colors";
import type {
HomeZoneMutableParams,
Zone,
@@ -52,19 +47,6 @@ 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;
@@ -79,36 +61,21 @@ 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[] = [];
// Registry creation order decides the zone colors
@state() private _entityReg: EntityRegistryEntry[] = [];
// Storage zone id (its unique id) to entity id
@state() private _zoneEntityIds: Record<string, string> = {};
// Bumped when entity map colors change to recompute the memoized locations
@state() private _colorVersion = 0;
private _getZones = memoizeOne(
(
storageItems: Zone[],
stateItems: HassEntity[],
zoneEntityIds: Record<string, string>,
pendingEdits: Record<string, PendingEdit>,
entityReg: EntityRegistryEntry[],
_colorVersion: number
): MarkerLocation[] => {
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const homeRadiusColor =
computedStyles.getPropertyValue("--primary-color");
const stateLocations: MarkerLocation[] = stateItems.map(
(entityState) => ({
@@ -118,28 +85,21 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
latitude: entityState.attributes.latitude,
longitude: entityState.attributes.longitude,
radius: entityState.attributes.radius,
...pendingEdits[entityState.entity_id],
radius_color: zoneColor(
entityState.entity_id,
!!entityState.attributes.passive,
entityReg,
computedStyles
),
radius_color:
entityState.entity_id === "zone.home"
? homeRadiusColor
: entityState.attributes.passive
? passiveRadiusColor
: zoneRadiusColor,
location_editable:
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
entityState.entity_id === "zone.home" && this._canEditCore,
radius_editable:
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
entityState.entity_id === "zone.home" && this._canEditCore,
})
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
...pendingEdits[zone.id],
radius_color: zoneColor(
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
!!zone.passive,
entityReg,
computedStyles
),
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
location_editable: true,
radius_editable: true,
}));
@@ -150,18 +110,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entityReg = 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();
}),
];
@@ -314,11 +265,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<ha-locations-editor
.locations=${this._getZones(
this._storageItems,
this._stateItems,
this._zoneEntityIds,
this._pendingEdits,
this._entityReg,
this._colorVersion
this._stateItems
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@@ -348,8 +295,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
protected updated() {
if (
!this.route.path.startsWith("/edit/") ||
!this._stateItems ||
@@ -367,99 +313,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
super.updated(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;
}
// Saves for one zone run in order, so each sees the entry the previous one
// produced and a failure only drops the values its own request carried
private _saveQueue: Record<string, Promise<void>> = {};
private _saveEdit(id: string, pending: PendingEdit): Promise<void> {
this._pendingEdits = {
...this._pendingEdits,
[id]: { ...this._pendingEdits[id], ...pending },
};
const save = (this._saveQueue[id] ?? Promise.resolve())
.then(() => this._performSave(id, pending))
.finally(() => {
// Only the last save in the chain removes the queue entry
if (this._saveQueue[id] === save) {
delete this._saveQueue[id];
}
});
this._saveQueue[id] = save;
return save;
}
private async _performSave(id: string, pending: PendingEdit) {
try {
if (id === HOME_ZONE_ENTITY_ID) {
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 for what this request changed;
// a later edit of other values stays pending for its own save
this._dropPendingValues(id, pending);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.zone.can_not_edit"),
text: err.message,
});
}
}
private _dropPendingValues(id: string, failed: PendingEdit) {
const current = this._pendingEdits[id];
if (!current) {
return;
}
const rest = Object.fromEntries(
Object.entries(current).filter(
([key, value]) => failed[key as keyof PendingEdit] !== value
)
) as PendingEdit;
if (Object.keys(rest).length) {
this._pendingEdits = { ...this._pendingEdits, [id]: rest };
} else {
this._dropPendingEdit(id);
}
}
private async _fetchData() {
@@ -501,19 +359,37 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
private _locationUpdated(ev: CustomEvent) {
this._saveEdit(ev.detail.id, {
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, {
latitude: ev.detail.location[0],
longitude: ev.detail.location[1],
});
}
private _radiusUpdated(ev: CustomEvent) {
this._saveEdit(ev.detail.id, {
radius:
ev.detail.id === HOME_ZONE_ENTITY_ID
? Math.round(ev.detail.radius)
: ev.detail.radius,
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,
});
}
@@ -540,7 +416,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
const entryId: string = (ev.currentTarget! as any).value;
if (this.narrow && entryId === HOME_ZONE_ENTITY_ID) {
if (this.narrow && entryId === "zone.home") {
this._editHomeZone(ev);
return;
}
@@ -599,7 +475,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
longitude: values.longitude,
radius: values.radius,
});
this._zoomZone(HOME_ZONE_ENTITY_ID);
this._zoomZone("zone.home");
}
private async _updateEntry(
+17 -58
View File
@@ -8,13 +8,8 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { ContextType } from "@lit/context";
import { consume, ContextConsumer } from "@lit/context";
import { getColorByIndex } from "../../../common/color/colors";
import { resolveThemeColor } from "../../../common/color/compute-color";
import {
entityMapColor,
zoneColor,
} from "../../../common/map/entity-map-colors";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
@@ -36,10 +31,6 @@ import type {
import type { MapLatLng } from "../../../common/map/map-engine";
import type { HistoryStates } from "../../../data/history";
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
import type { Themes } from "../../../data/ws-themes";
import { fullEntitiesContext, uiContext } from "../../../data/context";
import { transform } from "../../../common/decorators/transform";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import {
@@ -67,18 +58,6 @@ interface GeoEntity {
@customElement("hui-map-card")
class HuiMapCard extends LitElement implements LovelaceCard {
constructor() {
super();
new ContextConsumer(this, {
context: fullEntitiesContext,
subscribe: true,
callback: (entries) => {
this._entityReg = entries;
this._mapEntities = this._getMapEntities();
},
});
}
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@@ -97,19 +76,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
private _filteredMapEntities: HaMapEntity[] = [];
private _colorDict: Record<string, string> = {};
private _colorIndex = 0;
@state() private _error?: { code: string; message: string };
// Registry creation order decides the palette colors
@state() private _entityReg: EntityRegistryEntry[] = [];
// Palette colors are read from the theme when the entities are built
@state()
@consume({ context: uiContext, subscribe: true })
@transform<ContextType<typeof uiContext>, Themes>({
transformer: ({ themes }) => themes,
})
private _themes?: Themes;
@state() private _clusterMarkers = true;
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
@@ -241,12 +213,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
<ha-map
.entities=${this._filteredMapEntities}
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
.paths=${this._getHistoryPaths(
this._config,
this._stateHistory,
this._entityReg,
this._themes
)}
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
.autoFit=${this._config.auto_fit || false}
.fitZones=${this._config.fit_zones || false}
.themeMode=${themeMode}
@@ -354,10 +321,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
) {
this._mapEntities = this._getMapEntities();
}
// Private state is not in keyof this
if ((changedProps as PropertyValues).has("_themes") && this.hasUpdated) {
this._mapEntities = this._getMapEntities();
}
// Filter entities by conditions
if (this._config?.conditions && this._mapEntities) {
@@ -469,19 +432,18 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._clusterMarkers = !this._clusterMarkers;
}
// The same color for an entity on every map; a config color still wins
private _getColor(entityId: string): string {
const computedStyles = getComputedStyle(this);
if (computeDomain(entityId) === "zone") {
const stateObj = this.hass?.states[entityId];
return zoneColor(
entityId,
!!stateObj?.attributes.passive,
this._entityReg,
computedStyles
);
let color = this._colorDict[entityId];
if (color) {
return color;
}
return entityMapColor(entityId, this._entityReg, computedStyles);
const computedStyles = getComputedStyle(this);
color = getColorByIndex(this._colorIndex, computedStyles);
if (color) {
this._colorIndex++;
this._colorDict[entityId] = color;
}
return color;
}
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
@@ -541,10 +503,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
private _getHistoryPaths = memoizeOne(
(
config: MapCardConfig,
history: HistoryStates | undefined,
// Trail colors follow the registry order and the theme like the markers
_entityReg: EntityRegistryEntry[],
_themes: Themes | undefined
history?: HistoryStates
): HaMapPaths[] | undefined => {
if (!history || !(config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW)) {
return undefined;
-4
View File
@@ -189,10 +189,6 @@ export const getMyRedirects = (): Redirects => ({
component: "radio_frequency",
redirect: "/config/radio-frequency",
},
config_serial: {
component: "usb",
redirect: "/config/serial",
},
config_ssdp: {
component: "ssdp",
redirect: "/config/ssdp",
-74
View File
@@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import {
entityMapColor,
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../src/common/map/entity-map-colors";
// 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;
const entry = (entityId: string, createdAt: number, id = entityId) =>
({
entity_id: entityId,
created_at: createdAt,
id,
}) as EntityRegistryEntry;
describe("entity map colors", () => {
it("colors zones, persons and trackers in one creation order", () => {
const entries = [
entry("zone.work", 40),
entry("zone.school", 10),
entry("person.anne", 20),
entry("device_tracker.phone", 30),
entry("light.kitchen", 25),
];
expect(entityMapColor("zone.school", entries, styles)).toBe("color-1");
// A person takes the next slot, never a zone's color
expect(entityMapColor("person.anne", entries, styles)).toBe("color-2");
expect(entityMapColor("device_tracker.phone", entries, styles)).toBe(
"color-3"
);
expect(entityMapColor("zone.work", entries, styles)).toBe("color-4");
});
it("breaks creation ties by registry id", () => {
const entries = [entry("zone.b", 10, "b"), entry("zone.a", 10, "a")];
expect(entityMapColor("zone.a", entries, styles)).toBe("color-1");
expect(entityMapColor("zone.b", entries, styles)).toBe("color-2");
});
it("keeps the home zone out of the palette", () => {
const entries = [entry(HOME_ZONE_ENTITY_ID, 10), entry("zone.work", 20)];
expect(entityMapColor("zone.work", entries, styles)).toBe("color-1");
expect(zoneColor(HOME_ZONE_ENTITY_ID, false, entries, styles)).toBe(
"primary-color"
);
});
it("mutes passive zones", () => {
const entries = [entry("zone.quiet", 10)];
expect(zoneColor("zone.quiet", true, entries, styles)).toBe(
"secondary-text-color"
);
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
});
it("gives entities outside the registry a stable color", () => {
const entries = [entry("zone.work", 10)];
const color = entityMapColor("zone.yaml_zone", entries, styles);
expect(entityMapColor("zone.yaml_zone", entries, styles)).toBe(color);
expect(entityMapColor("zone.other_yaml_zone", entries, styles)).not.toBe(
color
);
});
});
+10 -6
View File
@@ -1030,7 +1030,10 @@ describe("MapLibreMapEngine", () => {
expect(handle.radius).toBeCloseTo(110, 5);
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
// Once committed, the host is the truth again
// 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
handle.update([52, 4], 100);
expect(handle.radius).toBe(100);
});
@@ -1097,24 +1100,25 @@ describe("MapLibreMapEngine", () => {
expect(element.getAttribute("aria-valuenow")).toBe("150000");
});
it("ignores host updates only while a drag is in progress", async () => {
it("ignores one update echoing the values from before a drag", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, center } = addCircle(engine);
center.lngLat = [4.01, 52];
center.fire("dragstart");
// The user's hand wins while dragging
// Nothing moves while a drag is in progress
handle.update([53, 5], 500);
expect(handle.center).toEqual([52, 4]);
center.fire("drag");
center.fire("dragend");
expect(handle.center).toEqual([52, 4.01]);
// Afterwards the host is the truth, even when it moves the circle back
// 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
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 () => {