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 -![](example1.png) +![](docs/resources/example1.png) ```yaml type: custom:plotly-graph @@ -81,7 +81,7 @@ refresh_interval: 10 # in seconds ### Range Selector buttons -![](rangeselector.apng) +![](docs/resources/rangeselector.apng) ```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 +``` + +![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 @@ -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;