Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/data/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const strokeWidth = 5;
109 changes: 109 additions & 0 deletions src/panels/lovelace/common/graph/coordinates.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions src/panels/lovelace/common/graph/get-history-coordinates.ts
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;
};
36 changes: 36 additions & 0 deletions src/panels/lovelace/common/graph/get-path.ts
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;
};
78 changes: 78 additions & 0 deletions src/panels/lovelace/components/hui-graph-base.ts
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
Expand Up @@ -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) =>
Expand Down
115 changes: 115 additions & 0 deletions src/panels/lovelace/header-footer/hui-graph-header-footer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading