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
7 changes: 4 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
preset: "ts-jest",
testEnvironment: "node",
maxWorkers: 1, // this makes local testing faster
};
7,304 changes: 3,488 additions & 3,816 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,17 @@
"license": "ISC",
"devDependencies": {
"@types/jest": "^27.0.2",
"@types/lodash": "^4.14.175",
"@types/node": "^16.10.3",
"@types/plotly.js": "^1.54.16",
"@types/plotly.js": "^2.12.8",
"esbuild": "^0.13.4",
"pretier": "0.0.1",
"ts-jest": "^27.0.6"
"ts-jest": "^29.0.3"
},
"dependencies": {
"@types/lodash": "^4.14.175",
"custom-card-helpers": "^1.8.0",
"date-fns": "^2.28.0",
"deepmerge": "^4.2.2",
"lodash": "^4.17.21",
"plotly.js": "^2.8.3"
"plotly.js": "^2.16.1"
}
}
10 changes: 5 additions & 5 deletions src/cache/Cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
TimestampRange,
History,
isEntityIdAttrConfig,
EntityIdConfig,
EntityConfig,
isEntityIdStateConfig,
isEntityIdStatisticsConfig,
HistoryInRange,
Expand All @@ -20,7 +20,7 @@ export function mapValues<T, S>(
}
async function fetchSingleRange(
hass: HomeAssistant,
entity: EntityIdConfig,
entity: EntityConfig,
[startT, endT]: number[],
significant_changes_only: boolean,
minimal_response: boolean
Expand Down Expand Up @@ -63,7 +63,7 @@ async function fetchSingleRange(
};
}

export function getEntityKey(entity: EntityIdConfig) {
export function getEntityKey(entity: EntityConfig) {
if (isEntityIdAttrConfig(entity)) {
return `${entity.entity}::${entity.attribute}`;
} else if (isEntityIdStatisticsConfig(entity)) {
Expand All @@ -81,14 +81,14 @@ export default class Cache {
this.ranges = {};
this.histories = {};
}
getHistory(entity: EntityIdConfig) {
getHistory(entity: EntityConfig) {
let key = getEntityKey(entity);
return this.histories[key] || [];
}
async update(
range: TimestampRange,
removeOutsideRange: boolean,
entities: EntityIdConfig[],
entities: EntityConfig[],
hass: HomeAssistant,
significant_changes_only: boolean,
minimal_response: boolean
Expand Down
4 changes: 2 additions & 2 deletions src/cache/fetch-states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ async function fetchStates(
)}`
);
}
if (!list) list = []; //throw new Error(`Error fetching ${entity.entity}`); // shutup typescript
if (!list) list = [];
return {
range: [+start, +end],
history: list
.map((entry) => ({
...entry,
state: isEntityIdAttrConfig(entity)
? entry.attributes[entity.attribute]
? entry.attributes[entity.attribute] || null
: entry.state,
last_updated: +new Date(entry.last_updated || entry.last_changed),
}))
Expand Down
91 changes: 56 additions & 35 deletions src/plotly-graph-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,19 @@ import {
STATISTIC_PERIODS,
STATISTIC_TYPES,
StatisticPeriod,
isAutoPeriodConfig as getIsAutoPeriodConfig,
getIsAutoPeriodConfig,
} from "./recorder-types";
import { parseTimeDuration } from "./duration/duration";

const componentName = isProduction ? "plotly-graph" : "plotly-graph-dev";

const isDefined = (y: any) => y !== null && y !== undefined;
function patchLonelyDatapoints(xs: Datum[], ys: Datum[]) {
/* Ghost traces when data has single non-unavailable states sandwiched between unavailable ones
see: https://github.com/dbuezas/lovelace-plotly-graph-card/issues/103
*/
for (let i = 1; i < xs.length - 1; i++) {
if (ys[i - 1] === null && ys[i] !== null && ys[i + 1] === null) {
if (!isDefined(ys[i - 1]) && isDefined(ys[i]) && !isDefined(ys[i + 1])) {
ys.splice(i, 0, ys[i]);
xs.splice(i, 0, xs[i]);
}
Expand All @@ -50,14 +51,14 @@ console.info(

const padding = 1;
export class PlotlyGraph extends HTMLElement {
contentEl!: Plotly.PlotlyHTMLElement & {
contentEl: Plotly.PlotlyHTMLElement & {
data: (Plotly.PlotData & { entity: string })[];
layout: Plotly.Layout;
};
msgEl!: HTMLElement;
cardEl!: HTMLElement;
buttonEl!: HTMLButtonElement;
titleEl!: HTMLElement;
msgEl: HTMLElement;
cardEl: HTMLElement;
resetButtonEl: HTMLButtonElement;
titleEl: HTMLElement;
config!: InputConfig;
parsed_config!: Config;
cache = new Cache();
Expand All @@ -78,10 +79,10 @@ export class PlotlyGraph extends HTMLElement {
this.handles.restyleListener!.off("plotly_restyle", this.onRestyle);
clearTimeout(this.handles.refreshTimeout!);
}
connectedCallback() {
if (!this.contentEl) {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<ha-card>
<style>
ha-card{
Expand Down Expand Up @@ -124,20 +125,21 @@ export class PlotlyGraph extends HTMLElement {
<span id="msg"> </span>
<button id="reset" class="hidden">↻</button>
</ha-card>`;
this.msgEl = shadow.querySelector("#msg")!;
this.cardEl = shadow.querySelector("ha-card")!;
this.contentEl = shadow.querySelector("div#plotly")!;
this.buttonEl = shadow.querySelector("button#reset")!;
this.titleEl = shadow.querySelector("ha-card > #title")!;
this.buttonEl.addEventListener("click", this.exitBrowsingMode);
insertStyleHack(shadow.querySelector("style")!);
this.contentEl.style.visibility = "hidden";
this.withoutRelayout(() => Plotly.newPlot(this.contentEl, [], {}));
}
this.msgEl = shadow.querySelector("#msg")!;
this.cardEl = shadow.querySelector("ha-card")!;
this.contentEl = shadow.querySelector("div#plotly")!;
this.resetButtonEl = shadow.querySelector("button#reset")!;
this.titleEl = shadow.querySelector("ha-card > #title")!;
this.resetButtonEl.addEventListener("click", this.exitBrowsingMode);
insertStyleHack(shadow.querySelector("style")!);
this.contentEl.style.visibility = "hidden";
this.withoutRelayout(() => Plotly.newPlot(this.contentEl, [], {}));
}
connectedCallback() {
this.setupListeners();
this.fetch(this.getAutoFetchRange())
.then(() => this.fetch(this.getAutoFetchRange())) // again so home assistant extends until end of time axis
.then(() => (this.contentEl.style.visibility = ""));
this.fetch(this.getAutoFetchRange()).then(
() => (this.contentEl.style.visibility = "")
);
}
async withoutRelayout(fn: Function) {
this.isInternalRelayout++;
Expand Down Expand Up @@ -184,21 +186,23 @@ export class PlotlyGraph extends HTMLElement {
return [+new Date() - ms, +new Date()] as [number, number];
}
getVisibleRange() {
return this.contentEl.layout.xaxis!.range!.map((date) => +parseISO(date));
return this.contentEl.layout.xaxis!.range!.map((date) =>
// if autoscale is used after scrolling, plotly returns the dates as numbers instead of strings
Number.isFinite(date) ? date : +parseISO(date)
);
}
async enterBrowsingMode() {
this.isBrowsing = true;
this.buttonEl.classList.remove("hidden");
this.resetButtonEl.classList.remove("hidden");
}
exitBrowsingMode = async () => {
this.isBrowsing = false;
this.buttonEl.classList.add("hidden");
this.resetButtonEl.classList.add("hidden");
this.withoutRelayout(async () => {
await Plotly.relayout(this.contentEl, {
uirevision: Math.random(),
xaxis: { range: this.getAutoFetchRange() },
uirevision: Math.random(), // to trigger the autoranges in all y-yaxes
xaxis: { range: this.getAutoFetchRange() }, // to reset xaxis to hours_to_show quickly, before refetching
});
await Plotly.restyle(this.contentEl, { visible: true });
});
await this.fetch(this.getAutoFetchRange());
};
Expand All @@ -218,6 +222,18 @@ export class PlotlyGraph extends HTMLElement {
// The user supplied configuration. Throw an exception and Lovelace will
// render an error card.
async setConfig(config: InputConfig) {
try {
this.msgEl.innerText = "";
return await this._setConfig(config);
} catch (e: any) {
console.error(e);
this.msgEl.innerText = JSON.stringify(e.message || "").replace(
/\\"/g,
'"'
);
}
}
async _setConfig(config: InputConfig) {
config = JSON.parse(JSON.stringify(config));
this.config = config;
const schemeName = config.color_scheme ?? "category10";
Expand Down Expand Up @@ -328,7 +344,7 @@ export class PlotlyGraph extends HTMLElement {
this.parsed_config = newConfig;
const is = this.parsed_config;
if (!this.contentEl) return;
if (is.hours_to_show !== was.hours_to_show) {
if (is.hours_to_show !== was?.hours_to_show) {
this.exitBrowsingMode();
}
await this.fetch(this.getAutoFetchRange());
Expand Down Expand Up @@ -460,7 +476,6 @@ export class PlotlyGraph extends HTMLElement {
if (mergedTrace.show_value) {
mergedTrace.legendgroup ??= "group" + traceIdx;
show_value_traces.push({
// @ts-expect-error (texttemplate missing in plotly typings)
texttemplate: `%{y:.2~f}%{customdata.unit_of_measurement}`, // here so it can be overwritten
...mergedTrace,
mode: "text+markers",
Expand Down Expand Up @@ -507,9 +522,15 @@ export class PlotlyGraph extends HTMLElement {
const yAxisTitles = Object.fromEntries(
units.map((unit, i) => ["yaxis" + (i == 0 ? "" : i + 1), { title: unit }])
);

const layout = merge(
{ uirevision: true },
{
xaxis: {
range: this.isBrowsing
? this.getVisibleRange()
: this.getAutoFetchRange(),
},
},
this.parsed_config.no_default_layout ? {} : yAxisTitles,
this.getThemedLayout(),
this.size,
Expand Down Expand Up @@ -579,9 +600,9 @@ export class PlotlyGraph extends HTMLElement {
return historyGraphCard.constructor.getConfigElement();
}
}
//@ts-ignore
//@ts-expect-error
window.customCards = window.customCards || [];
//@ts-ignore
//@ts-expect-error
window.customCards.push({
type: componentName,
name: "Plotly Graph Card",
Expand Down
1 change: 0 additions & 1 deletion src/plotly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

window.global = window;
var Plotly = require("plotly.js/lib/core") as typeof import("plotly.js");
//@ts-ignore
Plotly.register([
// traces
require("plotly.js/lib/bar"),
Expand Down
2 changes: 1 addition & 1 deletion src/recorder-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const STATISTIC_PERIODS = [
export type StatisticPeriod = typeof STATISTIC_PERIODS[number];
export type AutoPeriodConfig = Record<TimeDurationStr, StatisticPeriod>;

export function isAutoPeriodConfig(val: any): val is AutoPeriodConfig {
export function getIsAutoPeriodConfig(val: any): val is AutoPeriodConfig {
const isObject =
typeof val === "object" && val !== null && !Array.isArray(val);
if (!isObject) return false;
Expand Down
8 changes: 4 additions & 4 deletions src/themed-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ const defaultLayout: Partial<Plotly.Layout> = {
height: 285,
dragmode: "pan",
xaxis: {
autorange: false,
// automargin: true, // it makes zooming very jumpy
},
yaxis: {
// automargin: true, // it makes zooming very jumpy
},
yaxis2: {
// automargin: true, // it makes zooming very jumpy
side: "right",
showgrid: false,
overlaying: "y",
...defaultExtraYAxes,
visible: true,
},
yaxis3: { ...defaultExtraYAxes },
yaxis4: { ...defaultExtraYAxes },
Expand All @@ -31,7 +31,7 @@ const defaultLayout: Partial<Plotly.Layout> = {
yaxis7: { ...defaultExtraYAxes },
yaxis8: { ...defaultExtraYAxes },
yaxis9: { ...defaultExtraYAxes },
// @ts-ignore (the types are missing yaxes > 9)
// @ts-expect-error (the types are missing yaxes > 9)
yaxis10: { ...defaultExtraYAxes },
yaxis11: { ...defaultExtraYAxes },
yaxis12: { ...defaultExtraYAxes },
Expand Down