Skip to content

Commit 64640c2

Browse files
authored
Feat/time offsets (#145)
* Added offsets. Work In Progress * Added offset yaml to readme * Don't clear out-of-view data from the cache Clearing out-of-view data was used to ensure xaxis autoranging would show the expected time range in the screen, but xaxis autoranging is not used anymore, and this technique is incompatible with offsets. * Add extend_to_present option to entities * Reintroduced range out-of-screen data cleaning, but out of the cache * Clear cache when exiting browsing mode (e.g scroll, zoom) This makes auto ranging after resetting behave as it used to (only show as much as zoomed out since the last reset) * refactor: move images in a separate folder * docs: add offset to the README * Fix inverted `entity.extend_to_present`default Co-authored-by: Zanna_37 <unknown>
1 parent 28dc3b4 commit 64640c2

File tree

11 files changed

+179
-109
lines changed

11 files changed

+179
-109
lines changed
File renamed without changes.
File renamed without changes.
File renamed without changes.

docs/resources/offset-nowline.png

25.3 KB
Loading
23.3 KB
Loading
File renamed without changes.

readme.md

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
# Plotly Graph Card
55

6-
<img src="demo.gif" width="300" align="left">
7-
<img src="demo2.gif" width="300" align="right">
6+
<img src="docs/resources/demo.gif" width="300" align="left">
7+
<img src="docs/resources/demo2.gif" width="300" align="right">
88

99
<br clear="both"/>
1010
<br clear="both"/>
@@ -54,7 +54,7 @@ refresh_interval: 10
5454
5555
### Filling, line width, color
5656
57-
![](example1.png)
57+
![](docs/resources/example1.png)
5858
5959
```yaml
6060
type: custom:plotly-graph
@@ -81,7 +81,7 @@ refresh_interval: 10 # in seconds
8181
8282
### Range Selector buttons
8383
84-
![](rangeselector.apng)
84+
![](docs/resources/rangeselector.apng)
8585
8686
```yaml
8787
type: custom:plotly-graph
@@ -242,6 +242,92 @@ entities:
242242
243243
Note that `5minute` period statistics are limited in time as normal recorder history is, contrary to other periods which keep data for years.
244244

245+
## Offsets
246+
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.
247+
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.
248+
249+
The `offset` flag can be specified in two places.
250+
**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.
251+
**2)** When used at the trace level, it offsets the trace by the specified amount.
252+
253+
254+
```yaml
255+
type: custom:plotly-graph
256+
hours_to_show: 16
257+
offset: 3h
258+
entities:
259+
- entity: sensor.current_temperature
260+
line:
261+
width: 3
262+
color: orange
263+
- entity: sensor.current_temperature
264+
name: Temperature yesterday
265+
offset: 1d
266+
line:
267+
width: 1
268+
dash: dot
269+
color: orange
270+
- entity: sensor.temperature_12h_forecast
271+
offset: 12h
272+
name: Forecast temperature
273+
line:
274+
width: 1
275+
dash: dot
276+
color: grey
277+
```
278+
279+
![Graph with offsets](docs/resources/offset-temperature.png)
280+
281+
### Now line
282+
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.
283+
284+
```yaml
285+
type: custom:plotly-graph
286+
hours_to_show: 6
287+
offset: 3h
288+
entities:
289+
- entity: sensor.forecast_temperature
290+
yaxis: y1
291+
offset: 3h
292+
- entity: sensor.nothing_now
293+
name: Now
294+
yaxis: y9
295+
showlegend: false
296+
line:
297+
width: 1
298+
dash: dot
299+
color: deepskyblue
300+
lambda: |-
301+
() => {
302+
return {x:[Date.now(),Date.now()], y:[0,1]}
303+
}
304+
layout:
305+
yaxis9:
306+
visible: false
307+
fixedrange: true
308+
```
309+
310+
![Graph with offsets and now-line](docs/resources/offset-nowline.png)
311+
312+
## Duration
313+
Whenever a time duration can be specified, this is the notation to use:
314+
315+
| Unit | Suffix | Notes |
316+
|--------------|--------|----------|
317+
| Milliseconds | `ms` | |
318+
| Seconds | `s` | |
319+
| Minutes | `m` | |
320+
| Hours | `h` | |
321+
| Days | `d` | |
322+
| Weeks | `w` | |
323+
| Months | `M` | 30 days |
324+
| Years | `y` | 365 days |
325+
326+
Example:
327+
```yaml
328+
offset: 3h
329+
```
330+
245331
## Extra entity attributes:
246332

247333
```yaml
@@ -266,6 +352,24 @@ entities:
266352
<extra></extra>
267353
```
268354

355+
### Extend_to_present
356+
357+
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`.
358+
This is useful to make the plot look fuller.
359+
It's recommended to turn it off when using `offset`s, or when setting the mode of the trace to `markers`.
360+
Defaults to `true` for state history, and `false` for statistics.
361+
362+
```yaml
363+
type: custom:plotly-graph
364+
entities:
365+
- entity: sensor.weather_24h_forecast
366+
mode: "markers"
367+
extend_to_present: false # true by default for state history
368+
- entity: sensor.actual_temperature
369+
statistics: mean
370+
extend_to_present: true # false by default for statistics
371+
```
372+
269373
### `lambda:` transforms
270374

271375
`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:

src/cache/Cache.ts

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,14 @@ export default class Cache {
124124
}
125125
getHistory(entity: EntityConfig) {
126126
let key = getEntityKey(entity);
127-
return this.histories[key] || [];
127+
const history = this.histories[key] || [];
128+
return history.map((datum) => ({
129+
...datum,
130+
timestamp: datum.timestamp + entity.offset,
131+
}));
128132
}
129133
async update(
130134
range: TimestampRange,
131-
removeOutsideRange: boolean,
132135
entities: EntityConfig[],
133136
hass: HomeAssistant,
134137
significant_changes_only: boolean,
@@ -138,13 +141,17 @@ export default class Cache {
138141
return (this.busy = this.busy
139142
.catch(() => {})
140143
.then(async () => {
141-
if (removeOutsideRange) {
142-
this.removeOutsideRange(range);
143-
}
144-
const promises = entities.flatMap(async (entity) => {
144+
const promises = entities.map(async (entity) => {
145145
const entityKey = getEntityKey(entity);
146146
this.ranges[entityKey] ??= [];
147-
const rangesToFetch = subtractRanges([range], this.ranges[entityKey]);
147+
const offsetRange = [
148+
range[0] - entity.offset,
149+
range[1] - entity.offset,
150+
];
151+
const rangesToFetch = subtractRanges(
152+
[offsetRange],
153+
this.ranges[entityKey]
154+
);
148155
for (const aRange of rangesToFetch) {
149156
const fetchedHistory = await fetchSingleRange(
150157
hass,
@@ -161,30 +168,4 @@ export default class Cache {
161168
await Promise.all(promises);
162169
}));
163170
}
164-
165-
removeOutsideRange(range: TimestampRange) {
166-
this.ranges = mapValues(this.ranges, (ranges) =>
167-
subtractRanges(ranges, [
168-
[Number.NEGATIVE_INFINITY, range[0] - 1],
169-
[range[1] + 1, Number.POSITIVE_INFINITY],
170-
])
171-
);
172-
this.histories = mapValues(this.histories, (history) => {
173-
let first: EntityState | undefined;
174-
let last: EntityState | undefined;
175-
const newHistory = history.filter((datum) => {
176-
if (datum.timestamp <= range[0]) first = datum;
177-
else if (!last && datum.timestamp >= range[1]) last = datum;
178-
else return true;
179-
return false;
180-
});
181-
if (first) {
182-
newHistory.unshift(first);
183-
}
184-
if (last) {
185-
newHistory.push(last);
186-
}
187-
return newHistory;
188-
});
189-
}
190171
}

src/plotly-graph-card.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import Plotly from "./plotly";
88
import {
99
Config,
1010
EntityConfig,
11+
EntityState,
1112
InputConfig,
1213
isEntityIdAttrConfig,
1314
isEntityIdStateConfig,
1415
isEntityIdStatisticsConfig,
16+
TimestampRange,
1517
} from "./types";
1618
import Cache from "./cache/Cache";
1719
import getThemedLayout from "./themed-layout";
@@ -49,12 +51,33 @@ function patchLonelyDatapoints(xs: Datum[], ys: Datum[]) {
4951
}
5052
}
5153

52-
function extendLastDatapointToPresent(xs: Datum[], ys: Datum[]) {
54+
function extendLastDatapointToPresent(
55+
xs: Datum[],
56+
ys: Datum[],
57+
offset: number
58+
) {
5359
if (xs.length === 0) return;
5460
const last = JSON.parse(JSON.stringify(ys[ys.length - 1]));
55-
xs.push(new Date());
61+
xs.push(new Date(Date.now() + offset));
5662
ys.push(last);
5763
}
64+
function removeOutOfRange(xs: Datum[], ys: Datum[], range: TimestampRange) {
65+
let first = -1;
66+
let last = -1;
67+
68+
for (let i = 0; i < xs.length; i++) {
69+
if (xs[i]! < range[0]) first = i;
70+
if (xs[i]! > range[1]) last = i;
71+
}
72+
if (last > -1) {
73+
xs = xs.splice(last);
74+
ys = ys.splice(last);
75+
}
76+
if (first > -1) {
77+
xs = xs.splice(0, first);
78+
ys = ys.splice(0, first);
79+
}
80+
}
5881

5982
console.info(
6083
`%c ${componentName.toUpperCase()} %c ${version} ${process.env.NODE_ENV}`,
@@ -199,8 +222,6 @@ export class PlotlyGraph extends HTMLElement {
199222
this.fetch();
200223
}
201224
if (shouldPlot) {
202-
if (!this.isBrowsing)
203-
this.cache.removeOutsideRange(this.getAutoFetchRange());
204225
this.plot();
205226
}
206227
}
@@ -252,7 +273,10 @@ export class PlotlyGraph extends HTMLElement {
252273
}
253274
getAutoFetchRange() {
254275
const ms = this.parsed_config.hours_to_show * 60 * 60 * 1000;
255-
return [+new Date() - ms, +new Date()] as [number, number];
276+
return [
277+
+new Date() - ms + this.parsed_config.offset,
278+
+new Date() + this.parsed_config.offset,
279+
] as [number, number];
256280
}
257281
getAutoFetchRangeWithValueMargins() {
258282
const [start, end] = this.getAutoFetchRange();
@@ -290,20 +314,18 @@ export class PlotlyGraph extends HTMLElement {
290314
return +parseISO(date);
291315
});
292316
}
293-
async enterBrowsingMode() {
317+
enterBrowsingMode = () => {
294318
this.isBrowsing = true;
295319
this.resetButtonEl.classList.remove("hidden");
296-
}
320+
};
297321
exitBrowsingMode = async () => {
298322
this.isBrowsing = false;
299323
this.resetButtonEl.classList.add("hidden");
300324
this.withoutRelayout(async () => {
301-
await Plotly.relayout(this.contentEl, {
302-
uirevision: Math.random(), // to trigger the autoranges in all y-yaxes
303-
xaxis: { range: this.getAutoFetchRangeWithValueMargins() }, // to reset xaxis to hours_to_show quickly, before refetching
304-
});
325+
await this.plot(); // to reset xaxis to hours_to_show quickly, before refetching
326+
this.cache.clearCache(); // so that when the user zooms out and autoranges, not more that what's visible will be autoranged
327+
await this.fetch();
305328
});
306-
await this.fetch();
307329
};
308330
onRestyle = async () => {
309331
// trace visibility changed, fetch missing traces
@@ -368,6 +390,7 @@ export class PlotlyGraph extends HTMLElement {
368390
title: config.title,
369391
hours_to_show: config.hours_to_show ?? 1,
370392
refresh_interval: config.refresh_interval ?? "auto",
393+
offset: parseTimeDuration(config.offset ?? "0s"),
371394
entities: config.entities.map((entityIn, entityIdx) => {
372395
if (typeof entityIn === "string") entityIn = { entity: entityIn };
373396

@@ -386,6 +409,7 @@ export class PlotlyGraph extends HTMLElement {
386409
config.defaults?.entity,
387410
entityIn
388411
);
412+
entity.offset = parseTimeDuration(entityIn.offset ?? "0s");
389413
if (entity.lambda) {
390414
entity.lambda = window.eval(entity.lambda);
391415
}
@@ -432,6 +456,7 @@ export class PlotlyGraph extends HTMLElement {
432456
throw new Error(
433457
`period: "${entity.period}" is not valid. Use ${STATISTIC_PERIODS}`
434458
);
459+
entity.extend_to_present ??= !entity.statistic;
435460
}
436461
const [oldAPI_entity, oldAPI_attribute] = entity.entity.split("::");
437462
if (oldAPI_attribute) {
@@ -487,8 +512,7 @@ export class PlotlyGraph extends HTMLElement {
487512
const was = this.parsed_config;
488513
this.parsed_config = newConfig;
489514
const is = this.parsed_config;
490-
if (!this.contentEl) return;
491-
if (is.hours_to_show !== was?.hours_to_show) {
515+
if (is.hours_to_show !== was?.hours_to_show || is.offset !== was?.offset) {
492516
this.exitBrowsingMode();
493517
}
494518
await this.fetch();
@@ -530,7 +554,6 @@ export class PlotlyGraph extends HTMLElement {
530554
try {
531555
await this.cache.update(
532556
range,
533-
!this.isBrowsing,
534557
visibleEntities,
535558
this.hass,
536559
this.parsed_config.minimal_response,
@@ -593,7 +616,14 @@ export class PlotlyGraph extends HTMLElement {
593616

594617
let xs: Datum[] = xsIn;
595618
let ys = ysIn;
596-
extendLastDatapointToPresent(xs, ys);
619+
if (trace.extend_to_present) {
620+
extendLastDatapointToPresent(xs, ys, trace.offset);
621+
}
622+
if (!this.isBrowsing) {
623+
// to ensure the y axis autorange containst the yaxis
624+
removeOutOfRange(xs, ys, this.getAutoFetchRangeWithValueMargins());
625+
}
626+
597627
if (trace.lambda) {
598628
try {
599629
const r = trace.lambda(ysIn, xsIn, history);
@@ -662,7 +692,11 @@ export class PlotlyGraph extends HTMLElement {
662692
units.map((unit, i) => ["yaxis" + (i == 0 ? "" : i + 1), { title: unit }])
663693
);
664694
const layout = merge(
665-
{ uirevision: true },
695+
{
696+
uirevision: this.isBrowsing
697+
? this.contentEl.layout.uirevision
698+
: Math.random(), // to trigger the autoranges in all y-yaxes
699+
},
666700
{
667701
xaxis: {
668702
range: this.isBrowsing

0 commit comments

Comments
 (0)