mirror of
https://github.com/home-assistant/frontend.git
synced 2025-04-24 21:37:21 +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 = {
|
||||
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) =>
|
||||
|
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>;
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
|
Loading…
x
Reference in New Issue
Block a user