Compare commits

...
Author SHA1 Message Date
Petar Petrov 66cc3f62c5 Refresh voice expose table when an entity alias changes (#54233) 2026-09-19 09:40:32 +02:00
Petar Petrov fb1a75edd4 Defer chart work while the chart is not visible (#54251)
* Defer chart work while the chart is not visible

ha-chart-base did its full update path no matter whether anyone could see
the chart: every data or options change re-ran the line downsampling and
chart.setOption, and the resulting animation kept repainting the canvas.
That applied to charts in a section hidden by a visibility condition, to
charts scrolled far off a long page, and to every chart in a background
tab, where live history charts kept paying it on each stream message.

Gate the work on rendered size, an IntersectionObserver with one viewport
of margin, and document.visibilityState. While invisible the chart records
which properties changed and replays them in a single setOption once it is
visible again. The initial setup is deferred the same way, so a chart that
first renders hidden is built at its real width, and a synced zoom that
arrives before that setup is replayed instead of dropped.

* Track chart visibility from the observers instead of measuring

Reading getBoundingClientRect() in willUpdate forced a synchronous layout
for every chart on every data or options change, right after the parent had
committed DOM. Keep the state the observers already report instead: whether
the resize entry has a non-zero content box, and whether the intersection
observer sees the element. Both start true so a chart on screen still
renders on its first update, and firstUpdated keeps one measurement so a
chart that first renders inside a hidden container defers its setup.

The observer margin is one screenful only on a phone; a desktop gets a fixed
band, where a whole viewport would cover most of the page.

A reset to the full range now clears a stored zoom rather than replaying it,
and the global reset no longer skips a chart that has not been built yet, so
a deferred chart cannot come back zoomed after everything else was reset.

* Queue a synced zoom while a chart rebuild is pending

* Use the observed chart width for the default height and downsampling

* Use MobileAwareMixin for the chart's mobile checks
2026-09-19 09:34:14 +02:00
Maarten Lakerveld 8f7184023a Improve zone editor (#54031)
* Show a zone's own icon and color on the zone dialog map

The location selector's icon option was never applied: the expression
parsed as (icon || radius) ? radius-icon : marker-icon, so the zone
dialog always showed the radius marker instead of the zone's icon. The
selector also gains a color option, and both zone dialogs pass the
zone's icon and its map color (the config panel hands the dialog the
zone's entity id for that), so the map in the dialog looks like the zone
does everywhere else, including the home zone and a live passive toggle.

* Show zone markers in the zone list

The zones list on the zone config page shows each zone as it looks on
the map: its color with its icon, or initials without one, instead of
a plain icon.

* Sort zones alphabetically with home first

The zone config page listed UI zones sorted and YAML zones after them in
state order; the map overview sorted all zones by name. Both now show the
home zone first and the rest alphabetically.

* Color the zone list and dialogs from the registry context

The zone list avatar and the zone dialog map take the registry entries; the
home zone dialog needs none, its color is fixed.

* Show zone initials in the editor and resolve color from registry

Fall back to the zone's initials (matching the list and map) when it has
no icon, and to a plain map-marker when there is no name yet, instead of
the map-marker-radius pin.

Resolve the zone's entity id reactively from the registry context the
dialog already consumes, so the marker color is correct even when the
registry loads after the dialog opens (e.g. direct /edit/ deep link).

* Preview the palette color a new zone will take

Creating a zone has no entity yet, so the marker fell back to the accent
color and then changed on save. Derive the prospective creation-order
color from the registry so the create preview matches the saved zone.

* Test the new zone's previewed color

Cover nextZoneColor: the next creation-order slot for an active zone (home
excluded) and the muted color for a passive one, so an off-by-one in the
slot count can't silently make the create preview differ from the saved zone.
2026-09-19 09:30:31 +02:00
Maarten Lakerveld f3aba8f142 Activity timeline dots colored by zone in map panel (#54030)
* Color activity timeline dots by zone

Zone changes in a person's or device's activity timeline show the dot in
that zone's color, the same color as its marker on the map and its row
in the zones tab; away and unknown stay grey. Arrivals and departures in
a zone's own timeline keep their green and grey.

* Color activity dots from the registry context

The zone color of a timeline entry takes the registry entries the overview
already consumes.
2026-09-19 08:23:36 +03:00
tzagimandYosi Levy 9c28c21792 Fix RTL bidi corruption of min/max temperature in more-info weather dialog (#54255)
* Fix RTL bidi corruption of min/max temperature in more-info weather dialog

* Fix direction also on attribution style

---------

Co-authored-by: Yosi Levy <[email protected]>
2026-09-19 08:15:50 +03:00
Jan-Philipp Benecke ccceddfd35 Add missing labels to previous and next tracked node in trace graph (#54244) 2026-09-19 08:13:19 +03:00
17 changed files with 552 additions and 157 deletions
+15
View File
@@ -56,6 +56,21 @@ export const entityMapColor = (
computedStyles
);
/** The color a new zone will take once created, from the next creation-order slot */
export const nextZoneColor = (
passive: boolean,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string => {
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return getColorByIndex(
Object.keys(creationIndex(entries)).length,
computedStyles
);
};
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
export const zoneColor = (
entityId: string,
+195 -36
View File
@@ -34,6 +34,7 @@ import type {
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type {
@@ -58,6 +59,16 @@ const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
const DEFAULT_CHART_WIDTH = 500;
// Slack so a chart is up to date before a scroll can reach it. A phone screen
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const DEFERRED_PROPS = [
"options",
"data",
"_hiddenDatasets",
"_isZoomed",
] as const;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -106,7 +117,7 @@ export type CustomLegendOption = ECOption["legend"] & {
};
@customElement("ha-chart-base")
export class HaChartBase extends LitElement {
export class HaChartBase extends MobileAwareMixin(LitElement) {
public chart?: EChartsType;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -183,20 +194,38 @@ export class HaChartBase extends LitElement {
private _layoutTransitionActive = false;
// Both start visible so a chart on screen renders on its first update rather
// than waiting a frame for the observers. A chart that starts off screen is
// therefore still built once; only its later updates are gated.
private _intersecting = true;
private _hasSize = true;
// Reported by the ResizeObserver, so rendering and downsampling need not
// measure layout themselves once it has fired.
private _contentWidth?: number;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => {
callback: (entries) => {
// The controller also fires once with no entries when it starts observing.
const contentRect = entries[entries.length - 1]?.contentRect;
if (contentRect) {
this._contentWidth = contentRect.width;
this._hasSize = contentRect.width > 0 && contentRect.height > 0;
}
if (this.chart) {
if (this._suspendResize) {
this._shouldResizeChart = true;
return;
}
if (!this.chart.getZr().animation.isFinished()) {
} else if (!this.chart.getZr().animation.isFinished()) {
this._shouldResizeChart = true;
} else {
this.chart.resize();
}
}
if (!this._suspendResize) {
this._applyDeferredWork();
}
},
});
@@ -210,10 +239,24 @@ export class HaChartBase extends LitElement {
private _pendingSetup = false;
private _pendingUpdate?: Set<PropertyKey>;
private _pendingOptions?: HaECOption;
private _pendingZoom?: [number, number, boolean];
public disconnectedCallback() {
super.disconnectedCallback();
this._legendPointerCancel();
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
this._intersecting = false;
this._hasSize = false;
this._contentWidth = undefined;
while (this._listeners.length) {
this._listeners.pop()!();
}
@@ -227,14 +270,31 @@ export class HaChartBase extends LitElement {
super.connectedCallback();
if (this.hasUpdated) {
this._pendingSetup = true;
afterNextRender(() => {
if (this.isConnected && this._pendingSetup) {
this._pendingSetup = false;
this._setupChart();
}
});
afterNextRender(() => this._applyDeferredWork());
}
const handleVisibilityChange = () => this._applyDeferredWork();
document.addEventListener("visibilitychange", handleVisibilityChange);
this._listeners.push(() =>
document.removeEventListener("visibilitychange", handleVisibilityChange)
);
const intersectionObserver = new IntersectionObserver(
(entries) => {
this._intersecting = entries[entries.length - 1].isIntersecting;
if (!this._suspendResize) {
this._applyDeferredWork();
}
},
{
rootMargin: this._isMobileSize
? VISIBILITY_ROOT_MARGIN_NARROW
: VISIBILITY_ROOT_MARGIN,
}
);
intersectionObserver.observe(this);
this._listeners.push(() => intersectionObserver.disconnect());
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
@@ -300,6 +360,7 @@ export class HaChartBase extends LitElement {
this._suspendResize = this._layoutTransitionActive;
if (!this._suspendResize) {
this._resizeChartIfNeeded();
this._applyDeferredWork();
}
};
window.addEventListener("hass-layout-transition", handleLayoutTransition);
@@ -312,24 +373,105 @@ export class HaChartBase extends LitElement {
}
protected firstUpdated() {
if (this.isConnected) {
this._setupChart();
if (!this.isConnected) {
return;
}
// The only measurement taken here: neither observer has reported yet, and a
// chart first rendered inside a hidden container has to defer its setup
// rather than build against a guessed width.
this._hasSize = this.clientWidth > 0 && this.clientHeight > 0;
if (this._isVisible()) {
this._setupChart();
} else {
this._pendingSetup = true;
}
}
private _isVisible() {
return (
document.visibilityState !== "hidden" &&
this._hasSize &&
this._intersecting
);
}
private _deferUpdate(changedProps: PropertyValues) {
for (const prop of DEFERRED_PROPS) {
if (!changedProps.has(prop)) {
continue;
}
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
private async _applyDeferredWork() {
if (!this._pendingSetup && !this._pendingUpdate) {
return;
}
if (!this.isConnected || !this._isVisible()) {
return;
}
if (this._pendingSetup) {
await this._setupChart();
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
}
public willUpdate(changedProps: PropertyValues): void {
if (!this.chart) {
return;
}
if (changedProps.has("_themes") && this.hasUpdated) {
this._setupChart();
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
}
const invisible = !this._isVisible();
if (themeChanged) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
return;
}
let chartOptions: ECOption = {};
if (changedProps.has("options")) {
// Separate 'if' from below since this must updated before _getSeries()
// Separate 'if' from below since this must be updated before _getSeries().
// It stays out of _applyChartUpdate so a replay cannot request another
// update and turn one catch-up render into two.
this._updateHiddenStatsFromOptions(this.options);
}
if (invisible) {
if (!this._pendingSetup) {
this._deferUpdate(changedProps);
}
return;
}
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
}
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
@@ -347,12 +489,7 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
if (
this._compareCustomLegendOptions(
changedProps.get("options"),
this.options
)
) {
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
@@ -463,10 +600,7 @@ export class HaChartBase extends LitElement {
}
}
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const overflowLimit = isMobile
const overflowLimit = this._isMobileSize
? LEGEND_OVERFLOW_LIMIT_MOBILE
: LEGEND_OVERFLOW_LIMIT;
return html`<div
@@ -578,7 +712,11 @@ export class HaChartBase extends LitElement {
// costs the user their place in the tab order, so stay programmatically
// focusable for as long as we hold focus, however we stop being sonifiable.
this._sonificationFocusHeld = true;
if (this._sonification || this._sonificationLoading || !this.chart) {
if (this._sonification || this._sonificationLoading) {
return;
}
await this._applyDeferredWork();
if (!this.chart) {
return;
}
this._sonificationLoading = true;
@@ -635,14 +773,22 @@ export class HaChartBase extends LitElement {
);
private async _setupChart() {
if (this._loading) return;
if (this._loading) {
this._pendingSetup = true;
return;
}
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
}
const echarts = (await import("../../resources/echarts/echarts")).default;
@@ -780,8 +926,14 @@ export class HaChartBase extends LitElement {
series: this._getSeries(),
});
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
this._pendingZoom = undefined;
this.chart.dispatchAction({ type: "dataZoom", start, end, silent });
}
} finally {
this._loading = false;
this._applyDeferredWork();
}
}
@@ -927,9 +1079,7 @@ export class HaChartBase extends LitElement {
};
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const isMobile = this._isMobileSize;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
@@ -1166,8 +1316,8 @@ export class HaChartBase extends LitElement {
data: downSampleLineData(
data as LineSeriesOption["data"],
// 0 while inside a hidden container, e.g. a section with a visibility condition
(this.clientWidth || DEFAULT_CHART_WIDTH) *
window.devicePixelRatio,
((this._contentWidth ?? this.clientWidth) ||
DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
minX,
maxX
),
@@ -1180,7 +1330,7 @@ export class HaChartBase extends LitElement {
}
private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 200);
return Math.max((this._contentWidth ?? this.clientWidth) / 2, 200);
}
private _setChartOptions(options: ECOption) {
@@ -1247,7 +1397,16 @@ export class HaChartBase extends LitElement {
};
public zoom(start: number, end: number, silent = false) {
this.chart?.dispatchAction({
if (!this.chart || this._pendingSetup) {
// Sibling charts sync their zoom imperatively, so a range that arrives
// before a deferred setup or rebuild has to be replayed rather than
// dropped. A reset to the full range is what a fresh chart is built with,
// so it just clears.
this._pendingZoom =
start === 0 && end === 100 ? undefined : [start, end, silent];
return;
}
this.chart.dispatchAction({
type: "dataZoom",
start,
end,
+1 -1
View File
@@ -375,7 +375,7 @@ export class StateHistoryCharts extends LitElement {
const chartBase =
chartComponent.renderRoot?.querySelector("ha-chart-base");
if (chartBase && chartBase.chart) {
if (chartBase) {
chartBase.zoom(0, 100);
}
});
@@ -127,11 +127,12 @@ export class HaLocationSelector extends LitElement {
? this.hass.config.longitude
: value.longitude,
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
radius_color: zoneRadiusColor,
radius_color: selector.location?.color || zoneRadiusColor,
name: selector.location?.name,
icon:
selector.location?.icon || selector.location?.radius
? "mdi:map-marker-radius"
: "mdi:map-marker",
selector.location?.icon ||
// No icon: show the name's initials, else a default marker
(selector.location?.name ? undefined : "mdi:map-marker"),
location_editable: true,
radius_editable:
!!selector.location?.radius && !selector.location?.radius_readonly,
+13 -1
View File
@@ -19,10 +19,12 @@ import {
mdiShuffleDisabled,
} from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import type { PropertyValues } from "lit";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { Condition, Trigger } from "../../data/automation";
import {
getActionType,
@@ -60,6 +62,10 @@ declare global {
@customElement("hat-script-graph")
export class HatScriptGraph extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public trace!: TraceExtended;
@property({ attribute: false }) public selected?: string;
@@ -472,11 +478,17 @@ export class HatScriptGraph extends LitElement {
</div>
<div class="actions">
<ha-icon-button
label=${this._localize(
"ui.panel.config.automation.trace.previous_tracked_node"
)}
.disabled=${paths.length === 0 || paths[0] === this.selected}
@click=${this._previousTrackedNode}
.path=${mdiChevronUp}
></ha-icon-button>
<ha-icon-button
label=${this._localize(
"ui.panel.config.automation.trace.next_tracked_node"
)}
.disabled=${
paths.length === 0 || paths[paths.length - 1] === this.selected
}
+4
View File
@@ -368,6 +368,10 @@ export interface LocationSelector {
radius?: boolean;
radius_readonly?: boolean;
icon?: string;
/** Name whose initials the marker shows when there is no icon */
name?: string;
/** Marker and radius color; defaults to the theme's zone color */
color?: string;
} | null;
}
@@ -567,6 +567,7 @@ class MoreInfoWeather extends LitElement {
.attribution {
text-align: center;
margin-top: var(--ha-space-4);
direction: ltr;
}
.time-ago,
@@ -642,6 +643,7 @@ class MoreInfoWeather extends LitElement {
.attribute {
font-size: var(--ha-font-size-m);
line-height: 1;
direction: ltr;
}
.name-state {
@@ -78,6 +78,7 @@ class DialogVoiceSettings extends LitElement {
private _entityEntryUpdated(ev: CustomEvent) {
this._params!.extEntityReg = ev.detail;
this._params!.entityEntryUpdated?.(ev.detail);
}
private _exposedEntitiesChanged() {
@@ -711,6 +711,9 @@ export class VoiceAssistantsExpose extends LitElement {
exposedEntitiesChanged: () => {
fireEvent(this, "exposed-entities-changed");
},
entityEntryUpdated: (entry) => {
this._extEntities = { ...this._extEntities, [entityId]: entry };
},
});
}
@@ -7,6 +7,7 @@ export interface VoiceSettingsDialogParams {
exposed: ExposeEntitySettings;
extEntityReg?: ExtEntityRegistryEntry;
exposedEntitiesChanged?: () => void;
entityEntryUpdated?: (entry: ExtEntityRegistryEntry) => void;
}
export const loadVoiceSettingsDialog = () => import("./dialog-voice-settings");
@@ -12,14 +12,21 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
import {
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../common/map/entity-map-colors";
const SCHEMA = [
{
name: "location",
required: true,
selector: { location: { radius: true } },
},
];
const SCHEMA = memoizeOne(
(icon: string, color: string) =>
[
{
name: "location",
required: true,
selector: { location: { radius: true, icon, color } },
},
] as const
);
@customElement("dialog-home-zone-detail")
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
@@ -81,7 +88,11 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
<ha-form
autofocus
.hass=${this.hass}
.schema=${SCHEMA}
.schema=${SCHEMA(
this.hass.states[HOME_ZONE_ENTITY_ID]?.attributes.icon ||
"mdi:home",
zoneColor(HOME_ZONE_ENTITY_ID, false, [], getComputedStyle(this))
)}
.data=${this._formData(this._data)}
.error=${this._error}
.computeLabel=${this._computeLabel}
+42 -4
View File
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -9,12 +10,18 @@ import "../../../components/ha-dialog";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-button";
import type { SchemaUnion } from "../../../components/ha-form/types";
import type { ZoneMutableParams } from "../../../data/zone";
import type { Zone, ZoneMutableParams } from "../../../data/zone";
import { getZoneEditorInitData } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
import {
nextZoneColor,
zoneColor,
} from "../../../common/map/entity-map-colors";
import { fullEntitiesContext } from "../../../data/context";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
@customElement("dialog-zone-detail")
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
@@ -22,6 +29,11 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
) {
@property({ attribute: false }) public hass!: HomeAssistant;
// Registry creation order decides the zone color
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entityReg: EntityRegistryEntry[] = [];
@state() private _error?: Record<string, string>;
@state() private _data?: ZoneMutableParams;
@@ -89,6 +101,22 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
!lngInvalid &&
!radiusInvalid;
// From the registry context, so a deep link opening before the registry
// loads still resolves the color
const entityId = this._zoneEntityId(this._params.entry, this._entityReg);
const color = entityId
? zoneColor(
entityId,
!!this._data.passive,
this._entityReg,
getComputedStyle(this)
)
: nextZoneColor(
!!this._data.passive,
this._entityReg,
getComputedStyle(this)
);
return html`
<ha-dialog
.open=${this._open}
@@ -105,7 +133,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
<ha-form
autofocus
.hass=${this.hass}
.schema=${this._schema(this._data.icon)}
.schema=${this._schema(this._data.icon, color, this._data.name)}
.data=${this._formData(this._data)}
.error=${this._error}
.computeLabel=${this._computeLabel}
@@ -152,8 +180,18 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
`;
}
// Storage zones register their entity with the zone id as unique id
private _zoneEntityId = memoizeOne(
(entry: Zone | undefined, entityReg: EntityRegistryEntry[]) =>
entry
? entityReg.find(
(ent) => ent.platform === "zone" && ent.unique_id === entry.id
)?.entity_id
: undefined
);
private _schema = memoizeOne(
(icon?: string) =>
(icon?: string, color?: string, name?: string) =>
[
{
name: "name",
@@ -172,7 +210,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
{
name: "location",
required: true,
selector: { location: { radius: true, icon } },
selector: { location: { radius: true, icon, color, name } },
},
{ name: "passive_note", type: "constant" },
{ name: "passive", selector: { boolean: {} } },
+175 -95
View File
@@ -3,6 +3,7 @@ import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
@@ -28,6 +29,10 @@ import {
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../common/map/entity-map-colors";
import {
contrastingZoneContent,
zoneInitials,
} from "../../../common/map/zone-marker";
import type {
HomeZoneMutableParams,
Zone,
@@ -96,9 +101,157 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
// 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
// Bumped on a theme change to recompute the memoized locations
@state() private _colorVersion = 0;
// Home first, then alphabetical, for UI and YAML zones alike
private _sortedItems = memoizeOne(
(
storageItems: Zone[],
stateItems: HassEntity[],
language: string
): ({ entry: Zone } | { stateObject: HassEntity })[] => {
const items = [
...storageItems.map((entry) => ({
entry,
name: entry.name,
home: false,
})),
...stateItems.map((stateObject) => ({
stateObject,
name: stateObject.attributes.friendly_name || stateObject.entity_id,
home: stateObject.entity_id === HOME_ZONE_ENTITY_ID,
})),
];
return items.sort((a, b) =>
a.home !== b.home
? a.home
? -1
: 1
: stringCompare(a.name, b.name, language)
);
}
);
private _renderStorageItem(entry: Zone) {
const hass = this.hass;
return html`
<ha-list-item
.entry=${entry}
.id=${this.narrow ? entry.id : ""}
graphic="avatar"
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
>
${this._renderZoneGraphic(
this._zoneEntityIds[entry.id] ?? `zone.${entry.id}`,
!!entry.passive,
entry.icon,
entry.name
)}
${entry.name}
${
!this.narrow
? html`
<div slot="meta">
<ha-icon-button
.id=${entry.id}
.entry=${entry}
@click=${this._openEditEntry}
.path=${mdiPencil}
.label=${hass.localize("ui.common.edit_item", {
name: entry.name,
})}
></ha-icon-button>
</div>
`
: ""
}
</ha-list-item>
`;
}
private _renderStateItem(stateObject: HassEntity) {
const hass = this.hass;
return html`
<ha-list-item
graphic="avatar"
.id=${this.narrow ? stateObject.entity_id : ""}
.hasMeta=${!this.narrow || stateObject.entity_id !== "zone.home"}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
.noEdit=${stateObject.entity_id !== "zone.home" || !this._canEditCore}
>
${this._renderZoneGraphic(
stateObject.entity_id,
!!stateObject.attributes.passive,
stateObject.attributes.icon,
stateObject.attributes.friendly_name || stateObject.entity_id
)}
${stateObject.attributes.friendly_name || stateObject.entity_id}
${
this.narrow &&
stateObject.entity_id === "zone.home" &&
!this._canEditCore
? nothing
: html`<ha-icon-button
.id="zone-${slugify(stateObject.entity_id)}"
.entityId=${stateObject.entity_id}
.noEdit=${
stateObject.entity_id !== "zone.home" || !this._canEditCore
}
.path=${
stateObject.entity_id === "zone.home" && this._canEditCore
? mdiPencil
: mdiPencilOff
}
.label=${hass.localize("ui.common.edit_item", {
name: hass.config.location_name,
})}
@click=${this._editHomeZone}
slot="meta"
></ha-icon-button>
<ha-tooltip
.for="zone-${slugify(stateObject.entity_id)}"
placement="left"
.disabled=${stateObject.entity_id === "zone.home"}
hoist
>
${hass.localize("ui.panel.config.zone.configured_in_yaml")}
</ha-tooltip>`
}
</ha-list-item>
`;
}
// The zone as it looks on the map: its color, with its icon or initials
private _renderZoneGraphic(
entityId: string,
passive: boolean,
icon: string | undefined,
name: string
) {
const color = zoneColor(
entityId,
passive,
this._entityReg,
getComputedStyle(this)
);
return html`
<div
slot="graphic"
class="zone-avatar"
style=${styleMap({
background: color,
color: contrastingZoneContent(color),
})}
>
${icon ? html`<ha-icon .icon=${icon}></ha-icon>` : zoneInitials(name)}
</div>
`;
}
private _getZones = memoizeOne(
(
storageItems: Zone[],
@@ -189,100 +342,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
`
: html`
<ha-list>
${this._storageItems.map(
(entry) => html`
<ha-list-item
.entry=${entry}
.id=${this.narrow ? entry.id : ""}
graphic="icon"
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
>
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
${entry.name}
${
!this.narrow
? html`
<div slot="meta">
<ha-icon-button
.id=${entry.id}
.entry=${entry}
@click=${this._openEditEntry}
.path=${mdiPencil}
.label=${hass.localize("ui.common.edit_item", {
name: entry.name,
})}
></ha-icon-button>
</div>
`
: ""
}
</ha-list-item>
`
)}
${this._stateItems.map(
(stateObject) => html`
<ha-list-item
graphic="icon"
.id=${this.narrow ? stateObject.entity_id : ""}
.hasMeta=${
!this.narrow || stateObject.entity_id !== "zone.home"
}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
.noEdit=${
stateObject.entity_id !== "zone.home" ||
!this._canEditCore
}
>
<ha-icon
.icon=${stateObject.attributes.icon}
slot="graphic"
>
</ha-icon>
${
stateObject.attributes.friendly_name ||
stateObject.entity_id
}
${
this.narrow &&
stateObject.entity_id === "zone.home" &&
!this._canEditCore
? nothing
: html`<ha-icon-button
.id="zone-${slugify(stateObject.entity_id)}"
.entityId=${stateObject.entity_id}
.noEdit=${
stateObject.entity_id !== "zone.home" ||
!this._canEditCore
}
.path=${
stateObject.entity_id === "zone.home" &&
this._canEditCore
? mdiPencil
: mdiPencilOff
}
.label=${hass.localize("ui.common.edit_item", {
name: hass.config.location_name,
})}
@click=${this._editHomeZone}
slot="meta"
></ha-icon-button>
<ha-tooltip
.for="zone-${slugify(stateObject.entity_id)}"
placement="left"
.disabled=${stateObject.entity_id === "zone.home"}
hoist
>
${hass.localize(
"ui.panel.config.zone.configured_in_yaml"
)}
</ha-tooltip>`
}
</ha-list-item>
`
${this._sortedItems(
this._storageItems,
this._stateItems,
hass.locale.language
).map((item) =>
"entry" in item
? this._renderStorageItem(item.entry)
: this._renderStateItem(item.stateObject)
)}
</ha-list>
`;
@@ -673,6 +740,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
ha-icon-button:not([disabled]) {
color: var(--secondary-text-color);
}
.zone-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--ha-font-weight-medium);
}
.zone-avatar ha-icon {
color: inherit;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
}
ha-icon-button {
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
}
+6 -2
View File
@@ -65,6 +65,9 @@ class HaLogbookEntry extends LitElement {
@property({ type: Boolean, attribute: false }) public graphColor = false;
/** Overrides the node color, e.g. the color of the zone a person entered */
@property({ attribute: false }) public nodeColor?: string;
@property({ type: String, attribute: "name-detail" })
public nameDetail?: LogbookNameDetail;
@@ -384,13 +387,14 @@ class HaLogbookEntry extends LitElement {
(domain === "sensor" && stateObj!.attributes.device_class === "enum");
const useGraphColor = this.graphColor || !isEnumDomain;
const color =
layout === "inline" && !isUnavailable && this.item.state && useGraphColor
this.nodeColor ??
(layout === "inline" && !isUnavailable && this.item.state && useGraphColor
? computeTimelineColor(
this.item.state,
(this._computedStyle ??= getComputedStyle(this)),
stateObj
)
: nodeColor(item.category, stateObj);
: nodeColor(item.category, stateObj));
const style = color ? styleMap({ "--node-color": color }) : nothing;
if (layout !== "timeline") {
return html`<span
@@ -12,8 +12,11 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { zoneColor } from "../../../../common/map/entity-map-colors";
import { contrastingZoneContent } from "../../../../common/map/zone-marker";
import {
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../../common/map/entity-map-colors";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../../common/entity/compute_state_name";
@@ -277,12 +280,19 @@ export class HuiMapOverview extends LitElement {
stateObj.attributes.latitude !== undefined &&
stateObj.attributes.longitude !== undefined
)
.sort((a, b) =>
computeStateName(a).localeCompare(
.sort((a, b) => {
// Home first
if (
(a.entity_id === HOME_ZONE_ENTITY_ID) !==
(b.entity_id === HOME_ZONE_ENTITY_ID)
) {
return a.entity_id === HOME_ZONE_ENTITY_ID ? -1 : 1;
}
return computeStateName(a).localeCompare(
computeStateName(b),
this._i18n.locale.language
)
);
);
});
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
@@ -502,6 +512,30 @@ export class HuiMapOverview extends LitElement {
`;
}
// The color of the zone a person's state names; undefined when away or unknown
private _zoneColorForState(entityState: string): string | undefined {
if (entityState === "not_home" || entityState === "unknown") {
return undefined;
}
const zone =
entityState === "home"
? this._states[HOME_ZONE_ENTITY_ID]
: Object.values(this._states).find(
(candidate) =>
computeStateDomain(candidate) === "zone" &&
computeStateName(candidate) === entityState
);
if (!zone) {
return undefined;
}
return zoneColor(
zone.entity_id,
!!zone.attributes.passive,
this._entityReg,
getComputedStyle(this)
);
}
// A logbook row: the person, the zone they are in now, and when. On the
// zone tab the row belongs to the person who arrived or left.
private _renderActivityEntry(
@@ -520,11 +554,17 @@ export class HuiMapOverview extends LitElement {
entity_id: entityId,
state: entry.state,
};
// Zone changes take the zone's color; arrivals and departures keep the
// logbook's own colors
const stateColor = entry.personId
? undefined
: this._zoneColorForState(entry.state);
return html`
<ha-logbook-entry
.hass=${this.hass}
.item=${item}
.lastOfDay=${last}
.nodeColor=${stateColor}
narrow
no-detail
></ha-logbook-entry>
@@ -894,7 +934,11 @@ export class HuiMapOverview extends LitElement {
border-radius: 50%;
border: none;
background: var(--accent-color);
color: var(--text-accent-color, var(--text-primary-color));
color: #fff;
}
.avatar.zone ha-state-icon {
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
}
ha-relative-time {
+2
View File
@@ -6185,6 +6185,8 @@
"edit_automation": "Edit automation",
"older_trace": "Older trace",
"newer_trace": "Newer trace",
"previous_tracked_node": "Previous tracked node",
"next_tracked_node": "Next tracked node",
"no_traces_found": "No traces found",
"trace_no_longer_available": "Chosen trace is no longer available",
"enter_downloaded_trace": "Enter downloaded trace",
+18
View File
@@ -3,6 +3,7 @@ import type { EntityRegistryEntry } from "../../../src/data/entity/entity_regist
import {
entityMapColor,
HOME_ZONE_ENTITY_ID,
nextZoneColor,
zoneColor,
} from "../../../src/common/map/entity-map-colors";
@@ -62,6 +63,23 @@ describe("entity map colors", () => {
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
});
it("previews the next slot for a new zone", () => {
const entries = [
entry(HOME_ZONE_ENTITY_ID, 10), // excluded from the palette
entry("zone.work", 20),
entry("person.anne", 30),
];
// Two ordered entities take color-1 and color-2, so the next is color-3
expect(nextZoneColor(false, entries, styles)).toBe("color-3");
});
it("mutes a new passive zone", () => {
expect(nextZoneColor(true, [entry("zone.work", 10)], styles)).toBe(
"secondary-text-color"
);
});
it("gives entities outside the registry a stable color", () => {
const entries = [entry("zone.work", 10)];