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``}
+ ${this._config.series_in_brush.length ? html`
` : ``}
@@ -484,8 +515,22 @@ class ChartsCard extends LitElement { if (!this._apexChart && this.shadowRoot && this._config && this.shadowRoot.querySelector('#graph')) { this._loaded = true; const graph = this.shadowRoot.querySelector('#graph'); - this._apexChart = new ApexCharts(graph, getLayoutConfig(this._config, this._hass)); + const layout = getLayoutConfig(this._config, this._hass); + if (this._config.series_in_brush.length) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (layout as any).chart.id = Math.random().toString(36).substring(7); + } + this._apexChart = new ApexCharts(graph, layout); this._apexChart.render(); + if (this._config.series_in_brush.length) { + const brush = this.shadowRoot.querySelector('#brush'); + this._apexBrush = new ApexCharts( + brush, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getBrushLayoutConfig(this._config, this._hass, (layout as any).chart.id), + ); + this._apexBrush.render(); + } this._firstDataLoad(); } } @@ -507,45 +552,50 @@ class ChartsCard extends LitElement { }); await Promise.all(promise); // eslint-disable-next-line @typescript-eslint/no-explicit-any - let graphData: any = {}; + let graphData: any = { series: [] }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const brushData: any = { series: [] }; if (TIMESERIES_TYPES.includes(this._config.chart_type)) { - graphData = { - series: this._graphs.flatMap((graph, index) => { - if (!graph) return []; - const inHeader = this._config?.series[index].show.in_header; - if (inHeader && inHeader !== 'raw') { - // not raw - if (graph.history.length === 0) { - this._headerState[index] = null; - } else if (inHeader === true) { - // last - const lastState = graph.history[graph.history.length - 1][1]; - this._headerState[index] = lastState; - } else { - // before_now / after_now - this._headerState[index] = graph.nowValue(inHeader === 'before_now'); - } - } - if (!this._config?.series[index].show.in_chart) { - return []; - } - if (graph.history.length === 0) return [{ data: [] }]; - 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)]; + this._graphs.forEach((graph, index) => { + if (!graph) return []; + const inHeader = this._config?.series[index].show.in_header; + if (inHeader && inHeader !== 'raw') { + // not raw + if (graph.history.length === 0) { + this._headerState[index] = null; + } else if (inHeader === true) { + // last + const lastState = graph.history[graph.history.length - 1][1]; + this._headerState[index] = lastState; } else { - data = graph.history; + // before_now / after_now + this._headerState[index] = graph.nowValue(inHeader === 'before_now'); } - data = offsetData(data, this._seriesOffset[index]); - return [this._config?.series[index].invert ? { data: this._invertData(data) } : { data }]; - }), - xaxis: { + } + if (!this._config?.series[index].show.in_chart && !this._config?.series[index].show.in_brush) { + return; + } + if (graph.history.length === 0) return [{ data: [] }]; + 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]); + const result = this._config?.series[index].invert ? { data: this._invertData(data) } : { data }; + if (this._config?.series[index].show.in_chart) graphData.series.push(result); + if (this._config?.series[index].show.in_brush) brushData.series.push(result); + return; + }); + graphData.annotations = this._computeAnnotations(start, end); + if (!this._apexBrush) { + graphData.xaxis = { min: start.getTime(), - max: this._findEndOfChart(end), - }, - annotations: this._computeAnnotations(start, end), - }; + max: this._findEndOfChart(end, false), + }; + } } else { // No timeline charts graphData = { @@ -576,7 +626,10 @@ class ChartsCard extends LitElement { }), }; } - graphData.colors = this._computeChartColors(); + graphData.colors = this._computeChartColors(false); + if (this._apexBrush) { + brushData.colors = this._computeChartColors(true); + } if (this._config.experimental?.color_threshold && this._config.series.some((serie) => serie.color_threshold)) { graphData.markers = { colors: computeColors( @@ -594,8 +647,8 @@ class ChartsCard extends LitElement { type: 'vertical', colorStops: this._config.series_in_graph.map((serie, index) => { if (!serie.color_threshold || ![undefined, 'area', 'line'].includes(serie.type)) return []; - const min = this._graphs?.[index]?.min; - const max = this._graphs?.[index]?.max; + const min = this._graphs?.[serie.index]?.min; + const max = this._graphs?.[serie.index]?.max; if (min === undefined || max === undefined) return []; return ( this._computeFillColorStops(serie, min, max, computeColor(this._colors[index]), serie.invert) || [] @@ -603,14 +656,73 @@ class ChartsCard extends LitElement { }), }, }; + if (this._apexBrush) { + brushData.fill = { + gradient: { + type: 'vertical', + colorStops: this._config.series_in_brush.map((serie, index) => { + if (!serie.color_threshold || ![undefined, 'area', 'line'].includes(serie.type)) return []; + const min = this._graphs?.[serie.index]?.min; + const max = this._graphs?.[serie.index]?.max; + if (min === undefined || max === undefined) return []; + return ( + this._computeFillColorStops(serie, min, max, computeColor(this._colors[index]), serie.invert) || [] + ); + }), + }, + }; + } } // graphData.tooltip = { marker: { fillColors: ['#ff0000', '#00ff00'] } }; + const brushIsAtEnd = + this._apexBrush && + this._brushInit && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this._apexChart as any).axes?.w?.globals?.maxX === (this._apexBrush as any).axes?.w?.globals?.maxX; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentMin = (this._apexChart as any).axes?.w?.globals?.minX; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentMax = (this._apexChart as any).axes?.w?.globals?.maxX; this._headerState = [...this._headerState]; this._apexChart?.updateOptions( graphData, false, TIMESERIES_TYPES.includes(this._config.chart_type) ? false : true, ); + if (this._apexBrush) { + const newMin = start.getTime(); + const newMax = this._findEndOfChart(end, false); + brushData.xaxis = { + min: newMin, + max: newMax, + }; + if (brushIsAtEnd || !this._brushInit) { + brushData.chart = { + selection: { + enabled: true, + xaxis: { + min: brushData.xaxis.max - (this._brushSelectionSpan ? this._brushSelectionSpan : this._graphSpan / 4), + max: brushData.xaxis.max, + }, + }, + }; + } else { + brushData.chart = { + selection: { + enabled: true, + xaxis: { + min: currentMin < newMin ? newMin : currentMin, + max: currentMin < newMin ? newMin + (currentMax - currentMin) : currentMax, + }, + }, + }; + } + const selectionColor = computeColor('var(--primary-text-color)'); + brushData.chart.selection.stroke = { color: selectionColor }; + brushData.chart.selection.fill = { color: selectionColor, opacity: 0.1 }; + this._brushInit = true; + this._apexBrush?.updateOptions(brushData, false, false); + } } catch (err) { log(err); } @@ -746,9 +858,10 @@ class ChartsCard extends LitElement { return {}; } - private _computeChartColors(): (string | (({ value }) => string))[] { - const defaultColors: (string | (({ value }) => string))[] = computeColors(this._colors); - this._config?.series_in_graph.forEach((serie, index) => { + private _computeChartColors(brush: boolean): (string | (({ value }) => string))[] { + const defaultColors: (string | (({ value }) => string))[] = computeColors(brush ? this._brushColors : this._colors); + const series = brush ? this._config?.series_in_brush : this._config?.series_in_graph; + series?.forEach((serie, index) => { if ( this._config?.experimental?.color_threshold && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -888,14 +1001,15 @@ class ChartsCard extends LitElement { 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 */ - private _findEndOfChart(end: Date): number { + private _findEndOfChart(end: Date, brush: boolean): number { const localEnd = new Date(end); let offsetEnd: number | undefined = 0; - const onlyBars = this._config?.series.reduce((acc, serie) => { + const series = brush ? this._config?.series_in_brush : this._config?.series_in_graph; + const onlyBars = series?.reduce((acc, serie) => { return acc && serie.type === 'column' && serie.group_by.func !== 'raw'; - }, this._config?.series.length > 0); + }, series?.length > 0); if (onlyBars) { - offsetEnd = this._config?.series.reduce((acc, serie) => { + offsetEnd = series?.reduce((acc, serie) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const dur = parse(serie.group_by.duration)!; if (acc === -1 || dur < acc) { diff --git a/src/graphEntry.ts b/src/graphEntry.ts index ca74058..b2f229a 100644 --- a/src/graphEntry.ts +++ b/src/graphEntry.ts @@ -403,14 +403,11 @@ export default class GraphEntry { (buckets[buckets.length - 1].data.length > 0 && buckets[buckets.length - 1].data[buckets[buckets.length - 1].data.length - 1][1] === null) ) - buckets = buckets - .reverse() - .flatMap((bucket) => { - if (bucket.data[1] === null) return []; - if (bucket.data.length === 0) return []; - else return [bucket]; - }) - .reverse(); + buckets = buckets.flatMap((bucket) => { + if (bucket.data[1] === null) return []; + if (bucket.data.length === 0) return []; + else return [bucket]; + }); return buckets; } diff --git a/src/locales.ts b/src/locales.ts new file mode 100644 index 0000000..e8079e8 --- /dev/null +++ b/src/locales.ts @@ -0,0 +1,73 @@ +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'; + +export function getLocales(): Record { + return { + 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, + }; +} + +export function getDefaultLocale(): unknown { + return en; +} diff --git a/src/styles.ts b/src/styles.ts index f4e3e1c..da28828 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -22,6 +22,10 @@ export const styles: CSSResult = css` grid-area: graph; } + #brush { + margin-top: -30px; + } + /* Needed for minimal layout */ svg:not(:root) { overflow: visible !important; diff --git a/src/types-config-ti.ts b/src/types-config-ti.ts index 63ee7bf..daebe73 100644 --- a/src/types-config-ti.ts +++ b/src/types-config-ti.ts @@ -13,9 +13,10 @@ export const ChartCardExternalConfig = t.iface([], { "color_threshold": t.opt("boolean"), "disable_config_validation": t.opt("boolean"), "hidden_by_default": t.opt("boolean"), + "brush": t.opt("boolean"), })), "hours_12": t.opt("boolean"), - "chart_type": t.opt(t.union(t.lit('line'), t.lit('scatter'), t.lit('pie'), t.lit('donut'), t.lit('radialBar'))), + "chart_type": t.opt("ChartCardChartType"), "update_interval": t.opt("string"), "update_delay": t.opt("string"), "all_series_config": t.opt("ChartCardAllSeriesExternalConfig"), @@ -37,6 +38,14 @@ export const ChartCardExternalConfig = t.iface([], { "apex_config": t.opt("any"), "header": t.opt("ChartCardHeaderExternalConfig"), "style": t.opt("any"), + "brush": t.opt("ChartCardBrushExtConfig"), +}); + +export const ChartCardChartType = t.union(t.lit('line'), t.lit('scatter'), t.lit('pie'), t.lit('donut'), t.lit('radialBar')); + +export const ChartCardBrushExtConfig = t.iface([], { + "selection_span": t.opt("string"), + "apex_config": t.opt("any"), }); export const ChartCardSpanExtConfig = t.iface([], { @@ -72,6 +81,7 @@ export const ChartCardAllSeriesExternalConfig = t.iface([], { "datalabels": t.opt(t.union("boolean", t.lit('total'))), "hidden_by_default": t.opt("boolean"), "extremas": t.opt(t.union("boolean", t.lit('time'))), + "in_brush": t.opt("boolean"), })), "group_by": t.opt(t.iface([], { "duration": t.opt("string"), @@ -108,6 +118,8 @@ export const ChartCardColorThreshold = t.iface([], { const exportedTypeSuite: t.ITypeSuite = { ChartCardExternalConfig, + ChartCardChartType, + ChartCardBrushExtConfig, ChartCardSpanExtConfig, ChartCardAllSeriesExternalConfig, ChartCardSeriesExternalConfig, diff --git a/src/types-config.ts b/src/types-config.ts index d437f30..28cc359 100644 --- a/src/types-config.ts +++ b/src/types-config.ts @@ -7,9 +7,10 @@ export interface ChartCardExternalConfig { color_threshold?: boolean; disable_config_validation?: boolean; hidden_by_default?: boolean; + brush?: boolean; }; hours_12?: boolean; - chart_type?: 'line' | 'scatter' | 'pie' | 'donut' | 'radialBar'; + chart_type?: ChartCardChartType; update_interval?: string; update_delay?: string; all_series_config?: ChartCardAllSeriesExternalConfig; @@ -34,6 +35,15 @@ export interface ChartCardExternalConfig { // Support to define style (card-mod or picture-entity) // eslint-disable-next-line @typescript-eslint/no-explicit-any style?: any; + brush?: ChartCardBrushExtConfig; +} + +export type ChartCardChartType = 'line' | 'scatter' | 'pie' | 'donut' | 'radialBar'; + +export interface ChartCardBrushExtConfig { + selection_span?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apex_config?: any; } export interface ChartCardSpanExtConfig { @@ -69,6 +79,7 @@ export interface ChartCardAllSeriesExternalConfig { datalabels?: boolean | 'total'; hidden_by_default?: boolean; extremas?: boolean | 'time'; + in_brush?: boolean; }; group_by?: { duration?: string; diff --git a/src/types.ts b/src/types.ts index c4aeeed..0fd5ab0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ import { export interface ChartCardConfig extends ChartCardExternalConfig { series: ChartCardSeriesConfig[]; series_in_graph: ChartCardSeriesConfig[]; + series_in_brush: ChartCardSeriesConfig[]; graph_span: string; cache: boolean; useCompress: boolean; @@ -32,6 +33,7 @@ export interface ChartCardSeriesConfig extends ChartCardSeriesExternalConfig { datalabels?: boolean | 'total'; hidden_by_default?: boolean; extremas?: boolean | 'time'; + in_brush?: boolean; }; }