diff --git a/demo.gif b/docs/resources/demo.gif
similarity index 100%
rename from demo.gif
rename to docs/resources/demo.gif
diff --git a/demo2.gif b/docs/resources/demo2.gif
similarity index 100%
rename from demo2.gif
rename to docs/resources/demo2.gif
diff --git a/example1.png b/docs/resources/example1.png
similarity index 100%
rename from example1.png
rename to docs/resources/example1.png
diff --git a/docs/resources/offset-nowline.png b/docs/resources/offset-nowline.png
new file mode 100644
index 0000000..4169f28
Binary files /dev/null and b/docs/resources/offset-nowline.png differ
diff --git a/docs/resources/offset-temperature.png b/docs/resources/offset-temperature.png
new file mode 100644
index 0000000..a4eb4ab
Binary files /dev/null and b/docs/resources/offset-temperature.png differ
diff --git a/rangeselector.apng b/docs/resources/rangeselector.apng
similarity index 100%
rename from rangeselector.apng
rename to docs/resources/rangeselector.apng
diff --git a/readme.md b/readme.md
index a230319..2a6df18 100644
--- a/readme.md
+++ b/readme.md
@@ -3,8 +3,8 @@
# Plotly Graph Card
-
-
+
+
@@ -54,7 +54,7 @@ refresh_interval: 10
### Filling, line width, color
-
+
```yaml
type: custom:plotly-graph
@@ -81,7 +81,7 @@ refresh_interval: 10 # in seconds
### Range Selector buttons
-
+
```yaml
type: custom:plotly-graph
@@ -242,6 +242,92 @@ entities:
Note that `5minute` period statistics are limited in time as normal recorder history is, contrary to other periods which keep data for years.
+## Offsets
+Offsets are useful to shift data in the temporal axis. For example, if you have a sensor that reports the forecasted temperature 3 hours from now, it means that the current value should be plotted in the future. With the `offset` attribute you can shift the data so it is placed in the correct position.
+Another possible use is to compare past data with the current one. For example, you can plot yesterday's temperature and the current one on top of each other.
+
+The `offset` flag can be specified in two places.
+**1)** When used at the top level of the configuration, it specifies how much "future" the graph shows by default. For example, if `hours_to_show` is 16 and `offset` is 3h, the graph shows the past 13 hours (16-3) plus the next 3 hours.
+**2)** When used at the trace level, it offsets the trace by the specified amount.
+
+
+```yaml
+type: custom:plotly-graph
+hours_to_show: 16
+offset: 3h
+entities:
+ - entity: sensor.current_temperature
+ line:
+ width: 3
+ color: orange
+ - entity: sensor.current_temperature
+ name: Temperature yesterday
+ offset: 1d
+ line:
+ width: 1
+ dash: dot
+ color: orange
+ - entity: sensor.temperature_12h_forecast
+ offset: 12h
+ name: Forecast temperature
+ line:
+ width: 1
+ dash: dot
+ color: grey
+```
+
+
+
+### Now line
+When using offsets, it is useful to have a line that indicates the current time. This can be done by using a lambda function that returns a line with the current time as x value and 0 and 1 as y values. The line is then hidden from the legend.
+
+```yaml
+type: custom:plotly-graph
+hours_to_show: 6
+offset: 3h
+entities:
+ - entity: sensor.forecast_temperature
+ yaxis: y1
+ offset: 3h
+ - entity: sensor.nothing_now
+ name: Now
+ yaxis: y9
+ showlegend: false
+ line:
+ width: 1
+ dash: dot
+ color: deepskyblue
+ lambda: |-
+ () => {
+ return {x:[Date.now(),Date.now()], y:[0,1]}
+ }
+layout:
+ yaxis9:
+ visible: false
+ fixedrange: true
+```
+
+
+
+## Duration
+Whenever a time duration can be specified, this is the notation to use:
+
+| Unit | Suffix | Notes |
+|--------------|--------|----------|
+| Milliseconds | `ms` | |
+| Seconds | `s` | |
+| Minutes | `m` | |
+| Hours | `h` | |
+| Days | `d` | |
+| Weeks | `w` | |
+| Months | `M` | 30 days |
+| Years | `y` | 365 days |
+
+Example:
+```yaml
+offset: 3h
+```
+
## Extra entity attributes:
```yaml
@@ -266,6 +352,24 @@ entities:
```
+### Extend_to_present
+
+The boolean `extend_to_present` will take the last known datapoint and "expand" it to the present by creating a duplicate and setting its date to `now`.
+This is useful to make the plot look fuller.
+It's recommended to turn it off when using `offset`s, or when setting the mode of the trace to `markers`.
+Defaults to `true` for state history, and `false` for statistics.
+
+```yaml
+type: custom:plotly-graph
+entities:
+ - entity: sensor.weather_24h_forecast
+ mode: "markers"
+ extend_to_present: false # true by default for state history
+ - entity: sensor.actual_temperature
+ statistics: mean
+ extend_to_present: true # false by default for statistics
+```
+
### `lambda:` transforms
`lambda` takes a js function (as a string) to pre process the data before plotting it. Here you can do things like normalisation, integration. For example:
diff --git a/src/cache/Cache.ts b/src/cache/Cache.ts
index dc4414d..fc23971 100644
--- a/src/cache/Cache.ts
+++ b/src/cache/Cache.ts
@@ -124,11 +124,14 @@ export default class Cache {
}
getHistory(entity: EntityConfig) {
let key = getEntityKey(entity);
- return this.histories[key] || [];
+ const history = this.histories[key] || [];
+ return history.map((datum) => ({
+ ...datum,
+ timestamp: datum.timestamp + entity.offset,
+ }));
}
async update(
range: TimestampRange,
- removeOutsideRange: boolean,
entities: EntityConfig[],
hass: HomeAssistant,
significant_changes_only: boolean,
@@ -138,13 +141,17 @@ export default class Cache {
return (this.busy = this.busy
.catch(() => {})
.then(async () => {
- if (removeOutsideRange) {
- this.removeOutsideRange(range);
- }
- const promises = entities.flatMap(async (entity) => {
+ const promises = entities.map(async (entity) => {
const entityKey = getEntityKey(entity);
this.ranges[entityKey] ??= [];
- const rangesToFetch = subtractRanges([range], this.ranges[entityKey]);
+ const offsetRange = [
+ range[0] - entity.offset,
+ range[1] - entity.offset,
+ ];
+ const rangesToFetch = subtractRanges(
+ [offsetRange],
+ this.ranges[entityKey]
+ );
for (const aRange of rangesToFetch) {
const fetchedHistory = await fetchSingleRange(
hass,
@@ -161,30 +168,4 @@ export default class Cache {
await Promise.all(promises);
}));
}
-
- removeOutsideRange(range: TimestampRange) {
- this.ranges = mapValues(this.ranges, (ranges) =>
- subtractRanges(ranges, [
- [Number.NEGATIVE_INFINITY, range[0] - 1],
- [range[1] + 1, Number.POSITIVE_INFINITY],
- ])
- );
- this.histories = mapValues(this.histories, (history) => {
- let first: EntityState | undefined;
- let last: EntityState | undefined;
- const newHistory = history.filter((datum) => {
- if (datum.timestamp <= range[0]) first = datum;
- else if (!last && datum.timestamp >= range[1]) last = datum;
- else return true;
- return false;
- });
- if (first) {
- newHistory.unshift(first);
- }
- if (last) {
- newHistory.push(last);
- }
- return newHistory;
- });
- }
}
diff --git a/src/plotly-graph-card.ts b/src/plotly-graph-card.ts
index ed9e768..c8b5e59 100644
--- a/src/plotly-graph-card.ts
+++ b/src/plotly-graph-card.ts
@@ -8,10 +8,12 @@ import Plotly from "./plotly";
import {
Config,
EntityConfig,
+ EntityState,
InputConfig,
isEntityIdAttrConfig,
isEntityIdStateConfig,
isEntityIdStatisticsConfig,
+ TimestampRange,
} from "./types";
import Cache from "./cache/Cache";
import getThemedLayout from "./themed-layout";
@@ -49,12 +51,33 @@ function patchLonelyDatapoints(xs: Datum[], ys: Datum[]) {
}
}
-function extendLastDatapointToPresent(xs: Datum[], ys: Datum[]) {
+function extendLastDatapointToPresent(
+ xs: Datum[],
+ ys: Datum[],
+ offset: number
+) {
if (xs.length === 0) return;
const last = JSON.parse(JSON.stringify(ys[ys.length - 1]));
- xs.push(new Date());
+ xs.push(new Date(Date.now() + offset));
ys.push(last);
}
+function removeOutOfRange(xs: Datum[], ys: Datum[], range: TimestampRange) {
+ let first = -1;
+ let last = -1;
+
+ for (let i = 0; i < xs.length; i++) {
+ if (xs[i]! < range[0]) first = i;
+ if (xs[i]! > range[1]) last = i;
+ }
+ if (last > -1) {
+ xs = xs.splice(last);
+ ys = ys.splice(last);
+ }
+ if (first > -1) {
+ xs = xs.splice(0, first);
+ ys = ys.splice(0, first);
+ }
+}
console.info(
`%c ${componentName.toUpperCase()} %c ${version} ${process.env.NODE_ENV}`,
@@ -199,8 +222,6 @@ export class PlotlyGraph extends HTMLElement {
this.fetch();
}
if (shouldPlot) {
- if (!this.isBrowsing)
- this.cache.removeOutsideRange(this.getAutoFetchRange());
this.plot();
}
}
@@ -252,7 +273,10 @@ export class PlotlyGraph extends HTMLElement {
}
getAutoFetchRange() {
const ms = this.parsed_config.hours_to_show * 60 * 60 * 1000;
- return [+new Date() - ms, +new Date()] as [number, number];
+ return [
+ +new Date() - ms + this.parsed_config.offset,
+ +new Date() + this.parsed_config.offset,
+ ] as [number, number];
}
getAutoFetchRangeWithValueMargins() {
const [start, end] = this.getAutoFetchRange();
@@ -290,20 +314,18 @@ export class PlotlyGraph extends HTMLElement {
return +parseISO(date);
});
}
- async enterBrowsingMode() {
+ enterBrowsingMode = () => {
this.isBrowsing = true;
this.resetButtonEl.classList.remove("hidden");
- }
+ };
exitBrowsingMode = async () => {
this.isBrowsing = false;
this.resetButtonEl.classList.add("hidden");
this.withoutRelayout(async () => {
- await Plotly.relayout(this.contentEl, {
- uirevision: Math.random(), // to trigger the autoranges in all y-yaxes
- xaxis: { range: this.getAutoFetchRangeWithValueMargins() }, // to reset xaxis to hours_to_show quickly, before refetching
- });
+ await this.plot(); // to reset xaxis to hours_to_show quickly, before refetching
+ this.cache.clearCache(); // so that when the user zooms out and autoranges, not more that what's visible will be autoranged
+ await this.fetch();
});
- await this.fetch();
};
onRestyle = async () => {
// trace visibility changed, fetch missing traces
@@ -368,6 +390,7 @@ export class PlotlyGraph extends HTMLElement {
title: config.title,
hours_to_show: config.hours_to_show ?? 1,
refresh_interval: config.refresh_interval ?? "auto",
+ offset: parseTimeDuration(config.offset ?? "0s"),
entities: config.entities.map((entityIn, entityIdx) => {
if (typeof entityIn === "string") entityIn = { entity: entityIn };
@@ -386,6 +409,7 @@ export class PlotlyGraph extends HTMLElement {
config.defaults?.entity,
entityIn
);
+ entity.offset = parseTimeDuration(entityIn.offset ?? "0s");
if (entity.lambda) {
entity.lambda = window.eval(entity.lambda);
}
@@ -432,6 +456,7 @@ export class PlotlyGraph extends HTMLElement {
throw new Error(
`period: "${entity.period}" is not valid. Use ${STATISTIC_PERIODS}`
);
+ entity.extend_to_present ??= !entity.statistic;
}
const [oldAPI_entity, oldAPI_attribute] = entity.entity.split("::");
if (oldAPI_attribute) {
@@ -487,8 +512,7 @@ export class PlotlyGraph extends HTMLElement {
const was = this.parsed_config;
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 || is.offset !== was?.offset) {
this.exitBrowsingMode();
}
await this.fetch();
@@ -530,7 +554,6 @@ export class PlotlyGraph extends HTMLElement {
try {
await this.cache.update(
range,
- !this.isBrowsing,
visibleEntities,
this.hass,
this.parsed_config.minimal_response,
@@ -593,7 +616,14 @@ export class PlotlyGraph extends HTMLElement {
let xs: Datum[] = xsIn;
let ys = ysIn;
- extendLastDatapointToPresent(xs, ys);
+ if (trace.extend_to_present) {
+ extendLastDatapointToPresent(xs, ys, trace.offset);
+ }
+ if (!this.isBrowsing) {
+ // to ensure the y axis autorange containst the yaxis
+ removeOutOfRange(xs, ys, this.getAutoFetchRangeWithValueMargins());
+ }
+
if (trace.lambda) {
try {
const r = trace.lambda(ysIn, xsIn, history);
@@ -662,7 +692,11 @@ export class PlotlyGraph extends HTMLElement {
units.map((unit, i) => ["yaxis" + (i == 0 ? "" : i + 1), { title: unit }])
);
const layout = merge(
- { uirevision: true },
+ {
+ uirevision: this.isBrowsing
+ ? this.contentEl.layout.uirevision
+ : Math.random(), // to trigger the autoranges in all y-yaxes
+ },
{
xaxis: {
range: this.isBrowsing
diff --git a/src/plotly-utils.ts b/src/plotly-utils.ts
deleted file mode 100644
index df69492..0000000
--- a/src/plotly-utils.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import Plotly from "./plotly";
-export function autorange(
- contentEl: HTMLElement,
- range: number[],
- data: Partial[],
- layout: Partial,
- config: Partial
-) {
- const boundedData = data.map((d) => {
- const x: number[] = [];
- const y: number[] = [];
- for (let i = 0; i < d.x!.length; i++) {
- const dx: number = (d as any).x[i];
- const dy: number = (d as any).y[i];
- if (range[0] <= dx && dx <= range[1]) {
- x.push(dx);
- y.push(dy);
- }
- }
- return {
- ...d,
- x,
- y,
- };
- });
- Plotly.relayout(contentEl, {
- "xaxis.autorange": true,
- "yaxis.autorange": true,
- });
-
- Plotly.react(contentEl, boundedData, layout, {
- displaylogo: false,
- ...config,
- });
- Plotly.relayout(contentEl, {
- "xaxis.autorange": false,
- "xaxis.range": range as [number, number],
- "yaxis.autorange": false,
- });
-}
-
-export const extractRanges = (layout: Partial) => {
- const justRanges: Partial = {};
- Object.keys(layout).forEach((key) => {
- if (layout[key]?.range) {
- justRanges[key] ??= {};
- justRanges[key].range = layout[key].range;
- }
- if (layout[key]?.autorange) {
- justRanges[key] ??= {};
- justRanges[key].autorange = layout[key].autorange;
- }
- });
- return justRanges;
-};
diff --git a/src/types.ts b/src/types.ts
index 49bb9ba..003544d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,6 @@
import { Datum } from "plotly.js";
import { ColorSchemeArray, ColorSchemeNames } from "./color-schemes";
+import { TimeDurationStr } from "./duration/duration";
import {
AutoPeriodConfig,
StatisticPeriod,
@@ -15,6 +16,7 @@ export type InputConfig = {
refresh_interval?: number | "auto"; // in seconds
color_scheme?: ColorSchemeNames | ColorSchemeArray | number;
title?: string;
+ offset?: TimeDurationStr;
entities: ({
entity: string;
attribute?: string;
@@ -27,6 +29,7 @@ export type InputConfig = {
| {
right_margin: number;
};
+ offset?: TimeDurationStr;
} & Partial)[];
defaults?: {
entity?: Partial;
@@ -52,12 +55,15 @@ export type EntityConfig = EntityIdConfig & {
| {
right_margin: number;
};
+ offset: number;
+ extend_to_present: boolean;
} & Partial;
export type Config = {
title?: string;
hours_to_show: number;
refresh_interval: number | "auto"; // in seconds
+ offset: number;
entities: EntityConfig[];
layout: Partial;
config: Partial;