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
File renamed without changes
File renamed without changes
File renamed without changes
Binary file added docs/resources/offset-nowline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/resources/offset-temperature.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes.
112 changes: 108 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>
Expand Down Expand Up @@ -54,7 +54,7 @@ refresh_interval: 10

### Filling, line width, color

![](example1.png)
![](docs/resources/example1.png)

```yaml
type: custom:plotly-graph
Expand All @@ -81,7 +81,7 @@ refresh_interval: 10 # in seconds

### Range Selector buttons

![](rangeselector.apng)
![](docs/resources/rangeselector.apng)

```yaml
type: custom:plotly-graph
Expand Down Expand Up @@ -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
```

![Graph with offsets](docs/resources/offset-temperature.png)

### 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
```

![Graph with offsets and now-line](docs/resources/offset-nowline.png)

## 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
Expand All @@ -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"
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:
Expand Down
47 changes: 14 additions & 33 deletions src/cache/Cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
});
}
}
68 changes: 51 additions & 17 deletions src/plotly-graph-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -199,8 +222,6 @@ export class PlotlyGraph extends HTMLElement {
this.fetch();
}
if (shouldPlot) {
if (!this.isBrowsing)
this.cache.removeOutsideRange(this.getAutoFetchRange());
this.plot();
}
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };

Expand All @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading