mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-19 11:48:08 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66cc3f62c5 | ||
|
|
fb1a75edd4 | ||
|
|
8f7184023a | ||
|
|
f3aba8f142 | ||
|
|
9c28c21792 | ||
|
|
ccceddfd35 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {} } },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user