diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index d588cd1688..7a2bf77f2a 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -5,7 +5,7 @@ import { formatDateTime } from "../datetime/format_date_time"; import { formatTime } from "../datetime/format_time"; import { LocalizeFunc } from "../translations/localize"; import { computeStateDomain } from "./compute_state_domain"; -import { numberFormat } from "../string/number-format"; +import { formatNumber } from "../string/format_number"; export const computeStateDisplay = ( localize: LocalizeFunc, @@ -20,7 +20,7 @@ export const computeStateDisplay = ( } if (stateObj.attributes.unit_of_measurement) { - return `${numberFormat(compareState, language)} ${ + return `${formatNumber(compareState, language)} ${ stateObj.attributes.unit_of_measurement }`; } diff --git a/src/common/string/format_number.ts b/src/common/string/format_number.ts new file mode 100644 index 0000000000..3dc345fbbc --- /dev/null +++ b/src/common/string/format_number.ts @@ -0,0 +1,54 @@ +/** + * Formats a number based on the specified language with thousands separator(s) and decimal character for better legibility. + * + * @param num The number to format + * @param language The language to use when formatting the number + */ +export const formatNumber = ( + num: string | number, + language: string, + options?: Intl.NumberFormatOptions +): string => { + // 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 (!Number.isNaN(Number(num)) && Intl) { + return new Intl.NumberFormat( + language, + getDefaultFormatOptions(num, options) + ).format(Number(num)); + } + return num.toString(); +}; + +/** + * Generates default options for Intl.NumberFormat + * @param num The number to be formatted + * @param options The Intl.NumberFormatOptions that should be included in the returned options + */ +const getDefaultFormatOptions = ( + num: string | number, + options?: Intl.NumberFormatOptions +): Intl.NumberFormatOptions => { + const defaultOptions: Intl.NumberFormatOptions = options || {}; + + if (typeof num !== "string") { + return defaultOptions; + } + + // Keep decimal trailing zeros if they are present in a string numeric value + if ( + !options || + (!options.minimumFractionDigits && !options.maximumFractionDigits) + ) { + const digits = num.indexOf(".") > -1 ? num.split(".")[1].length : 0; + defaultOptions.minimumFractionDigits = digits; + defaultOptions.maximumFractionDigits = digits; + } + + return defaultOptions; +}; diff --git a/src/common/string/number-format.ts b/src/common/string/number-format.ts deleted file mode 100644 index 39d20e47a5..0000000000 --- a/src/common/string/number-format.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Formats a number based on the specified language with thousands separator(s) and decimal character for better legibility. - * - * @param num The number to format - * @param language The language to use when formatting the number - */ -export const numberFormat = ( - num: string | number, - language: string -): string => { - // Polyfill for Number.isNaN, which is more reliable that the global isNaN() - Number.isNaN = - Number.isNaN || - function isNaN(input) { - return typeof input === "number" && isNaN(input); - }; - - if (!Number.isNaN(Number(num)) && Intl) { - return new Intl.NumberFormat(language).format(Number(num)); - } - return num.toString(); -}; diff --git a/src/components/entity/ha-state-label-badge.ts b/src/components/entity/ha-state-label-badge.ts index 61d68fc1c1..11a4761a2c 100644 --- a/src/components/entity/ha-state-label-badge.ts +++ b/src/components/entity/ha-state-label-badge.ts @@ -21,6 +21,7 @@ import { timerTimeRemaining } from "../../common/entity/timer_time_remaining"; import { HomeAssistant } from "../../types"; import "../ha-label-badge"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; +import { formatNumber } from "../../common/string/format_number"; @customElement("ha-state-label-badge") export class HaStateLabelBadge extends LitElement { @@ -115,7 +116,7 @@ export class HaStateLabelBadge extends LitElement { : state.state === UNKNOWN ? "-" : state.attributes.unit_of_measurement - ? state.state + ? formatNumber(state.state, this.hass!.language) : computeStateDisplay( this.hass!.localize, state, diff --git a/src/components/ha-climate-state.ts b/src/components/ha-climate-state.ts index 1aa49ae6b2..ba5e5844ec 100644 --- a/src/components/ha-climate-state.ts +++ b/src/components/ha-climate-state.ts @@ -11,6 +11,7 @@ import { HassEntity } from "home-assistant-js-websocket"; import { CLIMATE_PRESET_NONE } from "../data/climate"; import type { HomeAssistant } from "../types"; +import { formatNumber } from "../common/string/format_number"; @customElement("ha-climate-state") class HaClimateState extends LitElement { @@ -51,11 +52,17 @@ class HaClimateState extends LitElement { } if (this.stateObj.attributes.current_temperature != null) { - return `${this.stateObj.attributes.current_temperature} ${this.hass.config.unit_system.temperature}`; + return `${formatNumber( + this.stateObj.attributes.current_temperature, + this.hass!.language + )} ${this.hass.config.unit_system.temperature}`; } if (this.stateObj.attributes.current_humidity != null) { - return `${this.stateObj.attributes.current_humidity} %`; + return `${formatNumber( + this.stateObj.attributes.current_humidity, + this.hass!.language + )} %`; } return undefined; @@ -70,21 +77,39 @@ class HaClimateState extends LitElement { this.stateObj.attributes.target_temp_low != null && this.stateObj.attributes.target_temp_high != null ) { - return `${this.stateObj.attributes.target_temp_low}-${this.stateObj.attributes.target_temp_high} ${this.hass.config.unit_system.temperature}`; + return `${formatNumber( + this.stateObj.attributes.target_temp_low, + this.hass!.language + )}-${formatNumber( + this.stateObj.attributes.target_temp_high, + this.hass!.language + )} ${this.hass.config.unit_system.temperature}`; } if (this.stateObj.attributes.temperature != null) { - return `${this.stateObj.attributes.temperature} ${this.hass.config.unit_system.temperature}`; + return `${formatNumber( + this.stateObj.attributes.temperature, + this.hass!.language + )} ${this.hass.config.unit_system.temperature}`; } if ( this.stateObj.attributes.target_humidity_low != null && this.stateObj.attributes.target_humidity_high != null ) { - return `${this.stateObj.attributes.target_humidity_low}-${this.stateObj.attributes.target_humidity_high}%`; + return `${formatNumber( + this.stateObj.attributes.target_humidity_low, + this.hass!.language + )}-${formatNumber( + this.stateObj.attributes.target_humidity_high, + this.hass!.language + )}%`; } if (this.stateObj.attributes.humidity != null) { - return `${this.stateObj.attributes.humidity} %`; + return `${formatNumber( + this.stateObj.attributes.humidity, + this.hass!.language + )} %`; } return ""; diff --git a/src/components/ha-gauge.ts b/src/components/ha-gauge.ts index 5e1adbf351..249a5639a5 100644 --- a/src/components/ha-gauge.ts +++ b/src/components/ha-gauge.ts @@ -12,6 +12,7 @@ import { afterNextRender } from "../common/util/render-status"; import { ifDefined } from "lit-html/directives/if-defined"; import { getValueInPercentage, normalize } from "../util/calculate"; +import { formatNumber } from "../common/string/format_number"; const getAngle = (value: number, min: number, max: number) => { const percentage = getValueInPercentage(normalize(value, min, max), min, max); @@ -29,6 +30,8 @@ export class Gauge extends LitElement { @property({ type: Number }) public value = 0; + @property({ type: String }) public language = ""; + @property() public label = ""; @internalProperty() private _angle = 0; @@ -88,7 +91,7 @@ export class Gauge extends LitElement { - ${this.value} ${this.label} + ${formatNumber(this.value, this.language)} ${this.label} `; } diff --git a/src/data/weather.ts b/src/data/weather.ts index 16333d2d1e..3b7959bf3b 100644 --- a/src/data/weather.ts +++ b/src/data/weather.ts @@ -7,10 +7,10 @@ import { } from "@mdi/js"; import { css, html, svg, SVGTemplateResult, TemplateResult } from "lit-element"; import { styleMap } from "lit-html/directives/style-map"; +import { formatNumber } from "../common/string/format_number"; import "../components/ha-icon"; import "../components/ha-svg-icon"; import type { HomeAssistant, WeatherEntity } from "../types"; -import { roundWithOneDecimal } from "../util/calculate"; export const weatherSVGs = new Set([ "clear-night", @@ -106,15 +106,19 @@ export const getWind = ( speed: string, bearing: string ): string => { + const speedText = `${formatNumber(speed, hass!.language)} ${getWeatherUnit( + hass!, + "wind_speed" + )}`; if (bearing !== null) { const cardinalDirection = getWindBearing(bearing); - return `${speed} ${getWeatherUnit(hass!, "wind_speed")} (${ + return `${speedText} (${ hass.localize( `ui.card.weather.cardinal_direction.${cardinalDirection.toLowerCase()}` ) || cardinalDirection })`; } - return `${speed} ${getWeatherUnit(hass!, "wind_speed")}`; + return speedText; }; export const getWeatherUnit = ( @@ -175,7 +179,8 @@ export const getSecondaryWeatherAttribute = ( ` : hass!.localize(`ui.card.weather.attributes.${attribute}`)} - ${roundWithOneDecimal(value)} ${getWeatherUnit(hass!, attribute)} + ${formatNumber(value, hass!.language, { maximumFractionDigits: 1 })} + ${getWeatherUnit(hass!, attribute)} `; }; diff --git a/src/dialogs/more-info/controls/more-info-sun.ts b/src/dialogs/more-info/controls/more-info-sun.ts index 135ee13c53..80fa678ee4 100644 --- a/src/dialogs/more-info/controls/more-info-sun.ts +++ b/src/dialogs/more-info/controls/more-info-sun.ts @@ -9,6 +9,7 @@ import { TemplateResult, } from "lit-element"; import { formatTime } from "../../../common/datetime/format_time"; +import { formatNumber } from "../../../common/string/format_number"; import "../../../components/ha-relative-time"; import { HomeAssistant } from "../../../types"; @@ -59,7 +60,12 @@ class MoreInfoSun extends LitElement {
${this.hass.localize("ui.dialogs.more_info_control.sun.elevation")}
-
${this.stateObj.attributes.elevation}
+
+ ${formatNumber( + this.stateObj.attributes.elevation, + this.hass!.language + )} +
`; } diff --git a/src/dialogs/more-info/controls/more-info-weather.ts b/src/dialogs/more-info/controls/more-info-weather.ts index 7e962071a5..4d3c38ca90 100644 --- a/src/dialogs/more-info/controls/more-info-weather.ts +++ b/src/dialogs/more-info/controls/more-info-weather.ts @@ -34,6 +34,7 @@ import { mdiWeatherWindy, mdiWeatherWindyVariant, } from "@mdi/js"; +import { formatNumber } from "../../../common/string/format_number"; const weatherIcons = { "clear-night": mdiWeatherNight, @@ -88,7 +89,10 @@ class MoreInfoWeather extends LitElement { ${this.hass.localize("ui.card.weather.attributes.temperature")}
- ${this.stateObj.attributes.temperature} + ${formatNumber( + this.stateObj.attributes.temperature, + this.hass!.language + )} ${getWeatherUnit(this.hass, "temperature")}
@@ -100,7 +104,10 @@ class MoreInfoWeather extends LitElement { ${this.hass.localize("ui.card.weather.attributes.air_pressure")}
- ${this.stateObj.attributes.pressure} + ${formatNumber( + this.stateObj.attributes.pressure, + this.hass!.language + )} ${getWeatherUnit(this.hass, "air_pressure")}
@@ -113,7 +120,13 @@ class MoreInfoWeather extends LitElement {
${this.hass.localize("ui.card.weather.attributes.humidity")}
-
${this.stateObj.attributes.humidity} %
+
+ ${formatNumber( + this.stateObj.attributes.humidity, + this.hass!.language + )} + % +
` : ""} @@ -142,7 +155,10 @@ class MoreInfoWeather extends LitElement { ${this.hass.localize("ui.card.weather.attributes.visibility")}
- ${this.stateObj.attributes.visibility} + ${formatNumber( + this.stateObj.attributes.visibility, + this.hass!.language + )} ${getWeatherUnit(this.hass, "length")}
@@ -176,13 +192,13 @@ class MoreInfoWeather extends LitElement { ${this.computeDate(item.datetime)}
- ${item.templow} + ${formatNumber(item.templow, this.hass!.language)} ${getWeatherUnit(this.hass, "temperature")}
` : ""}
- ${item.temperature} + ${formatNumber(item.temperature, this.hass!.language)} ${getWeatherUnit(this.hass, "temperature")}
diff --git a/src/panels/lovelace/cards/hui-entity-card.ts b/src/panels/lovelace/cards/hui-entity-card.ts index 3cc2cdaf63..66b071c752 100644 --- a/src/panels/lovelace/cards/hui-entity-card.ts +++ b/src/panels/lovelace/cards/hui-entity-card.ts @@ -31,6 +31,7 @@ import { import { HuiErrorCard } from "./hui-error-card"; import { EntityCardConfig } from "./types"; import { computeCardSize } from "../common/compute-card-size"; +import { formatNumber } from "../../../common/string/format_number"; @customElement("hui-entity-card") export class HuiEntityCard extends LitElement implements LovelaceCard { @@ -128,7 +129,7 @@ export class HuiEntityCard extends LitElement implements LovelaceCard { ? stateObj.attributes[this._config.attribute!] ?? this.hass.localize("state.default.unknown") : stateObj.attributes.unit_of_measurement - ? stateObj.state + ? formatNumber(stateObj.state, this.hass!.language) : computeStateDisplay( this.hass.localize, stateObj, diff --git a/src/panels/lovelace/cards/hui-gauge-card.ts b/src/panels/lovelace/cards/hui-gauge-card.ts index 5c14283062..47be53b049 100644 --- a/src/panels/lovelace/cards/hui-gauge-card.ts +++ b/src/panels/lovelace/cards/hui-gauge-card.ts @@ -128,6 +128,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard { .min=${this._config.min!} .max=${this._config.max!} .value=${state} + .language=${this.hass!.language} .label=${this._config!.unit || this.hass?.states[this._config!.entity].attributes .unit_of_measurement || diff --git a/src/panels/lovelace/cards/hui-thermostat-card.ts b/src/panels/lovelace/cards/hui-thermostat-card.ts index 0f7935db30..d78318531f 100644 --- a/src/panels/lovelace/cards/hui-thermostat-card.ts +++ b/src/panels/lovelace/cards/hui-thermostat-card.ts @@ -19,6 +19,7 @@ import { UNIT_F } from "../../../common/const"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { fireEvent } from "../../../common/dom/fire_event"; import { computeStateName } from "../../../common/entity/compute_state_name"; +import { formatNumber } from "../../../common/string/format_number"; import "../../../components/ha-card"; import type { HaCard } from "../../../components/ha-card"; import "../../../components/ha-icon-button"; @@ -141,7 +142,10 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard { text-anchor="middle" style="font-size: 13px;" > - ${stateObj.attributes.current_temperature} + ${formatNumber( + stateObj.attributes.current_temperature, + this.hass!.language + )} ${this.hass.config.unit_system.temperature} @@ -162,19 +166,34 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard { : Array.isArray(this._setTemp) ? this._stepSize === 1 ? svg` - ${this._setTemp[0].toFixed()} - - ${this._setTemp[1].toFixed()} + ${formatNumber(this._setTemp[0], this.hass!.language, { + maximumFractionDigits: 0, + })} - + ${formatNumber(this._setTemp[1], this.hass!.language, { + maximumFractionDigits: 0, + })} ` : svg` - ${this._setTemp[0].toFixed(1)} - - ${this._setTemp[1].toFixed(1)} + ${formatNumber(this._setTemp[0], this.hass!.language, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + })} - + ${formatNumber(this._setTemp[1], this.hass!.language, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + })} ` : this._stepSize === 1 ? svg` - ${this._setTemp.toFixed()} + ${formatNumber(this._setTemp, this.hass!.language, { + maximumFractionDigits: 0, + })} ` : svg` - ${this._setTemp.toFixed(1)} + ${formatNumber(this._setTemp, this.hass!.language, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + })} ` } diff --git a/src/panels/lovelace/cards/hui-weather-forecast-card.ts b/src/panels/lovelace/cards/hui-weather-forecast-card.ts index 336842b4fa..e0425180b9 100644 --- a/src/panels/lovelace/cards/hui-weather-forecast-card.ts +++ b/src/panels/lovelace/cards/hui-weather-forecast-card.ts @@ -15,6 +15,7 @@ import { computeStateDisplay } from "../../../common/entity/compute_state_displa import { computeStateName } from "../../../common/entity/compute_state_name"; import { stateIcon } from "../../../common/entity/state_icon"; import { isValidEntityId } from "../../../common/entity/valid_entity_id"; +import { formatNumber } from "../../../common/string/format_number"; import { debounce } from "../../../common/util/debounce"; import "../../../components/ha-card"; import "../../../components/ha-icon"; @@ -214,9 +215,10 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
- ${stateObj.attributes.temperature}${getWeatherUnit(this.hass, "temperature")} + ${formatNumber( + stateObj.attributes.temperature, + this.hass!.language + )}${getWeatherUnit(this.hass, "temperature")}
${this._config.secondary_info_attribute !== undefined @@ -241,9 +243,12 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { stateObj.attributes.wind_bearing ) : html` - ${stateObj.attributes[ - this._config.secondary_info_attribute - ]} + ${formatNumber( + stateObj.attributes[ + this._config.secondary_info_attribute + ], + this.hass!.language + )} ${getWeatherUnit( this.hass, this._config.secondary_info_attribute @@ -307,14 +312,20 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { item.temperature !== null ? html`
- ${item.temperature}° + ${formatNumber( + item.temperature, + this.hass!.language + )}°
` : ""} ${item.templow !== undefined && item.templow !== null ? html`
- ${item.templow}° + ${formatNumber( + item.templow, + this.hass!.language + )}°
` : ""}