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..807a2d61d04c --- /dev/null +++ b/src/panels/lovelace/common/graph/coordinates.ts @@ -0,0 +1,109 @@ +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[][] | undefined => { + 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), []) + ); + } + + if (!history.length) { + return undefined; + } + + return calcPoints(history, hours, width, detail, min, max); +}; 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/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..880cb7137223 --- /dev/null +++ b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts @@ -0,0 +1,115 @@ +import { + html, + LitElement, + TemplateResult, + customElement, + property, + PropertyValues, + CSSResult, + css, +} from "lit-element"; + +import "../components/hui-graph-base"; + +import { LovelaceHeaderFooter } from "../types"; +import { HomeAssistant } from "../../../types"; +import { GraphHeaderFooterConfig } from "./types"; +import { getHistoryCoordinates } from "../common/graph/get-history-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``; + } + + if (!this._coordinates) { + return html` +