mirror of
https://github.com/home-assistant/frontend.git
synced 2025-04-25 22:07:20 +00:00
Footer/Header: Graph (#5273)
* Add Graph as a footerheader option * Move get Coordinates to a new file * await * Comments
This commit is contained in:
parent
40c94b6596
commit
ce92add096
1
src/data/graph.ts
Normal file
1
src/data/graph.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const strokeWidth = 5;
|
109
src/panels/lovelace/common/graph/coordinates.ts
Normal file
109
src/panels/lovelace/common/graph/coordinates.ts
Normal 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);
|
||||||
|
};
|
24
src/panels/lovelace/common/graph/get-history-coordinates.ts
Normal file
24
src/panels/lovelace/common/graph/get-history-coordinates.ts
Normal 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;
|
||||||
|
};
|
36
src/panels/lovelace/common/graph/get-path.ts
Normal file
36
src/panels/lovelace/common/graph/get-path.ts
Normal 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;
|
||||||
|
};
|
78
src/panels/lovelace/components/hui-graph-base.ts
Normal file
78
src/panels/lovelace/components/hui-graph-base.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { createLovelaceElement } from "./create-element-base";
|
|||||||
const LAZY_LOAD_TYPES = {
|
const LAZY_LOAD_TYPES = {
|
||||||
picture: () => import("../header-footer/hui-picture-header-footer"),
|
picture: () => import("../header-footer/hui-picture-header-footer"),
|
||||||
buttons: () => import("../header-footer/hui-buttons-header-footer"),
|
buttons: () => import("../header-footer/hui-buttons-header-footer"),
|
||||||
|
graph: () => import("../header-footer/hui-graph-header-footer"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createHeaderFooterElement = (config: LovelaceHeaderFooterConfig) =>
|
export const createHeaderFooterElement = (config: LovelaceHeaderFooterConfig) =>
|
||||||
|
115
src/panels/lovelace/header-footer/hui-graph-header-footer.ts
Normal file
115
src/panels/lovelace/header-footer/hui-graph-header-footer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,12 @@ export interface ButtonsHeaderFooterConfig extends LovelaceHeaderFooterConfig {
|
|||||||
entities: Array<string | EntityConfig>;
|
entities: Array<string | EntityConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GraphHeaderFooterConfig extends LovelaceHeaderFooterConfig {
|
||||||
|
entity: string;
|
||||||
|
detail?: number;
|
||||||
|
hours_to_show?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PictureHeaderFooterConfig extends LovelaceHeaderFooterConfig {
|
export interface PictureHeaderFooterConfig extends LovelaceHeaderFooterConfig {
|
||||||
image: string;
|
image: string;
|
||||||
tap_action?: ActionConfig;
|
tap_action?: ActionConfig;
|
||||||
@ -31,7 +37,15 @@ export const buttonsHeaderFooterConfigStruct = struct({
|
|||||||
entities: [entitiesConfigStruct],
|
entities: [entitiesConfigStruct],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const graphHeaderFooterConfigStruct = struct({
|
||||||
|
type: "string",
|
||||||
|
entity: "string",
|
||||||
|
detail: "number?",
|
||||||
|
hours_to_show: "number?",
|
||||||
|
});
|
||||||
|
|
||||||
export const headerFooterConfigStructs = struct.union([
|
export const headerFooterConfigStructs = struct.union([
|
||||||
pictureHeaderFooterConfigStruct,
|
pictureHeaderFooterConfigStruct,
|
||||||
buttonsHeaderFooterConfigStruct,
|
buttonsHeaderFooterConfigStruct,
|
||||||
|
graphHeaderFooterConfigStruct,
|
||||||
]);
|
]);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user