mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-07 18:03:29 +00:00
Compare commits
6 Commits
ha-list-sh
...
fix-3383
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
337f0e9f34 | ||
|
|
0e1aa400d7 | ||
|
|
00e57454ed | ||
|
|
0e6b342b3f | ||
|
|
7ad8c27aa3 | ||
|
|
f01c202bbd |
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Walks up the composed tree (jumping shadow roots → their hosts), returning
|
||||
* the ancestor chain top-down. Used to compare two nodes that may live in
|
||||
* different shadow trees — `Node.compareDocumentPosition` only works within a
|
||||
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
|
||||
*/
|
||||
const composedAncestorPath = (node: Node): Node[] => {
|
||||
const path: Node[] = [];
|
||||
let cur: Node | null = node;
|
||||
while (cur) {
|
||||
path.push(cur);
|
||||
const parent = cur.parentNode;
|
||||
if (parent instanceof ShadowRoot) {
|
||||
cur = parent.host;
|
||||
} else if (parent) {
|
||||
cur = parent;
|
||||
} else {
|
||||
const root = cur.getRootNode();
|
||||
cur = root instanceof ShadowRoot ? root.host : null;
|
||||
}
|
||||
}
|
||||
return path.reverse();
|
||||
};
|
||||
|
||||
/**
|
||||
* Document-order comparator that works across shadow boundaries. Suitable as
|
||||
* the `Array.prototype.sort` callback for collections of nodes that may live
|
||||
* in different shadow trees.
|
||||
*/
|
||||
export const compareNodeOrder = (a: Node, b: Node): number => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
const pa = composedAncestorPath(a);
|
||||
const pb = composedAncestorPath(b);
|
||||
let i = 0;
|
||||
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
|
||||
i++;
|
||||
}
|
||||
if (i === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (i === pa.length) {
|
||||
return -1;
|
||||
}
|
||||
if (i === pb.length) {
|
||||
return 1;
|
||||
}
|
||||
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
? -1
|
||||
: 1;
|
||||
};
|
||||
40
src/components/chart/chart-tooltip-position.ts
Normal file
40
src/components/chart/chart-tooltip-position.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
|
||||
* with the displayed content.
|
||||
*/
|
||||
export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -60,6 +61,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public names?: Record<string, string>;
|
||||
|
||||
@property({ attribute: false }) public colors?: Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
@property() public unit?: string;
|
||||
|
||||
@property() public identifier?: string;
|
||||
@@ -405,8 +411,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -435,9 +440,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
this._chartTime = new Date();
|
||||
const endTime = this.endTime;
|
||||
const names = this.names || {};
|
||||
const colors = this.colors || {};
|
||||
entityStates.forEach((states, dataIdx) => {
|
||||
const domain = states.domain;
|
||||
const name = names[states.entity_id] || states.name;
|
||||
const color = colors[states.entity_id];
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: any[] | null = null;
|
||||
|
||||
@@ -468,11 +475,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const addDataSet = (
|
||||
id: string,
|
||||
nameY: string,
|
||||
color?: string,
|
||||
clr?: string,
|
||||
fill = false
|
||||
) => {
|
||||
if (!color) {
|
||||
color = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
if (!clr) {
|
||||
clr = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
colorIndex++;
|
||||
}
|
||||
data.push({
|
||||
@@ -481,7 +488,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
type: "line",
|
||||
cursor: "default",
|
||||
name: nameY,
|
||||
color,
|
||||
color: clr,
|
||||
symbol: "circle",
|
||||
symbolSize: 1,
|
||||
step: "end",
|
||||
@@ -492,7 +499,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
areaStyle: fill
|
||||
? {
|
||||
color: color + "7F",
|
||||
color: clr + "7F",
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
@@ -740,7 +747,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(states.entity_id, name);
|
||||
addDataSet(states.entity_id, name, color);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: Date;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
@@ -256,8 +257,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
|
||||
@@ -52,6 +52,11 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public names?: Record<string, string>;
|
||||
|
||||
@property({ attribute: false }) public colors?: Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public virtualize = false;
|
||||
|
||||
@property({ attribute: false }) public endTime?: Date;
|
||||
@@ -181,6 +186,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.endTime=${this._computedEndTime}
|
||||
.paddingYAxis=${this._maxYWidth}
|
||||
.names=${this.names}
|
||||
.colors=${this.colors}
|
||||
.chartIndex=${index}
|
||||
.clickForMoreInfo=${this.clickForMoreInfo}
|
||||
.logarithmicScale=${this.logarithmicScale}
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
@@ -68,6 +69,11 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public names?: Record<string, string>;
|
||||
|
||||
@property({ attribute: false }) public colors?: Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
@property() public unit?: string;
|
||||
|
||||
@property({ attribute: false }) public startTime?: Date;
|
||||
@@ -393,8 +399,7 @@ export class StatisticsChart extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -485,6 +490,7 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
|
||||
const names = this.names || {};
|
||||
const colors = this.colors || {};
|
||||
statisticsData.forEach(([statistic_id, stats]) => {
|
||||
const meta = statisticsMetaData?.[statistic_id];
|
||||
let name = names[statistic_id];
|
||||
@@ -529,11 +535,14 @@ export class StatisticsChart extends LitElement {
|
||||
prevEndTime = end;
|
||||
};
|
||||
|
||||
const color = getGraphColorByIndex(
|
||||
colorIndex,
|
||||
this._computedStyle || getComputedStyle(this)
|
||||
);
|
||||
colorIndex++;
|
||||
let color = colors[statistic_id];
|
||||
if (color === undefined) {
|
||||
color = getGraphColorByIndex(
|
||||
colorIndex,
|
||||
this._computedStyle || getComputedStyle(this)
|
||||
);
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
const statTypes: this["statTypes"] = [];
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../list/types";
|
||||
import { HaRowItem } from "./ha-row-item";
|
||||
|
||||
/**
|
||||
@@ -41,12 +39,6 @@ export class HaListItemBase extends HaRowItem {
|
||||
if (!this.hasAttribute("role")) {
|
||||
this.setAttribute("role", this.defaultRole);
|
||||
}
|
||||
fireEvent(this, "ha-list-item-register", { item: this });
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
fireEvent(this, "ha-list-item-unregister", { item: this });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,20 +62,26 @@ export class HaRowItem extends LitElement {
|
||||
}
|
||||
|
||||
protected _renderInner(): TemplateResult {
|
||||
const hasStart = this._slotController.test("start");
|
||||
const hasEnd = this._slotController.test("end");
|
||||
const hasContent = this._slotController.test("content");
|
||||
|
||||
return html`
|
||||
<div part="start" class="start">
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
${hasStart
|
||||
? html`<div part="start" class="start">
|
||||
<slot name="start"></slot>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div part="content" class="content">
|
||||
${hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()}
|
||||
</div>
|
||||
<div part="end" class="end">
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
${hasEnd
|
||||
? html`<div part="end" class="end">
|
||||
<slot name="end"></slot>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -142,10 +148,6 @@ export class HaRowItem extends LitElement {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
:host(:not(:has([slot="start"]))) .start,
|
||||
:host(:not(:has([slot="end"]))) .end {
|
||||
display: none;
|
||||
}
|
||||
.headline {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { compareNodeOrder } from "../../common/dom/compare-node-order";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import "./types";
|
||||
import type { HaListItemRegistrationDetail } from "./types";
|
||||
|
||||
/**
|
||||
* @element ha-list-base
|
||||
@@ -14,11 +12,9 @@ import type { HaListItemRegistrationDetail } from "./types";
|
||||
*
|
||||
* @summary
|
||||
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
|
||||
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
|
||||
* `HaListItemBase` descendants via the `ha-list-item-register` /
|
||||
* `ha-list-item-unregister` events they fire on connect/disconnect — works
|
||||
* across any nesting depth and shadow boundaries. Subclasses override
|
||||
* `hostRole` and/or `render()` to specialize.
|
||||
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
|
||||
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
|
||||
* `render()` to specialize.
|
||||
*
|
||||
* @slot - List items (`<ha-list-item-*>`).
|
||||
*
|
||||
@@ -72,14 +68,6 @@ export class HaListBase extends LitElement {
|
||||
Space: this._onActivate,
|
||||
});
|
||||
this.addEventListener("focusin", this._onFocusIn);
|
||||
this.addEventListener(
|
||||
"ha-list-item-register",
|
||||
this._onItemRegister as EventListener
|
||||
);
|
||||
this.addEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this._onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -87,14 +75,11 @@ export class HaListBase extends LitElement {
|
||||
this._unbindKeys?.();
|
||||
this._unbindKeys = undefined;
|
||||
this.removeEventListener("focusin", this._onFocusIn);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-register",
|
||||
this._onItemRegister as EventListener
|
||||
);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this._onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
public firstUpdated(changed: PropertyValues) {
|
||||
super.firstUpdated(changed);
|
||||
this.updateListItems();
|
||||
}
|
||||
|
||||
public focus(options?: FocusOptions) {
|
||||
@@ -130,14 +115,18 @@ export class HaListBase extends LitElement {
|
||||
this._applyActive(focusItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook called whenever the items array has changed. Subclasses can override
|
||||
* to layer in extra bookkeeping (e.g. selection state sync).
|
||||
*/
|
||||
public updateListItems() {
|
||||
const next = this._discoverListItems();
|
||||
const changed =
|
||||
next.length !== this.items.length ||
|
||||
next.some((it, i) => it !== this.items[i]);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
this.items = next;
|
||||
this._recomputeFocusableIndexes();
|
||||
if (
|
||||
this._activeItemIndex >= this.items.length ||
|
||||
this._activeItemIndex >= next.length ||
|
||||
!this._hasFocusableItem ||
|
||||
this._activeItemIndex < 0
|
||||
) {
|
||||
@@ -146,32 +135,6 @@ export class HaListBase extends LitElement {
|
||||
this._applyActive(false);
|
||||
}
|
||||
|
||||
private _onItemRegister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const item = ev.detail.item;
|
||||
if (this.items.includes(item)) {
|
||||
return;
|
||||
}
|
||||
const next = [...this.items, item];
|
||||
next.sort(compareNodeOrder);
|
||||
this.items = next;
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
private _onItemUnregister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const item = ev.detail.item;
|
||||
if (!this.items.includes(item)) {
|
||||
return;
|
||||
}
|
||||
this.items = this.items.filter((it) => it !== item);
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
private _recomputeFocusableIndexes() {
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
@@ -188,12 +151,27 @@ export class HaListBase extends LitElement {
|
||||
this._hasFocusableItem = first !== -1;
|
||||
}
|
||||
|
||||
public handleSlotChange = () => {
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<div part="base" class="base">
|
||||
<slot></slot>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _discoverListItems(): HaListItemBase[] {
|
||||
const slot =
|
||||
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
|
||||
if (!slot) {
|
||||
return [];
|
||||
}
|
||||
return slot
|
||||
.assignedElements({ flatten: true })
|
||||
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
|
||||
}
|
||||
|
||||
private _isFocusable(index: number): boolean {
|
||||
const item = this.items[index];
|
||||
return !!item && item.interactive && !item.disabled;
|
||||
|
||||
@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
|
||||
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
|
||||
>
|
||||
<div part="base" class="base" role="list">
|
||||
<slot></slot>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>
|
||||
</nav>`;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,9 @@ export interface HaListActivatedDetail {
|
||||
item: HaListItemBase;
|
||||
}
|
||||
|
||||
export interface HaListItemRegistrationDetail {
|
||||
item: HaListItemBase;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"ha-list-selected": HaListSelectedDetail;
|
||||
"ha-list-activated": HaListActivatedDetail;
|
||||
"ha-list-item-register": HaListItemRegistrationDetail;
|
||||
"ha-list-item-unregister": HaListItemRegistrationDetail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class PanelHome extends LitElement {
|
||||
|
||||
@state() private _extraActionItems?: ExtraActionItem[];
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private get _showBanner(): boolean {
|
||||
// Don't show if already dismissed
|
||||
if (this._config.welcome_banner_dismissed) {
|
||||
@@ -121,6 +123,12 @@ class PanelHome extends LitElement {
|
||||
|
||||
private async _setup() {
|
||||
this._updateExtraActionItems();
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await this._loadConfigPromise;
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
try {
|
||||
const [_, data] = await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
@@ -132,7 +140,6 @@ class PanelHome extends LitElement {
|
||||
console.error("Failed to load favorites:", err);
|
||||
this._config = {};
|
||||
}
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
@@ -313,6 +320,9 @@ class PanelHome extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
const strategyConfig: LovelaceDashboardStrategyConfig = {
|
||||
strategy: {
|
||||
type: "home",
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
mdiSkipNext,
|
||||
mdiSkipPrevious,
|
||||
mdiStop,
|
||||
mdiVolumeMinus,
|
||||
mdiVolumePlus,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -52,6 +54,8 @@ const MEDIA_PLAYER_PLAYBACK_CONTROLS_FEATURES: Record<
|
||||
media_stop: [MediaPlayerEntityFeature.STOP],
|
||||
media_previous_track: [MediaPlayerEntityFeature.PREVIOUS_TRACK],
|
||||
media_next_track: [MediaPlayerEntityFeature.NEXT_TRACK],
|
||||
volume_down: [MediaPlayerEntityFeature.VOLUME_STEP],
|
||||
volume_up: [MediaPlayerEntityFeature.VOLUME_STEP],
|
||||
};
|
||||
|
||||
export const supportsMediaPlayerPlaybackControl = (
|
||||
@@ -266,6 +270,16 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
buttons.push({ icon: mdiSkipNext, action: "media_next_track" });
|
||||
}
|
||||
break;
|
||||
case "volume_down":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
|
||||
buttons.push({ icon: mdiVolumeMinus, action: "volume_down" });
|
||||
}
|
||||
break;
|
||||
case "volume_up":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
|
||||
buttons.push({ icon: mdiVolumePlus, action: "volume_up" });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { isNumericFromAttributes } from "../../../common/number/format_number";
|
||||
import "../../../components/ha-spinner";
|
||||
import {
|
||||
limitedHistoryFromStateObj,
|
||||
subscribeHistoryStatesTimeWindow,
|
||||
@@ -49,6 +48,8 @@ class HuiHistoryChartCardFeature
|
||||
|
||||
@state() private _yAxisOrigin?: number;
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
|
||||
@@ -90,9 +91,29 @@ class HuiHistoryChartCardFeature
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._setLoadingCoordinates();
|
||||
this._subscribeHistory();
|
||||
}
|
||||
|
||||
private _setLoadingCoordinates() {
|
||||
const entityId = this.context?.entity_id;
|
||||
if (!entityId || !this.hass) {
|
||||
return;
|
||||
}
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState(
|
||||
limitedHistoryFromStateObj(stateObj),
|
||||
this.clientWidth,
|
||||
this.clientHeight,
|
||||
10
|
||||
);
|
||||
this._coordinates = points;
|
||||
this._yAxisOrigin = yAxisOrigin;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (
|
||||
!this._config ||
|
||||
@@ -109,14 +130,7 @@ class HuiHistoryChartCardFeature
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (!this._coordinates) {
|
||||
return html`
|
||||
<div class="container loading">
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (!this._coordinates.length) {
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">No state history found.</div>
|
||||
@@ -125,6 +139,7 @@ class HuiHistoryChartCardFeature
|
||||
}
|
||||
return html`
|
||||
<hui-graph-base
|
||||
?loading=${this._loading}
|
||||
.coordinates=${this._coordinates}
|
||||
.yAxisOrigin=${this._yAxisOrigin}
|
||||
></hui-graph-base>
|
||||
@@ -197,6 +212,7 @@ class HuiHistoryChartCardFeature
|
||||
);
|
||||
this._coordinates = points;
|
||||
this._yAxisOrigin = yAxisOrigin;
|
||||
this._loading = false;
|
||||
},
|
||||
hourToShow,
|
||||
[this.context!.entity_id!]
|
||||
@@ -218,13 +234,6 @@ class HuiHistoryChartCardFeature
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.container.loading {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
hui-graph-base {
|
||||
width: 100%;
|
||||
--accent-color: var(--feature-color);
|
||||
|
||||
@@ -63,6 +63,8 @@ export const MEDIA_PLAYER_PLAYBACK_CONTROLS = [
|
||||
"media_stop",
|
||||
"media_previous_track",
|
||||
"media_next_track",
|
||||
"volume_down",
|
||||
"volume_up",
|
||||
] as const;
|
||||
|
||||
export type MediaPlayerPlaybackControl =
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { theme2hex } from "../../../common/color/convert-color";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/chart/state-history-charts";
|
||||
@@ -21,9 +22,8 @@ import { getSensorNumericDeviceClasses } from "../../../data/sensor";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
|
||||
import { processConfigEntities } from "../common/process-config-entities";
|
||||
import type { EntityConfig } from "../entity-rows/types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import type { HistoryGraphCardConfig } from "./types";
|
||||
import type { GraphEntityConfig, HistoryGraphCardConfig } from "./types";
|
||||
|
||||
export const DEFAULT_HOURS_TO_SHOW = 24;
|
||||
|
||||
@@ -51,9 +51,11 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _names: Record<string, string> = {};
|
||||
|
||||
private _colors: Record<string, string | undefined> = {};
|
||||
|
||||
private _entityIds: string[] = [];
|
||||
|
||||
private _entities: EntityConfig[] = [];
|
||||
private _entities: GraphEntityConfig[] = [];
|
||||
|
||||
private _historyLinkId = `history-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
@@ -95,6 +97,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
|
||||
this._config = config;
|
||||
this._computeNames();
|
||||
this._computeColors();
|
||||
}
|
||||
|
||||
private _computeNames() {
|
||||
@@ -110,6 +113,19 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
});
|
||||
}
|
||||
|
||||
private _computeColors() {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
this._colors = {};
|
||||
this._entities.forEach((entity) => {
|
||||
// if color = undefined, it is automatically defined inside a chart component
|
||||
this._colors[entity.entity] = entity.color
|
||||
? theme2hex(entity.color)
|
||||
: undefined;
|
||||
});
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("hass")) {
|
||||
@@ -371,6 +387,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
.minYAxis=${this._config.min_y_axis}
|
||||
.maxYAxis=${this._config.max_y_axis}
|
||||
.fitYData=${this._config.fit_y_data || false}
|
||||
.colors=${this._colors}
|
||||
.height=${hasFixedHeight ? "100%" : undefined}
|
||||
.narrow=${narrow}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { theme2hex } from "../../../common/color/convert-color";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-next";
|
||||
@@ -27,10 +28,9 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
|
||||
import { processConfigEntities } from "../common/process-config-entities";
|
||||
import type { EntityConfig } from "../entity-rows/types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import { getSuggestedMax } from "./energy/common/energy-chart-options";
|
||||
import type { StatisticsGraphCardConfig } from "./types";
|
||||
import type { GraphEntityConfig, StatisticsGraphCardConfig } from "./types";
|
||||
|
||||
export const DEFAULT_DAYS_TO_SHOW = 30;
|
||||
|
||||
@@ -72,7 +72,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@state() private _unit?: string;
|
||||
|
||||
private _entities: EntityConfig[] = [];
|
||||
private _entities: GraphEntityConfig[] = [];
|
||||
|
||||
private _entityIds: string[] = [];
|
||||
|
||||
@@ -80,6 +80,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _names: Record<string, string> = {};
|
||||
|
||||
private _colors: Record<string, string | undefined> = {};
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
private _statTypes?: StatisticType[];
|
||||
@@ -178,6 +180,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
this._config = config;
|
||||
this._computeNames();
|
||||
this._computeColors();
|
||||
}
|
||||
|
||||
private _computeNames() {
|
||||
@@ -199,6 +202,19 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
||||
});
|
||||
}
|
||||
|
||||
private _computeColors() {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
this._colors = {};
|
||||
this._entities.forEach((entity) => {
|
||||
// if color = undefined, it is automatically defined inside a chart component
|
||||
this._colors[entity.entity] = entity.color
|
||||
? theme2hex(entity.color)
|
||||
: undefined;
|
||||
});
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
return (
|
||||
hasConfigOrEntitiesChanged(this, changedProps) ||
|
||||
@@ -353,6 +369,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
||||
.unit=${this._unit}
|
||||
.minYAxis=${this._config.min_y_axis}
|
||||
.maxYAxis=${this._config.max_y_axis}
|
||||
.colors=${this._colors}
|
||||
.startTime=${this._energyStart}
|
||||
.endTime=${this._energyEnd && this._energyStart
|
||||
? getSuggestedMax(
|
||||
|
||||
@@ -458,8 +458,14 @@ export interface MediaControlCardConfig extends LovelaceCardConfig {
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface GraphEntityConfig {
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface HistoryGraphCardConfig extends LovelaceCardConfig {
|
||||
entities: (EntityConfig | string)[];
|
||||
entities: (GraphEntityConfig | string)[];
|
||||
hours_to_show?: number;
|
||||
title?: string;
|
||||
show_names?: boolean;
|
||||
@@ -472,7 +478,7 @@ export interface HistoryGraphCardConfig extends LovelaceCardConfig {
|
||||
}
|
||||
|
||||
export interface StatisticsGraphCardConfig extends EnergyCardBaseConfig {
|
||||
entities: (EntityConfig | string)[];
|
||||
entities: (GraphEntityConfig | string)[];
|
||||
unit?: string;
|
||||
days_to_show?: number;
|
||||
period?: "auto" | StatisticPeriod;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, svg } from "lit";
|
||||
import { css, html, LitElement, nothing, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { MediaQueriesListener } from "../../../common/dom/media_query";
|
||||
import { listenMediaQuery } from "../../../common/dom/media_query";
|
||||
import { parseAnimationDuration } from "../../../common/util/parse-animation-duration";
|
||||
import { strokeWidth } from "../../../data/graph";
|
||||
import { getPath } from "../common/graph/get-path";
|
||||
|
||||
@@ -11,58 +14,197 @@ export class HuiGraphBase extends LitElement {
|
||||
@property({ attribute: "y-axis-origin", type: Number })
|
||||
public yAxisOrigin?: number;
|
||||
|
||||
@state() private _path?: string;
|
||||
@property({ type: Boolean, reflect: true }) public loading = false;
|
||||
|
||||
private _uniqueId = `graph-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
@state()
|
||||
private _displayCoordinates?: number[][];
|
||||
|
||||
@state()
|
||||
private _reducedMotion = false;
|
||||
|
||||
private _unsubMediaQuery?: MediaQueriesListener;
|
||||
|
||||
private _animationFrame?: number;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const width = this.clientWidth || 500;
|
||||
const height = this.clientHeight || width / 5;
|
||||
const yAxisOrigin = this.yAxisOrigin ?? height;
|
||||
const lastX = this.coordinates?.length
|
||||
? this.coordinates[this.coordinates.length - 1][0]
|
||||
const path =
|
||||
(this._displayCoordinates && getPath(this._displayCoordinates)) ??
|
||||
(this.loading ? `M 0,${height / 2} L ${width},${height / 2}` : undefined);
|
||||
const lastX = this._displayCoordinates?.length
|
||||
? this._displayCoordinates[this._displayCoordinates.length - 1][0]
|
||||
: width;
|
||||
|
||||
if (!path) {
|
||||
return html`
|
||||
${svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}
|
||||
`;
|
||||
}
|
||||
|
||||
const showShimmer = this.loading && !this._reducedMotion;
|
||||
const shimmerId = `${this._uniqueId}-shimmer`;
|
||||
const fillRect = showShimmer ? `url(#${shimmerId})` : "var(--accent-color)";
|
||||
|
||||
return html`
|
||||
${this._path
|
||||
? svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
||||
<g>
|
||||
<mask id="${this._uniqueId}-fill">
|
||||
<path
|
||||
class='fill'
|
||||
fill='white'
|
||||
d="${this._path} L ${lastX}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
|
||||
/>
|
||||
</mask>
|
||||
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-fill)"></rect>
|
||||
<mask id="${this._uniqueId}-line">
|
||||
<path
|
||||
vector-effect="non-scaling-stroke"
|
||||
class='line'
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="${strokeWidth}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d=${this._path}
|
||||
></path>
|
||||
</mask>
|
||||
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-line)"></rect>
|
||||
</g>
|
||||
</svg>`
|
||||
: svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}
|
||||
${svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
||||
${
|
||||
showShimmer
|
||||
? svg`<defs>
|
||||
<linearGradient id=${shimmerId} x1="-50%" x2="-30%" y1="0" y2="0">
|
||||
<stop offset="0%" stop-color="var(--accent-color)" />
|
||||
<stop offset="50%" stop-color="white" />
|
||||
<stop offset="100%" stop-color="var(--accent-color)" />
|
||||
<animate attributeName="x1" values="-50%;120%" dur="1.8s" repeatCount="indefinite" />
|
||||
<animate attributeName="x2" values="-30%;140%" dur="1.8s" repeatCount="indefinite" />
|
||||
</linearGradient>
|
||||
</defs>`
|
||||
: nothing
|
||||
}
|
||||
<g>
|
||||
<mask id="${this._uniqueId}-fill">
|
||||
<path
|
||||
class="fill"
|
||||
fill="white"
|
||||
d="${path} L ${lastX}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
|
||||
/>
|
||||
</mask>
|
||||
<rect height="100%" width="100%" fill=${fillRect} mask="url(#${this._uniqueId}-fill)"></rect>
|
||||
<mask id="${this._uniqueId}-line">
|
||||
<path
|
||||
vector-effect="non-scaling-stroke"
|
||||
class="line"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="${strokeWidth}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d=${path}
|
||||
></path>
|
||||
</mask>
|
||||
<rect height="100%" width="100%" fill=${fillRect} mask="url(#${this._uniqueId}-line)"></rect>
|
||||
</g>
|
||||
</svg>`}
|
||||
`;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._unsubMediaQuery = listenMediaQuery(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
(matches) => {
|
||||
if (this._reducedMotion !== matches) {
|
||||
this._reducedMotion = matches;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (!this.coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedProps.has("coordinates")) {
|
||||
this._path = getPath(this.coordinates);
|
||||
this._setCoordinates(this.coordinates);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubMediaQuery?.();
|
||||
this._unsubMediaQuery = undefined;
|
||||
this._cancelAnimation();
|
||||
}
|
||||
|
||||
private _setCoordinates(coordinates: number[][]) {
|
||||
this._cancelAnimation();
|
||||
|
||||
const displayCoordinates = this._displayCoordinates;
|
||||
|
||||
if (!displayCoordinates || coordinates.length < 2) {
|
||||
this._displayCoordinates = coordinates;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = parseAnimationDuration(
|
||||
getComputedStyle(this).getPropertyValue("--ha-animation-duration-slow")
|
||||
);
|
||||
|
||||
if (duration <= 1) {
|
||||
this._displayCoordinates = coordinates;
|
||||
return;
|
||||
}
|
||||
|
||||
const fromCoordinates = coordinates.map((coord) => [
|
||||
coord[0],
|
||||
this._interpolateY(displayCoordinates, coord[0]),
|
||||
]);
|
||||
const start = performance.now();
|
||||
|
||||
const animate = (now: number) => {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
const easedProgress = 1 - (1 - progress) ** 3;
|
||||
|
||||
this._displayCoordinates = coordinates.map((coord, index) => [
|
||||
coord[0],
|
||||
fromCoordinates[index][1] +
|
||||
(coord[1] - fromCoordinates[index][1]) * easedProgress,
|
||||
]);
|
||||
|
||||
if (progress < 1) {
|
||||
this._animationFrame = requestAnimationFrame(animate);
|
||||
} else {
|
||||
this._animationFrame = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
this._animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
private _interpolateY(coordinates: number[][], x: number): number {
|
||||
if (!coordinates.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x <= coordinates[0][0]) {
|
||||
return coordinates[0][1];
|
||||
}
|
||||
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const current = coordinates[i];
|
||||
|
||||
if (x > current[0]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = coordinates[i - 1];
|
||||
const xDelta = current[0] - previous[0];
|
||||
|
||||
if (!xDelta) {
|
||||
return current[1];
|
||||
}
|
||||
|
||||
const progress = (x - previous[0]) / xDelta;
|
||||
|
||||
return previous[1] + (current[1] - previous[1]) * progress;
|
||||
}
|
||||
|
||||
return coordinates[coordinates.length - 1][1];
|
||||
}
|
||||
|
||||
private _cancelAnimation() {
|
||||
if (this._animationFrame === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimationFrame(this._animationFrame);
|
||||
this._animationFrame = undefined;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
|
||||
@@ -12,26 +12,35 @@ import {
|
||||
optional,
|
||||
string,
|
||||
} from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { HistoryGraphCardConfig } from "../../cards/types";
|
||||
import "../../components/hui-entity-editor";
|
||||
import "../hui-sub-element-editor";
|
||||
import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EntityConfig } from "../../entity-rows/types";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { isNumericFromAttributes } from "../../../../common/number/format_number";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-history-graph-card";
|
||||
import type {
|
||||
GraphEntityConfig,
|
||||
HistoryGraphCardConfig,
|
||||
} from "../../cards/types";
|
||||
import "../../components/hui-entity-editor";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import "../hui-sub-element-editor";
|
||||
import { processEditorEntities } from "../process-editor-entities";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { entitiesConfigStruct } from "../structs/entities-struct";
|
||||
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-history-graph-card";
|
||||
import { graphEntitiesConfigStruct } from "../structs/entities-struct";
|
||||
import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
|
||||
import { orderPropertiesGraphCard } from "./order-properties/order-properties-graph";
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
entities: array(entitiesConfigStruct),
|
||||
entities: array(graphEntitiesConfigStruct),
|
||||
title: optional(string()),
|
||||
hours_to_show: optional(number()),
|
||||
refresh_interval: optional(number()), // deprecated
|
||||
@@ -43,19 +52,6 @@ const cardConfigStruct = assign(
|
||||
})
|
||||
);
|
||||
|
||||
const SUB_FORM = {
|
||||
schema: [
|
||||
{ name: "entity", selector: { entity: {} }, required: true },
|
||||
{
|
||||
name: "name",
|
||||
selector: { entity_name: {} },
|
||||
context: {
|
||||
entity: "entity",
|
||||
},
|
||||
},
|
||||
] as const,
|
||||
};
|
||||
|
||||
@customElement("hui-history-graph-card-editor")
|
||||
export class HuiHistoryGraphCardEditor
|
||||
extends LitElement
|
||||
@@ -67,12 +63,9 @@ export class HuiHistoryGraphCardEditor
|
||||
|
||||
@state() private _subElementEditorConfig?: SubElementEditorConfig;
|
||||
|
||||
@state() private _configEntities?: EntityConfig[];
|
||||
|
||||
public setConfig(config: HistoryGraphCardConfig): void {
|
||||
assert(config, cardConfigStruct);
|
||||
this._config = config;
|
||||
this._configEntities = processEditorEntities(config.entities);
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
@@ -123,17 +116,46 @@ export class HuiHistoryGraphCardEditor
|
||||
] as const
|
||||
);
|
||||
|
||||
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
|
||||
schema: [
|
||||
{ name: "entity", selector: { entity: {} }, required: true },
|
||||
{
|
||||
name: "name",
|
||||
selector: { entity_name: {} },
|
||||
context: {
|
||||
entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
disabled: this._shouldDisableColorOption(entityId),
|
||||
selector: { ui_color: {} },
|
||||
},
|
||||
] as const,
|
||||
computeLabel: (item: HaFormSchema) => {
|
||||
switch (item.name) {
|
||||
case "color":
|
||||
return localize(`ui.panel.lovelace.editor.card.generic.${item.name}`);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (this._subElementEditorConfig) {
|
||||
const entityId = (
|
||||
this._subElementEditorConfig.elementConfig! as { entity: string }
|
||||
).entity;
|
||||
return html`
|
||||
<hui-sub-element-editor
|
||||
.hass=${this.hass}
|
||||
.config=${this._subElementEditorConfig}
|
||||
.form=${SUB_FORM}
|
||||
.form=${this._subForm(this.hass.localize, entityId)}
|
||||
@go-back=${this._goBack}
|
||||
@config-changed=${this._handleSubEntityChanged}
|
||||
>
|
||||
@@ -146,6 +168,9 @@ export class HuiHistoryGraphCardEditor
|
||||
this._config!.max_y_axis !== undefined
|
||||
);
|
||||
|
||||
const configEntities = this._config.entities
|
||||
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
|
||||
: [];
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
@@ -156,7 +181,7 @@ export class HuiHistoryGraphCardEditor
|
||||
></ha-form>
|
||||
<hui-entity-editor
|
||||
.hass=${this.hass}
|
||||
.entities=${this._configEntities}
|
||||
.entities=${configEntities}
|
||||
can-edit
|
||||
@entities-changed=${this._entitiesChanged}
|
||||
@edit-detail-element=${this._editDetailElement}
|
||||
@@ -175,36 +200,75 @@ export class HuiHistoryGraphCardEditor
|
||||
private _handleSubEntityChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
|
||||
const index = this._subElementEditorConfig!.index!;
|
||||
// get updated entity config
|
||||
let newEntityConfig = ev.detail.config as GraphEntityConfig;
|
||||
const entityId = newEntityConfig.entity;
|
||||
if (this._shouldDisableColorOption(entityId)) {
|
||||
// remove unused "color" option
|
||||
newEntityConfig = this._deleteColorOption(newEntityConfig);
|
||||
}
|
||||
|
||||
const newEntities = this._configEntities!.concat();
|
||||
const newConfig = ev.detail.config as EntityConfig;
|
||||
this._subElementEditorConfig = {
|
||||
...this._subElementEditorConfig!,
|
||||
elementConfig: newConfig,
|
||||
};
|
||||
newEntities[index] = newConfig;
|
||||
// update card config with updated entity config
|
||||
const index = this._subElementEditorConfig!.index!;
|
||||
const newEntities = [...this._config!.entities];
|
||||
newEntities[index] = newEntityConfig;
|
||||
let config = this._config!;
|
||||
config = { ...config, entities: newEntities };
|
||||
config = this._orderProperties(config);
|
||||
this._config = config;
|
||||
this._configEntities = processEditorEntities(config.entities);
|
||||
|
||||
// update sub-element editor config
|
||||
this._subElementEditorConfig = {
|
||||
...this._subElementEditorConfig!,
|
||||
elementConfig: {
|
||||
...(this._config!.entities[index] as GraphEntityConfig),
|
||||
},
|
||||
};
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
const config = this._orderProperties(ev.detail.value);
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _entitiesChanged(ev: CustomEvent): void {
|
||||
let config = this._config!;
|
||||
|
||||
config = { ...config, entities: ev.detail.entities };
|
||||
this._configEntities = processEditorEntities(config.entities);
|
||||
|
||||
config = this._orderProperties(config);
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
// a rough assumption about a numerical entity
|
||||
// which may use state-history-chart-line
|
||||
// where "color" option may be used
|
||||
private _shouldDisableColorOption = (entityId: string) => {
|
||||
const domain = computeDomain(entityId);
|
||||
const isNumberDomain =
|
||||
domain === "counter" || domain === "number" || domain === "input_number";
|
||||
const stateObj = this.hass!.states[entityId];
|
||||
const attributes = stateObj?.attributes;
|
||||
return !isNumericFromAttributes(attributes) && !isNumberDomain;
|
||||
};
|
||||
|
||||
// remove "color" option when needed
|
||||
private _deleteColorOption(config: GraphEntityConfig): GraphEntityConfig {
|
||||
const { color, ...rest } = config;
|
||||
return rest as GraphEntityConfig;
|
||||
}
|
||||
|
||||
// normalize a generated yaml code by placing lines in a consistent order
|
||||
private _orderProperties(
|
||||
config: HistoryGraphCardConfig
|
||||
): HistoryGraphCardConfig {
|
||||
return orderPropertiesGraphCard(
|
||||
config,
|
||||
cardConfigStruct
|
||||
) as HistoryGraphCardConfig;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
|
||||
@@ -33,12 +33,13 @@ import {
|
||||
statisticsMetaHasType,
|
||||
} from "../../../../data/recorder";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { DEFAULT_DAYS_TO_SHOW } from "../../cards/hui-statistics-graph-card";
|
||||
import type { StatisticsGraphCardConfig } from "../../cards/types";
|
||||
import { processConfigEntities } from "../../common/process-config-entities";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { entitiesConfigStruct } from "../structs/entities-struct";
|
||||
import { DEFAULT_DAYS_TO_SHOW } from "../../cards/hui-statistics-graph-card";
|
||||
import { graphEntitiesConfigStruct } from "../structs/entities-struct";
|
||||
import { orderPropertiesGraphCard } from "./order-properties/order-properties-graph";
|
||||
|
||||
const statTypeStruct = union([
|
||||
literal("state"),
|
||||
@@ -52,7 +53,7 @@ const statTypeStruct = union([
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
entities: array(entitiesConfigStruct),
|
||||
entities: array(graphEntitiesConfigStruct),
|
||||
title: optional(string()),
|
||||
days_to_show: optional(number()),
|
||||
period: optional(
|
||||
@@ -394,7 +395,8 @@ export class HuiStatisticsGraphCardEditor
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
const config = this._orderProperties(ev.detail.value);
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private async _entitiesChanged(ev: CustomEvent): Promise<void> {
|
||||
@@ -408,7 +410,7 @@ export class HuiStatisticsGraphCardEditor
|
||||
return matchEntity ?? newEnt;
|
||||
});
|
||||
|
||||
const config = { ...this._config!, entities: newEntities };
|
||||
let config = { ...this._config!, entities: newEntities };
|
||||
if (
|
||||
newEntityIds?.some((statistic_id) => isExternalStatistic(statistic_id)) &&
|
||||
config.period === "5minute"
|
||||
@@ -437,11 +439,22 @@ export class HuiStatisticsGraphCardEditor
|
||||
) {
|
||||
delete config.unit;
|
||||
}
|
||||
config = this._orderProperties(config);
|
||||
fireEvent(this, "config-changed", {
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
// normalize a generated yaml code by placing lines in a consistent order
|
||||
private _orderProperties(
|
||||
config: StatisticsGraphCardConfig
|
||||
): StatisticsGraphCardConfig {
|
||||
return orderPropertiesGraphCard(
|
||||
config,
|
||||
cardConfigStruct
|
||||
) as StatisticsGraphCardConfig;
|
||||
}
|
||||
|
||||
private _computeHelperCallback = (schema) => {
|
||||
switch (schema.name) {
|
||||
case "collection_key":
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Struct } from "superstruct";
|
||||
import { orderProperties } from "../../../../../common/util/order-properties";
|
||||
import type { GraphEntityConfig } from "../../../cards/types";
|
||||
|
||||
// normalize a generated yaml code by placing lines in a consistent order
|
||||
export const orderPropertiesGraphCard = (
|
||||
config: Record<string, any>,
|
||||
cardConfigStruct: Struct<any, any>
|
||||
): Record<string, any> => {
|
||||
const fieldOrderCard = Object.keys(cardConfigStruct.schema);
|
||||
const fieldOrderEntity = [
|
||||
// ideally should be taken from a schema
|
||||
"entity",
|
||||
"name",
|
||||
"color",
|
||||
];
|
||||
// normalize card's options
|
||||
let orderedConfig = { ...orderProperties(config, fieldOrderCard) };
|
||||
// normalize entities' options
|
||||
const entitiesOrderedCfg = config.entities.map(
|
||||
(entry: GraphEntityConfig | string) =>
|
||||
typeof entry !== "string"
|
||||
? orderProperties(entry, fieldOrderEntity)
|
||||
: entry
|
||||
);
|
||||
// merge normalized config
|
||||
orderedConfig = { ...orderedConfig, ...{ entities: entitiesOrderedCfg } };
|
||||
return orderedConfig;
|
||||
};
|
||||
@@ -24,3 +24,12 @@ export const entitiesConfigStruct = union([
|
||||
}),
|
||||
string(),
|
||||
]);
|
||||
|
||||
export const graphEntitiesConfigStruct = union([
|
||||
object({
|
||||
entity: string(),
|
||||
name: optional(entityNameStruct),
|
||||
color: optional(string()),
|
||||
}),
|
||||
string(),
|
||||
]);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-spinner";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import {
|
||||
limitedHistoryFromStateObj,
|
||||
@@ -69,6 +68,8 @@ export class HuiGraphHeaderFooter
|
||||
|
||||
@state() private _coordinates?: [number, number][];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
private _history?: HistoryStates;
|
||||
@@ -118,15 +119,7 @@ export class HuiGraphHeaderFooter
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._coordinates) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._coordinates.length) {
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">No state history found.</div>
|
||||
@@ -135,7 +128,10 @@ export class HuiGraphHeaderFooter
|
||||
}
|
||||
|
||||
return html`
|
||||
<hui-graph-base .coordinates=${this._coordinates}></hui-graph-base>
|
||||
<hui-graph-base
|
||||
?loading=${this._loading}
|
||||
.coordinates=${this._coordinates}
|
||||
></hui-graph-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -166,6 +162,7 @@ export class HuiGraphHeaderFooter
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._setLoadingCoordinates();
|
||||
this._subscribed = subscribeHistoryStatesTimeWindow(
|
||||
this.hass!,
|
||||
(combinedHistory) => {
|
||||
@@ -193,6 +190,28 @@ export class HuiGraphHeaderFooter
|
||||
this._setRedrawTimer();
|
||||
}
|
||||
|
||||
private _setLoadingCoordinates() {
|
||||
if (!this._config || !this.hass) {
|
||||
return;
|
||||
}
|
||||
const stateObj = this.hass.states[this._config.entity];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
const width = this.clientWidth || this.offsetWidth;
|
||||
const { points } = coordinatesMinimalResponseCompressedState(
|
||||
limitedHistoryFromStateObj(stateObj),
|
||||
width,
|
||||
width / 5,
|
||||
10,
|
||||
{
|
||||
minY: this._config.limits?.min,
|
||||
maxY: this._config.limits?.max,
|
||||
}
|
||||
);
|
||||
this._coordinates = points;
|
||||
}
|
||||
|
||||
private _computeCoordinates() {
|
||||
if (!this._history || !this._config) {
|
||||
return;
|
||||
@@ -225,6 +244,7 @@ export class HuiGraphHeaderFooter
|
||||
useMean
|
||||
);
|
||||
this._coordinates = points;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
private _redrawGraph() {
|
||||
@@ -302,10 +322,6 @@ export class HuiGraphHeaderFooter
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
ha-spinner {
|
||||
position: absolute;
|
||||
top: calc(50% - 14px);
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"media_stop": "Stop",
|
||||
"media_next_track": "Next track",
|
||||
"media_previous_track": "Previous track",
|
||||
"volume_up": "Volume up",
|
||||
"volume_down": "Volume down",
|
||||
"media_volume_up": "Volume up",
|
||||
"media_volume_down": "Volume down",
|
||||
"media_volume_mute": "Volume mute",
|
||||
|
||||
66
test/components/chart/chart-tooltip-position.test.ts
Normal file
66
test/components/chart/chart-tooltip-position.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TOOLTIP_GAP_PX,
|
||||
TOOLTIP_TOP_OFFSET_PX,
|
||||
sideTooltipPosition,
|
||||
} from "../../../src/components/chart/chart-tooltip-position";
|
||||
|
||||
const callPosition = (
|
||||
cursorX: number,
|
||||
options: {
|
||||
viewSize?: [number, number];
|
||||
contentSize?: [number, number];
|
||||
rtl?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const dom = document.createElement("div");
|
||||
if (options.rtl) {
|
||||
dom.setAttribute("dir", "rtl");
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
const result = sideTooltipPosition([cursorX, 0], [], dom, null, {
|
||||
viewSize: options.viewSize ?? [800, 400],
|
||||
contentSize: options.contentSize ?? [200, 120],
|
||||
}) as [number, number];
|
||||
if (options.rtl) {
|
||||
document.body.removeChild(dom);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
describe("sideTooltipPosition", () => {
|
||||
it("places tooltip to the right of the cursor by default", () => {
|
||||
const [x, y] = callPosition(100);
|
||||
expect(x).toBe(100 + TOOLTIP_GAP_PX);
|
||||
expect(y).toBe(TOOLTIP_TOP_OFFSET_PX);
|
||||
});
|
||||
|
||||
it("flips to the left when right side overflows the chart", () => {
|
||||
const [x] = callPosition(700, {
|
||||
viewSize: [800, 400],
|
||||
contentSize: [200, 120],
|
||||
});
|
||||
expect(x).toBe(700 - TOOLTIP_GAP_PX - 200);
|
||||
});
|
||||
|
||||
it("clamps to chart bounds when neither side fits", () => {
|
||||
const [x] = callPosition(50, {
|
||||
viewSize: [120, 400],
|
||||
contentSize: [200, 120],
|
||||
});
|
||||
expect(x).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps Y when chart is shorter than the tooltip", () => {
|
||||
const [, y] = callPosition(100, {
|
||||
viewSize: [800, 100],
|
||||
contentSize: [200, 120],
|
||||
});
|
||||
expect(y).toBe(0);
|
||||
});
|
||||
|
||||
it("prefers the left of the cursor in RTL mode", () => {
|
||||
const [x] = callPosition(400, { rtl: true });
|
||||
expect(x).toBe(400 - TOOLTIP_GAP_PX - 200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user