diff --git a/.devcontainer/ui-lovelace.yaml b/.devcontainer/ui-lovelace.yaml index abe0992..a4bb497 100644 --- a/.devcontainer/ui-lovelace.yaml +++ b/.devcontainer/ui-lovelace.yaml @@ -51,6 +51,8 @@ views: type: area curve: straight color: yellow + show: + extremas: true - entity: sensor.random_0_1000 name: Sensor 2 type: area @@ -130,6 +132,8 @@ views: series: - entity: sensor.humidity curve: straight + show: + extremas: true - type: custom:apexcharts-card graph_span: 1h @@ -339,6 +343,8 @@ views: series: - entity: sensor.pvpc float_precision: 5 + show: + extremas: true data_generator: | return [...Array(22).keys()].map((hour) => { const attr = 'price_' + `${hour}`.padStart(2, '0') + 'h'; @@ -441,6 +447,8 @@ views: title: line series: - entity: sensor.random0_100 + show: + extremas: true color_threshold: - value: 0 color: '#0000ff' @@ -449,6 +457,8 @@ views: - value: 66 color: '#ff0000' - entity: sensor.random_0_1000 + show: + extremas: true - type: custom:apexcharts-card header: show: true diff --git a/README.md b/README.md index 02b1563..6cde16c 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ The card stricly validates all the options available (but not for the `apex_conf | `in_chart` | boolean | `true` | v1.4.0 | If `false`, hides the serie from the chart | | `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 NEXT_VERSION), 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 | `false` | NEXT_VERSION | If enabled, will show the min and the max of the serie in the chart | ### Main `show` Options diff --git a/rollup.config.js b/rollup.config.js index bbe0a65..5e5bc60 100755 --- a/rollup.config.js +++ b/rollup.config.js @@ -5,7 +5,6 @@ import babel from '@rollup/plugin-babel'; import { terser } from 'rollup-plugin-terser'; import serve from 'rollup-plugin-serve'; import json from '@rollup/plugin-json'; -// import cleanup from 'rollup-plugin-cleanup'; // eslint-disable-next-line no-undef const dev = process.env.ROLLUP_WATCH; @@ -37,7 +36,6 @@ const plugins = [ }, ], }), - // cleanup({ comments: 'none' }), dev && serve(serveopts), !dev && terser({ diff --git a/src/apexcharts-card.ts b/src/apexcharts-card.ts index 0988199..88fba11 100644 --- a/src/apexcharts-card.ts +++ b/src/apexcharts-card.ts @@ -1,7 +1,7 @@ import 'array-flat-polyfill'; import { LitElement, html, customElement, property, TemplateResult, CSSResult, PropertyValues } from 'lit-element'; import { ClassInfo, classMap } from 'lit-html/directives/class-map'; -import { ChartCardConfig, ChartCardSeriesConfig, EntityCachePoints, EntityEntryCache } from './types'; +import { ChartCardConfig, ChartCardSeriesConfig, EntityCachePoints, EntityEntryCache, HistoryPoint } from './types'; import { getLovelace, HomeAssistant } from 'custom-card-helpers'; import localForage from 'localforage'; import * as pjson from '../package.json'; @@ -307,6 +307,7 @@ class ChartsCard extends LitElement { const defColors = this._config?.color_list || DEFAULT_COLORS; if (this._config) { this._graphs = this._config.series.map((serie, index) => { + serie.index = index; if (!this._headerColors[index]) { this._headerColors[index] = defColors[index % defColors.length]; } @@ -540,28 +541,8 @@ class ChartsCard extends LitElement { min: start.getTime(), max: this._findEndOfChart(end), }, + annotations: this._computeAnnotations(start, end), }; - if (this._config.now?.show) { - const color = computeColor(this._config.now.color || 'var(--primary-color)'); - const textColor = computeTextColor(color); - graphData.annotations = { - xaxis: [ - { - x: new Date().getTime(), - strokeDashArray: 3, - label: { - text: this._config.now.label, - borderColor: color, - style: { - color: textColor, - background: color, - }, - }, - borderColor: color, - }, - ], - }; - } } else { // No timeline charts graphData = { @@ -633,6 +614,87 @@ class ChartsCard extends LitElement { this._updating = false; } + private _computeAnnotations(start: Date, end: Date) { + return { + ...this._computeMinMaxPointsAnnotations(start, end), + ...this._computeNowAnnotation(), + }; + } + + private _computeMinMaxPointsAnnotations(start: Date, end: Date) { + return { + points: this._config?.series_in_graph.flatMap((serie, index) => { + if (serie.show.extremas) { + const { min, max } = this._graphs?.[serie.index]?.minMaxWithTimestamp(start.getTime(), end.getTime()) || { + min: [0, null], + max: [0, null], + }; + const bgColor = computeColor(this._colors[index]); + const txtColor = computeTextColor(bgColor); + if (!min[0] || !max[0]) return []; + return [ + this._getPointAnnotationStyle(min, bgColor, txtColor, serie, index), + this._getPointAnnotationStyle(max, bgColor, txtColor, serie, index), + ]; + } else { + return []; + } + }), + }; + } + + private _getPointAnnotationStyle( + value: HistoryPoint, + bgColor: string, + txtColor: string, + serie: ChartCardSeriesConfig, + index: number, + ) { + return { + x: value[0], + y: value[1], + seriesIndex: index, + marker: { + strokeColor: bgColor, + fillColor: 'var(--card-background-color)', + }, + label: { + text: truncateFloat(value[1], serie.float_precision)?.toString(), + borderColor: 'var(--card-background-color)', + borderWidth: 2, + style: { + background: bgColor, + color: txtColor, + }, + }, + }; + } + + private _computeNowAnnotation() { + if (this._config?.now?.show) { + const color = computeColor(this._config.now.color || 'var(--primary-color)'); + const textColor = computeTextColor(color); + return { + xaxis: [ + { + x: new Date().getTime(), + strokeDashArray: 3, + label: { + text: this._config.now.label, + borderColor: color, + style: { + color: textColor, + background: color, + }, + }, + borderColor: color, + }, + ], + }; + } + return {}; + } + private _computeChartColors(): (string | (({ value }) => string))[] { const defaultColors: (string | (({ value }) => string))[] = computeColorsWithAlpha( this._colors, diff --git a/src/graphEntry.ts b/src/graphEntry.ts index 7a91715..4f28459 100644 --- a/src/graphEntry.ts +++ b/src/graphEntry.ts @@ -6,6 +6,7 @@ import { HassHistory, HassHistoryEntry, HistoryBuckets, + HistoryPoint, } from './types'; import { compress, decompress, log } from './utils'; import localForage from 'localforage'; @@ -117,6 +118,20 @@ export default class GraphEntry { return Math.max(...this._computedHistory.flatMap((item) => (item[1] === null ? [] : [item[1]]))); } + public minMaxWithTimestamp(start: number, end: number): { min: HistoryPoint; max: HistoryPoint } | undefined { + if (!this._computedHistory || this._computedHistory.length === 0) return undefined; + return this._computedHistory.reduce( + (acc: { min: HistoryPoint; max: HistoryPoint }, point) => { + if (point[1] === null) return acc; + if (point[0] > end || point[0] < start) return acc; + if (acc.max[1] === null || acc.max[1] < point[1]) acc.max = point; + if (acc.min[1] === null || (point[1] !== null && acc.min[1] > point[1])) acc.min = point; + return acc; + }, + { min: [0, null], max: [0, null] }, + ); + } + private async _getCache(key: string, compressed: boolean): Promise { const data: EntityEntryCache | undefined | null = await localForage.getItem( `${key}_${this._md5Config}${compressed ? '' : '-raw'}`, diff --git a/src/types-config-ti.ts b/src/types-config-ti.ts index a0a360f..3da8b12 100644 --- a/src/types-config-ti.ts +++ b/src/types-config-ti.ts @@ -68,6 +68,7 @@ export const ChartCardAllSeriesExternalConfig = t.iface([], { "in_chart": t.opt("boolean"), "datalabels": t.opt(t.union("boolean", t.lit('total'))), "hidden_by_default": t.opt("boolean"), + "extremas": t.opt("boolean"), })), "group_by": t.opt(t.iface([], { "duration": t.opt("string"), @@ -103,6 +104,7 @@ export const ChartCardSeriesExternalConfig = t.iface([], { "in_chart": t.opt("boolean"), "datalabels": t.opt(t.union("boolean", t.lit('total'))), "hidden_by_default": t.opt("boolean"), + "extremas": t.opt("boolean"), })), "group_by": t.opt(t.iface([], { "duration": t.opt("string"), diff --git a/src/types-config.ts b/src/types-config.ts index a1e5a04..83feeeb 100644 --- a/src/types-config.ts +++ b/src/types-config.ts @@ -65,6 +65,7 @@ export interface ChartCardAllSeriesExternalConfig { in_chart?: boolean; datalabels?: boolean | 'total'; hidden_by_default?: boolean; + extremas?: boolean; }; group_by?: { duration?: string; @@ -104,6 +105,7 @@ export interface ChartCardSeriesExternalConfig { in_chart?: boolean; datalabels?: boolean | 'total'; hidden_by_default?: boolean; + extremas?: boolean; }; group_by?: { duration?: string; diff --git a/src/types.ts b/src/types.ts index 99481e4..1cf467d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,7 @@ export interface ChartCardSeriesConfig extends ChartCardSeriesExternalConfig { in_chart: boolean; datalabels?: boolean | 'total'; hidden_by_default?: boolean; + extremas?: boolean; }; } @@ -40,7 +41,9 @@ export interface EntityEntryCache { data: EntityCachePoints; } -export type EntityCachePoints = Array<[number, number | null]>; +export type EntityCachePoints = Array; + +export type HistoryPoint = [number, number | null]; export type HassHistory = Array<[HassHistoryEntry] | undefined>;