mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-17 23:50:41 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b13f5107da | ||
|
|
801743050c | ||
|
|
193cbcbfac | ||
|
|
d43f7e58b6 | ||
|
|
fb419538c7 | ||
|
|
7c7549349a | ||
|
|
051f51dd26 | ||
|
|
9a9d747899 | ||
|
|
5b99462fdf | ||
|
|
14ef770c44 | ||
|
|
6a9fca2cb9 | ||
|
|
55ba59074e | ||
|
|
eeb674dd5a |
@@ -10,15 +10,11 @@ import { computeAreaName } from "./compute_area_name";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import {
|
||||
computeEntityEntryName,
|
||||
computeEntityName,
|
||||
entityUseDeviceName,
|
||||
} from "./compute_entity_name";
|
||||
import { computeFloorName } from "./compute_floor_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import {
|
||||
getEntityContext,
|
||||
getEntityEntryContext,
|
||||
} from "./context/get_entity_context";
|
||||
import { getEntityEntryContext } from "./context/get_entity_context";
|
||||
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
|
||||
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
@@ -110,7 +106,8 @@ export const computeEntityNameDisplay = (
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
floors,
|
||||
{ keepAllParts: true }
|
||||
);
|
||||
|
||||
// If after processing there is only one name, return that
|
||||
@@ -121,13 +118,18 @@ export const computeEntityNameDisplay = (
|
||||
return names.filter((n) => n).join(separator);
|
||||
};
|
||||
|
||||
export interface EntityNameListOptions {
|
||||
keepAllParts?: boolean;
|
||||
}
|
||||
|
||||
export const computeEntityNameList = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem[],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameListOptions
|
||||
): (string | undefined)[] => {
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
@@ -148,7 +150,8 @@ export const computeEntityNameList = (
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
floors,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
@@ -158,7 +161,8 @@ export const computeEntityEntryNameList = (
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameListOptions
|
||||
): (string | undefined)[] => {
|
||||
const { device, parentDevice, area, floor } = getEntityEntryContext(
|
||||
entry,
|
||||
@@ -167,15 +171,33 @@ export const computeEntityEntryNameList = (
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const entityName = computeEntityEntryName(entry, devices);
|
||||
const keepAllParts = options?.keepAllParts ?? false;
|
||||
|
||||
// Same rule as the backend: an owner only adds its name part while the
|
||||
// next_name_part links reach it, so owners above the first node with its own
|
||||
// area are left out. An entity without a name of its own is still named
|
||||
// after its device.
|
||||
const nameDevice =
|
||||
keepAllParts || entry.next_name_part !== "area" || !entityName
|
||||
? device
|
||||
: null;
|
||||
const nameParentDevice =
|
||||
keepAllParts ||
|
||||
(entry.next_name_part !== "area" && device?.next_name_part !== "area")
|
||||
? parentDevice
|
||||
: null;
|
||||
|
||||
return name.map((item) => {
|
||||
switch (item.type) {
|
||||
case "entity":
|
||||
return computeEntityEntryName(entry, devices);
|
||||
return entityName;
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
return nameDevice ? computeDeviceName(nameDevice) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
return nameParentDevice
|
||||
? computeDeviceName(nameParentDevice)
|
||||
: undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -195,19 +217,27 @@ export const computeEntitySearchLabels = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
) => {
|
||||
const { device, parentDevice, area } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
{ keepAllParts: true }
|
||||
);
|
||||
return {
|
||||
entityName: computeEntityName(stateObj, entities, devices) || null,
|
||||
entityName: entityName || null,
|
||||
friendlyName: computeStateName(stateObj) || null,
|
||||
deviceName: (device && computeDeviceName(device)) || null,
|
||||
parentDeviceName: (parentDevice && computeDeviceName(parentDevice)) || null,
|
||||
areaName: (area && computeAreaName(area)) || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ interface ManagedMarker {
|
||||
draggable?: boolean;
|
||||
onDragEnd?: (location: MapLatLng) => void;
|
||||
dragging?: boolean;
|
||||
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
|
||||
staleLocation?: MapLatLng;
|
||||
mlMarker?: MapLibreMarker;
|
||||
decoration?: MapItemHandle;
|
||||
removed?: boolean;
|
||||
@@ -600,19 +598,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
},
|
||||
clusterData: options.clusterData,
|
||||
setLocation: (newLocation) => {
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
if (managed.dragging) {
|
||||
return;
|
||||
}
|
||||
// Skip one update echoing the pre-drag location (the save has not returned yet)
|
||||
const stale = managed.staleLocation;
|
||||
managed.staleLocation = undefined;
|
||||
if (
|
||||
stale &&
|
||||
newLocation[0] === stale[0] &&
|
||||
newLocation[1] === stale[1]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
managed.location = newLocation;
|
||||
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
|
||||
},
|
||||
@@ -663,7 +652,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (managed.draggable) {
|
||||
managed.mlMarker.on("dragstart", () => {
|
||||
managed.dragging = true;
|
||||
managed.staleLocation = managed.location;
|
||||
});
|
||||
managed.mlMarker.on("dragend", () => {
|
||||
managed.dragging = false;
|
||||
@@ -801,10 +789,8 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
resizeHandle?.setAttribute("aria-valuetext", radiusText);
|
||||
};
|
||||
|
||||
// Skip one update echoing the pre-edit values (the save has not returned yet)
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
let dragging = false;
|
||||
let staleCenter: MapLatLng | undefined;
|
||||
let staleRadius: number | undefined;
|
||||
|
||||
if (options.resizable) {
|
||||
const east = pointEastOf(center, options.radius);
|
||||
@@ -847,8 +833,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (keyboardRadius === undefined) {
|
||||
// Host updates treat a key resize like a drag
|
||||
dragging = true;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
}
|
||||
currentRadius = Math.max(
|
||||
1,
|
||||
@@ -875,19 +859,11 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[centerMarker, resizeMarker].forEach((handleMarker) => {
|
||||
handleMarker?.on("dragstart", () => {
|
||||
dragging = true;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
});
|
||||
handleMarker?.on("dragend", () => {
|
||||
dragging = false;
|
||||
});
|
||||
});
|
||||
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
|
||||
staleCenter !== undefined &&
|
||||
staleRadius !== undefined &&
|
||||
candidateCenter[0] === staleCenter[0] &&
|
||||
candidateCenter[1] === staleCenter[1] &&
|
||||
candidateRadius === staleRadius;
|
||||
|
||||
if (options.moveable) {
|
||||
centerMarker.on("drag", () => {
|
||||
@@ -944,12 +920,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
const stale = isStale(newCenter, newRadius);
|
||||
staleCenter = undefined;
|
||||
staleRadius = undefined;
|
||||
if (stale) {
|
||||
return;
|
||||
}
|
||||
currentCenter = newCenter;
|
||||
currentRadius = newRadius;
|
||||
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
|
||||
@@ -1219,13 +1189,16 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const clusterable = this._markers.filter(
|
||||
(managed) => managed.options.cluster && !managed.removed
|
||||
);
|
||||
// A member that had focus hands it to the icon replacing it; read before
|
||||
// the open bubble holding it is removed
|
||||
// A member or bubble that had focus hands it to the icon replacing it;
|
||||
// read before the 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))
|
||||
? (clusterable.find((managed) => managed.element.contains(active)) ??
|
||||
this._clusterGroups.find((group) =>
|
||||
group.iconMarker?.getElement().contains(active)
|
||||
)?.members[0])
|
||||
: undefined;
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
|
||||
@@ -1379,8 +1352,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const group = this._clusterGroups.find((candidate) =>
|
||||
candidate.members.includes(focusedMember)
|
||||
);
|
||||
if (group?.iconMarker && !group.open) {
|
||||
group.iconMarker.getElement().focus();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
};
|
||||
@@ -16,7 +16,11 @@ export const setMarkerAccessibility = (
|
||||
): void => {
|
||||
clearMarkerAccessibility(element);
|
||||
const owned: string[] = [];
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
if (
|
||||
title &&
|
||||
!element.hasAttribute("aria-label") &&
|
||||
!element.hasAttribute("aria-labelledby")
|
||||
) {
|
||||
element.setAttribute("aria-label", title);
|
||||
owned.push("aria-label");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getContrastedColorHex } from "../color/rgb";
|
||||
|
||||
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
|
||||
|
||||
export const ZONE_CIRCLE_SIZE = 36;
|
||||
|
||||
// Content color contrasting the fill; not every theme color parses
|
||||
export const contrastingZoneContent = (color: string): string => {
|
||||
try {
|
||||
return getContrastedColorHex(color.trim());
|
||||
} catch {
|
||||
return "#ffffff";
|
||||
}
|
||||
};
|
||||
|
||||
export const zoneInitials = (name: string): string =>
|
||||
name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2);
|
||||
|
||||
export const createZoneMarkerElement = (options: {
|
||||
color: string;
|
||||
icon?: string;
|
||||
/** Path for an ha-svg-icon, when there is no icon name */
|
||||
iconPath?: string;
|
||||
name: string;
|
||||
}): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "zone-circle";
|
||||
element.style.backgroundColor = options.color;
|
||||
element.style.color = contrastingZoneContent(options.color);
|
||||
if (options.icon) {
|
||||
const icon = document.createElement("ha-icon");
|
||||
icon.setAttribute("icon", options.icon);
|
||||
element.appendChild(icon);
|
||||
} else if (options.iconPath) {
|
||||
const icon = document.createElement("ha-svg-icon");
|
||||
icon.setAttribute("path", options.iconPath);
|
||||
element.appendChild(icon);
|
||||
} else {
|
||||
const initials = document.createElement("span");
|
||||
initials.textContent = zoneInitials(options.name);
|
||||
element.appendChild(initials);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the zone marker, included by ha-map */
|
||||
export const zoneMarkerStyles = `
|
||||
.zone-circle {
|
||||
width: ${ZONE_CIRCLE_SIZE}px;
|
||||
height: ${ZONE_CIRCLE_SIZE}px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
overflow: hidden;
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
|
||||
}
|
||||
`;
|
||||
@@ -360,7 +360,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
this._initialFieldValue = undefined;
|
||||
}
|
||||
if (
|
||||
this._hassConfig?.auth.external &&
|
||||
this._hassConfig?.auth?.external &&
|
||||
isIosApp(this._hassConfig.auth.external)
|
||||
) {
|
||||
this._hassConfig.auth.external.fireMessage({
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mdiArrowRightThin } from "@mdi/js";
|
||||
import { mdiArrowRightThin, mdiDelete } from "@mdi/js";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import type { Segment } from "../data/vacuum";
|
||||
import { getVacuumSegments } from "../data/vacuum";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-area-picker";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
|
||||
@@ -76,6 +79,8 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
// Group segments by group (if available)
|
||||
const groupedSegments = this._groupSegments(this._segments);
|
||||
|
||||
const orphanedAreas = this._getOrphanedAreas();
|
||||
|
||||
return html`
|
||||
${Object.entries(groupedSegments).map(
|
||||
([groupName, segments]) => html`
|
||||
@@ -83,9 +88,71 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
${segments.map((segment) => this._renderSegment(segment))}
|
||||
`
|
||||
)}
|
||||
${orphanedAreas.length ? this._renderOrphanedAreas(orphanedAreas) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _getOrphanedAreas(): string[] {
|
||||
if (!this.value || !this._segments || this._segments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const liveIds = new Set(this._segments.map((segment) => segment.id));
|
||||
return Object.entries(this.value)
|
||||
.filter(([, segmentIds]) => segmentIds.some((id) => !liveIds.has(id)))
|
||||
.map(([areaId]) => areaId);
|
||||
}
|
||||
|
||||
private _renderOrphanedAreas(areaIds: string[]) {
|
||||
return html`
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_header"
|
||||
)}
|
||||
</h2>
|
||||
<p class="orphaned-description">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_description"
|
||||
)}
|
||||
</p>
|
||||
${areaIds.map((areaId) => {
|
||||
const area = this.hass.areas[areaId];
|
||||
const name = (area ? computeAreaName(area) : undefined) || areaId;
|
||||
return html`
|
||||
<div class="orphaned-row">
|
||||
<span class="orphaned-name">${name}</span>
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_remove",
|
||||
{ name }
|
||||
)}
|
||||
data-area-id=${areaId}
|
||||
@click=${this._removeOrphanedArea}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
private _removeOrphanedArea = (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
|
||||
) => {
|
||||
const areaId = ev.currentTarget.dataset.areaId;
|
||||
if (!areaId || !this.value || !this._segments) {
|
||||
return;
|
||||
}
|
||||
const liveIds = new Set(this._segments.map((segment) => segment.id));
|
||||
const newMapping: AreaSegmentMapping = { ...this.value };
|
||||
const kept = (newMapping[areaId] ?? []).filter((id) => liveIds.has(id));
|
||||
if (kept.length) {
|
||||
newMapping[areaId] = kept;
|
||||
} else {
|
||||
delete newMapping[areaId];
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newMapping });
|
||||
};
|
||||
|
||||
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
|
||||
const grouped: Record<string, Segment[]> = {};
|
||||
|
||||
@@ -209,8 +276,33 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
margin-inline-start: var(--ha-space-4);
|
||||
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.orphaned-description {
|
||||
margin: var(--ha-space-1) var(--ha-space-4) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font: var(--ha-font-body-s);
|
||||
}
|
||||
|
||||
.orphaned-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
padding-block: var(--ha-space-2);
|
||||
padding-inline: var(--ha-space-4) var(--ha-space-2);
|
||||
}
|
||||
|
||||
.orphaned-name {
|
||||
flex: 1;
|
||||
font: var(--ha-font-body-l);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -24,11 +24,13 @@ class HaEntityMarker extends LitElement {
|
||||
|
||||
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
class="marker ${this.entityPicture ? "picture" : ""}"
|
||||
style=${styleMap({ "border-color": this.entityColor })}
|
||||
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
|
||||
@click=${this._badgeTap}
|
||||
>
|
||||
${
|
||||
@@ -94,13 +96,22 @@ class HaEntityMarker extends LitElement {
|
||||
height: var(--ha-marker-size, 48px);
|
||||
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
|
||||
border-radius: var(--ha-marker-border-radius, 50%);
|
||||
border: 1px solid var(--ha-marker-color, var(--primary-color));
|
||||
border: var(--ha-marker-border-width, 3px) solid
|
||||
var(--ha-marker-color, var(--card-background-color, #fff));
|
||||
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
background-color: var(
|
||||
--ha-marker-background,
|
||||
var(--card-background-color)
|
||||
);
|
||||
}
|
||||
.marker.picture {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* A ring in the entity color outside the frame marks the selected marker */
|
||||
:host([selected]) .marker {
|
||||
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
|
||||
}
|
||||
.entity-picture {
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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 } from "../../data/context";
|
||||
import { internationalizationContext, uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-input-helper-text";
|
||||
@@ -16,6 +19,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
|
||||
@@ -68,6 +75,15 @@ 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;
|
||||
@@ -108,7 +124,10 @@ export class HaLocationsEditor extends LitElement {
|
||||
return html`
|
||||
<div class="map">
|
||||
<ha-map
|
||||
.editableLocations=${this._editableLocations(this.locations)}
|
||||
.editableLocations=${this._editableLocations(
|
||||
this.locations,
|
||||
this._themes
|
||||
)}
|
||||
.zoom=${this.zoom}
|
||||
.autoFit=${this.autoFit}
|
||||
.themeMode=${this.themeMode}
|
||||
@@ -140,7 +159,14 @@ export class HaLocationsEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _editableLocations = memoizeOne(
|
||||
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
|
||||
(
|
||||
locations: MarkerLocation[] | undefined,
|
||||
themes: Themes | undefined
|
||||
): HaMapEditableLocation[] => {
|
||||
if (themes !== this._elementsThemes) {
|
||||
this._elementsThemes = themes;
|
||||
this._elements.clear();
|
||||
}
|
||||
const ids = new Set((locations ?? []).map((location) => location.id));
|
||||
for (const id of this._elements.keys()) {
|
||||
if (!ids.has(id)) {
|
||||
@@ -152,7 +178,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,
|
||||
@@ -165,30 +193,46 @@ 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.location_editable,
|
||||
location.radius_color,
|
||||
]);
|
||||
const cached = this._elements.get(location.id);
|
||||
if (cached?.key === key) {
|
||||
return cached.element;
|
||||
}
|
||||
const element = this._createIcon(location);
|
||||
// The zone marker doubles as the circle's draggable center
|
||||
const element = isZone
|
||||
? this._createZoneMarker(location)
|
||||
: this._createIcon(location);
|
||||
this._elements.set(location.id, { key, element });
|
||||
return element;
|
||||
}
|
||||
|
||||
private _createZoneMarker(location: MarkerLocation): HTMLElement {
|
||||
return createZoneMarkerElement({
|
||||
color:
|
||||
location.radius_color ||
|
||||
getComputedStyle(this).getPropertyValue("--accent-color"),
|
||||
icon: location.icon,
|
||||
iconPath: location.iconPath,
|
||||
name: location.name ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
|
||||
if (!location.icon && !location.iconPath) {
|
||||
return undefined;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
el.className = `named-icon ${
|
||||
location.location_editable ? "draggable" : ""
|
||||
}`;
|
||||
el.className = "named-icon";
|
||||
if (location.name !== undefined) {
|
||||
el.innerText = location.name;
|
||||
}
|
||||
@@ -296,12 +340,13 @@ export class HaLocationsEditor extends LitElement {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
|
||||
/* 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. */
|
||||
ha-alert {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
inset-inline-start: 56px;
|
||||
inset-inline-end: var(--ha-space-2);
|
||||
left: 56px;
|
||||
right: var(--ha-space-2);
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
+294
-68
@@ -1,4 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
import { isToday } from "date-fns";
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -31,15 +31,23 @@ 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,
|
||||
@@ -211,9 +219,37 @@ 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 {
|
||||
@@ -264,6 +300,8 @@ 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";
|
||||
|
||||
@@ -296,6 +334,11 @@ 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[] = [];
|
||||
@@ -323,6 +366,21 @@ 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(() => {
|
||||
@@ -385,7 +443,7 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("clusterMarkers")) {
|
||||
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
@@ -401,6 +459,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("paths")) {
|
||||
this._drawPaths();
|
||||
// Cluster bubbles show entity colors only while trails are visible
|
||||
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
|
||||
if (!!oldPaths?.length !== !!this.paths?.length) {
|
||||
this._engine?.refreshClusters();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
|
||||
@@ -433,13 +496,19 @@ export class HaMap extends ReactiveElement {
|
||||
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
|
||||
if (
|
||||
!changedProps.has("themeMode") &&
|
||||
(!changedProps.has("_ui") ||
|
||||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
|
||||
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateMapStyle();
|
||||
// Marker, 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() {
|
||||
@@ -1031,16 +1100,36 @@ export class HaMap extends ReactiveElement {
|
||||
engine.setClustering(null);
|
||||
return;
|
||||
}
|
||||
this._watchRegistry();
|
||||
|
||||
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)];
|
||||
// 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
@@ -1069,26 +1158,29 @@ 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,
|
||||
this._entityReg,
|
||||
computedStyles
|
||||
);
|
||||
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._zoneHandles.push(
|
||||
engine.addCircle(position, { radius, color: zoneMarkerColor })
|
||||
engine.addCircle(position, { radius, color: markerColor })
|
||||
);
|
||||
}
|
||||
|
||||
// create icon
|
||||
const iconEl = document.createElement("div");
|
||||
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
|
||||
if (icon) {
|
||||
const el = document.createElement("ha-icon");
|
||||
el.setAttribute("icon", icon);
|
||||
iconEl.appendChild(el);
|
||||
} else {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = title;
|
||||
iconEl.appendChild(el);
|
||||
}
|
||||
const circleEl = createZoneMarkerElement({
|
||||
color: markerColor,
|
||||
icon,
|
||||
name: title,
|
||||
});
|
||||
|
||||
if (this.interactiveZones) {
|
||||
const openMoreInfo = (ev: Event) => {
|
||||
@@ -1097,8 +1189,8 @@ export class HaMap extends ReactiveElement {
|
||||
entityId: stateObj.entity_id,
|
||||
});
|
||||
};
|
||||
iconEl.addEventListener("click", openMoreInfo);
|
||||
iconEl.addEventListener("keydown", (ev) => {
|
||||
circleEl.addEventListener("click", openMoreInfo);
|
||||
circleEl.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
openMoreInfo(ev);
|
||||
@@ -1106,10 +1198,9 @@ export class HaMap extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
|
||||
this._zoneHandles.push(
|
||||
engine.addMarker(iconEl, position, {
|
||||
size: [zoneIconSize, zoneIconSize],
|
||||
engine.addMarker(circleEl, position, {
|
||||
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
|
||||
interactive: this.interactiveZones,
|
||||
title,
|
||||
})
|
||||
@@ -1119,7 +1210,7 @@ export class HaMap extends ReactiveElement {
|
||||
this.fitZones &&
|
||||
(typeof entity === "string" || entity.focus !== false)
|
||||
) {
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
|
||||
} else {
|
||||
this._focusZonePoints.push(position);
|
||||
@@ -1163,19 +1254,42 @@ 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.entityColor = entity.color;
|
||||
entityMarker.selected = entity.selected ?? false;
|
||||
}
|
||||
|
||||
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,
|
||||
// create circle around if entity has accuracy
|
||||
decoration: gpsAccuracy
|
||||
? { radius: gpsAccuracy, color: darkPrimaryColor }
|
||||
clusterData,
|
||||
decoration: showAccuracy
|
||||
? { radius: gpsAccuracy!, color: entityColor }
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
@@ -1187,22 +1301,97 @@ 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.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],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
private _drawScaleRuler(): void {
|
||||
@@ -1263,6 +1452,7 @@ export class HaMap extends ReactiveElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
}
|
||||
.cluster-open-members {
|
||||
display: flex;
|
||||
@@ -1276,6 +1466,13 @@ 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;
|
||||
@@ -1349,17 +1546,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;
|
||||
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;
|
||||
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-control,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
@@ -1410,19 +1649,6 @@ export class HaMap extends ReactiveElement {
|
||||
ha-icon {
|
||||
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
box-sizing: border-box;
|
||||
background-clip: padding-box;
|
||||
background-color: var(--primary-color);
|
||||
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -639,9 +639,10 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const parentDevice =
|
||||
device?.parent_device_id && device.next_name_part !== "area"
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const context = [
|
||||
area ? computeAreaName(area) : undefined,
|
||||
parentDevice ? computeDeviceName(parentDevice) : undefined,
|
||||
|
||||
@@ -761,9 +761,9 @@ export class HatScriptGraph extends LitElement {
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
display: grid;
|
||||
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,9 +782,10 @@ export class HatScriptGraph extends LitElement {
|
||||
--hat-graph-branch-height: ${BRANCH_HEIGHT}px;
|
||||
}
|
||||
.graph-scroll {
|
||||
flex: 1;
|
||||
grid-area: 1 / 1;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-container {
|
||||
display: flex;
|
||||
@@ -795,6 +796,19 @@ 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;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
EntityRegistryEntry,
|
||||
} from "../entity/entity_registry";
|
||||
import type { EntitySources } from "../entity/entity_sources";
|
||||
import type { RegistryEntry } from "../registry";
|
||||
import type { NextNamePart, RegistryEntry } from "../registry";
|
||||
|
||||
export {
|
||||
fetchDeviceRegistry,
|
||||
@@ -46,6 +46,7 @@ export interface DeviceRegistryEntry extends RegistryEntry {
|
||||
// Set when this device is a child (logical part) of another device.
|
||||
// null for regular top-level devices.
|
||||
parent_device_id: string | null;
|
||||
next_name_part?: NextNamePart | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,7 @@ export interface ChildDeviceRegistryEntry extends RegistryEntry {
|
||||
area_id: string | null;
|
||||
disabled_by: DeviceDisabler | null;
|
||||
parent_device_id: string;
|
||||
next_name_part?: NextNamePart | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LightColor } from "../light";
|
||||
import type { RegistryEntry } from "../registry";
|
||||
import type { NextNamePart, RegistryEntry } from "../registry";
|
||||
import type { Segment } from "../vacuum";
|
||||
|
||||
type EntityCategory = "config" | "diagnostic";
|
||||
@@ -19,6 +19,7 @@ export interface EntityRegistryDisplayEntry {
|
||||
icon?: string;
|
||||
device_id?: string;
|
||||
area_id?: string;
|
||||
next_name_part?: NextNamePart;
|
||||
labels: string[];
|
||||
hidden?: boolean;
|
||||
entity_category?: EntityCategory;
|
||||
@@ -33,6 +34,7 @@ export interface EntityRegistryDisplayEntryResponse {
|
||||
ei: string;
|
||||
di?: string;
|
||||
ai?: string;
|
||||
np?: NextNamePart;
|
||||
lb: string[];
|
||||
ec?: number;
|
||||
en?: string;
|
||||
@@ -56,6 +58,7 @@ export interface EntityRegistryEntry extends RegistryEntry {
|
||||
config_subentry_id: string | null;
|
||||
device_id: string | null;
|
||||
area_id: string | null;
|
||||
next_name_part?: NextNamePart | null;
|
||||
labels: string[];
|
||||
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
|
||||
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
|
||||
|
||||
+17
-3
@@ -5,6 +5,7 @@ import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { UNAVAILABLE, UNKNOWN } from "./entity/entity";
|
||||
import { isNumericEntity } from "./history";
|
||||
|
||||
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
|
||||
@@ -280,17 +281,22 @@ const STATE_ACTION_MESSAGES: Record<
|
||||
radio_frequency: "command_sent",
|
||||
};
|
||||
|
||||
const RESTORABLE_BUTTON_DOMAINS = new Set(["button", "input_button"]);
|
||||
|
||||
export const localizeStateMessage = (
|
||||
hass: HomeAssistant,
|
||||
state: string,
|
||||
stateObj: HassEntity,
|
||||
domain: string,
|
||||
attributes?: LogbookEntry["attributes"]
|
||||
entry?: LogbookEntry
|
||||
): string => {
|
||||
if (state === UNKNOWN || state === UNAVAILABLE) {
|
||||
return hass.formatEntityState(stateObj, state);
|
||||
}
|
||||
// Events show the triggered event type, falling back to a generic label when
|
||||
// the type is unknown (the timestamp state is meaningless on its own).
|
||||
if (domain === "event") {
|
||||
const eventType = attributes?.event_type;
|
||||
const eventType = entry?.attributes?.event_type;
|
||||
if (eventType != null) {
|
||||
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
|
||||
}
|
||||
@@ -298,7 +304,15 @@ export const localizeStateMessage = (
|
||||
}
|
||||
const actionKey: LogbookActionMessage | undefined =
|
||||
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
|
||||
if (actionKey) {
|
||||
const stateTimestamp = Date.parse(state);
|
||||
const matchesEntryTime =
|
||||
entry === undefined ||
|
||||
(Number.isFinite(stateTimestamp) &&
|
||||
Math.abs(stateTimestamp - entry.when * 1000) < 1000);
|
||||
if (
|
||||
actionKey &&
|
||||
(!RESTORABLE_BUTTON_DOMAINS.has(domain) || matchesEntryTime)
|
||||
) {
|
||||
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.${actionKey}`);
|
||||
}
|
||||
// Every other domain reuses the backend state translation, so the logbook
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type NextNamePart = "area" | "device" | "parent_device";
|
||||
|
||||
export interface RegistryEntry {
|
||||
created_at: number;
|
||||
modified_at: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-attribute-icon";
|
||||
@@ -85,12 +86,26 @@ class MoreInfoLight extends LitElement {
|
||||
|
||||
private _setMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._mainControl = ev.currentTarget.control;
|
||||
this._changeMainControl(ev.currentTarget.control);
|
||||
}
|
||||
|
||||
private _resetMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._mainControl = "brightness";
|
||||
this._changeMainControl("brightness");
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// A container that outlives this control (e.g. more-info-content when the
|
||||
// dialog moves between entities) resyncs with the default control.
|
||||
fireEvent(this, "light-main-control-changed", {
|
||||
control: this._mainControl,
|
||||
});
|
||||
}
|
||||
|
||||
private _changeMainControl(control: MainControl) {
|
||||
this._mainControl = control;
|
||||
fireEvent(this, "light-main-control-changed", { control });
|
||||
}
|
||||
|
||||
private get _stateOverride() {
|
||||
@@ -399,4 +414,14 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"more-info-light": MoreInfoLight;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"light-main-control-changed": { control: MainControl };
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"light-main-control-changed": HASSDomEvent<
|
||||
HASSDomEvents["light-main-control-changed"]
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
@@ -11,6 +12,7 @@ import "../../components/ha-badge";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "../../panels/lovelace/card-features/hui-light-color-temp-card-feature";
|
||||
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
|
||||
import type { TileCardConfig } from "../../panels/lovelace/cards/types";
|
||||
import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers";
|
||||
@@ -25,6 +27,8 @@ interface EntityInfo {
|
||||
deviceId: string | undefined;
|
||||
}
|
||||
|
||||
type LightMainControl = HASSDomEvents["light-main-control-changed"]["control"];
|
||||
|
||||
@customElement("more-info-content")
|
||||
class MoreInfoContent extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -37,6 +41,17 @@ class MoreInfoContent extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public data?: Record<string, any>;
|
||||
|
||||
// Mirrors the mode selected in the light control so group members
|
||||
// show the matching slider.
|
||||
@state() private _lightMainControl: LightMainControl = "brightness";
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("light-main-control-changed", (ev) => {
|
||||
this._lightMainControl = ev.detail.control;
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
let moreInfoType: string | undefined;
|
||||
|
||||
@@ -73,7 +88,10 @@ class MoreInfoContent extends LitElement {
|
||||
? html`
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${this._entitiesSectionConfig(memberIds)}
|
||||
.config=${this._entitiesSectionConfig(
|
||||
memberIds,
|
||||
this._lightMainControl
|
||||
)}
|
||||
>
|
||||
</hui-section>
|
||||
`
|
||||
@@ -104,97 +122,110 @@ class MoreInfoContent extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
|
||||
const hass = this.hass!;
|
||||
private _entitiesSectionConfig = memoizeOne(
|
||||
(entityIds: string[], lightMainControl: LightMainControl) => {
|
||||
const hass = this.hass!;
|
||||
|
||||
// Get entity names and areas for all visible entities
|
||||
const entityInfos = entityIds
|
||||
.map<EntityInfo | null>((entityId) => {
|
||||
const entry = hass.entities[entityId];
|
||||
if (entry?.hidden) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const entityName = computeEntityName(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
// Get entity names and areas for all visible entities
|
||||
const entityInfos = entityIds
|
||||
.map<EntityInfo | null>((entityId) => {
|
||||
const entry = hass.entities[entityId];
|
||||
if (entry?.hidden) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const entityName = computeEntityName(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
|
||||
// Check if all entities have the same entity name
|
||||
const entityNames = new Set(entityInfos.map((info) => info.entityName));
|
||||
const allSameEntityName = entityNames.size === 1;
|
||||
// Check if all entities have the same entity name
|
||||
const entityNames = new Set(entityInfos.map((info) => info.entityName));
|
||||
const allSameEntityName = entityNames.size === 1;
|
||||
|
||||
// Check if all entities have the same area
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
// Check if all entities have the same area
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
|
||||
// Build name and state content config based on conditions. The device name
|
||||
// is redundant when every member belongs to the same device, so omit it
|
||||
// (and fall back to the entity name so the tile still has a label).
|
||||
const name: EntityNameItem[] = [];
|
||||
// Build name and state content config based on conditions. The device name
|
||||
// is redundant when every member belongs to the same device, so omit it
|
||||
// (and fall back to the entity name so the tile still has a label).
|
||||
const name: EntityNameItem[] = [];
|
||||
|
||||
if (!allSameDevice) {
|
||||
name.push({ type: "device" });
|
||||
}
|
||||
|
||||
if (!allSameEntityName || allSameDevice) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
const stateContent = ["state"];
|
||||
if (!allSameArea) {
|
||||
stateContent.push("area_name");
|
||||
}
|
||||
|
||||
const cards = entityInfos.map(({ entityId }) => {
|
||||
const features: LovelaceCardFeatureConfig[] = [];
|
||||
const context = { entity_id: entityId };
|
||||
if (supportsCoverPositionCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "cover-position",
|
||||
});
|
||||
} else if (supportsLightBrightnessCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "light-brightness",
|
||||
});
|
||||
if (!allSameDevice) {
|
||||
name.push({ type: "device" });
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
name,
|
||||
state_content: stateContent,
|
||||
features_position: "inline",
|
||||
features,
|
||||
grid_options: { columns: 12 },
|
||||
} as TileCardConfig;
|
||||
});
|
||||
if (!allSameEntityName || allSameDevice) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
return {
|
||||
type: "grid",
|
||||
cards,
|
||||
};
|
||||
});
|
||||
const stateContent = ["state"];
|
||||
if (!allSameArea) {
|
||||
stateContent.push("area_name");
|
||||
}
|
||||
|
||||
const cards = entityInfos.map(({ entityId }) => {
|
||||
const features: LovelaceCardFeatureConfig[] = [];
|
||||
const context = { entity_id: entityId };
|
||||
if (supportsCoverPositionCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "cover-position",
|
||||
});
|
||||
} else if (lightMainControl === "color") {
|
||||
// There is no RGB tile feature yet, so when the group is set to
|
||||
// color the members show no control rather than a mismatched
|
||||
// brightness slider.
|
||||
} else if (
|
||||
lightMainControl === "color_temp" &&
|
||||
supportsLightColorTempCardFeature(hass, context)
|
||||
) {
|
||||
features.push({
|
||||
type: "light-color-temp",
|
||||
});
|
||||
} else if (supportsLightBrightnessCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "light-brightness",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
name,
|
||||
state_content: stateContent,
|
||||
features_position: "inline",
|
||||
features,
|
||||
grid_options: { columns: 12 },
|
||||
} as TileCardConfig;
|
||||
});
|
||||
|
||||
return {
|
||||
type: "grid",
|
||||
cards,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
hui-section {
|
||||
|
||||
@@ -21,6 +21,10 @@ 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;
|
||||
@@ -48,6 +52,7 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
showDialog({ narrow }) {
|
||||
this.narrow = narrow;
|
||||
this._unsubNotifications = subscribeNotifications(
|
||||
this.hass.connection,
|
||||
(notifications) => {
|
||||
@@ -218,11 +223,9 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
: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);
|
||||
}
|
||||
|
||||
.list-container {
|
||||
@@ -246,11 +249,9 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
:host([narrow]) .notifications {
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
padding-inline-end: var(--safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
.notification {
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
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));
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
@@ -35,7 +38,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
.header img {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createContext, provide } from "@lit/context";
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
@@ -9,6 +10,10 @@ import { getLocalLanguage, getTranslation } from "../util/common-translation";
|
||||
|
||||
const empty = () => "";
|
||||
|
||||
export const translationsReadyContext = createContext<boolean>(
|
||||
"translationsReadyContext"
|
||||
);
|
||||
|
||||
export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
|
||||
superClass: T
|
||||
) => {
|
||||
@@ -16,6 +21,10 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
|
||||
// Initialized to empty will prevent undefined errors if called before connected to DOM.
|
||||
@property({ attribute: false }) public localize: LocalizeFunc = empty;
|
||||
|
||||
@provide({ context: translationsReadyContext })
|
||||
@state()
|
||||
public translationsReady = false;
|
||||
|
||||
// Use browser language setup before login.
|
||||
@property() public language: string = getLocalLanguage();
|
||||
|
||||
@@ -59,6 +68,7 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
|
||||
this._resources
|
||||
).then((localize) => {
|
||||
this.localize = localize;
|
||||
this.translationsReady = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
|
||||
href="https://www.home-assistant.io/getting-started/onboarding/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>${this.localize("ui.panel.page-onboarding.help")}</a
|
||||
>${this.localize("ui.panel.page-onboarding.help") || "Help"}</a
|
||||
>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../components/ha-card";
|
||||
import "../components/ha-ripple";
|
||||
import "../components/ha-svg-icon";
|
||||
import { translationsReadyContext } from "../mixins/lit-localize-lite-mixin";
|
||||
import { renderSkeleton, skeletonStyles } from "./render-skeleton";
|
||||
|
||||
@customElement("onboarding-welcome-link")
|
||||
class OnboardingWelcomeLink extends LitElement {
|
||||
@property() public label!: string;
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ attribute: false }) public iconPath!: string;
|
||||
|
||||
@property({ type: Boolean }) public noninteractive = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: translationsReadyContext, subscribe: true })
|
||||
private _translationsReady?;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card
|
||||
@@ -20,7 +27,7 @@ class OnboardingWelcomeLink extends LitElement {
|
||||
@keydown=${this._handleKeyDown}
|
||||
>
|
||||
<ha-svg-icon .path=${this.iconPath}></ha-svg-icon>
|
||||
${this.label}
|
||||
${this._translationsReady === false ? renderSkeleton("label") : this.label}
|
||||
<ha-ripple></ha-ripple>
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -32,36 +39,39 @@ class OnboardingWelcomeLink extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
cursor: pointer;
|
||||
}
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding: 32px 16px;
|
||||
height: 100%;
|
||||
}
|
||||
ha-svg-icon {
|
||||
color: var(--text-primary-color);
|
||||
background: var(--welcome-link-color, var(--primary-color));
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
padding: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card:focus-visible:before {
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: "";
|
||||
inset: 0;
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.08;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
skeletonStyles,
|
||||
css`
|
||||
:host {
|
||||
cursor: pointer;
|
||||
}
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding: 32px 16px;
|
||||
height: 100%;
|
||||
}
|
||||
ha-svg-icon {
|
||||
color: var(--text-primary-color);
|
||||
background: var(--welcome-link-color, var(--primary-color));
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
padding: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card:focus-visible:before {
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: "";
|
||||
inset: 0;
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.08;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
@@ -9,6 +10,8 @@ import "../components/ha-button";
|
||||
import "../components/ha-icon-next";
|
||||
import "../components/item/ha-list-item-button";
|
||||
import "../components/list/ha-list-base";
|
||||
import { translationsReadyContext } from "../mixins/lit-localize-lite-mixin";
|
||||
import { renderSkeleton, skeletonStyles } from "./render-skeleton";
|
||||
import { onBoardingStyles } from "./styles";
|
||||
|
||||
@customElement("onboarding-welcome")
|
||||
@@ -17,44 +20,89 @@ class OnboardingWelcome extends LitElement {
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<h1>${this._localize("ui.panel.page-onboarding.welcome.header")}</h1>
|
||||
<p>${this._localize("ui.panel.page-onboarding.intro")}</p>
|
||||
@state()
|
||||
@consume({ context: translationsReadyContext, subscribe: true })
|
||||
private _translationsReady!: boolean;
|
||||
|
||||
<ha-button @click=${this._start} class="start">
|
||||
${this._localize("ui.panel.page-onboarding.welcome.start")}
|
||||
protected render(): TemplateResult {
|
||||
const ready = this._translationsReady;
|
||||
return html`
|
||||
<h1>
|
||||
${
|
||||
ready
|
||||
? this._localize("ui.panel.page-onboarding.welcome.header")
|
||||
: renderSkeleton("title")
|
||||
}
|
||||
</h1>
|
||||
<p>
|
||||
${
|
||||
ready
|
||||
? this._localize("ui.panel.page-onboarding.intro")
|
||||
: renderSkeleton("line")
|
||||
}
|
||||
</p>
|
||||
|
||||
<ha-button @click=${this._start} class="start" .disabled=${!ready}>
|
||||
${
|
||||
ready
|
||||
? this._localize("ui.panel.page-onboarding.welcome.start")
|
||||
: renderSkeleton("button")
|
||||
}
|
||||
</ha-button>
|
||||
|
||||
<div class="divider">
|
||||
<wa-divider></wa-divider>
|
||||
<div>
|
||||
<span
|
||||
>${this._localize(
|
||||
"ui.panel.page-onboarding.welcome.or_restore"
|
||||
)}</span
|
||||
>
|
||||
<span>
|
||||
${
|
||||
ready
|
||||
? this._localize("ui.panel.page-onboarding.welcome.or_restore")
|
||||
: renderSkeleton("chip")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ha-list-base>
|
||||
<ha-list-item-button @click=${this._restoreBackupUpload}>
|
||||
<ha-list-item-button
|
||||
@click=${this._restoreBackupUpload}
|
||||
.disabled=${!ready}
|
||||
>
|
||||
<div slot="headline">
|
||||
${this._localize("ui.panel.page-onboarding.restore.upload_backup")}
|
||||
${
|
||||
ready
|
||||
? this._localize(
|
||||
"ui.panel.page-onboarding.restore.upload_backup"
|
||||
)
|
||||
: renderSkeleton("headline")
|
||||
}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this._localize(
|
||||
"ui.panel.page-onboarding.restore.options.upload_description"
|
||||
)}
|
||||
${
|
||||
ready
|
||||
? this._localize(
|
||||
"ui.panel.page-onboarding.restore.options.upload_description"
|
||||
)
|
||||
: renderSkeleton("line")
|
||||
}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button @click=${this._restoreBackupCloud}>
|
||||
<div slot="headline">Home Assistant Cloud</div>
|
||||
<ha-list-item-button
|
||||
@click=${this._restoreBackupCloud}
|
||||
.disabled=${!ready}
|
||||
>
|
||||
<div slot="headline">
|
||||
${ready ? "Home Assistant Cloud" : renderSkeleton("headline")}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this._localize(
|
||||
"ui.panel.page-onboarding.restore.ha-cloud.description"
|
||||
)}
|
||||
${
|
||||
ready
|
||||
? this._localize(
|
||||
"ui.panel.page-onboarding.restore.ha-cloud.description"
|
||||
)
|
||||
: renderSkeleton("line")
|
||||
}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
@@ -93,10 +141,12 @@ class OnboardingWelcome extends LitElement {
|
||||
margin-bottom: calc(var(--ha-space-4) * -1);
|
||||
}
|
||||
h1 {
|
||||
width: 100%;
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
p {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.start {
|
||||
@@ -129,6 +179,7 @@ class OnboardingWelcome extends LitElement {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
`,
|
||||
skeletonStyles,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
|
||||
/** Placeholder shown in place of a text while onboarding translations load. */
|
||||
export const renderSkeleton = (variant: string): TemplateResult =>
|
||||
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
|
||||
|
||||
export const skeletonStyles = css`
|
||||
.skeleton {
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
.skeleton.title {
|
||||
width: 200px;
|
||||
}
|
||||
.skeleton.line {
|
||||
width: 100%;
|
||||
}
|
||||
.skeleton.headline {
|
||||
width: 40%;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.skeleton.chip {
|
||||
width: 80px;
|
||||
}
|
||||
.skeleton.button {
|
||||
width: 120px;
|
||||
}
|
||||
.skeleton.label {
|
||||
width: 80px;
|
||||
}
|
||||
`;
|
||||
@@ -476,13 +476,6 @@ export class HAFullCalendar extends LitElement {
|
||||
const wasShowingToday = this._isShowingToday();
|
||||
const nextMidnight = new TZDate(new Date(), this._calendarTimeZone());
|
||||
nextMidnight.setHours(24, 0, 0, 0);
|
||||
const delay = nextMidnight.getTime() - Date.now();
|
||||
|
||||
// Guard against a NaN/negative delay (e.g. Intl longOffset unsupported on
|
||||
// Chromium < 95) so the midnight refresh can't fire in a tight loop (#54182).
|
||||
if (!Number.isFinite(delay) || delay <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._midnightRefreshTimeout = window.setTimeout(() => {
|
||||
if (wasShowingToday) {
|
||||
@@ -492,7 +485,7 @@ export class HAFullCalendar extends LitElement {
|
||||
}
|
||||
|
||||
this._scheduleMidnightRefresh();
|
||||
}, delay);
|
||||
}, nextMidnight.getTime() - Date.now());
|
||||
}
|
||||
|
||||
private _clearMidnightRefreshTimeout(): void {
|
||||
|
||||
@@ -925,9 +925,10 @@ class DialogAddAutomationElement
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const parentDevice =
|
||||
device?.parent_device_id && device.next_name_part !== "area"
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
if (area) {
|
||||
subtitle = [
|
||||
computeAreaName(area) || area.area_id,
|
||||
|
||||
@@ -109,11 +109,11 @@ export const rowStyles = css`
|
||||
text-box-edge: cap alphabetic;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 180ms ease-out,
|
||||
width 180ms ease-out,
|
||||
margin-inline-end 180ms ease-out,
|
||||
border-width 180ms ease-out;
|
||||
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 {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiLinkVariantOff } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } 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";
|
||||
@@ -32,6 +33,7 @@ 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)
|
||||
@@ -52,7 +54,28 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
selectedTriggers.map(
|
||||
(option) => html`
|
||||
<span class="trigger-reference">
|
||||
<span class="trigger-index-badge">${option.index + 1}</span>
|
||||
<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
|
||||
}
|
||||
<ha-trigger-icon
|
||||
.trigger=${"trigger" in option.trigger ? option.trigger.trigger : ""}
|
||||
></ha-trigger-icon>
|
||||
@@ -132,6 +155,22 @@ 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ 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,
|
||||
@@ -47,6 +52,19 @@ import { configSections } from "../config-sections";
|
||||
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
|
||||
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
|
||||
|
||||
interface PendingEdit {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
// How close the saved value must come to a pending one to count as saved
|
||||
const PENDING_TOLERANCE: Record<keyof PendingEdit, number> = {
|
||||
latitude: 1e-7,
|
||||
longitude: 1e-7,
|
||||
radius: 0.5,
|
||||
};
|
||||
|
||||
@customElement("ha-config-zone")
|
||||
export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -61,21 +79,36 @@ 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[]): MarkerLocation[] => {
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
zoneEntityIds: Record<string, string>,
|
||||
pendingEdits: Record<string, PendingEdit>,
|
||||
entityReg: EntityRegistryEntry[],
|
||||
_colorVersion: number
|
||||
): MarkerLocation[] => {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
|
||||
const passiveRadiusColor = computedStyles.getPropertyValue(
|
||||
"--secondary-text-color"
|
||||
);
|
||||
const homeRadiusColor =
|
||||
computedStyles.getPropertyValue("--primary-color");
|
||||
|
||||
const stateLocations: MarkerLocation[] = stateItems.map(
|
||||
(entityState) => ({
|
||||
@@ -85,21 +118,28 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
latitude: entityState.attributes.latitude,
|
||||
longitude: entityState.attributes.longitude,
|
||||
radius: entityState.attributes.radius,
|
||||
radius_color:
|
||||
entityState.entity_id === "zone.home"
|
||||
? homeRadiusColor
|
||||
: entityState.attributes.passive
|
||||
? passiveRadiusColor
|
||||
: zoneRadiusColor,
|
||||
...pendingEdits[entityState.entity_id],
|
||||
radius_color: zoneColor(
|
||||
entityState.entity_id,
|
||||
!!entityState.attributes.passive,
|
||||
entityReg,
|
||||
computedStyles
|
||||
),
|
||||
location_editable:
|
||||
entityState.entity_id === "zone.home" && this._canEditCore,
|
||||
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
|
||||
radius_editable:
|
||||
entityState.entity_id === "zone.home" && this._canEditCore,
|
||||
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
|
||||
})
|
||||
);
|
||||
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
|
||||
...zone,
|
||||
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
|
||||
...pendingEdits[zone.id],
|
||||
radius_color: zoneColor(
|
||||
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
|
||||
!!zone.passive,
|
||||
entityReg,
|
||||
computedStyles
|
||||
),
|
||||
location_editable: true,
|
||||
radius_editable: true,
|
||||
}));
|
||||
@@ -110,9 +150,18 @@ 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();
|
||||
}),
|
||||
];
|
||||
@@ -265,7 +314,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
<ha-locations-editor
|
||||
.locations=${this._getZones(
|
||||
this._storageItems,
|
||||
this._stateItems
|
||||
this._stateItems,
|
||||
this._zoneEntityIds,
|
||||
this._pendingEdits,
|
||||
this._entityReg,
|
||||
this._colorVersion
|
||||
)}
|
||||
@location-updated=${this._locationUpdated}
|
||||
@radius-updated=${this._radiusUpdated}
|
||||
@@ -295,7 +348,8 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
!this.route.path.startsWith("/edit/") ||
|
||||
!this._stateItems ||
|
||||
@@ -313,11 +367,99 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
super.willUpdate(changedProps);
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (oldHass && this._stateItems) {
|
||||
this._getStates(oldHass);
|
||||
}
|
||||
// Zone colors come from theme variables
|
||||
if (oldHass && oldHass.themes !== this.hass.themes) {
|
||||
this._colorVersion++;
|
||||
}
|
||||
this._settlePendingEdits();
|
||||
}
|
||||
|
||||
// A pending edit is done once the saved data carries its values
|
||||
private _settlePendingEdits() {
|
||||
for (const [id, pending] of Object.entries(this._pendingEdits)) {
|
||||
const saved =
|
||||
this._storageItems?.find((zone) => zone.id === id) ??
|
||||
this.hass.states[id]?.attributes;
|
||||
if (
|
||||
saved &&
|
||||
(Object.keys(pending) as (keyof PendingEdit)[]).every(
|
||||
(key) =>
|
||||
Math.abs((saved[key] as number) - pending[key]!) <
|
||||
PENDING_TOLERANCE[key]
|
||||
)
|
||||
) {
|
||||
this._dropPendingEdit(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _dropPendingEdit(id: string) {
|
||||
const { [id]: _done, ...rest } = this._pendingEdits;
|
||||
this._pendingEdits = rest;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -359,37 +501,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private async _locationUpdated(ev: CustomEvent) {
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
latitude: ev.detail.location[0],
|
||||
longitude: ev.detail.location[1],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this._updateEntry(entry, {
|
||||
private _locationUpdated(ev: CustomEvent) {
|
||||
this._saveEdit(ev.detail.id, {
|
||||
latitude: ev.detail.location[0],
|
||||
longitude: ev.detail.location[1],
|
||||
});
|
||||
}
|
||||
|
||||
private async _radiusUpdated(ev: CustomEvent) {
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
radius: Math.round(ev.detail.radius),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this._updateEntry(entry, {
|
||||
radius: ev.detail.radius,
|
||||
private _radiusUpdated(ev: CustomEvent) {
|
||||
this._saveEdit(ev.detail.id, {
|
||||
radius:
|
||||
ev.detail.id === HOME_ZONE_ENTITY_ID
|
||||
? Math.round(ev.detail.radius)
|
||||
: ev.detail.radius,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -416,7 +540,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
const entryId: string = (ev.currentTarget! as any).value;
|
||||
|
||||
if (this.narrow && entryId === "zone.home") {
|
||||
if (this.narrow && entryId === HOME_ZONE_ENTITY_ID) {
|
||||
this._editHomeZone(ev);
|
||||
return;
|
||||
}
|
||||
@@ -475,7 +599,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
longitude: values.longitude,
|
||||
radius: values.radius,
|
||||
});
|
||||
this._zoomZone("zone.home");
|
||||
this._zoomZone(HOME_ZONE_ENTITY_ID);
|
||||
}
|
||||
|
||||
private async _updateEntry(
|
||||
|
||||
@@ -320,13 +320,7 @@ const computeLogbookValue = (
|
||||
if (item.entity_id && item.state) {
|
||||
return {
|
||||
text: stateObj
|
||||
? localizeStateMessage(
|
||||
hass,
|
||||
item.state,
|
||||
stateObj,
|
||||
domain!,
|
||||
item.attributes
|
||||
)
|
||||
? localizeStateMessage(hass, item.state, stateObj, domain!, item)
|
||||
: item.state,
|
||||
type: "state",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Home-circle stroke lengths for the energy distribution card.
|
||||
*
|
||||
* Hourly allocation can produce used_solar + used_battery + used_grid larger
|
||||
* than net used_total when some hours export more than they produce. Dividing
|
||||
* by net used_total then overflows the circle and the leftover grid arc goes
|
||||
* negative, which browsers paint as a solid grid ring.
|
||||
*
|
||||
* These arcs use the sum of the allocated home flows as the denominator so
|
||||
* they always fit the circumference.
|
||||
*/
|
||||
export const ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
|
||||
export interface EnergyDistributionHomeCircleArcs {
|
||||
solar?: number;
|
||||
battery?: number;
|
||||
lowCarbon?: number;
|
||||
highCarbon?: number;
|
||||
grid?: number;
|
||||
}
|
||||
|
||||
export const computeEnergyDistributionHomeCircleArcs = ({
|
||||
usedSolar = 0,
|
||||
usedBattery = 0,
|
||||
usedGrid = 0,
|
||||
hasSolar,
|
||||
hasGrid,
|
||||
highCarbonConsumption,
|
||||
circumference = ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE,
|
||||
}: {
|
||||
usedSolar?: number;
|
||||
usedBattery?: number;
|
||||
usedGrid?: number;
|
||||
hasSolar: boolean;
|
||||
hasGrid: boolean;
|
||||
highCarbonConsumption?: number;
|
||||
circumference?: number;
|
||||
}): EnergyDistributionHomeCircleArcs => {
|
||||
const solar = Math.max(usedSolar, 0);
|
||||
const battery = Math.max(usedBattery, 0);
|
||||
const grid = Math.max(usedGrid, 0);
|
||||
const ringTotal = solar + battery + grid;
|
||||
|
||||
const arcs: EnergyDistributionHomeCircleArcs = {};
|
||||
// Leave arcs unset so the card can fall back to the plain home border
|
||||
// instead of painting zero-length dashes over a borderless circle.
|
||||
if (ringTotal <= 0) {
|
||||
return arcs;
|
||||
}
|
||||
|
||||
const share = (value: number): number => circumference * (value / ringTotal);
|
||||
|
||||
if (hasSolar) {
|
||||
arcs.solar = share(solar);
|
||||
}
|
||||
if (battery > 0) {
|
||||
arcs.battery = share(battery);
|
||||
}
|
||||
if (hasGrid) {
|
||||
if (highCarbonConsumption !== undefined) {
|
||||
const highCarbon = Math.min(Math.max(highCarbonConsumption, 0), grid);
|
||||
arcs.highCarbon = share(highCarbon);
|
||||
// Keep 0 defined: the card mounts the home SVG when solar or
|
||||
// lowCarbon is defined, not when the low-carbon stroke is painted.
|
||||
arcs.lowCarbon = share(grid - highCarbon);
|
||||
arcs.grid = arcs.highCarbon;
|
||||
} else {
|
||||
arcs.grid = share(grid);
|
||||
}
|
||||
}
|
||||
|
||||
return arcs;
|
||||
};
|
||||
@@ -37,8 +37,10 @@ import type { LovelaceCard } from "../../types";
|
||||
import type { EnergyDistributionCardConfig } from "../types";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { round } from "../../../../common/number/round";
|
||||
|
||||
const CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
import {
|
||||
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE_CIRCUMFERENCE,
|
||||
computeEnergyDistributionHomeCircleArcs,
|
||||
} from "./energy-distribution-home-circle";
|
||||
|
||||
// Flows are differences of sums; anything that rounds to 0 Wh is noise
|
||||
const hasFlow = (value: number | null): value is number =>
|
||||
@@ -321,22 +323,8 @@ class HuiEnergyDistrubutionCard
|
||||
|
||||
const totalHomeConsumption = Math.max(0, consumption.total.used_total);
|
||||
|
||||
let homeSolarCircumference: number | undefined;
|
||||
if (hasSolarProduction) {
|
||||
homeSolarCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption);
|
||||
}
|
||||
|
||||
let homeBatteryCircumference: number | undefined;
|
||||
if (batteryConsumption) {
|
||||
homeBatteryCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption);
|
||||
}
|
||||
|
||||
let lowCarbonEnergy: number | undefined;
|
||||
|
||||
let homeLowCarbonCircumference: number | undefined;
|
||||
let homeHighCarbonCircumference: number | undefined;
|
||||
let highCarbonConsumption: number | undefined;
|
||||
|
||||
// This fallback is used in the demo
|
||||
let electricityMapUrl = "https://app.electricitymaps.com";
|
||||
@@ -360,7 +348,6 @@ class HuiEnergyDistrubutionCard
|
||||
if (highCarbonEnergy !== null) {
|
||||
lowCarbonEnergy = totalFromGrid - highCarbonEnergy;
|
||||
|
||||
let highCarbonConsumption: number;
|
||||
if (gridConsumption !== totalFromGrid) {
|
||||
// Only get the part that was used for consumption and not the battery
|
||||
highCarbonConsumption =
|
||||
@@ -368,18 +355,23 @@ class HuiEnergyDistrubutionCard
|
||||
} else {
|
||||
highCarbonConsumption = highCarbonEnergy;
|
||||
}
|
||||
|
||||
homeHighCarbonCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
|
||||
|
||||
homeLowCarbonCircumference =
|
||||
CIRCLE_CIRCUMFERENCE -
|
||||
(homeSolarCircumference || 0) -
|
||||
(homeBatteryCircumference || 0) -
|
||||
homeHighCarbonCircumference;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
solar: homeSolarCircumference,
|
||||
battery: homeBatteryCircumference,
|
||||
lowCarbon: homeLowCarbonCircumference,
|
||||
grid: homeGridCircumference,
|
||||
} = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: solarConsumption ?? 0,
|
||||
usedBattery: batteryConsumption ?? 0,
|
||||
usedGrid: gridConsumption,
|
||||
hasSolar: hasSolarProduction,
|
||||
hasGrid: Boolean(hasGrid),
|
||||
highCarbonConsumption,
|
||||
});
|
||||
|
||||
const totalLines =
|
||||
gridConsumption +
|
||||
(solarConsumption || 0) +
|
||||
@@ -670,16 +662,8 @@ class HuiEnergyDistrubutionCard
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="38"
|
||||
stroke-dasharray="${
|
||||
homeHighCarbonCircumference ??
|
||||
CIRCLE_CIRCUMFERENCE -
|
||||
homeSolarCircumference! -
|
||||
(homeBatteryCircumference || 0)
|
||||
} ${
|
||||
homeHighCarbonCircumference !== undefined
|
||||
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
|
||||
: homeSolarCircumference! +
|
||||
(homeBatteryCircumference || 0)
|
||||
stroke-dasharray="${homeGridCircumference} ${
|
||||
CIRCLE_CIRCUMFERENCE - (homeGridCircumference ?? 0)
|
||||
}"
|
||||
stroke-dashoffset="0"
|
||||
shape-rendering="geometricPrecision"
|
||||
|
||||
@@ -8,8 +8,13 @@ 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 { getColorByIndex } from "../../../common/color/colors";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
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";
|
||||
@@ -31,6 +36,10 @@ 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 {
|
||||
@@ -58,6 +67,18 @@ 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;
|
||||
@@ -76,12 +97,19 @@ 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>;
|
||||
@@ -213,7 +241,12 @@ 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)}
|
||||
.paths=${this._getHistoryPaths(
|
||||
this._config,
|
||||
this._stateHistory,
|
||||
this._entityReg,
|
||||
this._themes
|
||||
)}
|
||||
.autoFit=${this._config.auto_fit || false}
|
||||
.fitZones=${this._config.fit_zones || false}
|
||||
.themeMode=${themeMode}
|
||||
@@ -321,6 +354,10 @@ 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) {
|
||||
@@ -432,18 +469,19 @@ 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 {
|
||||
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,
|
||||
this._entityReg,
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
return color;
|
||||
return entityMapColor(entityId, this._entityReg, computedStyles);
|
||||
}
|
||||
|
||||
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
|
||||
@@ -503,7 +541,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
private _getHistoryPaths = memoizeOne(
|
||||
(
|
||||
config: MapCardConfig,
|
||||
history?: HistoryStates
|
||||
history: HistoryStates | undefined,
|
||||
// Trail colors follow the registry order and the theme like the markers
|
||||
_entityReg: EntityRegistryEntry[],
|
||||
_themes: Themes | undefined
|
||||
): HaMapPaths[] | undefined => {
|
||||
if (!history || !(config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW)) {
|
||||
return undefined;
|
||||
|
||||
@@ -189,6 +189,10 @@ 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",
|
||||
|
||||
@@ -274,6 +274,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
|
||||
entity_id: entity.ei,
|
||||
device_id: entity.di,
|
||||
area_id: entity.ai,
|
||||
next_name_part: entity.np,
|
||||
labels: entity.lb,
|
||||
translation_key: entity.tk,
|
||||
platform: entity.pl,
|
||||
|
||||
@@ -1537,7 +1537,10 @@
|
||||
"title": "Map vacuum segments to areas",
|
||||
"no_segments": "No segments available",
|
||||
"area_label": "Area",
|
||||
"description": "Configure which areas correspond to each vacuum segment"
|
||||
"description": "Configure which areas correspond to each vacuum segment",
|
||||
"orphaned_header": "Segments no longer on the vacuum",
|
||||
"orphaned_description": "These areas were mapped to segments the vacuum no longer reports. Remove them to keep the mapping up to date.",
|
||||
"orphaned_remove": "Remove mapping for {name}"
|
||||
},
|
||||
"codemirror": {
|
||||
"open_documentation": "Open documentation"
|
||||
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
computeEntityNameDisplay,
|
||||
computeEntityNameDisplayWithoutContext,
|
||||
computeEntityNameList,
|
||||
computeEntitySearchLabels,
|
||||
DEFAULT_ENTITY_NAME,
|
||||
type EntityNameItem,
|
||||
} from "../../../src/common/entity/compute_entity_name_display";
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
mockArea,
|
||||
@@ -381,6 +386,117 @@ describe("computeEntityNameDisplay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("name context", () => {
|
||||
const areas = {
|
||||
kitchen: mockArea({ area_id: "kitchen", name: "Kitchen" }),
|
||||
garage: mockArea({ area_id: "garage", name: "Garage" }),
|
||||
};
|
||||
const powerStrip = mockDevice({
|
||||
id: "strip",
|
||||
name: "Power strip",
|
||||
area_id: "kitchen",
|
||||
next_name_part: "area",
|
||||
});
|
||||
const stateObj = mockStateObj({ entity_id: "switch.freezer" });
|
||||
const chain: EntityNameItem[] = [
|
||||
{ type: "area" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
];
|
||||
|
||||
const registries = (
|
||||
entity: Partial<EntityRegistryDisplayEntry>,
|
||||
freezer: Partial<DeviceRegistryEntry>
|
||||
) => ({
|
||||
entities: {
|
||||
"switch.freezer": mockEntity({
|
||||
entity_id: "switch.freezer",
|
||||
device_id: "freezer",
|
||||
...entity,
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
strip: powerStrip,
|
||||
freezer: mockDevice({
|
||||
id: "freezer",
|
||||
name: "Freezer",
|
||||
parent_device_id: "strip",
|
||||
...freezer,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const joinedNameList = (
|
||||
{ entities, devices }: ReturnType<typeof registries>,
|
||||
name: EntityNameItem[] = chain
|
||||
) =>
|
||||
computeEntityNameList(stateObj, name, entities, devices, areas, {})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
it("leaves out the parent device when the child device has its own area", () => {
|
||||
const result = joinedNameList(
|
||||
registries(
|
||||
{ name: "Power", next_name_part: "device" },
|
||||
{ area_id: "garage", next_name_part: "area" }
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toBe("Garage Freezer Power");
|
||||
});
|
||||
|
||||
it("leaves out the device and its parent when the entity has its own area", () => {
|
||||
const result = joinedNameList(
|
||||
registries(
|
||||
{ name: "Power", area_id: "garage", next_name_part: "area" },
|
||||
{ next_name_part: "parent_device" }
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toBe("Garage Power");
|
||||
});
|
||||
|
||||
it("keeps the device name for an entity without a name of its own", () => {
|
||||
const result = joinedNameList(
|
||||
registries(
|
||||
{ area_id: "garage", next_name_part: "area" },
|
||||
{ next_name_part: "parent_device" }
|
||||
),
|
||||
DEFAULT_ENTITY_NAME
|
||||
);
|
||||
|
||||
expect(result).toBe("Freezer");
|
||||
});
|
||||
|
||||
it("keeps every configured part in the formatted name", () => {
|
||||
const { entities, devices } = registries(
|
||||
{ name: "Power", area_id: "garage", next_name_part: "area" },
|
||||
{ next_name_part: "parent_device" }
|
||||
);
|
||||
|
||||
expect(
|
||||
computeEntityNameDisplay(stateObj, chain, entities, devices, areas, {})
|
||||
).toBe("Garage Power strip Freezer Power");
|
||||
});
|
||||
|
||||
it("keeps the owners left out of the name in the search labels", () => {
|
||||
const { entities, devices } = registries(
|
||||
{ name: "Power", area_id: "garage", next_name_part: "area" },
|
||||
{ next_name_part: "parent_device" }
|
||||
);
|
||||
|
||||
expect(
|
||||
computeEntitySearchLabels(stateObj, entities, devices, areas, {})
|
||||
).toMatchObject({
|
||||
entityName: "Power",
|
||||
deviceName: "Freezer",
|
||||
parentDeviceName: "Power strip",
|
||||
areaName: "Garage",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEntityNameList", () => {
|
||||
it("returns list of names for each item type", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1030,10 +1030,7 @@ describe("MapLibreMapEngine", () => {
|
||||
expect(handle.radius).toBeCloseTo(110, 5);
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
|
||||
// The host echoes the old radius once before its save returns
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.radius).toBeCloseTo(110, 5);
|
||||
// A later identical update is a real change
|
||||
// Once committed, the host is the truth again
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.radius).toBe(100);
|
||||
});
|
||||
@@ -1100,25 +1097,24 @@ describe("MapLibreMapEngine", () => {
|
||||
expect(element.getAttribute("aria-valuenow")).toBe("150000");
|
||||
});
|
||||
|
||||
it("ignores one update echoing the values from before a drag", async () => {
|
||||
it("ignores host updates only while a drag is in progress", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, center } = addCircle(engine);
|
||||
|
||||
center.lngLat = [4.01, 52];
|
||||
center.fire("dragstart");
|
||||
// Nothing moves while a drag is in progress
|
||||
// The user's hand wins while dragging
|
||||
handle.update([53, 5], 500);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
center.fire("drag");
|
||||
center.fire("dragend");
|
||||
|
||||
// The host saves and echoes the old values once; that is not a move back
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.center).toEqual([52, 4.01]);
|
||||
// A later identical update is a real change
|
||||
|
||||
// Afterwards the host is the truth, even when it moves the circle back
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
expect(handle.radius).toBe(100);
|
||||
});
|
||||
|
||||
it("activates the center by click, Enter and Space, but not after a drag", async () => {
|
||||
|
||||
@@ -1,11 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import {
|
||||
localizeStateMessage,
|
||||
localizeTriggerSource,
|
||||
parseTriggerSource,
|
||||
} from "../../src/data/logbook";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
const fakeLocalize = ((key: string) => `<${key}>`) as any;
|
||||
|
||||
const fakeHass = {
|
||||
localize: fakeLocalize,
|
||||
formatEntityState: (_stateObj, state: string) => `<state:${state}>`,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const fakeStateObj = {
|
||||
entity_id: "button.restart",
|
||||
state: "unknown",
|
||||
attributes: {},
|
||||
} as HassEntity;
|
||||
|
||||
describe("localizeStateMessage", () => {
|
||||
it.each(["unknown", "unavailable"])(
|
||||
"preserves the %s lifecycle state for timestamp entities",
|
||||
(state) => {
|
||||
expect(
|
||||
localizeStateMessage(fakeHass, state, fakeStateObj, "button")
|
||||
).toBe(`<state:${state}>`);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the action label for a timestamp state", () => {
|
||||
expect(
|
||||
localizeStateMessage(
|
||||
fakeHass,
|
||||
"2026-09-15T11:05:05+00:00",
|
||||
fakeStateObj,
|
||||
"button",
|
||||
{
|
||||
name: "Restart",
|
||||
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
|
||||
}
|
||||
)
|
||||
).toBe("<ui.components.logbook.messages.pressed>");
|
||||
});
|
||||
|
||||
it("does not treat a restored timestamp with inherited context as a new action", () => {
|
||||
const restoredState = "2026-09-15T10:55:25+00:00";
|
||||
expect(
|
||||
localizeStateMessage(fakeHass, restoredState, fakeStateObj, "button", {
|
||||
name: "Restart",
|
||||
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
|
||||
context_domain: "homeassistant",
|
||||
context_service: "reload_config_entry",
|
||||
})
|
||||
).toBe(`<state:${restoredState}>`);
|
||||
});
|
||||
|
||||
it("keeps the action label for a producer timestamp", () => {
|
||||
expect(
|
||||
localizeStateMessage(
|
||||
fakeHass,
|
||||
"2026-09-15T10:55:25+00:00",
|
||||
fakeStateObj,
|
||||
"image",
|
||||
{
|
||||
name: "Satellite image",
|
||||
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
|
||||
}
|
||||
)
|
||||
).toBe("<ui.components.logbook.messages.updated>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeTriggerSource", () => {
|
||||
it("replaces a known phrase with the bare translation", () => {
|
||||
expect(localizeTriggerSource(fakeLocalize, "Home Assistant starting")).toBe(
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Protects the energy-distribution home ring from overflowing when hourly
|
||||
* allocation yields more used_solar/used_battery/used_grid than net used_total
|
||||
* (https://github.com/home-assistant/frontend/issues/54185).
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE,
|
||||
computeEnergyDistributionHomeCircleArcs,
|
||||
} from "../../../../../src/panels/lovelace/cards/energy/energy-distribution-home-circle";
|
||||
|
||||
const sumDefinedArcs = (
|
||||
arcs: ReturnType<typeof computeEnergyDistributionHomeCircleArcs>
|
||||
): number =>
|
||||
(arcs.solar ?? 0) +
|
||||
(arcs.battery ?? 0) +
|
||||
(arcs.lowCarbon ?? 0) +
|
||||
(arcs.grid ?? 0);
|
||||
|
||||
describe("computeEnergyDistributionHomeCircleArcs", () => {
|
||||
it("sizes arcs from allocated home flows so they fill the circle", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 8.5,
|
||||
usedBattery: 4.6,
|
||||
usedGrid: 0.12,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.approximately(arcs.solar!, CIRCLE * (8.5 / 13.22), 1e-6);
|
||||
assert.approximately(arcs.battery!, CIRCLE * (4.6 / 13.22), 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * (0.12 / 13.22), 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
assert.isAtLeast(arcs.grid!, 0);
|
||||
});
|
||||
|
||||
it("does not paint a full grid ring when used_solar exceeds net home energy", () => {
|
||||
// Screenshot totals from #54185 with solar and export in different hours:
|
||||
// used_solar 77.1, used_battery 0, used_grid 0, net used_total 13.22.
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 77.1,
|
||||
usedBattery: 0,
|
||||
usedGrid: 0,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.approximately(arcs.solar!, CIRCLE, 1e-6);
|
||||
assert.isUndefined(arcs.battery);
|
||||
assert.equal(arcs.grid, 0);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
|
||||
it("keeps a zero-consumption ring from producing NaN or negative dashes", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 0,
|
||||
usedBattery: 0,
|
||||
usedGrid: 0,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(arcs, {});
|
||||
});
|
||||
|
||||
it("splits grid into low-carbon and high-carbon without exceeding the grid share", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 4,
|
||||
usedBattery: 0,
|
||||
usedGrid: 6,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 2,
|
||||
});
|
||||
|
||||
const ringTotal = 10;
|
||||
assert.approximately(arcs.solar!, CIRCLE * (4 / ringTotal), 1e-6);
|
||||
assert.approximately(arcs.lowCarbon!, CIRCLE * (4 / ringTotal), 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * (2 / ringTotal), 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
assert.isAtLeast(arcs.lowCarbon!, 0);
|
||||
assert.isAtLeast(arcs.grid!, 0);
|
||||
});
|
||||
|
||||
it("clamps high-carbon consumption to the grid share", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 5,
|
||||
usedBattery: 0,
|
||||
usedGrid: 5,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 50,
|
||||
});
|
||||
|
||||
assert.equal(arcs.lowCarbon, 0);
|
||||
assert.approximately(arcs.grid!, CIRCLE * 0.5, 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
|
||||
it("keeps a defined zero low-carbon arc so a grid-only high-carbon ring still renders", () => {
|
||||
// The card only mounts the home SVG when solar or lowCarbon is defined.
|
||||
// Omitting 0 would drop battery/grid arcs in a no-solar 100% fossil grid.
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 0,
|
||||
usedBattery: 4,
|
||||
usedGrid: 6,
|
||||
hasSolar: false,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 6,
|
||||
});
|
||||
|
||||
assert.isUndefined(arcs.solar);
|
||||
assert.equal(arcs.lowCarbon, 0);
|
||||
assert.approximately(arcs.battery!, CIRCLE * 0.4, 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * 0.6, 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user