diff --git a/.devcontainer/ui-lovelace.yaml b/.devcontainer/ui-lovelace.yaml index 64bb4b8..5d84b93 100644 --- a/.devcontainer/ui-lovelace.yaml +++ b/.devcontainer/ui-lovelace.yaml @@ -834,3 +834,30 @@ views: group_by: func: sum duration: 120min + + - type: custom:apexcharts-card + experimental: + color_threshold: true + brush: true + graph_span: 2h + brush: + selection_span: 10m + series: + - entity: sensor.random0_100 + color: blue + type: area + stroke_width: 1 + color_threshold: + - value: 0 + color: red + - value: 50 + color: yellow + - value: 100 + color: green + - entity: sensor.random0_100 + color: blue + stroke_width: 1 + float_precision: 0 + show: + in_brush: true + in_chart: false diff --git a/README.md b/README.md index 086cbfa..3e79d00 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ However, some things might be broken :grin: - [Configuration options](#configuration-options) - [`color_threshold` experimental feature](#color_threshold-experimental-feature) - [`hidden_by_default` experimental feature](#hidden_by_default-experimental-feature) + - [`brush` experimental feature](#brush-experimental-feature) - [Known issues](#known-issues) - [Roadmap](#roadmap) - [Examples](#examples) @@ -142,6 +143,7 @@ The card stricly validates all the options available (but not for the `apex_conf | `apex_config`| object | | v1.0.0 | Apexcharts API 1:1 mapping. You call see all the options [here](https://apexcharts.com/docs/installation/) --> `Options (Reference)` in the Menu. See [Apex Charts](#apex-charts-options-example) | | `experimental` | object | | v1.6.0 | See [experimental](#experimental-features) | | `locale` | string | | v1.7.0 | Default is to inherit from Home-Assistant's user configuration. This overrides it and forces the locale. Eg: `en`, or `fr`. Reverts to `en` if locale is unknown. | +| `brush` | object | | NEXT_VERSION | See [brush](#brush-experimental-feature) | @@ -182,6 +184,7 @@ The card stricly validates all the options available (but not for the `apex_conf | `datalabels` | boolean or string | `false` | v1.5.0 | If `true` will show the value of each point for this serie directly in the chart. Don't use it if you have a lot of points displayed, it will be a mess. If you set it to `total` (introduced in v1.7.0), it will display the stacked total value (only works when `stacked: true`) | | `hidden_by_default` | boolean | `false` | v1.6.0 | See [experimental](#hidden_by_default-experimental-feature) | | `extremas` | boolean or string | `false` | v1.7.0 | If `true`, will show the min and the max of the serie in the chart. If the value is `time`, it will display also the time of the min/max value on top of the value. This feature doesn't work with `stacked: true`. | +| `in_brush` | boolean | `false` | NEXT_VERSION | See [brush](#brush-experimental-feature) | ### Main `show` Options @@ -580,6 +583,7 @@ Generates the same result as repeating the configuration in each series: | `color_threshold` | boolean | `false` | v1.6.0 | Will enable the color threshold feature. See [color_threshold](#color_threshold-experimental-feature) | | `disable_config_validation` | boolean | `false` | v1.6.0 | If `true`, will disable the config validation. Useful if you have cards adding parameters to this one. Use at your own risk. | | `hidden_by_default` | boolean | `false` | v1.6.0 | Will allow you to use the `hidden_by_default` option. See [hidden_by_default](#hidden_by_default-experimental-feature) | +| `brush` | boolean | `false` | NEXT_VERSION | Will display a brush which allows you to select a portion time to display on the main chart. See [brush](#brush-experimental-feature) | ### `color_threshold` experimental feature @@ -646,6 +650,58 @@ series: - entity: sensor.temperature_office ``` +### `brush` experimental feature + +`brush` will allow you to display a small chart on the bottom of the card to select a time frame to display on the main chart. + +![brush_image](docs/brush.png) + +Things to know: +* You might have some glitches if you are using colums in either the top or the bottom of the chart. There's nothing I can do about it. +* All the features from normal series can be applied to the brush series +* You can configure the bottom chart the way you want with another specific `apex_config` also +* It might be compute heavy and slow with a lot of history data points +* It is recommended to not show too much data on the bottom chart for the sake of lisibility + +Here is how to use it (this represents the chart above): +```yaml +type: custom:apexcharts-card +experimental: + color_threshold: true + brush: true # This is required +graph_span: 2h # This will represent the span of the brush +brush: + # selection_span: optional + # defines the default selected span in the brush + # Defaults to 1/4 of the `graph_span` + selection_span: 10m + # apex_config: optional + apex_config: + # Any ApexCharts settings you want to apply to the brush + # Same as the standard apex_config +series: + - entity: sensor.random0_100 + color: blue + type: area + stroke_width: 1 + color_threshold: + - value: 0 + color: red + - value: 50 + color: yellow + - value: 100 + color: green + - entity: sensor.random0_100 + color: blue + stroke_width: 1 + float_precision: 0 + show: + # in_brush: set it to true and the serie will show up in the brush + in_brush: true + # add this also if you want your serie to only show up in the brush + in_chart: false +``` + ## Known issues * Sometimes, if `smoothing` is used alongside `area` and there is missing data in the chart, the background will be glitchy. See [apexcharts.js/#2180](https://github.com/apexcharts/apexcharts.js/issues/2180) diff --git a/docs/brush.png b/docs/brush.png new file mode 100644 index 0000000..6a675bb Binary files /dev/null and b/docs/brush.png differ diff --git a/rollup.config.js b/rollup.config.js index 5e5bc60..27d6841 100755 --- a/rollup.config.js +++ b/rollup.config.js @@ -60,5 +60,8 @@ export default [ watch: { exclude: 'node_modules/**', }, + globals: { + apexcharts: 'ApexCharts', + }, }, ]; diff --git a/src/apex-layouts.ts b/src/apex-layouts.ts index 3f836af..85e18e4 100644 --- a/src/apex-layouts.ts +++ b/src/apex-layouts.ts @@ -12,77 +12,15 @@ import { import { ChartCardConfig } from './types'; import { computeName, computeUom, is12Hour, mergeDeep, prettyPrintTime, truncateFloat } from './utils'; import { layoutMinimal } from './layouts/minimal'; -import * as ca from 'apexcharts/dist/locales/ca.json'; -import * as cs from 'apexcharts/dist/locales/cs.json'; -import * as de from 'apexcharts/dist/locales/de.json'; -import * as el from 'apexcharts/dist/locales/el.json'; -import * as en from 'apexcharts/dist/locales/en.json'; -import * as es from 'apexcharts/dist/locales/es.json'; -import * as fi from 'apexcharts/dist/locales/fi.json'; -import * as fr from 'apexcharts/dist/locales/fr.json'; -import * as he from 'apexcharts/dist/locales/he.json'; -import * as hi from 'apexcharts/dist/locales/hi.json'; -import * as hr from 'apexcharts/dist/locales/hr.json'; -import * as hy from 'apexcharts/dist/locales/hy.json'; -import * as id from 'apexcharts/dist/locales/id.json'; -import * as it from 'apexcharts/dist/locales/it.json'; -import * as ka from 'apexcharts/dist/locales/ka.json'; -import * as ko from 'apexcharts/dist/locales/ko.json'; -import * as lt from 'apexcharts/dist/locales/lt.json'; -import * as nb from 'apexcharts/dist/locales/nb.json'; -import * as nl from 'apexcharts/dist/locales/nl.json'; -import * as pl from 'apexcharts/dist/locales/pl.json'; -import * as pt_br from 'apexcharts/dist/locales/pt-br.json'; -import * as pt from 'apexcharts/dist/locales/pt.json'; -import * as rs from 'apexcharts/dist/locales/rs.json'; -import * as ru from 'apexcharts/dist/locales/ru.json'; -import * as se from 'apexcharts/dist/locales/se.json'; -import * as sk from 'apexcharts/dist/locales/sk.json'; -import * as sl from 'apexcharts/dist/locales/sl.json'; -import * as sq from 'apexcharts/dist/locales/sq.json'; -import * as th from 'apexcharts/dist/locales/th.json'; -import * as tr from 'apexcharts/dist/locales/tr.json'; -import * as ua from 'apexcharts/dist/locales/ua.json'; -import * as zh_cn from 'apexcharts/dist/locales/zh-cn.json'; +import { getLocales, getDefaultLocale } from './locales'; export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | undefined = undefined): unknown { - const locales = { - ca: ca, - cs: cs, - de: de, - el: el, - en: en, - es: es, - fi: fi, - fr: fr, - he: he, - hi: hi, - hr: hr, - hy: hy, - id: id, - it: it, - ka: ka, - ko: ko, - lt: lt, - nb: nb, - nl: nl, - pl: pl, - 'pt-br': pt_br, - pt: pt, - rs: rs, - ru: ru, - se: se, - sk: sk, - sl: sl, - sq: sq, - th: th, - tr: tr, - ua: ua, - 'zh-cn': zh_cn, - }; + const locales = getLocales(); const def = { chart: { - locales: [(config.locale && locales[config.locale]) || (hass?.language && locales[hass.language]) || en], + locales: [ + (config.locale && locales[config.locale]) || (hass?.language && locales[hass.language]) || getDefaultLocale(), + ], defaultLocale: (config.locale && locales[config.locale] && config.locale) || (hass?.language && locales[hass.language] && hass.language) || @@ -102,10 +40,10 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u strokeDashArray: 3, }, fill: { - opacity: getFillOpacity(config), - type: getFillType(config), + opacity: getFillOpacity(config, false), + type: getFillType(config, false), }, - series: getSeries(config, hass), + series: getSeries(config, hass, false), labels: getLabels(config, hass), xaxis: getXAxis(config, hass), yaxis: getYAxis(config), @@ -131,11 +69,11 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u formatter: getLegendFormatter(config, hass), }, stroke: { - curve: getStrokeCurve(config), + curve: getStrokeCurve(config, false), lineCap: config.chart_type === 'radialBar' ? 'round' : 'butt', colors: config.chart_type === 'pie' || config.chart_type === 'donut' ? ['var(--card-background-color)'] : undefined, - width: getStrokeWidth(config), + width: getStrokeWidth(config, false), }, markers: { showNullDataPoints: false, @@ -158,17 +96,90 @@ export function getLayoutConfig(config: ChartCardConfig, hass: HomeAssistant | u return config.apex_config ? mergeDeep(mergeDeep(def, conf), config.apex_config) : mergeDeep(def, conf); } -function getFillOpacity(config: ChartCardConfig): number[] { - return config.series_in_graph.map((serie) => { +export function getBrushLayoutConfig( + config: ChartCardConfig, + hass: HomeAssistant | undefined = undefined, + id: string, +): unknown { + const locales = getLocales(); + const def = { + chart: { + locales: [ + (config.locale && locales[config.locale]) || (hass?.language && locales[hass.language]) || getDefaultLocale(), + ], + defaultLocale: + (config.locale && locales[config.locale] && config.locale) || + (hass?.language && locales[hass.language] && hass.language) || + 'en', + type: config.chart_type || DEFAULT_SERIE_TYPE, + stacked: config?.stacked, + foreColor: 'var(--primary-text-color)', + width: '100%', + height: '120px', + zoom: { + enabled: false, + }, + toolbar: { + show: false, + }, + id: Math.random().toString(36).substring(7), + brush: { + target: id, + enabled: true, + }, + }, + grid: { + strokeDashArray: 3, + }, + fill: { + opacity: getFillOpacity(config, true), + type: getFillType(config, true), + }, + series: getSeries(config, hass, true), + xaxis: getXAxis(config, hass), + yaxis: { + tickAmount: 2, + decimalsInFloat: DEFAULT_FLOAT_PRECISION, + }, + tooltip: { + enabled: false, + }, + dataLabels: { + enabled: false, + }, + legend: { + show: false, + }, + stroke: { + curve: getStrokeCurve(config, true), + lineCap: config.chart_type === 'radialBar' ? 'round' : 'butt', + colors: + config.chart_type === 'pie' || config.chart_type === 'donut' ? ['var(--card-background-color)'] : undefined, + width: getStrokeWidth(config, true), + }, + markers: { + showNullDataPoints: false, + }, + noData: { + text: 'Loading...', + }, + }; + return config.brush?.apex_config ? mergeDeep(def, config.brush.apex_config) : def; +} + +function getFillOpacity(config: ChartCardConfig, brush: boolean): number[] { + const series = brush ? config.series_in_brush : config.series_in_graph; + return series.map((serie) => { return serie.opacity !== undefined ? serie.opacity : serie.type === 'area' ? DEFAULT_AREA_OPACITY : 1; }); } -function getSeries(config: ChartCardConfig, hass: HomeAssistant | undefined) { +function getSeries(config: ChartCardConfig, hass: HomeAssistant | undefined, brush: boolean) { + const series = brush ? config.series_in_brush : config.series_in_graph; if (TIMESERIES_TYPES.includes(config.chart_type)) { - return config?.series_in_graph.map((serie, index) => { + return series.map((serie, index) => { return { - name: computeName(index, config.series_in_graph, undefined, hass?.states[serie.entity]), + name: computeName(index, series, undefined, hass?.states[serie.entity]), type: serie.type, data: [], }; @@ -365,8 +376,9 @@ function getLegendFormatter(config: ChartCardConfig, hass: HomeAssistant | undef }; } -function getStrokeCurve(config: ChartCardConfig) { - return config.series_in_graph.map((serie) => { +function getStrokeCurve(config: ChartCardConfig, brush: boolean) { + const series = brush ? config.series_in_brush : config.series_in_graph; + return series.map((serie) => { return serie.curve || 'smooth'; }); } @@ -377,10 +389,11 @@ function getDataLabels_enabledOnSeries(config: ChartCardConfig) { }); } -function getStrokeWidth(config: ChartCardConfig) { +function getStrokeWidth(config: ChartCardConfig, brush: boolean) { if (config.chart_type !== undefined && config.chart_type !== 'line') return config.apex_config?.stroke?.width === undefined ? 3 : config.apex_config?.stroke?.width; - return config.series_in_graph.map((serie) => { + const series = brush ? config.series_in_brush : config.series_in_graph; + return series.map((serie) => { if (serie.stroke_width !== undefined) { return serie.stroke_width; } @@ -388,11 +401,12 @@ function getStrokeWidth(config: ChartCardConfig) { }); } -function getFillType(config: ChartCardConfig) { +function getFillType(config: ChartCardConfig, brush: boolean) { if (!config.experimental?.color_threshold) { - return config.apex_config?.fill?.type || 'solid'; + return brush ? config.brush?.apex_config?.fill?.type || 'solid' : config.apex_config?.fill?.type || 'solid'; } else { - return config.series_in_graph.map((serie) => { + const series = brush ? config.series_in_brush : config.series_in_graph; + return series.map((serie) => { if ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion !PLAIN_COLOR_TYPES.includes(config.chart_type!) && diff --git a/src/apexcharts-card.ts b/src/apexcharts-card.ts index 24a104c..c1b8461 100644 --- a/src/apexcharts-card.ts +++ b/src/apexcharts-card.ts @@ -27,7 +27,7 @@ import { import ApexCharts from 'apexcharts'; import { styles } from './styles'; import { HassEntity } from 'home-assistant-js-websocket'; -import { getLayoutConfig } from './apex-layouts'; +import { getBrushLayoutConfig, getLayoutConfig } from './apex-layouts'; import GraphEntry from './graphEntry'; import { createCheckers } from 'ts-interface-checker'; import { ChartCardColorThreshold, ChartCardExternalConfig, ChartCardSeriesExternalConfig } from './types-config'; @@ -63,6 +63,9 @@ console.info( 'color: white; font-weight: bold; background: dimgray', ); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).ApexCharts = ApexCharts; + localForage.config({ name: 'apexchart-card', version: 1.0, @@ -93,6 +96,8 @@ class ChartsCard extends LitElement { private _apexChart?: ApexCharts; + private _apexBrush?: ApexCharts; + private _loaded = false; @property({ type: Boolean }) private _updating = false; @@ -109,6 +114,8 @@ class ChartsCard extends LitElement { private _colors: string[] = []; + private _brushColors: string[] = []; + private _headerColors: string[] = []; private _graphSpan: number = HOUR_24; @@ -123,6 +130,10 @@ class ChartsCard extends LitElement { private _updateDelay: number = DEFAULT_UPDATE_DELAY; + private _brushInit = false; + + private _brushSelectionSpan = 0; + @property({ type: Boolean }) private _warning = false; public connectedCallback() { @@ -242,6 +253,11 @@ class ChartsCard extends LitElement { this._loaded = false; this._dataLoaded = false; this._updating = false; + if (this._apexBrush) { + this._apexBrush.destroy(); + this._apexBrush = undefined; + this._brushInit = false; + } } if (this._config && this._hass && !this._loaded) { this._initialLoad(); @@ -288,6 +304,9 @@ class ChartsCard extends LitElement { if (configDup.span?.end && configDup.span?.start) { throw new Error(`span: Only one of 'start' or 'end' is allowed.`); } + if (configDup.brush?.selection_span) { + this._brushSelectionSpan = validateInterval(configDup.brush.selection_span, 'brush.selection_span'); + } configDup.series.forEach((serie, index) => { if (serie.offset) { this._seriesOffset[index] = validateOffset(serie.offset, `series[${index}].offset`); @@ -338,7 +357,12 @@ class ChartsCard extends LitElement { serie.show.legend_value = serie.show.legend_value === undefined ? DEFAULT_SHOW_LEGEND_VALUE : serie.show.legend_value; serie.show.in_chart = serie.show.in_chart === undefined ? DEFAULT_SHOW_IN_CHART : serie.show.in_chart; - serie.show.in_header = serie.show.in_header === undefined ? DEFAULT_SHOW_IN_HEADER : serie.show.in_header; + serie.show.in_header = + serie.show.in_header === undefined + ? !serie.show.in_chart && serie.show.in_brush + ? false + : DEFAULT_SHOW_IN_HEADER + : serie.show.in_header; } validateInterval(serie.group_by.duration, `series[${index}].group_by.duration`); if (serie.color_threshold && serie.color_threshold.length > 0) { @@ -367,12 +391,18 @@ class ChartsCard extends LitElement { return undefined; }); this._config.series_in_graph = []; + this._config.series_in_brush = []; this._config.series.forEach((serie, index) => { if (serie.show.in_chart) { this._colors.push(this._headerColors[index]); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this._config!.series_in_graph.push(serie); } + if (this._config?.experimental?.brush && serie.show.in_brush) { + this._brushColors.push(this._headerColors[index]); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._config!.series_in_brush.push(serie); + } }); this._headerColors = this._headerColors.slice(0, this._config?.series.length); } @@ -415,6 +445,7 @@ class ChartsCard extends LitElement { ${this._config.header?.show ? this._renderHeader() : html``}