mirror of
https://github.com/home-assistant/frontend
synced 2024-09-28 00:43:28 +02: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";
|
||||
|
||||
@customElement("hui-entity-card")
|
||||
class HuiEntityCard extends LitElement implements LovelaceCard {
|
||||
export class HuiEntityCard extends LitElement implements LovelaceCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
await import(
|
||||
/* webpackChunkName: "hui-entity-card-editor" */ "../editor/config-elements/hui-entity-card-editor"
|
||||
|
@ -1,174 +1,15 @@
|
||||
import {
|
||||
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 { customElement } from "lit-element";
|
||||
import { HassEntity } from "home-assistant-js-websocket/dist/types";
|
||||
|
||||
const strokeWidth = 5;
|
||||
|
||||
const average = (items): number => {
|
||||
return (
|
||||
items.reduce((sum, entry) => sum + parseFloat(entry.state), 0) /
|
||||
items.length
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
import { LovelaceCardEditor } from "../types";
|
||||
import { HomeAssistant } from "../../../types";
|
||||
import { SensorCardConfig, EntityCardConfig } from "./types";
|
||||
import { GraphHeaderFooterConfig } from "../header-footer/types";
|
||||
import { findEntities } from "../common/find-entites";
|
||||
import { HuiEntityCard } from "./hui-entity-card";
|
||||
|
||||
@customElement("hui-sensor-card")
|
||||
class HuiSensorCard extends LitElement implements LovelaceCard {
|
||||
class HuiSensorCard extends HuiEntityCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
await import(
|
||||
/* 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" };
|
||||
}
|
||||
|
||||
@property() public hass?: HomeAssistant;
|
||||
|
||||
@property() private _config?: SensorCardConfig;
|
||||
|
||||
@property() private _history?: any;
|
||||
|
||||
private _date?: Date;
|
||||
|
||||
public setConfig(config: SensorCardConfig): void {
|
||||
if (!config.entity || config.entity.split(".")[0] !== "sensor") {
|
||||
throw new Error("Specify an entity from within the sensor domain.");
|
||||
}
|
||||
|
||||
const cardConfig = {
|
||||
detail: 1,
|
||||
hours_to_show: 24,
|
||||
...config,
|
||||
const { graph, detail, hours_to_show, ...cardConfig } = config;
|
||||
|
||||
const entityCardConfig: EntityCardConfig = {
|
||||
...cardConfig,
|
||||
type: "entity",
|
||||
};
|
||||
|
||||
cardConfig.hours_to_show = Number(cardConfig.hours_to_show);
|
||||
cardConfig.detail =
|
||||
cardConfig.detail === 1 || cardConfig.detail === 2
|
||||
? cardConfig.detail
|
||||
: 1;
|
||||
if (graph === "line") {
|
||||
const footerConfig: GraphHeaderFooterConfig = {
|
||||
type: "graph",
|
||||
entity: config.entity,
|
||||
detail: detail || 1,
|
||||
hours_to_show: hours_to_show || 24,
|
||||
};
|
||||
|
||||
this._config = cardConfig;
|
||||
entityCardConfig.footer = footerConfig;
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
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;
|
||||
}
|
||||
`;
|
||||
super.setConfig(entityCardConfig);
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user