Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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;
104 changes: 104 additions & 0 deletions src/panels/lovelace/common/graph/coordinates.ts
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);
};
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
107 changes: 107 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,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;
Comment thread
zsarnett marked this conversation as resolved.
Outdated

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need a unit_of_measurement for a line graph?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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;
Comment thread
zsarnett marked this conversation as resolved.
Outdated
this._date = new Date();
}
}

declare global {
interface HTMLElementTagNameMap {
"hui-graph-header-footer": HuiGraphHeaderFooter;
}
}
14 changes: 14 additions & 0 deletions src/panels/lovelace/header-footer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
]);