From 778b96d11a0ba9cb61c20c52557f6308c4f34ed0 Mon Sep 17 00:00:00 2001 From: Zack Arnett Date: Wed, 18 Mar 2020 22:55:55 -0400 Subject: [PATCH 1/4] Add Graph as a footerheader option --- src/data/graph.ts | 1 + .../lovelace/common/graph/coordinates.ts | 104 +++++++++++++++ src/panels/lovelace/common/graph/get-path.ts | 36 +++++ .../lovelace/components/hui-graph-base.ts | 78 +++++++++++ .../create-header-footer-element.ts | 1 + .../header-footer/hui-graph-header-footer.ts | 123 ++++++++++++++++++ src/panels/lovelace/header-footer/types.ts | 14 ++ 7 files changed, 357 insertions(+) create mode 100644 src/data/graph.ts create mode 100644 src/panels/lovelace/common/graph/coordinates.ts create mode 100644 src/panels/lovelace/common/graph/get-path.ts create mode 100644 src/panels/lovelace/components/hui-graph-base.ts create mode 100644 src/panels/lovelace/header-footer/hui-graph-header-footer.ts diff --git a/src/data/graph.ts b/src/data/graph.ts new file mode 100644 index 000000000000..72fb0ad4fa35 --- /dev/null +++ b/src/data/graph.ts @@ -0,0 +1 @@ +export const strokeWidth = 5; diff --git a/src/panels/lovelace/common/graph/coordinates.ts b/src/panels/lovelace/common/graph/coordinates.ts new file mode 100644 index 000000000000..1f69e377bcbe --- /dev/null +++ b/src/panels/lovelace/common/graph/coordinates.ts @@ -0,0 +1,104 @@ +import { strokeWidth } from "../../../../data/graph"; + +const average = (items: any[]): number => { + return ( + items.reduce((sum, entry) => sum + parseFloat(entry.state), 0) / + items.length + ); +}; + +const lastValue = (items: any[]): number => { + return parseFloat(items[items.length - 1].state) || 0; +}; + +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: any[], i: number, 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; +}; + +export 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); +}; diff --git a/src/panels/lovelace/common/graph/get-path.ts b/src/panels/lovelace/common/graph/get-path.ts new file mode 100644 index 000000000000..2c97ed5bc06f --- /dev/null +++ b/src/panels/lovelace/common/graph/get-path.ts @@ -0,0 +1,36 @@ +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]; +}; + +export const getPath = (coords: number[][]): string => { + if (!coords.length) { + return ""; + } + + let next: number[]; + let Z: number[]; + 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; +}; diff --git a/src/panels/lovelace/components/hui-graph-base.ts b/src/panels/lovelace/components/hui-graph-base.ts new file mode 100644 index 000000000000..05d3d6a2221e --- /dev/null +++ b/src/panels/lovelace/components/hui-graph-base.ts @@ -0,0 +1,78 @@ +import { + html, + LitElement, + TemplateResult, + customElement, + property, + css, + CSSResult, + svg, + PropertyValues, +} from "lit-element"; + +import { strokeWidth } from "../../../data/graph"; +import { getPath } from "../common/graph/get-path"; + +@customElement("hui-graph-base") +export class HuiGraphBase extends LitElement { + @property() public coordinates?: any; + @property() private _path?: string; + + protected render(): TemplateResult { + return html` + ${this._path + ? svg` + + + + + + + + + + + ` + : svg``} + `; + } + + protected updated(changedProps: PropertyValues) { + if (!this.coordinates) { + return; + } + + if (changedProps.has("coordinates")) { + this._path = getPath(this.coordinates); + } + } + + static get styles(): CSSResult { + return css` + :host { + display: flex; + width: 100%; + } + .fill { + opacity: 0.1; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-graph-base": HuiGraphBase; + } +} diff --git a/src/panels/lovelace/create-element/create-header-footer-element.ts b/src/panels/lovelace/create-element/create-header-footer-element.ts index f2e2859f3c30..4ec039f3f835 100644 --- a/src/panels/lovelace/create-element/create-header-footer-element.ts +++ b/src/panels/lovelace/create-element/create-header-footer-element.ts @@ -4,6 +4,7 @@ import { createLovelaceElement } from "./create-element-base"; const LAZY_LOAD_TYPES = { picture: () => import("../header-footer/hui-picture-header-footer"), buttons: () => import("../header-footer/hui-buttons-header-footer"), + graph: () => import("../header-footer/hui-graph-header-footer"), }; export const createHeaderFooterElement = (config: LovelaceHeaderFooterConfig) => diff --git a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts new file mode 100644 index 000000000000..bcdff9b3b039 --- /dev/null +++ b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts @@ -0,0 +1,123 @@ +import { + html, + LitElement, + TemplateResult, + customElement, + property, + PropertyValues, +} from "lit-element"; + +import "../components/hui-graph-base"; + +import { LovelaceHeaderFooter } from "../types"; +import { HomeAssistant } from "../../../types"; +import { GraphHeaderFooterConfig } from "./types"; +import { fetchRecent } from "../../../data/history"; +import { coordinates } from "../common/graph/coordinates"; + +const minute = 60000; + +@customElement("hui-graph-header-footer") +export class HuiGraphHeaderFooter extends LitElement + implements LovelaceHeaderFooter { + public static getStubConfig(): object { + return {}; + } + + @property() public hass?: HomeAssistant; + @property() protected _config?: GraphHeaderFooterConfig; + @property() private _coordinates?: any; + private _date?: Date; + + public setConfig(config: GraphHeaderFooterConfig): void { + if (!config?.entity || config.entity.split(".")[0] !== "sensor") { + throw new Error( + "Invalid Configuration: An entity from within the sensor domain required" + ); + } + + const cardConfig = { + detail: 1, + hours_to_show: 24, + ...config, + }; + + cardConfig.hours_to_show = Number(cardConfig.hours_to_show); + cardConfig.detail = + cardConfig.detail === 1 || cardConfig.detail === 2 + ? cardConfig.detail + : 1; + + this._config = cardConfig; + } + + protected render(): TemplateResult { + if (!this._config || !this.hass) { + return html``; + } + + const stateObj = this.hass!.states[this._config.entity]; + + if (!stateObj.attributes.unit_of_measurement) { + return html` + Entity: ${this._config.entity} - Has no Unit of Measurement and + therefore can not display a line graph. + `; + } + + return html` + + `; + } + + protected firstUpdated(): void { + this._date = new Date(); + } + + protected updated(changedProps: PropertyValues) { + if (!this._config || !this.hass) { + return; + } + + if (changedProps.has("_config")) { + this._getCoordinates(); + } else if (Date.now() - this._date!.getTime() >= minute) { + this._getCoordinates(); + } + } + + private async _getCoordinates(): Promise { + 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._coordinates = coords; + this._date = new Date(); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-graph-header-footer": HuiGraphHeaderFooter; + } +} diff --git a/src/panels/lovelace/header-footer/types.ts b/src/panels/lovelace/header-footer/types.ts index 78d5d5554754..a65c17720c23 100644 --- a/src/panels/lovelace/header-footer/types.ts +++ b/src/panels/lovelace/header-footer/types.ts @@ -11,6 +11,12 @@ export interface ButtonsHeaderFooterConfig extends LovelaceHeaderFooterConfig { entities: Array; } +export interface GraphHeaderFooterConfig extends LovelaceHeaderFooterConfig { + entity: string; + detail?: number; + hours_to_show?: number; +} + export interface PictureHeaderFooterConfig extends LovelaceHeaderFooterConfig { image: string; tap_action?: ActionConfig; @@ -31,7 +37,15 @@ export const buttonsHeaderFooterConfigStruct = struct({ entities: [entitiesConfigStruct], }); +export const graphHeaderFooterConfigStruct = struct({ + type: "string", + entity: "string", + detail: "number?", + hours_to_show: "number?", +}); + export const headerFooterConfigStructs = struct.union([ pictureHeaderFooterConfigStruct, buttonsHeaderFooterConfigStruct, + graphHeaderFooterConfigStruct, ]); From ea53027e25247e5d3a870abfd086b05cd0be2d59 Mon Sep 17 00:00:00 2001 From: Zack Arnett Date: Thu, 19 Mar 2020 00:03:47 -0400 Subject: [PATCH 2/4] Move get Coordinates to a new file --- .../common/graph/get-history-coordinates.ts | 24 +++++++++++++++++++ .../header-footer/hui-graph-header-footer.ts | 22 +++-------------- 2 files changed, 27 insertions(+), 19 deletions(-) create mode 100644 src/panels/lovelace/common/graph/get-history-coordinates.ts diff --git a/src/panels/lovelace/common/graph/get-history-coordinates.ts b/src/panels/lovelace/common/graph/get-history-coordinates.ts new file mode 100644 index 000000000000..73157df09428 --- /dev/null +++ b/src/panels/lovelace/common/graph/get-history-coordinates.ts @@ -0,0 +1,24 @@ +import { fetchRecent } from "../../../../data/history"; +import { coordinates } from "../graph/coordinates"; +import { HomeAssistant } from "../../../../types"; + +export const getHistoryCoordinates = async ( + hass: HomeAssistant, + entity: string, + hours: number, + detail: number +) => { + const endTime = new Date(); + const startTime = new Date(); + startTime.setHours(endTime.getHours() - hours); + + const stateHistory = await fetchRecent(hass, entity, startTime, endTime); + + if (stateHistory.length < 1 || stateHistory[0].length < 1) { + return; + } + + const coords = coordinates(stateHistory[0], hours, 500, detail); + + return coords; +}; diff --git a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts index bcdff9b3b039..41c8ecb639e7 100644 --- a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts +++ b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts @@ -12,8 +12,7 @@ import "../components/hui-graph-base"; import { LovelaceHeaderFooter } from "../types"; import { HomeAssistant } from "../../../types"; import { GraphHeaderFooterConfig } from "./types"; -import { fetchRecent } from "../../../data/history"; -import { coordinates } from "../common/graph/coordinates"; +import { getHistoryCoordinates } from "../common/graph/get-history-coordinates"; const minute = 60000; @@ -89,25 +88,10 @@ export class HuiGraphHeaderFooter extends LitElement } private async _getCoordinates(): Promise { - const endTime = new Date(); - const startTime = new Date(); - startTime.setHours(endTime.getHours() - this._config!.hours_to_show!); - - const stateHistory = await fetchRecent( - this.hass, + const coords = getHistoryCoordinates( + 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! ); From b14efde36d3b7fe41e31c6cba361602f4975ed22 Mon Sep 17 00:00:00 2001 From: Zack Arnett Date: Thu, 19 Mar 2020 00:04:47 -0400 Subject: [PATCH 3/4] await --- src/panels/lovelace/header-footer/hui-graph-header-footer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts index 41c8ecb639e7..ad0ca65e1e25 100644 --- a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts +++ b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts @@ -88,7 +88,7 @@ export class HuiGraphHeaderFooter extends LitElement } private async _getCoordinates(): Promise { - const coords = getHistoryCoordinates( + const coords = await getHistoryCoordinates( this.hass!, this._config!.entity, this._config!.hours_to_show!, From ae73a8023ee311ba34c17508ee40e4be4571d587 Mon Sep 17 00:00:00 2001 From: Zack Arnett Date: Sat, 21 Mar 2020 14:42:07 -0400 Subject: [PATCH 4/4] Comments --- .../lovelace/common/graph/coordinates.ts | 7 ++++- .../header-footer/hui-graph-header-footer.ts | 30 ++++++++++++------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/panels/lovelace/common/graph/coordinates.ts b/src/panels/lovelace/common/graph/coordinates.ts index 1f69e377bcbe..807a2d61d04c 100644 --- a/src/panels/lovelace/common/graph/coordinates.ts +++ b/src/panels/lovelace/common/graph/coordinates.ts @@ -63,7 +63,7 @@ export const coordinates = ( hours: number, width: number, detail: number -): number[][] => { +): number[][] | undefined => { history.forEach((item) => (item.state = Number(item.state))); history = history.filter((item) => !Number.isNaN(item.state)); @@ -100,5 +100,10 @@ export const coordinates = ( entry.reduce((res, item) => reduce(res, item, true), []) ); } + + if (!history.length) { + return undefined; + } + return calcPoints(history, hours, width, detail, min, max); }; diff --git a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts index ad0ca65e1e25..880cb7137223 100644 --- a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts +++ b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts @@ -5,6 +5,8 @@ import { customElement, property, PropertyValues, + CSSResult, + css, } from "lit-element"; import "../components/hui-graph-base"; @@ -14,7 +16,7 @@ import { HomeAssistant } from "../../../types"; import { GraphHeaderFooterConfig } from "./types"; import { getHistoryCoordinates } from "../common/graph/get-history-coordinates"; -const minute = 60000; +const MINUTE = 60000; @customElement("hui-graph-header-footer") export class HuiGraphHeaderFooter extends LitElement @@ -55,14 +57,11 @@ export class HuiGraphHeaderFooter extends LitElement return html``; } - const stateObj = this.hass!.states[this._config.entity]; - - if (!stateObj.attributes.unit_of_measurement) { + if (!this._coordinates) { return html` - Entity: ${this._config.entity} - Has no Unit of Measurement and - therefore can not display a line graph. +
+ No state history found. +
`; } @@ -82,22 +81,31 @@ export class HuiGraphHeaderFooter extends LitElement if (changedProps.has("_config")) { this._getCoordinates(); - } else if (Date.now() - this._date!.getTime() >= minute) { + } else if (Date.now() - this._date!.getTime() >= MINUTE) { this._getCoordinates(); } } private async _getCoordinates(): Promise { - const coords = await getHistoryCoordinates( + this._coordinates = await getHistoryCoordinates( this.hass!, this._config!.entity, this._config!.hours_to_show!, this._config!.detail! ); - this._coordinates = coords; this._date = new Date(); } + + static get styles(): CSSResult { + return css` + .info { + text-align: center; + line-height: 58px; + color: var(--secondary-text-color); + } + `; + } } declare global {