Skip to content

Commit

Permalink
feat(span): Display the graph from the start of the hour, day, month,…
Browse files Browse the repository at this point in the history
…… with an offset or not (RomRider#10)

* feat(span): Display the graph from the start of the hour, day, month, ...

* fix missing `year` option in `start`

* Update documentation
  • Loading branch information
RomRider authored Jan 27, 2021
1 parent 15aa785 commit bb6e88c
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 31 deletions.
15 changes: 15 additions & 0 deletions .devcontainer/ui-lovelace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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/).
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/apex-layouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
64 changes: 41 additions & 23 deletions src/apexcharts-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -22,7 +32,6 @@ import {
DEFAULT_SERIE_TYPE,
HOUR_24,
} from './const';
import parse from 'parse-duration';

/* eslint no-console: 0 */
console.info(
Expand Down Expand Up @@ -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)[] = [];

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
18 changes: 13 additions & 5 deletions src/graphEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/types-config-ti.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
})),
Expand All @@ -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"),
Expand Down Expand Up @@ -48,6 +54,7 @@ export const ChartCardHeaderExternalConfig = t.iface([], {

const exportedTypeSuite: t.ITypeSuite = {
ChartCardExternalConfig,
ChartCardSpanExtConfig,
ChartCardSeriesExternalConfig,
GroupByFill,
GroupByFunc,
Expand Down
5 changes: 5 additions & 0 deletions src/types-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface ChartCardExternalConfig {
update_interval?: string;
series: ChartCardSeriesExternalConfig[];
graph_span?: string;
span?: ChartCardSpanExtConfig;
show?: {
loading?: boolean;
};
Expand All @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
}

0 comments on commit bb6e88c

Please sign in to comment.