From bb6e88c6766262b96d7b66414db26d6f9f83b1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20W?= Date: Wed, 27 Jan 2021 01:56:15 +0100 Subject: [PATCH] =?UTF-8?q?feat(span):=20Display=20the=20graph=20from=20th?= =?UTF-8?q?e=20start=20of=20the=20hour,=20day,=20month,=E2=80=A6=20with=20?= =?UTF-8?q?an=20offset=20or=20not=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(span): Display the graph from the start of the hour, day, month, ... * fix missing `year` option in `start` * Update documentation --- .devcontainer/ui-lovelace.yaml | 15 ++++++++ README.md | 50 ++++++++++++++++++++++++-- src/apex-layouts.ts | 2 +- src/apexcharts-card.ts | 64 ++++++++++++++++++++++------------ src/graphEntry.ts | 18 +++++++--- src/types-config-ti.ts | 7 ++++ src/types-config.ts | 5 +++ src/utils.ts | 16 +++++++++ 8 files changed, 146 insertions(+), 31 deletions(-) diff --git a/.devcontainer/ui-lovelace.yaml b/.devcontainer/ui-lovelace.yaml index fc179c6..1433377 100644 --- a/.devcontainer/ui-lovelace.yaml +++ b/.devcontainer/ui-lovelace.yaml @@ -135,3 +135,18 @@ views: group_by: func: avg duration: 30min + + - type: custom:apexcharts-card + header: + show: true + title: Start of day + graph_span: 24h + span: + start: day + series: + - entity: sensor.random0_100 + extend_to_end: false + type: column + group_by: + func: avg + fill: 'null' diff --git a/README.md b/README.md index dc4457d..a7ba557 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ However, some things might be broken :grin: - [`header` Options](#header-options) - [`group_by` Options](#group_by-options) - [`func` Options](#func-options) + - [`span` Options](#span-options) - [Apex Charts Options Example](#apex-charts-options-example) - [Layouts](#layouts) - [Known issues](#known-issues) @@ -93,6 +94,7 @@ The card stricly validates all the options available (but not for the `apex_conf | :white_check_mark: `series` | array | | v1.0.0 | See [series](#series-options) | | `update_interval` | string | | v1.1.0 | By default the card updates on every state change. Setting this overrides the behaviour. Valid values are any time string, eg: `1h`, `12min`, `1d`, `1h25`, `10sec`, ... | | `graph_span` | string | `24h` | v1.1.0 | The span of the graph as a time interval. Valid values are any time string, eg: `1h`, `12min`, `1d`, `1h25`, `10sec`, ... | +| `span` | object | | NEXT_VERSION | See [span](#span-options) | | `show` | object | | v1.0.0 | See [show](#show-options) | | `cache` | boolean | `true` | v1.0.0 | Use in-browser data caching to reduce the load on Home Assistant's server | | `stacked` | boolean | `false` | v1.0.0 | Enable if you want the data to be stacked on the graph | @@ -153,6 +155,50 @@ The card stricly validates all the options available (but not for the `apex_conf | `median` | v1.0.0 | Will return the median of all the states in each bucket | | `delta` | v1.0.0 | Will return the delta between the biggest and smallest state in each bucket | +### `span` Options + +| Name | Since | Description | +| ---- | :---: | ----------- | +| `start` | NEXT_VERSION | Display the graph from the begining of the `minute`, `day`, `hour`, `week`, `month`, `year` | +| `offset` | NEXT_VERSION | Offset the graph by an amount of time. To offset in the past, start with `-`. Eg. of valid values: `-1day`, `-12h`, `12h`, `30min`, ... | + +Span enables you to: +* Offset the graph by an amount of time +* Display the graph from the begining of the `minute`, `day`, `hour`, `week`, `month`, `year` +* Combined with `group_by` in a serie, the group will begin at the tick of the `start` unit (+/- `offset` if defined) + +```yaml +graph_span: If start is defined, it should be <= to 1 unit of the one defined in `start` +span: + start: minute, day, hour, week, month or year + offset: Needs to start with a + or - followed by a timerange like 1day, 12h, 10min, ... +``` + +Eg: +* Display 24h from the start of the current day (00:00 -> 23:59) + ```yaml + type: custom:apexcharts-card + graph_span: 24h + span: + start: day + ``` +* Display 24h from the start of the previous day (00:00 -> 23:59, -1 day) + ```yaml + type: custom:apexcharts-card + graph_span: 24h + span: + start: day + offset: -1d + ``` +* Display 12h between 06:00 and 18:00 of the current day + ```yaml + type: custom:apexcharts-card + graph_span: 12h + span: + start: day + offset: +6h + ``` + ### Apex Charts Options Example This is how you could change some options from ApexCharts as described on the [`Options (Reference)` menu entry](https://apexcharts.com/docs/installation/). @@ -192,8 +238,8 @@ Not ordered by priority: * [ ] Support more types of charts (pie, radial, polar area at least) * [ ] Support for `binary_sensors` -* [ ] Support for aggregating data with exact boundaries (ex: aggregating data with `1h` could aggregate from `2:00:00am` to `2:59:59am` then `3:00:00am` to `3:59:59` exactly, etc...) -* [ ] Display the graph from start of day, week, month, ... with support for "up to now" or until the "end of the period" +* [X] ~~Support for aggregating data with exact boundaries (ex: aggregating data with `1h` could aggregate from `2:00:00am` to `2:59:59am` then `3:00:00am` to `3:59:59` exactly, etc...)~~ +* [X] ~~Display the graph from start of day, week, month, ... with support for "up to now" or until the "end of the period"~~ * [ ] Support for any number of Y-axis * [ ] Support for logarithmic * [ ] Support for state mapping for non-numerical state sensors diff --git a/src/apex-layouts.ts b/src/apex-layouts.ts index ec6db04..38a8dcf 100644 --- a/src/apex-layouts.ts +++ b/src/apex-layouts.ts @@ -47,7 +47,7 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u x: { formatter: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - parse(config.graph_span!)! < HOUR_24 + parse(config.graph_span!)! < HOUR_24 && !config.span?.offset ? function (val) { return moment(new Date(val)).format('HH:mm:ss'); } diff --git a/src/apexcharts-card.ts b/src/apexcharts-card.ts index 18a7221..177a804 100644 --- a/src/apexcharts-card.ts +++ b/src/apexcharts-card.ts @@ -4,7 +4,16 @@ import { ChartCardConfig, EntityEntryCache } from './types'; import { HomeAssistant } from 'custom-card-helpers'; import localForage from 'localforage'; import * as pjson from '../package.json'; -import { computeColors, computeName, computeUom, decompress, log, mergeDeep } from './utils'; +import { + computeColors, + computeName, + computeUom, + decompress, + log, + mergeDeep, + validateInterval, + validateOffset, +} from './utils'; import ApexCharts from 'apexcharts'; import { styles } from './styles'; import { HassEntity } from 'home-assistant-js-websocket'; @@ -13,6 +22,7 @@ import GraphEntry from './graphEntry'; import { createCheckers } from 'ts-interface-checker'; import { ChartCardExternalConfig } from './types-config'; import exportedTypeSuite from './types-config-ti'; +import { moment } from './const'; import { DEFAULT_COLORS, DEFAULT_DURATION, @@ -22,7 +32,6 @@ import { DEFAULT_SERIE_TYPE, HOUR_24, } from './const'; -import parse from 'parse-duration'; /* eslint no-console: 0 */ console.info( @@ -72,13 +81,15 @@ class ChartsCard extends LitElement { private _entities: HassEntity[] = []; - private _interval?: number | null; + private _interval?: number; private _intervalTimeout?: NodeJS.Timeout; private _colors?: string[]; - private _graphSpan: number | null = HOUR_24; + private _graphSpan: number = HOUR_24; + + private _offset = 0; @property({ attribute: false }) private _lastState: (number | string | null)[] = []; @@ -150,16 +161,13 @@ class ChartsCard extends LitElement { const { ChartCardExternalConfig } = createCheckers(exportedTypeSuite); ChartCardExternalConfig.strictCheck(config); if (config.update_interval) { - this._interval = parse(config.update_interval); - if (this._interval === null) { - throw new Error(`'update_interval: ${config.update_interval}' is not a valid interval of time`); - } + this._interval = validateInterval(config.update_interval, 'update_interval'); } if (config.graph_span) { - this._graphSpan = parse(config.graph_span); - if (this._graphSpan === null) { - throw new Error(`'graph_span: ${config.update_interval}' is not a valid range of time`); - } + this._graphSpan = validateInterval(config.graph_span, 'graph_span'); + } + if (config.span?.offset) { + this._offset = validateOffset(config.span.offset, 'span.offset'); } this._config = mergeDeep( @@ -188,18 +196,16 @@ class ChartsCard extends LitElement { serie.group_by.func = serie.group_by.func || DEFAULT_FUNC; serie.group_by.fill = serie.group_by.fill || DEFAULT_GROUP_BY_FILL; } - if (!parse(serie.group_by.duration)) { - throw new Error(`Can't parse 'series[${index}].group_by.duration': '${serie.group_by.duration}'`); - } + validateInterval(serie.group_by.duration, `series[${index}].group_by.duration`); if (serie.entity) { return new GraphEntry( - serie.entity, index, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this._graphSpan!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this._config!.cache, serie, + this._config?.span, ); } return undefined; @@ -310,13 +316,7 @@ class ChartsCard extends LitElement { private async _updateData() { if (!this._config || !this._graphs) return; - // const end = this.getEndDate(); - const end = new Date(); - const start = new Date(end); - // validated during Init - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - start.setTime(start.getTime() - this._graphSpan!); - + const { start, end } = this._getSpanDates(); try { const promise = this._graphs.map((graph) => graph?._updateHistory(start, end)); await Promise.all(promise); @@ -356,6 +356,24 @@ class ChartsCard extends LitElement { this._updating = false; } + private _getSpanDates(): { start: Date; end: Date } { + let end = new Date(); + let start = new Date(end); + start.setTime(start.getTime() - this._graphSpan); + // Span + if (this._config?.span?.start) { + // Just Span + const startM = moment().startOf(this._config.span.start); + start = startM.toDate(); + end = new Date(start.getTime() + this._graphSpan); + } + if (this._offset) { + end.setTime(end.getTime() + this._offset); + start.setTime(start.getTime() + this._offset); + } + return { start, end }; + } + public getCardSize(): number { return 3; } diff --git a/src/graphEntry.ts b/src/graphEntry.ts index c7d8b08..78e196b 100644 --- a/src/graphEntry.ts +++ b/src/graphEntry.ts @@ -7,6 +7,7 @@ import { DateRange } from 'moment-range'; import { HOUR_24, moment } from './const'; import parse from 'parse-duration'; import SparkMD5 from 'spark-md5'; +import { ChartCardSpanExtConfig } from './types-config'; export default class GraphEntry { private _history?: EntityEntryCache; @@ -45,7 +46,13 @@ export default class GraphEntry { private _md5Config: string; - constructor(entity: string, index: number, graphSpan: number, cache: boolean, config: ChartCardSeriesConfig) { + constructor( + index: number, + graphSpan: number, + cache: boolean, + config: ChartCardSeriesConfig, + span: ChartCardSpanExtConfig | undefined, + ) { const aggregateFuncMap = { avg: this._average, max: this._maximum, @@ -58,7 +65,7 @@ export default class GraphEntry { }; this._index = index; this._cache = cache; - this._entityID = entity; + this._entityID = config.entity; this._history = undefined; this._graphSpan = graphSpan; this._config = config; @@ -72,7 +79,7 @@ export default class GraphEntry { // Valid because tested during init; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this._groupByDurationMs = parse(this._config.group_by.duration)!; - this._md5Config = SparkMD5.hash(`${this._graphSpan}${JSON.stringify(this._config)}`); + this._md5Config = SparkMD5.hash(`${this._graphSpan}${JSON.stringify(this._config)}${JSON.stringify(span)}`); } set hass(hass: HomeAssistant) { @@ -274,8 +281,9 @@ export default class GraphEntry { } private _average(items: EntityCachePoints): number | null { - if (items.length === 0) return null; - return this._sum(items) / items.length; + const nonNull = this._filterNulls(items); + if (nonNull.length === 0) return null; + return this._sum(nonNull) / nonNull.length; } private _minimum(items: EntityCachePoints): number | null { diff --git a/src/types-config-ti.ts b/src/types-config-ti.ts index acb7239..c4f3671 100644 --- a/src/types-config-ti.ts +++ b/src/types-config-ti.ts @@ -9,6 +9,7 @@ export const ChartCardExternalConfig = t.iface([], { "update_interval": t.opt("string"), "series": t.array("ChartCardSeriesExternalConfig"), "graph_span": t.opt("string"), + "span": t.opt("ChartCardSpanExtConfig"), "show": t.opt(t.iface([], { "loading": t.opt("boolean"), })), @@ -19,6 +20,11 @@ export const ChartCardExternalConfig = t.iface([], { "header": t.opt("ChartCardHeaderExternalConfig"), }); +export const ChartCardSpanExtConfig = t.iface([], { + "start": t.opt(t.union(t.lit('minute'), t.lit('hour'), t.lit('day'), t.lit('week'), t.lit('month'), t.lit('year'))), + "offset": t.opt("string"), +}); + export const ChartCardSeriesExternalConfig = t.iface([], { "entity": "string", "name": t.opt("string"), @@ -48,6 +54,7 @@ export const ChartCardHeaderExternalConfig = t.iface([], { const exportedTypeSuite: t.ITypeSuite = { ChartCardExternalConfig, + ChartCardSpanExtConfig, ChartCardSeriesExternalConfig, GroupByFill, GroupByFunc, diff --git a/src/types-config.ts b/src/types-config.ts index 8ab57a1..92a87ac 100644 --- a/src/types-config.ts +++ b/src/types-config.ts @@ -3,6 +3,7 @@ export interface ChartCardExternalConfig { update_interval?: string; series: ChartCardSeriesExternalConfig[]; graph_span?: string; + span?: ChartCardSpanExtConfig; show?: { loading?: boolean; }; @@ -14,6 +15,10 @@ export interface ChartCardExternalConfig { header?: ChartCardHeaderExternalConfig; } +export interface ChartCardSpanExtConfig { + start?: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; + offset?: string; +} export interface ChartCardSeriesExternalConfig { entity: string; name?: string; diff --git a/src/utils.ts b/src/utils.ts index ff3b835..9199973 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { compress as lzStringCompress, decompress as lzStringDecompress } from 'lz-string'; import { ChartCardConfig } from './types'; import { TinyColor } from '@ctrl/tinycolor'; +import parse from 'parse-duration'; export function compress(data: unknown): string { return lzStringCompress(JSON.stringify(data)); @@ -108,3 +109,18 @@ export function computeColor(color: string): string { return new TinyColor(color).toHexString(); } } + +export function validateInterval(interval: string, prefix: string): number { + const parsed = parse(interval); + if (parsed === null) { + throw new Error(`'${prefix}: ${interval}' is not a valid range of time`); + } + return parsed; +} + +export function validateOffset(interval: string, prefix: string): number { + if (interval[0] !== '+' && interval[0] !== '-') { + throw new Error(`'${prefix}: ${interval}' should start with a '+' or a '-'`); + } + return validateInterval(interval, prefix); +}