-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Footer/Header: Graph #5273
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Footer/Header: Graph #5273
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const strokeWidth = 5; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 width="100%" height="100%" viewBox="0 0 500 100"> | ||
| <g> | ||
| <mask id="fill"> | ||
| <path | ||
| class='fill' | ||
| fill='white' | ||
| d="${this._path} 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._path} | ||
| ></path> | ||
| </mask> | ||
| <rect height="100%" width="100%" id="rect" fill="var(--accent-color)" mask="url(#line)"></rect> | ||
| </g> | ||
| </svg>` | ||
| : svg`<svg width="100%" height="100%" viewBox="0 0 500 100"></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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| 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 { 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``; | ||
| } | ||
|
|
||
| const stateObj = this.hass!.states[this._config.entity]; | ||
|
|
||
| if (!stateObj.attributes.unit_of_measurement) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No 100% sure. That's how we built it back when we built the sensor card. I tried a sensor with no UOM and received an error. but the state was also not a number. Added in a catch for if the state history is undefined. That should catch this. |
||
| return html` | ||
| <hui-warning | ||
| >Entity: ${this._config.entity} - Has no Unit of Measurement and | ||
| therefore can not display a line graph.</hui-warning | ||
| > | ||
| `; | ||
| } | ||
|
|
||
| return html` | ||
| <hui-graph-base .coordinates=${this._coordinates}></hui-graph-base> | ||
| `; | ||
| } | ||
|
|
||
| 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<void> { | ||
| const coords = await getHistoryCoordinates( | ||
| this.hass!, | ||
| this._config!.entity, | ||
| this._config!.hours_to_show!, | ||
| this._config!.detail! | ||
| ); | ||
|
|
||
| this._coordinates = coords; | ||
|
zsarnett marked this conversation as resolved.
Outdated
|
||
| this._date = new Date(); | ||
| } | ||
| } | ||
|
|
||
| declare global { | ||
| interface HTMLElementTagNameMap { | ||
| "hui-graph-header-footer": HuiGraphHeaderFooter; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.