Skip to content

Commit

Permalink
feat(graph): New chart types scatter, donut, pie and radialBar (
Browse files Browse the repository at this point in the history
#24)

* Add scatter graph type

* pie, donut, radialBar and documentation
  • Loading branch information
RomRider committed Jan 30, 2021
1 parent ff7f35b commit a0e4f85
Show file tree
Hide file tree
Showing 9 changed files with 463 additions and 316 deletions.
582 changes: 323 additions & 259 deletions .devcontainer/ui-lovelace.yaml

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions README.md
Expand Up @@ -34,6 +34,7 @@ However, some things might be broken :grin:
- [`header` Options](#header-options)
- [`group_by` Options](#group_by-options)
- [`func` Options](#func-options)
- [`chart_type` Options](#chart_type-options)
- [`span` Options](#span-options)
- [`data_generator` Option](#data_generator-option)
- [Apex Charts Options Example](#apex-charts-options-example)
Expand Down Expand Up @@ -103,6 +104,7 @@ The card stricly validates all the options available (but not for the `apex_conf
| ---- | :--: | :-----: | :---: | ----------- |
| :white_check_mark: `type` | string | | v1.0.0 | `custom:apexcharts-card` |
| :white_check_mark: `series` | array | | v1.0.0 | See [series](#series-options) |
| `chart_type` | string | `line` | NEXT_VERSION | See [chart_type](#chart_type-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 | | v1.2.0 | See [span](#span-options) |
Expand Down Expand Up @@ -132,7 +134,8 @@ The card stricly validates all the options available (but not for the `apex_conf
| `invert` | boolean | `false` | v1.2.0 | Negates the data (`1` -> `-1`). Usefull to display opposites values like network in (standard)/out (inverted) |
| `data_generator` | string | | v1.2.0 | See [data_generator](#data_generator-option) |
| `offset` | string | | v1.3.0 | This is different from the main `offset` parameter. This is at the series level. It is only usefull if you want to display data from for eg. yesterday on top of the data from today for the same sensor and compare the data. The time displayed in the tooltip will be wrong as will the x axis information. Valid values are any negative time string, eg: `-1h`, `-12min`, `-1d`, `-1h25`, `-10sec`, ... |

| `min` | number | `0` | NEXT_VERSION | Only used when `chart_type = radialBar`, see [chart_type](#chart_type-options). Used to convert the value into a percentage. Minimum value of the sensor |
| `max` | number | `100` | NEXT_VERSION | Only used when `chart_type = radialBar`, see [chart_type](#chart_type-options). Used to convert the value into a percentage. Maximum value of the sensor |

### `series.show` Options

Expand Down Expand Up @@ -180,6 +183,18 @@ 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 |

### `chart_type` Options

| Name | Since | Description |
| ---- | :---: | ----------- |
| `line` | v1.0.0 | This is the default and will show a timeline. It is compatible with `series.type` = `column`, `line` and `area` |
| `scatter` | NEXT_VERSION | Displays a cloud of points without a line between the values |
| `pie` | NEXT_VERSION | This will display a pie chart with the last value computed of each sensor |
| `donut` | NEXT_VERSION | This will display a donut chart with the last value computed of each sensor, same as pie but with a hole in the center |
| `radialBar` | NEXT_VERSION | This will display a radial bar chart with the last value computed of each sensor. The value is represented in percentage only. It is required to provide `min` and `max` for each series displayed as it requires to convert the value into percentage. The default value for `min` is `0` and for `max` it is `100`. This graph works well if you want to display sensors natively in percentages |

![Charts Type](docs/charts_type.png)

### `span` Options

| Name | Since | Description |
Expand Down Expand Up @@ -346,7 +361,7 @@ For code junkies, you'll find the default options I use in [`src/apex-layouts.ts

Not ordered by priority:

* [ ] Support more types of charts (pie, radial, polar area at least)
* [X] ~~Support more types of charts (pie, radial, polar area at least)~~
* [ ] Support for `binary_sensors`
* [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"~~
Expand Down
Binary file added docs/charts_type.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 43 additions & 17 deletions src/apex-layouts.ts
@@ -1,12 +1,13 @@
import { HomeAssistant } from 'custom-card-helpers';
import parse from 'parse-duration';
import { DEFAULT_FLOAT_PRECISION, HOUR_24, moment, NO_VALUE } from './const';
import { DEFAULT_FLOAT_PRECISION, DEFAULT_SERIE_TYPE, HOUR_24, moment, NO_VALUE, TIMESERIES_TYPES } from './const';
import { ChartCardConfig } from './types';
import { computeName, computeUom, mergeDeep, prettyPrintTime } from './utils';

export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | undefined = undefined): unknown {
const def = {
chart: {
type: config.chart_type || DEFAULT_SERIE_TYPE,
stacked: config?.stacked,
// type: 'line',
foreColor: 'var(--primary-text-color)',
Expand All @@ -26,20 +27,29 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u
return serie.type === 'area' ? 0.7 : 1;
}),
},
series: config?.series.map((serie, index) => {
return {
name: computeName(index, config, undefined, hass?.states[serie.entity]),
type: serie.type,
data: [],
};
}),
xaxis: {
type: 'datetime',
// range: getMilli(config.hours_to_show),
labels: {
datetimeUTC: false,
},
},
series: TIMESERIES_TYPES.includes(config.chart_type)
? config?.series.map((serie, index) => {
return {
name: computeName(index, config, undefined, hass?.states[serie.entity]),
type: serie.type,
data: [],
};
})
: [],
labels: TIMESERIES_TYPES.includes(config.chart_type)
? []
: config.series.map((serie, index) => {
return computeName(index, config, undefined, hass?.states[serie.entity]);
}),
xaxis: TIMESERIES_TYPES.includes(config.chart_type)
? {
type: 'datetime',
// range: getMilli(config.hours_to_show),
labels: {
datetimeUTC: false,
},
}
: {},
yaxis: Array.isArray(config.apex_config?.yaxis)
? undefined
: {
Expand Down Expand Up @@ -98,7 +108,19 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u
return lValue;
},
},
plotOptions: {
radialBar:
config.chart_type === 'radialBar'
? {
track: {
background: 'rgba(128, 128, 128, 0.2)',
},
}
: {},
},
legend: {
position: 'bottom',
show: true,
formatter: function (_, opts, conf = config, hass2 = hass) {
const name = computeName(
opts.seriesIndex,
Expand All @@ -109,7 +131,9 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u
if (!conf.series[opts.seriesIndex].show.legend_value) {
return [name];
} else {
let value = opts.w.globals.series[opts.seriesIndex].slice(-1)[0];
let value = TIMESERIES_TYPES.includes(config.chart_type)
? opts.w.globals.series[opts.seriesIndex].slice(-1)[0]
: opts.w.globals.series[opts.seriesIndex];
if (
value !== null &&
typeof value === 'number' &&
Expand Down Expand Up @@ -150,7 +174,9 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u
curve: config.series.map((serie) => {
return serie.curve || 'smooth';
}),
lineCap: 'butt',
lineCap: config.chart_type === 'radialBar' ? 'round' : 'butt',
colors:
config.chart_type === 'pie' || config.chart_type === 'donut' ? ['var(--card-background-color)'] : undefined,
},
markers: {
showNullDataPoints: false,
Expand Down
100 changes: 63 additions & 37 deletions src/apexcharts-card.ts
Expand Up @@ -9,6 +9,7 @@ import {
computeName,
computeUom,
decompress,
getPercentFromValue,
log,
mergeDeep,
offsetData,
Expand All @@ -24,7 +25,7 @@ import GraphEntry from './graphEntry';
import { createCheckers } from 'ts-interface-checker';
import { ChartCardExternalConfig } from './types-config';
import exportedTypeSuite from './types-config-ti';
import { DEFAULT_FLOAT_PRECISION, DEFAULT_SHOW_LEGEND_VALUE, moment, NO_VALUE } from './const';
import { DEFAULT_FLOAT_PRECISION, DEFAULT_SHOW_LEGEND_VALUE, moment, NO_VALUE, TIMESERIES_TYPES } from './const';
import {
DEFAULT_COLORS,
DEFAULT_DURATION,
Expand Down Expand Up @@ -222,7 +223,8 @@ class ChartsCard extends LitElement {
this._colors![index] = serie.color;
}
serie.extend_to_end = serie.extend_to_end !== undefined ? serie.extend_to_end : true;
serie.type = serie.type || DEFAULT_SERIE_TYPE;
serie.type = this._config?.chart_type ? undefined : serie.type || DEFAULT_SERIE_TYPE;
serie.unit = this._config?.chart_type === 'radialBar' ? '%' : serie.unit;
if (!serie.group_by) {
serie.group_by = { duration: DEFAULT_DURATION, func: DEFAULT_FUNC, fill: DEFAULT_GROUP_BY_FILL };
} else {
Expand Down Expand Up @@ -372,49 +374,73 @@ class ChartsCard extends LitElement {
),
);
await Promise.all(promise);
const graphData = {
series: this._graphs.map((graph) => {
if (!graph || graph.history.length === 0) return { data: [] };
const index = graph.index;
if (graph.history.length > 0) {
this._lastState[index] = graph.history[graph.history.length - 1][1];
if (
this._lastState[index] !== null &&
typeof this._lastState[index] === 'number' &&
!Number.isInteger(this._lastState[index])
) {
const precision =
this._config?.series[index].float_precision === undefined
? DEFAULT_FLOAT_PRECISION
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._config.series[index].float_precision!;
this._lastState[index] = (this._lastState[index] as number).toFixed(precision);
let graphData: unknown = {};
if (TIMESERIES_TYPES.includes(this._config.chart_type)) {
graphData = {
series: this._graphs.map((graph, index) => {
if (!graph || graph.history.length === 0) return { data: [] };
this._lastState[index] = this._computeLastState(graph.history[graph.history.length - 1][1], index);
let data: EntityCachePoints = [];
if (this._config?.series[index].extend_to_end && this._config?.series[index].type !== 'column') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data = [...graph.history, ...([[end.getTime(), graph.history.slice(-1)[0]![1]]] as EntityCachePoints)];
} else {
data = graph.history;
}
}
let data: EntityCachePoints = [];
if (this._config?.series[index].extend_to_end && this._config?.series[index].type !== 'column') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data = [...graph.history, ...([[end.getTime(), graph.history.slice(-1)[0]![1]]] as EntityCachePoints)];
} else {
data = graph.history;
}
data = offsetData(data, this._seriesOffset[index]);
return this._config?.series[index].invert ? { data: this._invertData(data) } : { data };
}),
xaxis: {
min: start.getTime(),
max: this._findEndOfChart(end),
},
colors: computeColors(this._colors),
};
data = offsetData(data, this._seriesOffset[index]);
return this._config?.series[index].invert ? { data: this._invertData(data) } : { data };
}),
xaxis: {
min: start.getTime(),
max: this._findEndOfChart(end),
},
colors: computeColors(this._colors),
};
} else {
// No timeline charts
graphData = {
series: this._graphs.map((graph, index) => {
if (!graph || graph.history.length === 0) return;
const lastState = graph.history[graph.history.length - 1][1];
this._lastState[index] = this._computeLastState(lastState, index);
if (lastState === null) {
return;
} else {
if (this._config?.chart_type === 'radialBar') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return getPercentFromValue(lastState, this._config.series[index].min, this._config.series[index].max);
} else {
return lastState;
}
}
}),
colors: computeColors(this._colors),
};
}
this._lastState = [...this._lastState];
this._apexChart?.updateOptions(graphData, false, false);
this._apexChart?.updateOptions(
graphData,
false,
TIMESERIES_TYPES.includes(this._config.chart_type) ? false : true,
);
} catch (err) {
log(err);
}
this._updating = false;
}

private _computeLastState(value: number | null, index: number): string | number | null {
if (value !== null && typeof value === 'number' && !Number.isInteger(value)) {
const precision =
this._config?.series[index].float_precision === undefined
? DEFAULT_FLOAT_PRECISION
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._config.series[index].float_precision!;
return (value as number).toFixed(precision);
}
return value;
}

/*
Makes the chart end at the last timestamp of the data when everything displayed is a
column and group_by is enabled for every serie
Expand Down
4 changes: 4 additions & 0 deletions src/const.ts
Expand Up @@ -33,3 +33,7 @@ export const DEFAULT_COLORS = [
];

export const NO_VALUE = 'N/A';
export const TIMESERIES_TYPES = ['line', 'scatter', undefined];

export const DEFAULT_MIN = 0;
export const DEFAULT_MAX = 100;
3 changes: 3 additions & 0 deletions src/types-config-ti.ts
Expand Up @@ -6,6 +6,7 @@ import * as t from "ts-interface-checker";

export const ChartCardExternalConfig = t.iface([], {
"type": t.lit('custom:apexcharts-card'),
"chart_type": t.opt(t.union(t.lit('line'), t.lit('scatter'), t.lit('pie'), t.lit('donut'), t.lit('radialBar'))),
"update_interval": t.opt("string"),
"series": t.array("ChartCardSeriesExternalConfig"),
"graph_span": t.opt("string"),
Expand Down Expand Up @@ -39,6 +40,8 @@ export const ChartCardSeriesExternalConfig = t.iface([], {
"invert": t.opt("boolean"),
"data_generator": t.opt("string"),
"float_precision": t.opt("number"),
"min": t.opt("number"),
"max": t.opt("number"),
"offset": t.opt("string"),
"show": t.opt(t.iface([], {
"as_duration": t.opt("ChartCardPrettyTime"),
Expand Down
3 changes: 3 additions & 0 deletions src/types-config.ts
@@ -1,5 +1,6 @@
export interface ChartCardExternalConfig {
type: 'custom:apexcharts-card';
chart_type?: 'line' | 'scatter' | 'pie' | 'donut' | 'radialBar';
update_interval?: string;
series: ChartCardSeriesExternalConfig[];
graph_span?: string;
Expand Down Expand Up @@ -35,6 +36,8 @@ export interface ChartCardSeriesExternalConfig {
invert?: boolean;
data_generator?: string;
float_precision?: number;
min?: number;
max?: number;
offset?: string;
show?: {
as_duration?: ChartCardPrettyTime;
Expand Down
8 changes: 7 additions & 1 deletion src/utils.ts
Expand Up @@ -4,7 +4,7 @@ import { ChartCardConfig, EntityCachePoints } from './types';
import { TinyColor } from '@ctrl/tinycolor';
import parse from 'parse-duration';
import { ChartCardPrettyTime } from './types-config';
import { moment, NO_VALUE } from './const';
import { DEFAULT_MAX, DEFAULT_MIN, moment, NO_VALUE } from './const';

export function compress(data: unknown): string {
return lzStringCompress(JSON.stringify(data));
Expand Down Expand Up @@ -142,3 +142,9 @@ export function prettyPrintTime(value: string | number | null, unit: ChartCardPr
if (value === null) return NO_VALUE;
return moment.duration(value, unit).format('y[y] d[d] h[h] m[m] s[s] S[ms]', { trim: 'both' });
}

export function getPercentFromValue(value: number, min: number | undefined, max: number | undefined): number {
const lMin = min === undefined ? DEFAULT_MIN : min;
const lMax = max === undefined ? DEFAULT_MAX : max;
return ((value - lMin) * 100) / (lMax - lMin);
}

0 comments on commit a0e4f85

Please sign in to comment.