diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 6020c9cb..8cf0e4e6 100755 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -10,6 +10,7 @@ rules: class-methods-use-this: 0 no-nested-ternary: 0 camelcase: 0 + import/prefer-default-export: 0 globals: window: true Event: true diff --git a/README.md b/README.md index 6a4567c5..a9191b29 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,9 @@ We recommend looking at the [Example usage section](#example-usage) to understan | line_color | string/list | `var(--accent-color)` | v0.0.1 | Set a custom color for the graph line, provide a list of colors for multiple graph entries. | color_thresholds | list | | v0.2.3 | Set thresholds for dynamic graph colors, see [Line color object](#line-color-object). | color_thresholds_transition | string | `smooth` | v0.4.3 | Color threshold transition, `smooth` or `hard`. -| decimals | integer | | v0.0.9 | Specify the exact number of decimals to show for states. +| decimals | integer | | v0.0.9 | Specify the exact number of decimals to show for number values, see [Number format](#number-format). +| decimals_primary_labels | integer | | v0.14.0 | Specify the exact number of decimals to show for primary Y-axis labels, see [Number format](#number-format). +| decimals_secondary_labels | integer | | v0.14.0 | Specify the exact number of decimals to show for secondary Y-axis labels, see [Number format](#number-format). | hour24 | boolean | `false` | v0.2.1 | Set to `true` to display times in 24-hour format. | font_size | number | `100` | v0.0.3 | Adjust the font size of the state, as percentage of the original size. | font_size_header | number | `14` | v0.3.1 | Adjust the font size of the header, size in pixels. @@ -132,6 +134,7 @@ properties of the Entity object detailed in the following table (as per `sensor. | color | string | | Set a custom color, overrides all other color options including thresholds. | unit | string | | Set a custom unit of measurement, overrides `unit` set in base config (`''` value for an empty unit). | aggregate_func | string | | Override for aggregate function used to calculate point on the graph, `avg`, `median`, `min`, `max`, `first`, `last`, `sum`. +| decimals | integer | | Override the exact number of decimals to show for number values, see [Number format](#number-format). | show_state | boolean | | Display the current state. | show_legend_state | boolean | false | Display the current state as part of the legend. | show_indicator | boolean | | Display a color indicator next to the state, (only when more than two states are visible). @@ -141,7 +144,7 @@ properties of the Entity object detailed in the following table (as per `sensor. | show_points | boolean | | Set to false to hide the points. | show_legend | boolean | | Set to false to turn hide from the legend. | state_adaptive_color | boolean | | Make the color of the state adapt to the entity color. -| y_axis | string | | If 'secondary', displays using the secondary y-axis on the right. +| y_axis | string | | If 'secondary', displays using the secondary Y-axis on the right. | fixed_value | boolean | | Set to true to graph the entity's current state as a fixed value instead of graphing its state history. | smoothing | boolean | | Override for a flag indicating whether to make graph line smooth. @@ -258,6 +261,32 @@ These buckets are converted later to single point/bar on the graph. Aggregate fu | `delta` | v0.9.4 | Calculates difference between max and min value | `diff` | v0.11.0 | Calculates difference between first and last value +### Number format + +Options `decimals` defined "card-wide" and/or for some entity are used to set an exact number of decimals according to the following rules: +1. For state & attribute values: +- if none `decimals` option is defined - a default presentation (see a note below) is used; +- if "card-wide" `decimals` is defined - this value is used; +- if `decimals` for some entity is defined - this value is used for this entity. +2. For extrema & average values (supported for the 1st entity only): +- if none `decimals` option is defined - a default presentation is used; +- if "card-wide" `decimals` is defined - this value is used; +- if `decimals` is defined for the 1st entity - this value is used. +3. For primary Y-axis labels: +- if "card-wide" `decimals` & `decimals_primary_labels` options are not defined - a default presentation is used; +- if "card-wide" `decimals` option is defined - this value is used; +- if "card-wide" `decimals_primary_labels` option is defined - this value is used. +4. For secondary Y-axis labels: +- if "card-wide" `decimals` & `decimals_secondary_labels` options are not defined - a default presentation is used; +- if "card-wide" `decimals` option is defined - this value is used; +- if "card-wide" `decimals_secondary_labels` option is defined - this value is used. + +A "default presentation" refers to a default look in HA: +1. For a state value (also for extrema & average): if accuracy settings are defined for an entity - these settings are used, otherwise some default HA settings (depend on many factors incl. a `device_class`; for template sensors - a user-defined accuracy set in jinja templates is used). +2. For an attribute value (also for extrema & average): default HA settings are used (for template sensors - a user-defined accuracy set in jinja templates is used). +3. For Y-axis labels: "maximum 2 decimals" accuracy is used. +And for all values, HA number format settings (like `xxxx.xx` or `x xxx.x` or `x,xxx.x`) are used. + ### Theme variables The following theme variables can be set in your HA theme to customize the appearance of the card. @@ -396,11 +425,11 @@ color_thresholds: color: "#c0392b" ``` -#### Alternate y-axis -Have one or more series plot on a separate y-axis, which appears on the right side of the graph. This example also +#### Alternate Y-axis +Have one or more series plot on a separate Y-axis, which appears on the right side of the graph. This example also shows turning off the line, points and legend. -![Alternate y-axis](https://user-images.githubusercontent.com/373079/60764115-63cf2780-a0c6-11e9-8b9a-97fc47161180.png) +![Alternate Y-axis](https://user-images.githubusercontent.com/373079/60764115-63cf2780-a0c6-11e9-8b9a-97fc47161180.png) ```yaml type: custom:mini-graph-card @@ -499,6 +528,7 @@ state_map: label: Detected ``` + #### Showing additional info on the card ![изображение](https://user-images.githubusercontent.com/71872483/170584118-ef826b60-dce3-42ec-a005-0f467616cd37.png) diff --git a/src/locale.js b/src/locale.js new file mode 100644 index 00000000..7402c645 --- /dev/null +++ b/src/locale.js @@ -0,0 +1,182 @@ +/** + * HA Frontend number format settings + */ +const NumberFormat = Object.freeze({ + language: 'language', + system: 'system', + comma_decimal: 'comma_decimal', + decimal_comma: 'decimal_comma', + quote_decimal: 'quote_decimal', + space_comma: 'space_comma', + none: 'none', +}); + +/* these types are used in FrontendLocaleData +Added here for a future need; now - for understaning what FrontendLocaleData is + +export enum TimeZone { + local = 'local', + server = 'server', +} + +export enum DateFormat { + language = 'language', + system = 'system', + DMY = 'DMY', + MDY = 'MDY', + YMD = 'YMD', +} + +export enum FirstWeekday { + language = 'language', + monday = 'monday', + tuesday = 'tuesday', + wednesday = 'wednesday', + thursday = 'thursday', + friday = 'friday', + saturday = 'saturday', + sunday = 'sunday', +} +*/ + +/* this type is used for hass.locale +export interface FrontendLocaleData { + language: string; + number_format: NumberFormat; + time_format: TimeFormat; + date_format: DateFormat; + first_weekday: FirstWeekday; + time_zone: TimeZone; +} +*/ + +/** + * Returns a possible language/languages based on a number format + * @param {FrontendLocaleData} localeOptions Object containing + * a user-selected language and formatting settings + * @returns {string | string[] | undefined} Possible language/languages + */ +const numberFormatToLocale = (localeOptions) => { + switch (localeOptions.number_format) { + case NumberFormat.comma_decimal: + return ['en-US', 'en']; // Use United States with fallback to English formatting 1,234,567.89 + case NumberFormat.decimal_comma: + return ['de', 'es', 'it']; // Use German with fallback to Spanish then Italian formatting 1.234.567,89 + case NumberFormat.space_comma: + return ['fr', 'sv', 'cs']; // Use French with fallback to Swedish and Czech formatting 1 234 567,89 + case NumberFormat.quote_decimal: + return ['de-CH']; // Use German (Switzerland) formatting 1'234'567.89 + case NumberFormat.system: + return undefined; + default: + return localeOptions.language; + } +}; + +/** + * Generates default options for Intl.NumberFormat + * @param {string | number} num Number to format + * @param {Intl.NumberFormatOptions} options Intl.NumberFormatOptions + * that should be included in the returned options + * @returns {Intl.NumberFormatOptions} Default options for Intl.NumberFormat + */ +const getDefaultFormatOptions = ( + num, + options, +) => { + const defaultOptions = { + maximumFractionDigits: 2, + ...options, + }; + + if (typeof num !== 'string') { + return defaultOptions; + } + + // Keep decimal trailing zeros if they are present in a string numeric value + if ( + !options + || (options.minimumFractionDigits === undefined + && options.maximumFractionDigits === undefined) + ) { + const digits = num.indexOf('.') > -1 ? num.split('.')[1].length : 0; + defaultOptions.minimumFractionDigits = digits; + defaultOptions.maximumFractionDigits = digits; + } + + return defaultOptions; +}; + +/** + * Returns an array of objects containing the formatted number in parts. + * Similar to Intl.NumberFormat.prototype.formatToParts() + * @param {string | number} num Number to format + * @param {FrontendLocaleData} localeOptions Object containing + * a user-selected language and formatting settings + * @param {Intl.NumberFormatOptions} options Intl.NumberFormatOptions to use + */ +const formatNumberToParts = ( + num, + localeOptions, + options, +) => { + const locale = localeOptions + ? numberFormatToLocale(localeOptions) + : undefined; + + // Polyfill for Number.isNaN, which is more reliable than the global isNaN() + Number.isNaN = Number.isNaN + || function isNaN(input) { + return typeof input === 'number' && isNaN(input); + }; + + if ( + localeOptions + && localeOptions.number_format !== NumberFormat.none + && !Number.isNaN(Number(num)) + ) { + return new Intl.NumberFormat( + locale, + getDefaultFormatOptions(num, options), + ).formatToParts(Number(num)); + } + + if ( + !Number.isNaN(Number(num)) + && num !== '' + && localeOptions + && localeOptions.number_format === NumberFormat.none + ) { + // If NumberFormat is none, use en-US format without grouping. + return new Intl.NumberFormat( + 'en-US', + getDefaultFormatOptions(num, { + ...options, + useGrouping: false, + }), + ).formatToParts(Number(num)); + } + + return [{ type: 'literal', value: num }]; +}; + +/** + * Formats a number based on the user's preference with thousands separator(s) + * and decimal character for better legibility. + * @param {string | number} num Number to format + * @param {FrontendLocaleData} localeOptions Object containing + * a user-selected language and formatting settings + * @param {Intl.NumberFormatOptions} options Intl.NumberFormatOptions to use + * @returns {string} Formatted number + */ +const formatNumber = ( + num, + localeOptions, + options, +) => formatNumberToParts(num, localeOptions, options) + .map(part => part.value) + .join(''); + +export { + formatNumber, +}; diff --git a/src/main.js b/src/main.js index d42dbc11..3c6e58d3 100644 --- a/src/main.js +++ b/src/main.js @@ -7,9 +7,11 @@ import Graph from './graph'; import style from './style'; import handleClick from './handleClick'; import buildConfig from './buildConfig'; +import { + formatNumber, +} from './locale'; import './initialize'; import { version } from '../package.json'; - import { ICONS, UPDATE_PROPS, @@ -25,6 +27,8 @@ import { log, } from './utils'; +const isUnavailableState = value => ['unavailable', 'unknown'].includes(value); + class MiniGraphCard extends LitElement { constructor() { super(); @@ -273,6 +277,15 @@ class MiniGraphCard extends LitElement { return path.split('.').reduce((res, key) => res && res[key], obj); } + /** + * Check if an attribute represents an object (dictionary or list) + * @returns {boolean} True if an attribute is an object, false - otherwise + * @param path Attribute defined as either a singular attribute or a tree-like path + */ + isObjectAttr(path) { + return path.includes('.'); + } + getEntityState(id) { const entityConfig = this.config.entities[id]; if (this.config.show.state === 'last') { @@ -301,7 +314,7 @@ class MiniGraphCard extends LitElement { style=${entityConfig.state_adaptive_color ? `color: ${this.computeColor(value, entity)}` : ''}> ${entityConfig.show_indicator ? this.renderIndicator(value, entity) : ''} - ${this.computeState(value)} + ${this.computeState(value, entity)} ${this.computeUom(entity)} @@ -354,7 +367,7 @@ class MiniGraphCard extends LitElement { const { show_legend_state = false } = this.config.entities[index]; if (show_legend_state) { - legend += ` (${this.computeState(state)}`; + legend += ` (${this.computeState(state, index)}`; if (!(['unavailable'].includes(state))) { const uom = this.computeUom(index); if (!(['%', ''].includes(uom))) @@ -369,7 +382,6 @@ class MiniGraphCard extends LitElement { renderLegend() { if (this.visibleLegends.length <= 1 || !this.config.show.legend) return; - /* eslint-disable indent */ return html`
@@ -601,6 +613,7 @@ class MiniGraphCard extends LitElement { renderLabels() { if (!this.config.show.labels || this.primaryYaxisSeries.length === 0) return; + // index is not passed into computeState() for a primary axis return html`
${this.computeState(this.bound[1])} @@ -611,22 +624,24 @@ class MiniGraphCard extends LitElement { renderLabelsSecondary() { if (!this.config.show.labels_secondary || this.secondaryYaxisSeries.length === 0) return; + // index "-1" is passed into computeState() for a secondary axis return html`
- ${this.computeState(this.boundSecondary[1])} - ${this.computeState(this.boundSecondary[0])} + ${this.computeState(this.boundSecondary[1], -1)} + ${this.computeState(this.boundSecondary[0], -1)}
`; } renderInfo() { + // index "0" is passed into computeState() since "info" is shown for the 1st entity return this.abs.length > 0 ? html`
${this.abs.map(entry => html`
${entry.type} - ${this.computeState(entry.state)} ${this.computeUom(0)} + ${this.computeState(entry.state, 0)} ${this.computeUom(0)} ${entry.type !== 'avg' ? getTime(new Date(entry.last_changed), this.config.format, this._hass.language) : ''} @@ -723,7 +738,15 @@ class MiniGraphCard extends LitElement { ); } - computeState(inState) { + /** + * Returns a string value for a state/attrubute: + * localized, following locale settings, + * accounting possible individual accuracy settings & possible "decimals" options + * @returns {string} value of a state/attribute + * @param {number|string} inState Value of a state/attribute ("unformatted") + * @param {number} index Index of an entity in config.entities + */ + computeState(inState, index) { if (this.config.state_map.length > 0) { const stateMap = Number.isInteger(inState) ? this.config.state_map[inState] @@ -737,22 +760,89 @@ class MiniGraphCard extends LitElement { } let state; - if (typeof inState === 'string') { + if (isUnavailableState(inState)) { + // as is + state = inState; + } else if (typeof inState === 'string') { + // attempt to fix an unexpected number format state = parseFloat(inState.replace(/,/g, '.')); } else { + // as is presented as a number state = Number(inState); } - const dec = this.config.decimals; const value_factor = 10 ** this.config.value_factor; + // safely process with a value_factor + state = Number.isNaN(Number(state)) ? state : state * value_factor; + + let dec; + // attempting to get "decimals" settings + if (index === undefined) { + // for a primary Y-axis + dec = this.config.decimals_primary_labels !== undefined + ? this.config.decimals_primary_labels + : this.config.decimals; + } else if (index === -1) { + // for a secondary Y-axis + dec = this.config.decimals_secondary_labels !== undefined + ? this.config.decimals_secondary_labels + : this.config.decimals; + } else { + // for a state or attribute value + dec = this.config.entities[index].decimals !== undefined + ? this.config.entities[index].decimals + : this.config.decimals; + } - if (dec === undefined || Number.isNaN(dec) || Number.isNaN(state)) { - return this.numberFormat(Math.round(state * value_factor * 100) / 100, this._hass.language); + let value; + + if (dec === undefined || Number.isNaN(Number(dec)) || Number.isNaN(Number(state))) { + // no valid "decimals" settings defined, use a default accuracy + if (index >= 0) { + // formatting a state or attribute + const entityId = this.config.entities[index].entity; + const { attribute } = this.config.entities[index]; + const stateObj = this._hass.states[entityId]; + if (attribute && !this.isObjectAttr(attribute)) { + // formatting not-object attribute + const attrParts = this._hass.formatEntityAttributeValueToParts( + stateObj, + attribute, + state, + ); + const partValue = attrParts.find(part => part.type === 'value'); + value = partValue && partValue.value; + return value; + } else if (attribute && this.isObjectAttr(attribute)) { + // formatting object attribute - similar to Y-axis labels + return formatNumber( + state, + this._hass.locale, + ); + } else { + // formatting state + const stateParts = this._hass.formatEntityStateToParts( + stateObj, + state, + ); + const partValue = stateParts.find(part => part.type === 'value'); + value = partValue && partValue.value; + return value; + } + } else { + // formatting Y-axis (primary, secondary) labels + // use a default hard-coded accuracy + return formatNumber( + state, + this._hass.locale, + ); + } } - const x = 10 ** dec; - return this.numberFormat( - (Math.round(state * value_factor * x) / x).toFixed(dec), - this._hass.language, dec, + // use an acuracy defined by "dec" variable + return formatNumber( + state, + this._hass.locale, + { minimumFractionDigits: dec, maximumFractionDigits: dec }, ); } diff --git a/src/utils.js b/src/utils.js index fe64cf7c..dd80e9b6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,3 @@ -/* eslint-disable no-bitwise */ import { compress as lzStringCompress, decompress as lzStringDecompress } from '@kalkih/lz-string'; const getMin = (arr, val) => arr.reduce((min, p) => (