Footer/Header: Graph (#5273)

* Add Graph as a footerheader option

* Move get Coordinates to a new file

* await

* Comments
This commit is contained in:
Zack Arnett 2020-03-23 09:46:56 -04:00 committed by GitHub
parent 40c94b6596
commit ce92add096
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 378 additions and 0 deletions

1
src/data/graph.ts Normal file
View File

@ -0,0 +1 @@
export const strokeWidth = 5;

View File

@ -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);
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
}
}

View File

@ -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) =>

View File

@ -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`
<div class="info">
No state history found.
</div>
`;
}
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> {
this._coordinates = await getHistoryCoordinates(
this.hass!,
this._config!.entity,
this._config!.hours_to_show!,
this._config!.detail!
);
this._date = new Date();
}
static get styles(): CSSResult {
return css`
.info {
text-align: center;
line-height: 58px;
color: var(--secondary-text-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-graph-header-footer": HuiGraphHeaderFooter;
}
}

View File

@ -11,6 +11,12 @@ export interface ButtonsHeaderFooterConfig extends LovelaceHeaderFooterConfig {
entities: Array<string | EntityConfig>;
}
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,
]);