-
-
Notifications
You must be signed in to change notification settings - Fork 24
Feat/time offsets #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/time offsets #109
Changes from all commits
7dbe2b2
60c2e4a
d8ac252
89528b1
923076f
7fd272b
0417972
f021a6a
8a40c4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,8 +3,8 @@ | |
|
||
# Plotly Graph Card | ||
|
||
<img src="demo.gif" width="300" align="left"> | ||
<img src="demo2.gif" width="300" align="right"> | ||
<img src="docs/resources/demo.gif" width="300" align="left"> | ||
<img src="docs/resources/demo2.gif" width="300" align="right"> | ||
|
||
<br clear="both"/> | ||
<br clear="both"/> | ||
|
@@ -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: | |
<extra></extra> | ||
``` | ||
|
||
### 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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Side note: is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not within this repo's readme, it's a plotlyjs feature, so it's in their docs |
||
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: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's discuss this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, it depends. 😂 Neither is perfect, but maybe the second scenario is more common so we should stick with your vision. Opinions? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True. Give it a try when you test it. I think when you see how a part of the plot disappears as soon as the offset is added you may get the same "this is wrong" feeling. It is true that it can be seen as a (poor) forecast, but it can also be interpreted as "the last known state at that point". Let me know what you find out from experimenting. We can always add an option to deactivate the extension There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For temperatures and statistics (which are currently timed at the start of their period) extending is surprising, because it extends horizontally between one and two hours but the real temperature didn't stay constant. It looks wierd. If you extended by adding the current state instead of the last fetched one it would look better, but that's IMHO too smart, non generalizable and out of scope. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If one offsets right (positive) by a month and the visible range is a week, the only the section [now - 1month -1 week, now - 1month] will he fetched] . Extending that to now will just add a huge 1 month straight line (visible when you zoom). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
X axis scaling is now done explicitly, so this is less of an issue now There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think i got you now. Whatever we do, it should be equivalent to expanding the true last state first and the offseting. (Not offsetting and then expanding the last fetched one, which may not be the latest state of the sensor) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If there is an existing state right of the visible range, it should probably be fetched for continuity purposes. Extending the last visible state to the right only works when plotting the values piecewise constant. With other interpolation methods the result is wrong. If there is no such later state, you may want to assume the state did not change by extending to the right, but never past Sadly, I don't think HA has a suitable API to fetch « whatever is the next point after this time » so the first paragraph may be infeasible at least for sensors updating very irregularly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
absolutly
Agree
It took me a second to realise why, but I agree now too
Also agree. Luckily very easy to implement with the new better structured code.
Yeah, I didn't find such an API either. I'll give this a try now and finally merge this PR |
||
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; | ||
dbuezas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@FrnchFrgg
Here's the toggle for
extend_to_present
with the defaults you suggested