mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-29 12:16:39 +00:00
Sensor Card: Use Entity card with graph footer (#5322)
* Sensor card changes * import move * reviews * Use spread Operator
This commit is contained in:
parent
525703d376
commit
087492fc0b
@ -34,7 +34,7 @@ import { UNKNOWN, UNAVAILABLE } from "../../../data/entity";
|
|||||||
import { HuiErrorCard } from "./hui-error-card";
|
import { HuiErrorCard } from "./hui-error-card";
|
||||||
|
|
||||||
@customElement("hui-entity-card")
|
@customElement("hui-entity-card")
|
||||||
class HuiEntityCard extends LitElement implements LovelaceCard {
|
export class HuiEntityCard extends LitElement implements LovelaceCard {
|
||||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||||
await import(
|
await import(
|
||||||
/* webpackChunkName: "hui-entity-card-editor" */ "../editor/config-elements/hui-entity-card-editor"
|
/* webpackChunkName: "hui-entity-card-editor" */ "../editor/config-elements/hui-entity-card-editor"
|
||||||
|
@ -1,174 +1,15 @@
|
|||||||
import {
|
import { customElement } from "lit-element";
|
||||||
html,
|
|
||||||
svg,
|
|
||||||
LitElement,
|
|
||||||
PropertyValues,
|
|
||||||
TemplateResult,
|
|
||||||
customElement,
|
|
||||||
property,
|
|
||||||
css,
|
|
||||||
CSSResult,
|
|
||||||
} from "lit-element";
|
|
||||||
import "@polymer/paper-spinner/paper-spinner";
|
|
||||||
|
|
||||||
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
|
|
||||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
|
||||||
import { stateIcon } from "../../../common/entity/state_icon";
|
|
||||||
|
|
||||||
import "../../../components/ha-card";
|
|
||||||
import "../../../components/ha-icon";
|
|
||||||
import "../components/hui-warning";
|
|
||||||
|
|
||||||
import { LovelaceCard, LovelaceCardEditor } from "../types";
|
|
||||||
import { HomeAssistant } from "../../../types";
|
|
||||||
import { fireEvent } from "../../../common/dom/fire_event";
|
|
||||||
import { fetchRecent } from "../../../data/history";
|
|
||||||
import { SensorCardConfig } from "./types";
|
|
||||||
import { hasConfigOrEntityChanged } from "../common/has-changed";
|
|
||||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
|
||||||
import { findEntities } from "../common/find-entites";
|
|
||||||
import { HassEntity } from "home-assistant-js-websocket/dist/types";
|
import { HassEntity } from "home-assistant-js-websocket/dist/types";
|
||||||
|
|
||||||
const strokeWidth = 5;
|
import { LovelaceCardEditor } from "../types";
|
||||||
|
import { HomeAssistant } from "../../../types";
|
||||||
const average = (items): number => {
|
import { SensorCardConfig, EntityCardConfig } from "./types";
|
||||||
return (
|
import { GraphHeaderFooterConfig } from "../header-footer/types";
|
||||||
items.reduce((sum, entry) => sum + parseFloat(entry.state), 0) /
|
import { findEntities } from "../common/find-entites";
|
||||||
items.length
|
import { HuiEntityCard } from "./hui-entity-card";
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastValue = (items): number => {
|
|
||||||
return parseFloat(items[items.length - 1].state) || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const midPoint = (
|
|
||||||
_Ax: number,
|
|
||||||
_Ay: number,
|
|
||||||
_Bx: number,
|
|
||||||
_By: number
|
|
||||||
): number[] => {
|
|
||||||
const _Zx = (_Ax - _Bx) / 2 + _Bx;
|
|
||||||
const _Zy = (_Ay - _By) / 2 + _By;
|
|
||||||
return [_Zx, _Zy];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPath = (coords: number[][]): string => {
|
|
||||||
let next;
|
|
||||||
let Z;
|
|
||||||
const X = 0;
|
|
||||||
const Y = 1;
|
|
||||||
let path = "";
|
|
||||||
let last = coords.filter(Boolean)[0];
|
|
||||||
|
|
||||||
path += `M ${last[X]},${last[Y]}`;
|
|
||||||
|
|
||||||
for (const coord of coords) {
|
|
||||||
next = coord;
|
|
||||||
Z = midPoint(last[X], last[Y], next[X], next[Y]);
|
|
||||||
path += ` ${Z[X]},${Z[Y]}`;
|
|
||||||
path += ` Q${next[X]},${next[Y]}`;
|
|
||||||
last = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
path += ` ${next[X]},${next[Y]}`;
|
|
||||||
return path;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calcPoints = (
|
|
||||||
history: any,
|
|
||||||
hours: number,
|
|
||||||
width: number,
|
|
||||||
detail: number,
|
|
||||||
min: number,
|
|
||||||
max: number
|
|
||||||
): number[][] => {
|
|
||||||
const coords = [] as number[][];
|
|
||||||
const height = 80;
|
|
||||||
let yRatio = (max - min) / height;
|
|
||||||
yRatio = yRatio !== 0 ? yRatio : height;
|
|
||||||
let xRatio = width / (hours - (detail === 1 ? 1 : 0));
|
|
||||||
xRatio = isFinite(xRatio) ? xRatio : width;
|
|
||||||
|
|
||||||
const first = history.filter(Boolean)[0];
|
|
||||||
let last = [average(first), lastValue(first)];
|
|
||||||
|
|
||||||
const getCoords = (item, i, offset = 0, depth = 1) => {
|
|
||||||
if (depth > 1 && item) {
|
|
||||||
return item.forEach((subItem, index) =>
|
|
||||||
getCoords(subItem, i, index, depth - 1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = xRatio * (i + offset / 6);
|
|
||||||
|
|
||||||
if (item) {
|
|
||||||
last = [average(item), lastValue(item)];
|
|
||||||
}
|
|
||||||
const y =
|
|
||||||
height + strokeWidth / 2 - ((item ? last[0] : last[1]) - min) / yRatio;
|
|
||||||
return coords.push([x, y]);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < history.length; i += 1) {
|
|
||||||
getCoords(history[i], i, 0, detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (coords.length === 1) {
|
|
||||||
coords[1] = [width, coords[0][1]];
|
|
||||||
}
|
|
||||||
|
|
||||||
coords.push([width, coords[coords.length - 1][1]]);
|
|
||||||
return coords;
|
|
||||||
};
|
|
||||||
|
|
||||||
const coordinates = (
|
|
||||||
history: any,
|
|
||||||
hours: number,
|
|
||||||
width: number,
|
|
||||||
detail: number
|
|
||||||
): number[][] => {
|
|
||||||
history.forEach((item) => (item.state = Number(item.state)));
|
|
||||||
history = history.filter((item) => !Number.isNaN(item.state));
|
|
||||||
|
|
||||||
const min = Math.min.apply(
|
|
||||||
Math,
|
|
||||||
history.map((item) => item.state)
|
|
||||||
);
|
|
||||||
const max = Math.max.apply(
|
|
||||||
Math,
|
|
||||||
history.map((item) => item.state)
|
|
||||||
);
|
|
||||||
const now = new Date().getTime();
|
|
||||||
|
|
||||||
const reduce = (res, item, point) => {
|
|
||||||
const age = now - new Date(item.last_changed).getTime();
|
|
||||||
|
|
||||||
let key = Math.abs(age / (1000 * 3600) - hours);
|
|
||||||
if (point) {
|
|
||||||
key = (key - Math.floor(key)) * 60;
|
|
||||||
key = Number((Math.round(key / 10) * 10).toString()[0]);
|
|
||||||
} else {
|
|
||||||
key = Math.floor(key);
|
|
||||||
}
|
|
||||||
if (!res[key]) {
|
|
||||||
res[key] = [];
|
|
||||||
}
|
|
||||||
res[key].push(item);
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|
||||||
history = history.reduce((res, item) => reduce(res, item, false), []);
|
|
||||||
if (detail > 1) {
|
|
||||||
history = history.map((entry) =>
|
|
||||||
entry.reduce((res, item) => reduce(res, item, true), [])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return calcPoints(history, hours, width, detail, min, max);
|
|
||||||
};
|
|
||||||
|
|
||||||
@customElement("hui-sensor-card")
|
@customElement("hui-sensor-card")
|
||||||
class HuiSensorCard extends LitElement implements LovelaceCard {
|
class HuiSensorCard extends HuiEntityCard {
|
||||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||||
await import(
|
await import(
|
||||||
/* webpackChunkName: "hui-sensor-card-editor" */ "../editor/config-elements/hui-sensor-card-editor"
|
/* webpackChunkName: "hui-sensor-card-editor" */ "../editor/config-elements/hui-sensor-card-editor"
|
||||||
@ -202,303 +43,30 @@ class HuiSensorCard extends LitElement implements LovelaceCard {
|
|||||||
return { type: "sensor", entity: foundEntities[0] || "", graph: "line" };
|
return { type: "sensor", entity: foundEntities[0] || "", graph: "line" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@property() public hass?: HomeAssistant;
|
|
||||||
|
|
||||||
@property() private _config?: SensorCardConfig;
|
|
||||||
|
|
||||||
@property() private _history?: any;
|
|
||||||
|
|
||||||
private _date?: Date;
|
|
||||||
|
|
||||||
public setConfig(config: SensorCardConfig): void {
|
public setConfig(config: SensorCardConfig): void {
|
||||||
if (!config.entity || config.entity.split(".")[0] !== "sensor") {
|
if (!config.entity || config.entity.split(".")[0] !== "sensor") {
|
||||||
throw new Error("Specify an entity from within the sensor domain.");
|
throw new Error("Specify an entity from within the sensor domain.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardConfig = {
|
const { graph, detail, hours_to_show, ...cardConfig } = config;
|
||||||
detail: 1,
|
|
||||||
hours_to_show: 24,
|
const entityCardConfig: EntityCardConfig = {
|
||||||
...config,
|
...cardConfig,
|
||||||
|
type: "entity",
|
||||||
};
|
};
|
||||||
|
|
||||||
cardConfig.hours_to_show = Number(cardConfig.hours_to_show);
|
if (graph === "line") {
|
||||||
cardConfig.detail =
|
const footerConfig: GraphHeaderFooterConfig = {
|
||||||
cardConfig.detail === 1 || cardConfig.detail === 2
|
type: "graph",
|
||||||
? cardConfig.detail
|
entity: config.entity,
|
||||||
: 1;
|
detail: detail || 1,
|
||||||
|
hours_to_show: hours_to_show || 24,
|
||||||
|
};
|
||||||
|
|
||||||
this._config = cardConfig;
|
entityCardConfig.footer = footerConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCardSize(): number {
|
super.setConfig(entityCardConfig);
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected render(): TemplateResult {
|
|
||||||
if (!this._config || !this.hass) {
|
|
||||||
return html``;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateObj = this.hass.states[this._config.entity];
|
|
||||||
|
|
||||||
if (!stateObj) {
|
|
||||||
return html`
|
|
||||||
<hui-warning
|
|
||||||
>${this.hass.localize(
|
|
||||||
"ui.panel.lovelace.warning.entity_not_found",
|
|
||||||
"entity",
|
|
||||||
this._config.entity
|
|
||||||
)}</hui-warning
|
|
||||||
>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let graph;
|
|
||||||
|
|
||||||
if (stateObj && this._config.graph === "line") {
|
|
||||||
if (!stateObj.attributes.unit_of_measurement) {
|
|
||||||
return html`
|
|
||||||
<hui-warning
|
|
||||||
>Entity: ${this._config.entity} - Has no Unit of Measurement and
|
|
||||||
therefore can not display a line graph.</hui-warning
|
|
||||||
>
|
|
||||||
`;
|
|
||||||
} else if (!this._history) {
|
|
||||||
graph = svg`
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 500 100"></svg>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
graph = svg`
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 500 100">
|
|
||||||
<g>
|
|
||||||
<mask id="fill">
|
|
||||||
<path
|
|
||||||
class='fill'
|
|
||||||
fill='white'
|
|
||||||
d="${this._history} L 500, 100 L 0, 100 z"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
<rect height="100%" width="100%" id="fill-rect" fill="var(--accent-color)" mask="url(#fill)"></rect>
|
|
||||||
<mask id="line">
|
|
||||||
<path
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--accent-color)"
|
|
||||||
stroke-width="${strokeWidth}"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d=${this._history}
|
|
||||||
></path>
|
|
||||||
</mask>
|
|
||||||
<rect height="100%" width="100%" id="rect" fill="var(--accent-color)" mask="url(#line)"></rect>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
graph = "";
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<ha-card
|
|
||||||
@action=${this._handleClick}
|
|
||||||
.actionHandler=${actionHandler()}
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<div class="flex header">
|
|
||||||
<div class="name">
|
|
||||||
<span>${this._config.name || computeStateName(stateObj)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon">
|
|
||||||
<ha-icon
|
|
||||||
.icon="${this._config.icon || stateIcon(stateObj)}"
|
|
||||||
></ha-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex info">
|
|
||||||
<span id="value">${stateObj.state}</span>
|
|
||||||
<span id="measurement"
|
|
||||||
>${this._config.unit ||
|
|
||||||
stateObj.attributes.unit_of_measurement}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="graph"><div>${graph}</div></div>
|
|
||||||
</ha-card>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected firstUpdated(): void {
|
|
||||||
this._date = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
|
||||||
if (changedProps.has("_history")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasConfigOrEntityChanged(this, changedProps);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues) {
|
|
||||||
super.updated(changedProps);
|
|
||||||
if (!this._config || !this.hass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
|
||||||
const oldConfig = changedProps.get("_config") as
|
|
||||||
| SensorCardConfig
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!oldHass ||
|
|
||||||
!oldConfig ||
|
|
||||||
oldHass.themes !== this.hass.themes ||
|
|
||||||
oldConfig.theme !== this._config.theme
|
|
||||||
) {
|
|
||||||
applyThemesOnElement(this, this.hass.themes, this._config!.theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._config.graph === "line") {
|
|
||||||
const minute = 60000;
|
|
||||||
if (changedProps.has("_config")) {
|
|
||||||
this._getHistory();
|
|
||||||
} else if (Date.now() - this._date!.getTime() >= minute) {
|
|
||||||
this._getHistory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleClick(): void {
|
|
||||||
fireEvent(this, "hass-more-info", { entityId: this._config!.entity });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _getHistory(): Promise<void> {
|
|
||||||
const endTime = new Date();
|
|
||||||
const startTime = new Date();
|
|
||||||
startTime.setHours(endTime.getHours() - this._config!.hours_to_show!);
|
|
||||||
|
|
||||||
const stateHistory = await fetchRecent(
|
|
||||||
this.hass,
|
|
||||||
this._config!.entity,
|
|
||||||
startTime,
|
|
||||||
endTime
|
|
||||||
);
|
|
||||||
|
|
||||||
if (stateHistory.length < 1 || stateHistory[0].length < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coords = coordinates(
|
|
||||||
stateHistory[0],
|
|
||||||
this._config!.hours_to_show!,
|
|
||||||
500,
|
|
||||||
this._config!.detail!
|
|
||||||
);
|
|
||||||
|
|
||||||
this._history = getPath(coords);
|
|
||||||
this._date = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
|
||||||
return css`
|
|
||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha-card:focus {
|
|
||||||
outline: none;
|
|
||||||
background: var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin: 8px 16px 0;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
opacity: 0.8;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name > span {
|
|
||||||
display: block;
|
|
||||||
display: -webkit-box;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
max-height: 1.4rem;
|
|
||||||
top: 2px;
|
|
||||||
opacity: 0.8;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: var(--paper-item-icon-color, #44739e);
|
|
||||||
line-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 0 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#value {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1em;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#measurement {
|
|
||||||
align-self: flex-end;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
line-height: 1.2em;
|
|
||||||
margin-top: 0.1em;
|
|
||||||
opacity: 0.6;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph {
|
|
||||||
align-self: flex-end;
|
|
||||||
margin: auto;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph > div {
|
|
||||||
align-self: flex-end;
|
|
||||||
margin: auto 0px;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fill {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user