diff --git a/build-scripts/bundle.js b/build-scripts/bundle.js index dc4b59b02a..cd6e1ba854 100644 --- a/build-scripts/bundle.js +++ b/build-scripts/bundle.js @@ -5,8 +5,6 @@ const paths = require("./paths.js"); // Files from NPM Packages that should not be imported module.exports.ignorePackages = ({ latestBuild }) => [ - // Bloats bundle and it's not used. - path.resolve(require.resolve("moment"), "../locale"), // Part of yaml.js and only used for !!js functions that we don't use require.resolve("esprima"), ]; diff --git a/package.json b/package.json index 5e99d75f81..934e858b68 100644 --- a/package.json +++ b/package.json @@ -96,11 +96,11 @@ "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", "@vue/web-component-wrapper": "^1.2.0", "@webcomponents/webcomponentsjs": "^2.2.7", - "chart.js": "^2.9.4", - "chartjs-chart-timeline": "^0.4.0", + "chart.js": "^3.3.2", "comlink": "^4.3.1", "core-js": "^3.6.5", "cropperjs": "^1.5.11", + "date-fns": "^2.22.1", "deep-clone-simple": "^1.1.1", "deep-freeze": "^0.0.1", "fecha": "^4.2.0", diff --git a/src/common/color/colors.ts b/src/common/color/colors.ts new file mode 100644 index 0000000000..d70aa5ce6a --- /dev/null +++ b/src/common/color/colors.ts @@ -0,0 +1,63 @@ +export const COLORS = [ + "#377eb8", + "#984ea3", + "#00d2d5", + "#ff7f00", + "#af8d00", + "#7f80cd", + "#b3e900", + "#c42e60", + "#a65628", + "#f781bf", + "#8dd3c7", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#fccde5", + "#bc80bd", + "#ffed6f", + "#c4eaff", + "#cf8c00", + "#1b9e77", + "#d95f02", + "#e7298a", + "#e6ab02", + "#a6761d", + "#0097ff", + "#00d067", + "#f43600", + "#4ba93b", + "#5779bb", + "#927acc", + "#97ee3f", + "#bf3947", + "#9f5b00", + "#f48758", + "#8caed6", + "#f2b94f", + "#eff26e", + "#e43872", + "#d9b100", + "#9d7a00", + "#698cff", + "#d9d9d9", + "#00d27e", + "#d06800", + "#009f82", + "#c49200", + "#cbe8ff", + "#fecddf", + "#c27eb6", + "#8cd2ce", + "#c4b8d9", + "#f883b0", + "#a49100", + "#f48800", + "#27d0df", + "#a04a9b", +]; + +export function getColorByIndex(index: number) { + return COLORS[index % COLORS.length]; +} diff --git a/src/common/color/rgb.ts b/src/common/color/rgb.ts index 54b69408dc..8e83eab6a0 100644 --- a/src/common/color/rgb.ts +++ b/src/common/color/rgb.ts @@ -1,4 +1,4 @@ -const luminosity = (rgb: [number, number, number]): number => { +export const luminosity = (rgb: [number, number, number]): number => { // http://www.w3.org/TR/WCAG20/#relativeluminancedef const lum: [number, number, number] = [0, 0, 0]; for (let i = 0; i < rgb.length; i++) { diff --git a/src/common/datetime/format_date.ts b/src/common/datetime/format_date.ts index fa82187a46..e9b70430a0 100644 --- a/src/common/datetime/format_date.ts +++ b/src/common/datetime/format_date.ts @@ -17,6 +17,19 @@ export const formatDate = toLocaleDateStringSupportsOptions formatDateMem(locale).format(dateObj) : (dateObj: Date) => format(dateObj, "longDate"); +const formatDateShortMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + day: "numeric", + month: "short", + }) +); + +export const formatDateShort = toLocaleDateStringSupportsOptions + ? (dateObj: Date, locale: FrontendLocaleData) => + formatDateShortMem(locale).format(dateObj) + : (dateObj: Date) => format(dateObj, "shortDate"); + const formatDateWeekdayMem = memoizeOne( (locale: FrontendLocaleData) => new Intl.DateTimeFormat(locale.language, { diff --git a/src/common/number/clamp.ts b/src/common/number/clamp.ts new file mode 100644 index 0000000000..4368d20add --- /dev/null +++ b/src/common/number/clamp.ts @@ -0,0 +1,2 @@ +export const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); diff --git a/src/common/style/icon_color_css.ts b/src/common/style/icon_color_css.ts index fbc663653a..b636ffa89c 100644 --- a/src/common/style/icon_color_css.ts +++ b/src/common/style/icon_color_css.ts @@ -29,31 +29,28 @@ export const iconColorCSS = css` } ha-icon[data-domain="climate"][data-state="cooling"] { - color: var(--cool-color, #2b9af9); + color: var(--cool-color, var(--state-climate-cool-color)); } ha-icon[data-domain="climate"][data-state="heating"] { - color: var(--heat-color, #ff8100); + color: var(--heat-color, var(--state-climate-heat-color)); } ha-icon[data-domain="climate"][data-state="drying"] { - color: var(--dry-color, #efbd07); + color: var(--dry-color, var(--state-climate-dry-color)); } ha-icon[data-domain="alarm_control_panel"] { color: var(--alarm-color-armed, var(--label-badge-red)); } - ha-icon[data-domain="alarm_control_panel"][data-state="disarmed"] { color: var(--alarm-color-disarmed, var(--label-badge-green)); } - ha-icon[data-domain="alarm_control_panel"][data-state="pending"], ha-icon[data-domain="alarm_control_panel"][data-state="arming"] { color: var(--alarm-color-pending, var(--label-badge-yellow)); animation: pulse 1s infinite; } - ha-icon[data-domain="alarm_control_panel"][data-state="triggered"] { color: var(--alarm-color-triggered, var(--label-badge-red)); animation: pulse 1s infinite; @@ -73,11 +70,11 @@ export const iconColorCSS = css` ha-icon[data-domain="plant"][data-state="problem"], ha-icon[data-domain="zwave"][data-state="dead"] { - color: var(--error-state-color, #db4437); + color: var(--state-icon-error-color); } /* Color the icon if unavailable */ ha-icon[data-state="unavailable"] { - color: var(--state-icon-unavailable-color); + color: var(--state-unavailable-color); } `; diff --git a/src/common/util/throttle.ts b/src/common/util/throttle.ts index 4832a2709b..2860f66be5 100644 --- a/src/common/util/throttle.ts +++ b/src/common/util/throttle.ts @@ -5,32 +5,20 @@ // as much as it can, without ever going more than once per `wait` duration; // but if you'd like to disable the execution on the leading edge, pass // `false for leading`. To disable execution on the trailing edge, ditto. -export const throttle = unknown>( - func: T, +export const throttle = ( + func: (...args: T) => void, wait: number, leading = true, trailing = true -): T => { +) => { let timeout: number | undefined; let previous = 0; - let context: any; - let args: any; - const later = () => { - previous = leading === false ? 0 : Date.now(); - timeout = undefined; - func.apply(context, args); - if (!timeout) { - context = null; - args = null; - } - }; - // @ts-ignore - return function (...argmnts) { - // @ts-ignore - // @typescript-eslint/no-this-alias - context = this; - args = argmnts; - + return (...args: T): void => { + const later = () => { + previous = leading === false ? 0 : Date.now(); + timeout = undefined; + func(...args); + }; const now = Date.now(); if (!previous && leading === false) { previous = now; @@ -42,7 +30,7 @@ export const throttle = unknown>( timeout = undefined; } previous = now; - func.apply(context, args); + func(...args); } else if (!timeout && trailing !== false) { timeout = window.setTimeout(later, remaining); } diff --git a/src/components/chart/chart-date-adapter.ts b/src/components/chart/chart-date-adapter.ts new file mode 100644 index 0000000000..a25163b662 --- /dev/null +++ b/src/components/chart/chart-date-adapter.ts @@ -0,0 +1,197 @@ +import { _adapters } from "chart.js"; +import { + startOfSecond, + startOfMinute, + startOfHour, + startOfDay, + startOfWeek, + startOfMonth, + startOfQuarter, + startOfYear, + addMilliseconds, + addSeconds, + addMinutes, + addHours, + addDays, + addWeeks, + addMonths, + addQuarters, + addYears, + differenceInMilliseconds, + differenceInSeconds, + differenceInMinutes, + differenceInHours, + differenceInDays, + differenceInWeeks, + differenceInMonths, + differenceInQuarters, + differenceInYears, + endOfSecond, + endOfMinute, + endOfHour, + endOfDay, + endOfWeek, + endOfMonth, + endOfQuarter, + endOfYear, +} from "date-fns"; +import { formatDate, formatDateShort } from "../../common/datetime/format_date"; +import { + formatDateTime, + formatDateTimeWithSeconds, +} from "../../common/datetime/format_date_time"; +import { + formatTime, + formatTimeWithSeconds, +} from "../../common/datetime/format_time"; + +const FORMATS = { + datetime: "datetime", + datetimeseconds: "datetimeseconds", + millisecond: "millisecond", + second: "second", + minute: "minute", + hour: "hour", + day: "day", + week: "week", + month: "month", + quarter: "quarter", + year: "year", +}; + +_adapters._date.override({ + formats: () => FORMATS, + parse: (value: Date | number) => { + if (!(value instanceof Date)) { + return value; + } + return value.getTime(); + }, + format: function (time, fmt: keyof typeof FORMATS) { + switch (fmt) { + case "datetime": + return formatDateTime(new Date(time), this.options.locale); + case "datetimeseconds": + return formatDateTimeWithSeconds(new Date(time), this.options.locale); + case "millisecond": + return formatTimeWithSeconds(new Date(time), this.options.locale); + case "second": + return formatTimeWithSeconds(new Date(time), this.options.locale); + case "minute": + return formatTime(new Date(time), this.options.locale); + case "hour": + return formatTime(new Date(time), this.options.locale); + case "day": + return formatDateShort(new Date(time), this.options.locale); + case "week": + return formatDate(new Date(time), this.options.locale); + case "month": + return formatDate(new Date(time), this.options.locale); + case "quarter": + return formatDate(new Date(time), this.options.locale); + case "year": + return formatDate(new Date(time), this.options.locale); + default: + return ""; + } + }, + // @ts-ignore + add: (time, amount, unit) => { + switch (unit) { + case "millisecond": + return addMilliseconds(time, amount); + case "second": + return addSeconds(time, amount); + case "minute": + return addMinutes(time, amount); + case "hour": + return addHours(time, amount); + case "day": + return addDays(time, amount); + case "week": + return addWeeks(time, amount); + case "month": + return addMonths(time, amount); + case "quarter": + return addQuarters(time, amount); + case "year": + return addYears(time, amount); + default: + return time; + } + }, + diff: (max, min, unit) => { + switch (unit) { + case "millisecond": + return differenceInMilliseconds(max, min); + case "second": + return differenceInSeconds(max, min); + case "minute": + return differenceInMinutes(max, min); + case "hour": + return differenceInHours(max, min); + case "day": + return differenceInDays(max, min); + case "week": + return differenceInWeeks(max, min); + case "month": + return differenceInMonths(max, min); + case "quarter": + return differenceInQuarters(max, min); + case "year": + return differenceInYears(max, min); + default: + return 0; + } + }, + // @ts-ignore + startOf: (time, unit, weekday) => { + switch (unit) { + case "second": + return startOfSecond(time); + case "minute": + return startOfMinute(time); + case "hour": + return startOfHour(time); + case "day": + return startOfDay(time); + case "week": + return startOfWeek(time); + case "isoWeek": + return startOfWeek(time, { + weekStartsOn: +weekday! as 0 | 1 | 2 | 3 | 4 | 5 | 6, + }); + case "month": + return startOfMonth(time); + case "quarter": + return startOfQuarter(time); + case "year": + return startOfYear(time); + default: + return time; + } + }, + // @ts-ignore + endOf: (time, unit) => { + switch (unit) { + case "second": + return endOfSecond(time); + case "minute": + return endOfMinute(time); + case "hour": + return endOfHour(time); + case "day": + return endOfDay(time); + case "week": + return endOfWeek(time); + case "month": + return endOfMonth(time); + case "quarter": + return endOfQuarter(time); + case "year": + return endOfYear(time); + default: + return time; + } + }, +}); diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts new file mode 100644 index 0000000000..a976083c34 --- /dev/null +++ b/src/components/chart/ha-chart-base.ts @@ -0,0 +1,229 @@ +import type { + Chart, + ChartType, + ChartData, + ChartOptions, + TooltipModel, +} from "chart.js"; +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { styleMap } from "lit/directives/style-map"; +import { clamp } from "../../common/number/clamp"; + +@customElement("ha-chart-base") +export default class HaChartBase extends LitElement { + public chart?: Chart; + + @property() + public chartType: ChartType = "line"; + + @property({ attribute: false }) + public data: ChartData = { datasets: [] }; + + @property({ attribute: false }) + public options?: ChartOptions; + + @state() private _tooltip?: TooltipModel; + + @state() private _height?: string; + + protected firstUpdated() { + this._setupChart(); + } + + public willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + + if (!this.hasUpdated || !this.chart) { + return; + } + + if (changedProps.has("type")) { + this.chart.config.type = this.chartType; + } + + if (changedProps.has("data")) { + this.chart.data = this.data; + } + if (changedProps.has("options")) { + this.chart.options = this._createOptions(); + } + this.chart.update("none"); + } + + protected render() { + return html` +
+ + ${this._tooltip + ? html`
+
${this._tooltip.title}
+ ${this._tooltip.beforeBody + ? html`
+ ${this._tooltip.beforeBody} +
` + : ""} +
+
    + ${this._tooltip.body.map( + (item, i) => html`
  • + ${item.lines.join("\n")} +
  • ` + )} +
+
+
` + : ""} +
+ `; + } + + private async _setupChart() { + const ctx: CanvasRenderingContext2D = this.renderRoot + .querySelector("canvas")! + .getContext("2d")!; + + this.chart = new (await import("../../resources/chartjs")).Chart(ctx, { + type: this.chartType, + data: this.data, + options: this._createOptions(), + plugins: [ + { + id: "afterRenderHook", + afterRender: (chart) => { + this._height = `${chart.height}px`; + }, + }, + ], + }); + } + + private _createOptions() { + return { + ...this.options, + plugins: { + ...this.options?.plugins, + tooltip: { + ...this.options?.plugins?.tooltip, + enabled: false, + external: (context) => this._handleTooltip(context), + }, + }, + }; + } + + private _handleTooltip(context: { + chart: Chart; + tooltip: TooltipModel; + }) { + if (context.tooltip.opacity === 0) { + this._tooltip = undefined; + return; + } + this._tooltip = { ...context.tooltip }; + } + + public updateChart = (): void => { + if (this.chart) { + this.chart.update(); + } + }; + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + } + .chartContainer { + position: relative; + overflow: hidden; + height: 0; + transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .chartTooltip { + padding: 4px; + font-size: 90%; + position: absolute; + background: rgba(80, 80, 80, 0.9); + color: white; + border-radius: 4px; + pointer-events: none; + transform: translate(-50%, 12px); + z-index: 1000; + width: 200px; + transition: opacity 0.15s ease-in-out; + } + :host([rtl]) .chartTooltip { + direction: rtl; + } + .chartTooltip ul { + display: inline-block; + padding: 0 0px; + margin: 5px 0 0 0; + width: 100%; + } + .chartTooltip ul { + margin: 0 3px; + } + .chartTooltip li { + display: block; + white-space: pre-line; + } + .chartTooltip li::first-line { + line-height: 0; + } + .chartTooltip .title { + text-align: center; + font-weight: 500; + } + .chartTooltip .beforeBody { + text-align: center; + font-weight: 300; + word-break: break-all; + } + .chartTooltip em { + border-radius: 4px; + display: inline-block; + height: 10px; + margin-right: 4px; + width: 10px; + } + :host([rtl]) .chartTooltip em { + margin-right: inherit; + margin-left: 4px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-chart-base": HaChartBase; + } +} diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts new file mode 100644 index 0000000000..24733ce3f4 --- /dev/null +++ b/src/components/chart/state-history-chart-line.ts @@ -0,0 +1,392 @@ +import type { ChartData, ChartDataset, ChartOptions } from "chart.js"; +import { html, LitElement, PropertyValues } from "lit"; +import { property, state } from "lit/decorators"; +import { getColorByIndex } from "../../common/color/colors"; +import { LineChartEntity, LineChartState } from "../../data/history"; +import { HomeAssistant } from "../../types"; +import "./ha-chart-base"; + +class StateHistoryChartLine extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public data: LineChartEntity[] = []; + + @property({ type: Boolean }) public names = false; + + @property() public unit?: string; + + @property() public identifier?: string; + + @property({ type: Boolean }) public isSingleDevice = false; + + @property({ attribute: false }) public endTime?: Date; + + @state() private _chartData?: ChartData<"line">; + + @state() private _chartOptions?: ChartOptions<"line">; + + protected render() { + return html` + + `; + } + + public willUpdate(changedProps: PropertyValues) { + if (!this.hasUpdated) { + this._chartOptions = { + parsing: false, + animation: false, + scales: { + x: { + type: "time", + adapters: { + date: { + locale: this.hass.locale, + }, + }, + ticks: { + maxRotation: 0, + sampleSize: 5, + autoSkipPadding: 20, + major: { + enabled: true, + }, + font: (context) => + context.tick && context.tick.major + ? ({ weight: "bold" } as any) + : {}, + }, + time: { + tooltipFormat: "datetimeseconds", + }, + }, + y: { + ticks: { + maxTicksLimit: 7, + }, + title: { + display: true, + text: this.unit, + }, + }, + }, + plugins: { + tooltip: { + mode: "nearest", + callbacks: { + label: (context) => + `${context.dataset.label}: ${context.parsed.y} ${this.unit}`, + }, + }, + filler: { + propagate: true, + }, + legend: { + display: !this.isSingleDevice, + labels: { + usePointStyle: true, + }, + }, + }, + hover: { + mode: "nearest", + }, + elements: { + line: { + tension: 0.1, + borderWidth: 1.5, + }, + point: { + hitRadius: 5, + }, + }, + }; + } + if (changedProps.has("data")) { + this._generateData(); + } + } + + private _generateData() { + let colorIndex = 0; + const computedStyles = getComputedStyle(this); + const deviceStates = this.data; + const datasets: ChartDataset<"line">[] = []; + let endTime: Date; + + if (deviceStates.length === 0) { + return; + } + + function safeParseFloat(value) { + const parsed = parseFloat(value); + return isFinite(parsed) ? parsed : null; + } + + endTime = + this.endTime || + // Get the highest date from the last date of each device + new Date( + Math.max.apply( + null, + deviceStates.map((devSts) => + new Date( + devSts.states[devSts.states.length - 1].last_changed + ).getMilliseconds() + ) + ) + ); + if (endTime > new Date()) { + endTime = new Date(); + } + + const names = this.names || {}; + deviceStates.forEach((states) => { + const domain = states.domain; + const name = names[states.entity_id] || states.name; + // array containing [value1, value2, etc] + let prevValues: any[] | null = null; + + const data: ChartDataset<"line">[] = []; + + const pushData = (timestamp: Date, datavalues: any[] | null) => { + if (!datavalues) return; + if (timestamp > endTime) { + // Drop datapoints that are after the requested endTime. This could happen if + // endTime is "now" and client time is not in sync with server time. + return; + } + data.forEach((d, i) => { + if (datavalues[i] === null && prevValues && prevValues[i] !== null) { + // null data values show up as gaps in the chart. + // If the current value for the dataset is null and the previous + // value of the data set is not null, then add an 'end' point + // to the chart for the previous value. Otherwise the gap will + // be too big. It will go from the start of the previous data + // value until the start of the next data value. + d.data.push({ x: timestamp.getTime(), y: prevValues[i] }); + } + d.data.push({ x: timestamp.getTime(), y: datavalues[i] }); + }); + prevValues = datavalues; + }; + + const addDataSet = ( + nameY: string, + step = false, + fill = false, + color?: string + ) => { + if (!color) { + color = getColorByIndex(colorIndex); + colorIndex++; + } + data.push({ + label: nameY, + fill: fill ? "origin" : false, + borderColor: color, + backgroundColor: color + "7F", + stepped: step ? "before" : false, + pointRadius: 0, + data: [], + }); + }; + + if ( + domain === "thermostat" || + domain === "climate" || + domain === "water_heater" + ) { + const hasHvacAction = states.states.some( + (entityState) => entityState.attributes?.hvac_action + ); + + const isHeating = + domain === "climate" && hasHvacAction + ? (entityState: LineChartState) => + entityState.attributes?.hvac_action === "heating" + : (entityState: LineChartState) => entityState.state === "heat"; + const isCooling = + domain === "climate" && hasHvacAction + ? (entityState: LineChartState) => + entityState.attributes?.hvac_action === "cooling" + : (entityState: LineChartState) => entityState.state === "cool"; + + const hasHeat = states.states.some(isHeating); + const hasCool = states.states.some(isCooling); + // We differentiate between thermostats that have a target temperature + // range versus ones that have just a target temperature + + // Using step chart by step-before so manually interpolation not needed. + const hasTargetRange = states.states.some( + (entityState) => + entityState.attributes && + entityState.attributes.target_temp_high !== + entityState.attributes.target_temp_low + ); + addDataSet( + `${this.hass.localize("ui.card.climate.current_temperature", { + name: name, + })}`, + true + ); + if (hasHeat) { + addDataSet( + `${this.hass.localize("ui.card.climate.heating", { name: name })}`, + true, + true, + computedStyles.getPropertyValue("--state-climate-heat-color") + ); + // The "heating" series uses steppedArea to shade the area below the current + // temperature when the thermostat is calling for heat. + } + if (hasCool) { + addDataSet( + `${this.hass.localize("ui.card.climate.cooling", { name: name })}`, + true, + true, + computedStyles.getPropertyValue("--state-climate-cool-color") + ); + // The "cooling" series uses steppedArea to shade the area below the current + // temperature when the thermostat is calling for heat. + } + + if (hasTargetRange) { + addDataSet( + `${this.hass.localize("ui.card.climate.target_temperature_mode", { + name: name, + mode: this.hass.localize("ui.card.climate.high"), + })}`, + true + ); + addDataSet( + `${this.hass.localize("ui.card.climate.target_temperature_mode", { + name: name, + mode: this.hass.localize("ui.card.climate.low"), + })}`, + true + ); + } else { + addDataSet( + `${this.hass.localize("ui.card.climate.target_temperature_entity", { + name: name, + })}`, + true + ); + } + + states.states.forEach((entityState) => { + if (!entityState.attributes) return; + const curTemp = safeParseFloat( + entityState.attributes.current_temperature + ); + const series = [curTemp]; + if (hasHeat) { + series.push(isHeating(entityState) ? curTemp : null); + } + if (hasCool) { + series.push(isCooling(entityState) ? curTemp : null); + } + if (hasTargetRange) { + const targetHigh = safeParseFloat( + entityState.attributes.target_temp_high + ); + const targetLow = safeParseFloat( + entityState.attributes.target_temp_low + ); + series.push(targetHigh, targetLow); + pushData(new Date(entityState.last_changed), series); + } else { + const target = safeParseFloat(entityState.attributes.temperature); + series.push(target); + pushData(new Date(entityState.last_changed), series); + } + }); + } else if (domain === "humidifier") { + addDataSet( + `${this.hass.localize("ui.card.humidifier.target_humidity_entity", { + name: name, + })}`, + true + ); + addDataSet( + `${this.hass.localize("ui.card.humidifier.on_entity", { + name: name, + })}`, + true, + true + ); + + states.states.forEach((entityState) => { + if (!entityState.attributes) return; + const target = safeParseFloat(entityState.attributes.humidity); + const series = [target]; + series.push(entityState.state === "on" ? target : null); + pushData(new Date(entityState.last_changed), series); + }); + } else { + // Only disable interpolation for sensors + const isStep = domain === "sensor"; + addDataSet(name, isStep); + + let lastValue: number; + let lastDate: Date; + let lastNullDate: Date | null = null; + + // Process chart data. + // When state is `unknown`, calculate the value and break the line. + states.states.forEach((entityState) => { + const value = safeParseFloat(entityState.state); + const date = new Date(entityState.last_changed); + if (value !== null && lastNullDate) { + const dateTime = date.getTime(); + const lastNullDateTime = lastNullDate.getTime(); + const lastDateTime = lastDate?.getTime(); + const tmpValue = + (value - lastValue) * + ((lastNullDateTime - lastDateTime) / + (dateTime - lastDateTime)) + + lastValue; + pushData(lastNullDate, [tmpValue]); + pushData(new Date(lastNullDateTime + 1), [null]); + pushData(date, [value]); + lastDate = date; + lastValue = value; + lastNullDate = null; + } else if (value !== null && lastNullDate === null) { + pushData(date, [value]); + lastDate = date; + lastValue = value; + } else if ( + value === null && + lastNullDate === null && + lastValue !== undefined + ) { + lastNullDate = date; + } + }); + } + + // Add an entry for final values + pushData(endTime, prevValues); + + // Concat two arrays + Array.prototype.push.apply(datasets, data); + }); + + this._chartData = { + datasets, + }; + } +} +customElements.define("state-history-chart-line", StateHistoryChartLine); + +declare global { + interface HTMLElementTagNameMap { + "state-history-chart-line": StateHistoryChartLine; + } +} diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts new file mode 100644 index 0000000000..ee2fc7d007 --- /dev/null +++ b/src/components/chart/state-history-chart-timeline.ts @@ -0,0 +1,310 @@ +import type { ChartData, ChartDataset, ChartOptions } from "chart.js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { getColorByIndex } from "../../common/color/colors"; +import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { computeRTL } from "../../common/util/compute_rtl"; +import { TimelineEntity } from "../../data/history"; +import { HomeAssistant } from "../../types"; +import "./ha-chart-base"; +import type { TimeLineData } from "./timeline-chart/const"; + +/** Binary sensor device classes for which the static colors for on/off need to be inverted. + * List the ones were "off" = good or normal state = should be rendered "green". + */ +const BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED = new Set([ + "battery", + "door", + "garage_door", + "gas", + "lock", + "opening", + "problem", + "safety", + "smoke", + "window", +]); + +const STATIC_STATE_COLORS = new Set([ + "on", + "off", + "home", + "not_home", + "unavailable", + "unknown", + "idle", +]); + +const stateColorMap: Map = new Map(); + +let colorIndex = 0; + +const invertOnOff = (entityState?: HassEntity) => + entityState && + computeDomain(entityState.entity_id) === "binary_sensor" && + "device_class" in entityState.attributes && + BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED.has( + entityState.attributes.device_class! + ); + +const getColor = ( + stateString: string, + entityState: HassEntity, + computedStyles: CSSStyleDeclaration +) => { + if (invertOnOff(entityState)) { + stateString = stateString === "on" ? "off" : "on"; + } + if (stateColorMap.has(stateString)) { + return stateColorMap.get(stateString); + } + if (STATIC_STATE_COLORS.has(stateString)) { + const color = computedStyles.getPropertyValue( + `--state-${stateString}-color` + ); + stateColorMap.set(stateString, color); + return color; + } + const color = getColorByIndex(colorIndex); + colorIndex++; + stateColorMap.set(stateString, color); + return color; +}; + +@customElement("state-history-chart-timeline") +export class StateHistoryChartTimeline extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public data: TimelineEntity[] = []; + + @property({ type: Boolean }) public names = false; + + @property() public unit?: string; + + @property() public identifier?: string; + + @property({ type: Boolean }) public isSingleDevice = false; + + @property({ attribute: false }) public endTime?: Date; + + @state() private _chartData?: ChartData<"timeline">; + + @state() private _chartOptions?: ChartOptions<"timeline">; + + protected render() { + return html` + + `; + } + + public willUpdate(changedProps: PropertyValues) { + if (!this.hasUpdated) { + this._chartOptions = { + maintainAspectRatio: false, + parsing: false, + animation: false, + scales: { + x: { + type: "timeline", + position: "bottom", + adapters: { + date: { + locale: this.hass.locale, + }, + }, + ticks: { + autoSkip: true, + maxRotation: 0, + sampleSize: 5, + autoSkipPadding: 20, + major: { + enabled: true, + }, + font: (context) => + context.tick && context.tick.major + ? ({ weight: "bold" } as any) + : {}, + }, + grid: { + offset: false, + }, + time: { + tooltipFormat: "datetimeseconds", + }, + }, + y: { + type: "category", + barThickness: 20, + offset: true, + grid: { + display: false, + drawBorder: false, + drawTicks: false, + }, + ticks: { + display: this.data.length !== 1, + }, + afterSetDimensions: (y) => { + y.maxWidth = y.chart.width * 0.18; + }, + position: computeRTL(this.hass) ? "right" : "left", + }, + }, + plugins: { + tooltip: { + mode: "nearest", + callbacks: { + title: (context) => + context![0].chart!.data!.labels![ + context[0].datasetIndex + ] as string, + beforeBody: (context) => context[0].dataset.label || "", + label: (item) => { + const d = item.dataset.data[item.dataIndex] as TimeLineData; + return [ + d.label || "", + formatDateTimeWithSeconds(d.start, this.hass.locale), + formatDateTimeWithSeconds(d.end, this.hass.locale), + ]; + }, + labelColor: (item) => ({ + borderColor: (item.dataset.data[item.dataIndex] as TimeLineData) + .color!, + backgroundColor: (item.dataset.data[ + item.dataIndex + ] as TimeLineData).color!, + }), + }, + }, + filler: { + propagate: true, + }, + }, + }; + } + if (changedProps.has("data")) { + this._generateData(); + } + } + + private _generateData() { + const computedStyles = getComputedStyle(this); + let stateHistory = this.data; + + if (!stateHistory) { + stateHistory = []; + } + + const startTime = new Date( + stateHistory.reduce( + (minTime, stateInfo) => + Math.min(minTime, new Date(stateInfo.data[0].last_changed).getTime()), + new Date().getTime() + ) + ); + + // end time is Math.max(startTime, last_event) + let endTime = + this.endTime || + new Date( + stateHistory.reduce( + (maxTime, stateInfo) => + Math.max( + maxTime, + new Date( + stateInfo.data[stateInfo.data.length - 1].last_changed + ).getTime() + ), + startTime.getTime() + ) + ); + + if (endTime > new Date()) { + endTime = new Date(); + } + + const labels: string[] = []; + const datasets: ChartDataset<"timeline">[] = []; + const names = this.names || {}; + // stateHistory is a list of lists of sorted state objects + stateHistory.forEach((stateInfo) => { + let newLastChanged: Date; + let prevState: string | null = null; + let locState: string | null = null; + let prevLastChanged = startTime; + const entityDisplay: string = + names[stateInfo.entity_id] || stateInfo.name; + + const dataRow: TimeLineData[] = []; + stateInfo.data.forEach((entityState) => { + let newState: string | null = entityState.state; + const timeStamp = new Date(entityState.last_changed); + if (!newState) { + newState = null; + } + if (timeStamp > endTime) { + // Drop datapoints that are after the requested endTime. This could happen if + // endTime is 'now' and client time is not in sync with server time. + return; + } + if (prevState === null) { + prevState = newState; + locState = entityState.state_localize; + prevLastChanged = new Date(entityState.last_changed); + } else if (newState !== prevState) { + newLastChanged = new Date(entityState.last_changed); + + dataRow.push({ + start: prevLastChanged, + end: newLastChanged, + label: locState, + color: getColor( + prevState, + this.hass.states[stateInfo.entity_id], + computedStyles + ), + }); + + prevState = newState; + locState = entityState.state_localize; + prevLastChanged = newLastChanged; + } + }); + + if (prevState !== null) { + dataRow.push({ + start: prevLastChanged, + end: endTime, + label: locState, + color: getColor( + prevState, + this.hass.states[stateInfo.entity_id], + computedStyles + ), + }); + } + datasets.push({ + data: dataRow, + label: stateInfo.entity_id, + }); + labels.push(entityDisplay); + }); + + this._chartData = { + labels: labels, + datasets: datasets, + }; + } +} + +declare global { + interface HTMLElementTagNameMap { + "state-history-chart-timeline": StateHistoryChartTimeline; + } +} diff --git a/src/components/state-history-charts.ts b/src/components/chart/state-history-charts.ts similarity index 88% rename from src/components/state-history-charts.ts rename to src/components/chart/state-history-charts.ts index 9be40220da..c31559c838 100644 --- a/src/components/state-history-charts.ts +++ b/src/components/chart/state-history-charts.ts @@ -7,10 +7,10 @@ import { TemplateResult, } from "lit"; import { customElement, property } from "lit/decorators"; -import { isComponentLoaded } from "../common/config/is_component_loaded"; -import { HistoryResult } from "../data/history"; -import type { HomeAssistant } from "../types"; -import "./ha-circular-progress"; +import { isComponentLoaded } from "../../common/config/is_component_loaded"; +import { HistoryResult } from "../../data/history"; +import type { HomeAssistant } from "../../types"; +import "../ha-circular-progress"; import "./state-history-chart-line"; import "./state-history-chart-timeline"; @@ -24,7 +24,7 @@ class StateHistoryCharts extends LitElement { @property({ attribute: false }) public endTime?: Date; - @property({ type: Boolean }) public upToNow = false; + @property({ type: Boolean, attribute: "up-to-now" }) public upToNow = false; @property({ type: Boolean, attribute: "no-single" }) public noSingle = false; @@ -101,12 +101,12 @@ class StateHistoryCharts extends LitElement { return css` :host { display: block; - /* height of single timeline chart = 58px */ - min-height: 58px; + /* height of single timeline chart = 60px */ + min-height: 60px; } .info { text-align: center; - line-height: 58px; + line-height: 60px; color: var(--secondary-text-color); } `; diff --git a/src/components/chart/timeline-chart/const.ts b/src/components/chart/timeline-chart/const.ts new file mode 100644 index 0000000000..ac5f234272 --- /dev/null +++ b/src/components/chart/timeline-chart/const.ts @@ -0,0 +1,18 @@ +export interface TimeLineData { + start: Date; + end: Date; + label?: string | null; + color?: string; +} + +declare module "chart.js" { + interface ChartTypeRegistry { + timeline: { + chartOptions: BarControllerChartOptions; + datasetOptions: BarControllerDatasetOptions; + defaultDataPoint: TimeLineData; + parsedDataType: any; + scales: "timeline"; + }; + } +} diff --git a/src/components/chart/timeline-chart/textbar-element.ts b/src/components/chart/timeline-chart/textbar-element.ts new file mode 100644 index 0000000000..1348021b0e --- /dev/null +++ b/src/components/chart/timeline-chart/textbar-element.ts @@ -0,0 +1,60 @@ +import { BarElement, BarOptions, BarProps } from "chart.js"; +import { hex2rgb } from "../../../common/color/convert-color"; +import { luminosity } from "../../../common/color/rgb"; + +export interface TextBarProps extends BarProps { + text?: string | null; + options?: Partial; +} + +export interface TextBaroptions extends BarOptions { + textPad?: number; + textColor?: string; + backgroundColor: string; +} + +export class TextBarElement extends BarElement { + static id = "textbar"; + + draw(ctx) { + super.draw(ctx); + const options = this.options as TextBaroptions; + const { x, y, base, width, text } = (this as BarElement< + TextBarProps, + TextBaroptions + >).getProps(["x", "y", "base", "width", "text"]); + + if (!text) { + return; + } + + ctx.beginPath(); + const textRect = ctx.measureText(text); + if ( + textRect.width === 0 || + textRect.width + (options.textPad || 4) + 2 > width + ) { + return; + } + const textColor = + options.textColor || + (options.backgroundColor && + (luminosity(hex2rgb(options.backgroundColor)) > 0.5 ? "#000" : "#fff")); + + // ctx.font = "12px arial"; + ctx.fillStyle = textColor; + ctx.lineWidth = 0; + ctx.strokeStyle = textColor; + ctx.textBaseline = "middle"; + ctx.fillText( + text, + x - width / 2 + (options.textPad || 4), + y + (base - y) / 2 + ); + } + + tooltipPosition(useFinalPosition: boolean) { + const { x, y, base } = this.getProps(["x", "y", "base"], useFinalPosition); + return { x, y: y + (base - y) / 2 }; + } +} diff --git a/src/components/chart/timeline-chart/timeline-controller.ts b/src/components/chart/timeline-chart/timeline-controller.ts new file mode 100644 index 0000000000..6b6ce7c41b --- /dev/null +++ b/src/components/chart/timeline-chart/timeline-controller.ts @@ -0,0 +1,160 @@ +import { BarController, BarElement } from "chart.js"; +import { TimeLineData } from "./const"; +import { TextBarProps } from "./textbar-element"; + +function parseValue(entry, item, vScale, i) { + const startValue = vScale.parse(entry.start, i); + const endValue = vScale.parse(entry.end, i); + const min = Math.min(startValue, endValue); + const max = Math.max(startValue, endValue); + let barStart = min; + let barEnd = max; + + if (Math.abs(min) > Math.abs(max)) { + barStart = max; + barEnd = min; + } + + // Store `barEnd` (furthest away from origin) as parsed value, + // to make stacking straight forward + item[vScale.axis] = barEnd; + + item._custom = { + barStart, + barEnd, + start: startValue, + end: endValue, + min, + max, + }; + + return item; +} + +export class TimelineController extends BarController { + static id = "timeline"; + + static defaults = { + dataElementType: "textbar", + dataElementOptions: ["text", "textColor", "textPadding"], + elements: { + showText: true, + textPadding: 4, + minBarWidth: 1, + }, + + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + }; + + static overrides = { + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + }, + }; + + parseObjectData(meta, data, start, count) { + const iScale = meta.iScale; + const vScale = meta.vScale; + const labels = iScale.getLabels(); + const singleScale = iScale === vScale; + const parsed: any[] = []; + let i; + let ilen; + let item; + let entry; + + for (i = start, ilen = start + count; i < ilen; ++i) { + entry = data[i]; + item = {}; + item[iScale.axis] = singleScale || iScale.parse(labels[i], i); + parsed.push(parseValue(entry, item, vScale, i)); + } + return parsed; + } + + getLabelAndValue(index) { + const meta = this._cachedMeta; + const { vScale } = meta; + const data = this.getDataset().data[index] as TimeLineData; + + return { + label: vScale!.getLabelForValue(this.index) || "", + value: data.label || "", + }; + } + + updateElements( + bars: BarElement[], + start: number, + count: number, + mode: "reset" | "resize" | "none" | "hide" | "show" | "normal" | "active" + ) { + const vScale = this._cachedMeta.vScale!; + const iScale = this._cachedMeta.iScale!; + const dataset = this.getDataset(); + + const firstOpts = this.resolveDataElementOptions(start, mode); + const sharedOptions = this.getSharedOptions(firstOpts); + const includeOptions = this.includeOptions(mode, sharedOptions!); + + const horizontal = vScale.isHorizontal(); + + this.updateSharedOptions(sharedOptions!, mode, firstOpts); + + for (let index = start; index < start + count; index++) { + const data = dataset.data[index] as TimeLineData; + + // @ts-ignore + const y = vScale.getPixelForValue(this.index); + + // @ts-ignore + const xStart = iScale.getPixelForValue(data.start.getTime()); + // @ts-ignore + const xEnd = iScale.getPixelForValue(data.end.getTime()); + const width = xEnd - xStart; + + const height = 10; + + const properties: TextBarProps = { + horizontal, + x: xStart + width / 2, // Center of the bar + y: y - height, // Top of bar + width, + height: 0, + base: y + height, // Bottom of bar, + // Text + text: data.label, + }; + + if (includeOptions) { + properties.options = + sharedOptions || this.resolveDataElementOptions(index, mode); + + properties.options = { + ...properties.options, + backgroundColor: data.color, + }; + } + + this.updateElement(bars[index], index, properties as any, mode); + } + } + + removeHoverStyle(_element, _datasetIndex, _index) { + // this._setStyle(element, index, 'active', false); + } + + setHoverStyle(_element, _datasetIndex, _index) { + // this._setStyle(element, index, 'active', true); + } +} diff --git a/src/components/chart/timeline-chart/timeline-scale.ts b/src/components/chart/timeline-chart/timeline-scale.ts new file mode 100644 index 0000000000..8d5086dafc --- /dev/null +++ b/src/components/chart/timeline-chart/timeline-scale.ts @@ -0,0 +1,55 @@ +import { TimeScale } from "chart.js"; +import { TimeLineData } from "./const"; + +export class TimeLineScale extends TimeScale { + static id = "timeline"; + + static defaults = { + position: "bottom", + tooltips: { + mode: "nearest", + }, + ticks: { + autoSkip: true, + }, + }; + + determineDataLimits() { + const options = this.options; + // @ts-ignore + const adapter = this._adapter; + const unit = options.time.unit || "day"; + let { min, max } = this.getUserBounds(); + + const chart = this.chart; + + // Convert data to timestamps + chart.data.datasets.forEach((dataset, index) => { + if (!chart.isDatasetVisible(index)) { + return; + } + for (const data of dataset.data as TimeLineData[]) { + let timestamp0 = adapter.parse(data.start, this); + let timestamp1 = adapter.parse(data.end, this); + if (timestamp0 > timestamp1) { + [timestamp0, timestamp1] = [timestamp1, timestamp0]; + } + if (min > timestamp0 && timestamp0) { + min = timestamp0; + } + if (max < timestamp1 && timestamp1) { + max = timestamp1; + } + } + }); + + // In case there is no valid min/max, var's use today limits + min = + isFinite(min) && !isNaN(min) ? min : +adapter.startOf(Date.now(), unit); + max = isFinite(max) && !isNaN(max) ? max : +adapter.endOf(Date.now(), unit); + + // Make sure that max is strictly higher than min (required by the lookup table) + this.min = Math.min(min, max - 1); + this.max = Math.max(min + 1, max); + } +} diff --git a/src/components/entity/ha-chart-base.js b/src/components/entity/ha-chart-base.js deleted file mode 100644 index 2ae4d7cf26..0000000000 --- a/src/components/entity/ha-chart-base.js +++ /dev/null @@ -1,661 +0,0 @@ -/* eslint-plugin-disable lit */ -import { IronResizableBehavior } from "@polymer/iron-resizable-behavior/iron-resizable-behavior"; -import { mixinBehaviors } from "@polymer/polymer/lib/legacy/class"; -import { timeOut } from "@polymer/polymer/lib/utils/async"; -import { Debouncer } from "@polymer/polymer/lib/utils/debounce"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { formatTime } from "../../common/datetime/format_time"; -import "../ha-icon-button"; - -// eslint-disable-next-line no-unused-vars -/* global Chart moment Color */ - -let scriptsLoaded = null; - -class HaChartBase extends mixinBehaviors( - [IronResizableBehavior], - PolymerElement -) { - static get template() { - return html` - - -
- -
-
[[tooltip.title]]
- -
-
    - -
-
-
-
- `; - } - - get chart() { - return this._chart; - } - - static get properties() { - return { - data: Object, - identifier: String, - rendered: { - type: Boolean, - notify: true, - value: false, - readOnly: true, - }, - metas: { - type: Array, - value: () => [], - }, - tooltip: { - type: Object, - value: () => ({ - opacity: "0", - left: "0", - top: "0", - xPadding: "5", - yPadding: "3", - }), - }, - unit: Object, - rtl: { - type: Boolean, - reflectToAttribute: true, - }, - }; - } - - static get observers() { - return ["onPropsChange(data)"]; - } - - connectedCallback() { - super.connectedCallback(); - this._isAttached = true; - this.onPropsChange(); - this._resizeListener = () => { - this._debouncer = Debouncer.debounce( - this._debouncer, - timeOut.after(10), - () => { - if (this._isAttached) { - this.resizeChart(); - } - } - ); - }; - - if (typeof ResizeObserver === "function") { - this.resizeObserver = new ResizeObserver((entries) => { - entries.forEach(() => { - this._resizeListener(); - }); - }); - this.resizeObserver.observe(this.$.chartTarget); - } else { - this.addEventListener("iron-resize", this._resizeListener); - } - - if (scriptsLoaded === null) { - scriptsLoaded = import("../../resources/ha-chart-scripts.js"); - } - scriptsLoaded.then((ChartModule) => { - this.ChartClass = ChartModule.default; - this.onPropsChange(); - }); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this._isAttached = false; - if (this.resizeObserver) { - this.resizeObserver.unobserve(this.$.chartTarget); - } - - this.removeEventListener("iron-resize", this._resizeListener); - - if (this._resizeTimer !== undefined) { - clearInterval(this._resizeTimer); - this._resizeTimer = undefined; - } - } - - onPropsChange() { - if (!this._isAttached || !this.ChartClass || !this.data) { - return; - } - this.drawChart(); - } - - _customTooltips(tooltip) { - // Hide if no tooltip - if (tooltip.opacity === 0) { - this.set(["tooltip", "opacity"], 0); - return; - } - // Set caret Position - if (tooltip.yAlign) { - this.set(["tooltip", "yAlign"], tooltip.yAlign); - } else { - this.set(["tooltip", "yAlign"], "no-transform"); - } - - const title = tooltip.title ? tooltip.title[0] || "" : ""; - this.set(["tooltip", "title"], title); - - if (tooltip.beforeBody) { - this.set(["tooltip", "beforeBody"], tooltip.beforeBody.join("\n")); - } - - const bodyLines = tooltip.body.map((n) => n.lines); - - // Set Text - if (tooltip.body) { - this.set( - ["tooltip", "lines"], - bodyLines.map((body, i) => { - const colors = tooltip.labelColors[i]; - return { - color: colors.borderColor, - bgColor: colors.backgroundColor, - text: body.join("\n"), - }; - }) - ); - } - const parentWidth = this.$.chartTarget.clientWidth; - let positionX = tooltip.caretX; - const positionY = this._chart.canvas.offsetTop + tooltip.caretY; - if (tooltip.caretX + 100 > parentWidth) { - positionX = parentWidth - 100; - } else if (tooltip.caretX < 100) { - positionX = 100; - } - positionX += this._chart.canvas.offsetLeft; - // Display, position, and set styles for font - this.tooltip = { - ...this.tooltip, - opacity: 1, - left: `${positionX}px`, - top: `${positionY}px`, - }; - } - - _legendClick(event) { - event = event || window.event; - event.stopPropagation(); - let target = event.target || event.srcElement; - while (target.nodeName !== "LI") { - // user clicked child, find parent LI - target = target.parentElement; - } - const index = event.model.itemsIndex; - - const meta = this._chart.getDatasetMeta(index); - meta.hidden = - meta.hidden === null ? !this._chart.data.datasets[index].hidden : null; - this.set( - ["metas", index, "hidden"], - this._chart.isDatasetVisible(index) ? null : "hidden" - ); - this._chart.update(); - } - - _drawLegend() { - const chart = this._chart; - // New data for old graph. Keep metadata. - const preserveVisibility = - this._oldIdentifier && this.identifier === this._oldIdentifier; - this._oldIdentifier = this.identifier; - this.set( - "metas", - this._chart.data.datasets.map((x, i) => ({ - label: x.label, - color: x.color, - bgColor: x.backgroundColor, - hidden: - preserveVisibility && i < this.metas.length - ? this.metas[i].hidden - : !chart.isDatasetVisible(i), - })) - ); - let updateNeeded = false; - if (preserveVisibility) { - for (let i = 0; i < this.metas.length; i++) { - const meta = chart.getDatasetMeta(i); - if (!!meta.hidden !== !!this.metas[i].hidden) updateNeeded = true; - meta.hidden = this.metas[i].hidden ? true : null; - } - } - if (updateNeeded) { - chart.update(); - } - this.unit = this.data.unit; - } - - _formatTickValue(value, index, values) { - if (values.length === 0) { - return value; - } - const date = new Date(values[index].value); - return formatTime(date, this.hass.locale); - } - - drawChart() { - const data = this.data.data; - const ctx = this.$.chartCanvas; - - if ((!data.datasets || !data.datasets.length) && !this._chart) { - return; - } - if (this.data.type !== "timeline" && data.datasets.length > 0) { - const cnt = data.datasets.length; - const colors = this.constructor.getColorList(cnt); - for (let loopI = 0; loopI < cnt; loopI++) { - data.datasets[loopI].borderColor = colors[loopI].rgbString(); - data.datasets[loopI].backgroundColor = colors[loopI] - .alpha(0.6) - .rgbaString(); - } - } - - if (this._chart) { - this._customTooltips({ opacity: 0 }); - this._chart.data = data; - this._chart.update({ duration: 0 }); - if (this.isTimeline) { - this._chart.options.scales.yAxes[0].gridLines.display = data.length > 1; - } else if (this.data.legend === true) { - this._drawLegend(); - } - this.resizeChart(); - } else { - if (!data.datasets) { - return; - } - this._customTooltips({ opacity: 0 }); - const plugins = [{ afterRender: () => this._setRendered(true) }]; - let options = { - responsive: true, - maintainAspectRatio: false, - animation: { - duration: 0, - }, - hover: { - animationDuration: 0, - }, - responsiveAnimationDuration: 0, - tooltips: { - enabled: false, - custom: this._customTooltips.bind(this), - }, - legend: { - display: false, - }, - line: { - spanGaps: true, - }, - elements: { - font: "12px 'Roboto', 'sans-serif'", - }, - ticks: { - fontFamily: "'Roboto', 'sans-serif'", - }, - }; - options = Chart.helpers.merge(options, this.data.options); - options.scales.xAxes[0].ticks.callback = this._formatTickValue.bind(this); - if (this.data.type === "timeline") { - this.set("isTimeline", true); - if (this.data.colors !== undefined) { - this._colorFunc = this.constructor.getColorGenerator( - this.data.colors.staticColors, - this.data.colors.staticColorIndex - ); - } - if (this._colorFunc !== undefined) { - options.elements.colorFunction = this._colorFunc; - } - if (data.datasets.length === 1) { - if (options.scales.yAxes[0].ticks) { - options.scales.yAxes[0].ticks.display = false; - } else { - options.scales.yAxes[0].ticks = { display: false }; - } - if (options.scales.yAxes[0].gridLines) { - options.scales.yAxes[0].gridLines.display = false; - } else { - options.scales.yAxes[0].gridLines = { display: false }; - } - } - this.$.chartTarget.style.height = "50px"; - } else { - this.$.chartTarget.style.height = "160px"; - } - const chartData = { - type: this.data.type, - data: this.data.data, - options: options, - plugins: plugins, - }; - // Async resize after dom update - this._chart = new this.ChartClass(ctx, chartData); - if (this.isTimeline !== true && this.data.legend === true) { - this._drawLegend(); - } - this.resizeChart(); - } - } - - resizeChart() { - if (!this._chart) return; - // Chart not ready - if (this._resizeTimer === undefined) { - this._resizeTimer = setInterval(this.resizeChart.bind(this), 10); - return; - } - - clearInterval(this._resizeTimer); - this._resizeTimer = undefined; - - this._resizeChart(); - } - - _resizeChart() { - const chartTarget = this.$.chartTarget; - - const options = this.data; - const data = options.data; - - if (data.datasets.length === 0) { - return; - } - - if (!this.isTimeline) { - this._chart.resize(); - return; - } - - // Recalculate chart height for Timeline chart - const areaTop = this._chart.chartArea.top; - const areaBot = this._chart.chartArea.bottom; - const height1 = this._chart.canvas.clientHeight; - if (areaBot > 0) { - this._axisHeight = height1 - areaBot + areaTop; - } - - if (!this._axisHeight) { - chartTarget.style.height = "50px"; - this._chart.resize(); - this.resizeChart(); - return; - } - if (this._axisHeight) { - const cnt = data.datasets.length; - const targetHeight = 30 * cnt + this._axisHeight + "px"; - if (chartTarget.style.height !== targetHeight) { - chartTarget.style.height = targetHeight; - } - this._chart.resize(); - } - } - - // Get HSL distributed color list - static getColorList(count) { - let processL = false; - if (count > 10) { - processL = true; - count = Math.ceil(count / 2); - } - const h1 = 360 / count; - const result = []; - for (let loopI = 0; loopI < count; loopI++) { - result[loopI] = Color().hsl(h1 * loopI, 80, 38); - if (processL) { - result[loopI + count] = Color().hsl(h1 * loopI, 80, 62); - } - } - return result; - } - - static getColorGenerator(staticColors, startIndex) { - // Known colors for static data, - // should add for very common state string manually. - // Palette modified from http://google.github.io/palette.js/ mpn65, Apache 2.0 - const palette = [ - "ff0029", - "66a61e", - "377eb8", - "984ea3", - "00d2d5", - "ff7f00", - "af8d00", - "7f80cd", - "b3e900", - "c42e60", - "a65628", - "f781bf", - "8dd3c7", - "bebada", - "fb8072", - "80b1d3", - "fdb462", - "fccde5", - "bc80bd", - "ffed6f", - "c4eaff", - "cf8c00", - "1b9e77", - "d95f02", - "e7298a", - "e6ab02", - "a6761d", - "0097ff", - "00d067", - "f43600", - "4ba93b", - "5779bb", - "927acc", - "97ee3f", - "bf3947", - "9f5b00", - "f48758", - "8caed6", - "f2b94f", - "eff26e", - "e43872", - "d9b100", - "9d7a00", - "698cff", - "d9d9d9", - "00d27e", - "d06800", - "009f82", - "c49200", - "cbe8ff", - "fecddf", - "c27eb6", - "8cd2ce", - "c4b8d9", - "f883b0", - "a49100", - "f48800", - "27d0df", - "a04a9b", - ]; - function getColorIndex(idx) { - // Reuse the color if index too large. - return Color("#" + palette[idx % palette.length]); - } - const colorDict = {}; - let colorIndex = 0; - if (startIndex > 0) colorIndex = startIndex; - if (staticColors) { - Object.keys(staticColors).forEach((c) => { - const c1 = staticColors[c]; - if (isFinite(c1)) { - colorDict[c.toLowerCase()] = getColorIndex(c1); - } else { - colorDict[c.toLowerCase()] = Color(staticColors[c]); - } - }); - } - // Custom color assign - function getColor(__, data) { - let ret; - const name = data[3]; - if (name === null) return Color().hsl(0, 40, 38); - if (name === undefined) return Color().hsl(120, 40, 38); - let name1 = name.toLowerCase(); - if (ret === undefined) { - if (data[4]) { - // Invert on/off if data[4] is true. Required for some binary_sensor device classes - // (BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED) where "off" is the good (= green color) value. - name1 = name1 === "on" ? "off" : name1 === "off" ? "on" : name1; - } - - ret = colorDict[name1]; - } - if (ret === undefined) { - ret = getColorIndex(colorIndex); - colorIndex++; - colorDict[name1] = ret; - } - return ret; - } - return getColor; - } -} -customElements.define("ha-chart-base", HaChartBase); diff --git a/src/components/state-history-chart-line.js b/src/components/state-history-chart-line.js deleted file mode 100644 index dc767fb45c..0000000000 --- a/src/components/state-history-chart-line.js +++ /dev/null @@ -1,433 +0,0 @@ -import "@polymer/polymer/lib/utils/debounce"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time"; -import LocalizeMixin from "../mixins/localize-mixin"; -import "./entity/ha-chart-base"; - -class StateHistoryChartLine extends LocalizeMixin(PolymerElement) { - static get template() { - return html` - - - `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - chartData: Object, - data: Object, - names: Object, - unit: String, - identifier: String, - - isSingleDevice: { - type: Boolean, - value: false, - }, - - endTime: Object, - rendered: { - type: Boolean, - value: false, - observer: "_onRenderedChanged", - }, - }; - } - - static get observers() { - return ["dataChanged(data, endTime, isSingleDevice)"]; - } - - connectedCallback() { - super.connectedCallback(); - this._isAttached = true; - this.drawChart(); - } - - ready() { - super.ready(); - // safari doesn't always render the canvas when we animate it, so we remove overflow hidden when the animation is complete - this.addEventListener("transitionend", () => { - this.style.overflow = "auto"; - }); - } - - dataChanged() { - this.drawChart(); - } - - _onRenderedChanged(rendered) { - if (rendered) { - this.animateHeight(); - } - } - - animateHeight() { - requestAnimationFrame(() => - requestAnimationFrame(() => { - this.style.height = this.$.chart.scrollHeight + "px"; - }) - ); - } - - drawChart() { - if (!this._isAttached) { - return; - } - - const unit = this.unit; - const deviceStates = this.data; - const datasets = []; - let endTime; - - if (deviceStates.length === 0) { - return; - } - - function safeParseFloat(value) { - const parsed = parseFloat(value); - return isFinite(parsed) ? parsed : null; - } - - endTime = - this.endTime || - // Get the highest date from the last date of each device - new Date( - Math.max.apply( - null, - deviceStates.map( - (devSts) => - new Date(devSts.states[devSts.states.length - 1].last_changed) - ) - ) - ); - if (endTime > new Date()) { - endTime = new Date(); - } - - const names = this.names || {}; - deviceStates.forEach((states) => { - const domain = states.domain; - const name = names[states.entity_id] || states.name; - // array containing [value1, value2, etc] - let prevValues; - const data = []; - - function pushData(timestamp, datavalues) { - if (!datavalues) return; - if (timestamp > endTime) { - // Drop datapoints that are after the requested endTime. This could happen if - // endTime is "now" and client time is not in sync with server time. - return; - } - data.forEach((d, i) => { - if (datavalues[i] === null && prevValues && prevValues[i] !== null) { - // null data values show up as gaps in the chart. - // If the current value for the dataset is null and the previous - // value of the data set is not null, then add an 'end' point - // to the chart for the previous value. Otherwise the gap will - // be too big. It will go from the start of the previous data - // value until the start of the next data value. - d.data.push({ x: timestamp, y: prevValues[i] }); - } - d.data.push({ x: timestamp, y: datavalues[i] }); - }); - prevValues = datavalues; - } - - function addColumn(nameY, step, fill) { - let dataFill = false; - let dataStep = false; - if (fill) { - dataFill = "origin"; - } - if (step) { - dataStep = "before"; - } - data.push({ - label: nameY, - fill: dataFill, - steppedLine: dataStep, - pointRadius: 0, - data: [], - unitText: unit, - }); - } - - if ( - domain === "thermostat" || - domain === "climate" || - domain === "water_heater" - ) { - const hasHvacAction = states.states.some( - (state) => state.attributes && state.attributes.hvac_action - ); - - const isHeating = - domain === "climate" && hasHvacAction - ? (state) => state.attributes.hvac_action === "heating" - : (state) => state.state === "heat"; - const isCooling = - domain === "climate" && hasHvacAction - ? (state) => state.attributes.hvac_action === "cooling" - : (state) => state.state === "cool"; - - const hasHeat = states.states.some(isHeating); - const hasCool = states.states.some(isCooling); - // We differentiate between thermostats that have a target temperature - // range versus ones that have just a target temperature - - // Using step chart by step-before so manually interpolation not needed. - const hasTargetRange = states.states.some( - (state) => - state.attributes && - state.attributes.target_temp_high !== - state.attributes.target_temp_low - ); - - addColumn( - `${this.hass.localize( - "ui.card.climate.current_temperature", - "name", - name - )}`, - true - ); - if (hasHeat) { - addColumn( - `${this.hass.localize("ui.card.climate.heating", "name", name)}`, - true, - true - ); - // The "heating" series uses steppedArea to shade the area below the current - // temperature when the thermostat is calling for heat. - } - if (hasCool) { - addColumn( - `${this.hass.localize("ui.card.climate.cooling", "name", name)}`, - true, - true - ); - // The "cooling" series uses steppedArea to shade the area below the current - // temperature when the thermostat is calling for heat. - } - - if (hasTargetRange) { - addColumn( - `${this.hass.localize( - "ui.card.climate.target_temperature_mode", - "name", - name, - "mode", - this.hass.localize("ui.card.climate.high") - )}`, - true - ); - addColumn( - `${this.hass.localize( - "ui.card.climate.target_temperature_mode", - "name", - name, - "mode", - this.hass.localize("ui.card.climate.low") - )}`, - true - ); - } else { - addColumn( - `${this.hass.localize( - "ui.card.climate.target_temperature_entity", - "name", - name - )}`, - true - ); - } - - states.states.forEach((state) => { - if (!state.attributes) return; - const curTemp = safeParseFloat(state.attributes.current_temperature); - const series = [curTemp]; - if (hasHeat) { - series.push(isHeating(state) ? curTemp : null); - } - if (hasCool) { - series.push(isCooling(state) ? curTemp : null); - } - if (hasTargetRange) { - const targetHigh = safeParseFloat( - state.attributes.target_temp_high - ); - const targetLow = safeParseFloat(state.attributes.target_temp_low); - series.push(targetHigh, targetLow); - pushData(new Date(state.last_changed), series); - } else { - const target = safeParseFloat(state.attributes.temperature); - series.push(target); - pushData(new Date(state.last_changed), series); - } - }); - } else if (domain === "humidifier") { - addColumn( - `${this.hass.localize( - "ui.card.humidifier.target_humidity_entity", - "name", - name - )}`, - true - ); - addColumn( - `${this.hass.localize("ui.card.humidifier.on_entity", "name", name)}`, - true, - true - ); - - states.states.forEach((state) => { - if (!state.attributes) return; - const target = safeParseFloat(state.attributes.humidity); - const series = [target]; - series.push(state.state === "on" ? target : null); - pushData(new Date(state.last_changed), series); - }); - } else { - // Only disable interpolation for sensors - const isStep = domain === "sensor"; - addColumn(name, isStep); - - let lastValue = null; - let lastDate = null; - let lastNullDate = null; - - // Process chart data. - // When state is `unknown`, calculate the value and break the line. - states.states.forEach((state) => { - const value = safeParseFloat(state.state); - const date = new Date(state.last_changed); - if (value !== null && lastNullDate !== null) { - const dateTime = date.getTime(); - const lastNullDateTime = lastNullDate.getTime(); - const lastDateTime = lastDate.getTime(); - const tmpValue = - (value - lastValue) * - ((lastNullDateTime - lastDateTime) / - (dateTime - lastDateTime)) + - lastValue; - pushData(lastNullDate, [tmpValue]); - pushData(new Date(lastNullDateTime + 1), [null]); - pushData(date, [value]); - lastDate = date; - lastValue = value; - lastNullDate = null; - } else if (value !== null && lastNullDate === null) { - pushData(date, [value]); - lastDate = date; - lastValue = value; - } else if ( - value === null && - lastNullDate === null && - lastValue !== null - ) { - lastNullDate = date; - } - }); - } - - // Add an entry for final values - pushData(endTime, prevValues, false); - - // Concat two arrays - Array.prototype.push.apply(datasets, data); - }); - - const formatTooltipTitle = (items, data) => { - const item = items[0]; - const date = data.datasets[item.datasetIndex].data[item.index].x; - - return formatDateTimeWithSeconds(date, this.hass.locale); - }; - - const chartOptions = { - type: "line", - unit: unit, - legend: !this.isSingleDevice, - options: { - scales: { - xAxes: [ - { - type: "time", - ticks: { - major: { - fontStyle: "bold", - }, - source: "auto", - sampleSize: 5, - autoSkipPadding: 20, - maxRotation: 0, - }, - }, - ], - yAxes: [ - { - ticks: { - maxTicksLimit: 7, - }, - }, - ], - }, - tooltips: { - mode: "neareach", - callbacks: { - title: formatTooltipTitle, - }, - }, - hover: { - mode: "neareach", - }, - layout: { - padding: { - top: 5, - }, - }, - elements: { - line: { - tension: 0.1, - pointRadius: 0, - borderWidth: 1.5, - }, - point: { - hitRadius: 5, - }, - }, - plugins: { - filler: { - propagate: true, - }, - }, - }, - data: { - labels: [], - datasets: datasets, - }, - }; - this.chartData = chartOptions; - } -} -customElements.define("state-history-chart-line", StateHistoryChartLine); diff --git a/src/components/state-history-chart-timeline.js b/src/components/state-history-chart-timeline.js deleted file mode 100644 index fa29388da3..0000000000 --- a/src/components/state-history-chart-timeline.js +++ /dev/null @@ -1,286 +0,0 @@ -import "@polymer/polymer/lib/utils/debounce"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time"; -import { computeDomain } from "../common/entity/compute_domain"; -import { computeRTL } from "../common/util/compute_rtl"; -import LocalizeMixin from "../mixins/localize-mixin"; -import "./entity/ha-chart-base"; - -/** Binary sensor device classes for which the static colors for on/off need to be inverted. - * List the ones were "off" = good or normal state = should be rendered "green". - */ -const BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED = new Set([ - "battery", - "door", - "garage_door", - "gas", - "lock", - "opening", - "problem", - "safety", - "smoke", - "window", -]); - -class StateHistoryChartTimeline extends LocalizeMixin(PolymerElement) { - static get template() { - return html` - - - `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - chartData: Object, - data: { - type: Object, - observer: "dataChanged", - }, - names: Object, - noSingle: Boolean, - endTime: Date, - rendered: { - type: Boolean, - value: false, - reflectToAttribute: true, - }, - rtl: { - reflectToAttribute: true, - computed: "_computeRTL(hass)", - }, - }; - } - - static get observers() { - return ["dataChanged(data, endTime, localize, language)"]; - } - - connectedCallback() { - super.connectedCallback(); - this._isAttached = true; - this.drawChart(); - } - - dataChanged() { - this.drawChart(); - } - - drawChart() { - const staticColors = { - on: 1, - off: 0, - home: 1, - not_home: 0, - unavailable: "#a0a0a0", - unknown: "#606060", - idle: 2, - }; - let stateHistory = this.data; - - if (!this._isAttached) { - return; - } - - if (!stateHistory) { - stateHistory = []; - } - - const startTime = new Date( - stateHistory.reduce( - (minTime, stateInfo) => - Math.min(minTime, new Date(stateInfo.data[0].last_changed)), - new Date() - ) - ); - - // end time is Math.max(startTime, last_event) - let endTime = - this.endTime || - new Date( - stateHistory.reduce( - (maxTime, stateInfo) => - Math.max( - maxTime, - new Date(stateInfo.data[stateInfo.data.length - 1].last_changed) - ), - startTime - ) - ); - - if (endTime > new Date()) { - endTime = new Date(); - } - - const labels = []; - const datasets = []; - // stateHistory is a list of lists of sorted state objects - const names = this.names || {}; - stateHistory.forEach((stateInfo) => { - let newLastChanged; - let prevState = null; - let locState = null; - let prevLastChanged = startTime; - const entityDisplay = names[stateInfo.entity_id] || stateInfo.name; - - const invertOnOff = - computeDomain(stateInfo.entity_id) === "binary_sensor" && - BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED.has( - this.hass.states[stateInfo.entity_id].attributes.device_class - ); - - const dataRow = []; - stateInfo.data.forEach((state) => { - let newState = state.state; - const timeStamp = new Date(state.last_changed); - if (newState === undefined || newState === "") { - newState = null; - } - if (timeStamp > endTime) { - // Drop datapoints that are after the requested endTime. This could happen if - // endTime is 'now' and client time is not in sync with server time. - return; - } - if (prevState !== null && newState !== prevState) { - newLastChanged = new Date(state.last_changed); - - dataRow.push([ - prevLastChanged, - newLastChanged, - locState, - prevState, - invertOnOff, - ]); - - prevState = newState; - locState = state.state_localize; - prevLastChanged = newLastChanged; - } else if (prevState === null) { - prevState = newState; - locState = state.state_localize; - prevLastChanged = new Date(state.last_changed); - } - }); - - if (prevState !== null) { - dataRow.push([ - prevLastChanged, - endTime, - locState, - prevState, - invertOnOff, - ]); - } - datasets.push({ - data: dataRow, - entity_id: stateInfo.entity_id, - }); - labels.push(entityDisplay); - }); - - const formatTooltipLabel = (item, data) => { - const values = data.datasets[item.datasetIndex].data[item.index]; - - const start = formatDateTimeWithSeconds(values[0], this.hass.locale); - const end = formatDateTimeWithSeconds(values[1], this.hass.locale); - const state = values[2]; - - return [state, start, end]; - }; - - const formatTooltipBeforeBody = (item, data) => { - if (!this.hass.userData || !this.hass.userData.showAdvanced || !item[0]) { - return ""; - } - // Extract the entity ID from the dataset. - const values = data.datasets[item[0].datasetIndex]; - return values.entity_id || ""; - }; - - const chartOptions = { - type: "timeline", - options: { - tooltips: { - callbacks: { - label: formatTooltipLabel, - beforeBody: formatTooltipBeforeBody, - }, - }, - scales: { - xAxes: [ - { - ticks: { - major: { - fontStyle: "bold", - }, - sampleSize: 5, - autoSkipPadding: 50, - maxRotation: 0, - }, - categoryPercentage: undefined, - barPercentage: undefined, - time: { - format: undefined, - }, - }, - ], - yAxes: [ - { - afterSetDimensions: (yaxe) => { - yaxe.maxWidth = yaxe.chart.width * 0.18; - }, - position: this._computeRTL ? "right" : "left", - categoryPercentage: undefined, - barPercentage: undefined, - time: { format: undefined }, - }, - ], - }, - }, - datasets: { - categoryPercentage: 0.8, - barPercentage: 0.9, - }, - data: { - labels: labels, - datasets: datasets, - }, - colors: { - staticColors: staticColors, - staticColorIndex: 3, - }, - }; - this.chartData = chartOptions; - } - - _computeRTL(hass) { - return computeRTL(hass); - } -} -customElements.define( - "state-history-chart-timeline", - StateHistoryChartTimeline -); diff --git a/src/dialogs/more-info/ha-more-info-history.ts b/src/dialogs/more-info/ha-more-info-history.ts index 6dcb3144de..199e8e9490 100644 --- a/src/dialogs/more-info/ha-more-info-history.ts +++ b/src/dialogs/more-info/ha-more-info-history.ts @@ -2,7 +2,7 @@ import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { throttle } from "../../common/util/throttle"; -import "../../components/state-history-charts"; +import "../../components/chart/state-history-charts"; import { getRecentWithCache } from "../../data/cached-history"; import { HistoryResult } from "../../data/history"; import { HomeAssistant } from "../../types"; diff --git a/src/dialogs/more-info/ha-more-info-logbook.ts b/src/dialogs/more-info/ha-more-info-logbook.ts index 90d01e75a9..f109633e53 100644 --- a/src/dialogs/more-info/ha-more-info-logbook.ts +++ b/src/dialogs/more-info/ha-more-info-logbook.ts @@ -4,7 +4,6 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { throttle } from "../../common/util/throttle"; import "../../components/ha-circular-progress"; -import "../../components/state-history-charts"; import { fetchUsers } from "../../data/user"; import { getLogbookData, LogbookEntry } from "../../data/logbook"; import { loadTraceContexts, TraceContexts } from "../../data/trace"; diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index 31737c8874..cef5aa53c3 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -8,7 +8,7 @@ import "../../components/ha-circular-progress"; import "../../components/ha-date-range-picker"; import type { DateRangePickerRanges } from "../../components/ha-date-range-picker"; import "../../components/ha-menu-button"; -import "../../components/state-history-charts"; +import "../../components/chart/state-history-charts"; import { computeHistory, fetchDate } from "../../data/history"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; diff --git a/src/panels/lovelace/cards/hui-history-graph-card.ts b/src/panels/lovelace/cards/hui-history-graph-card.ts index b17a1ad1e0..b61f3c0512 100644 --- a/src/panels/lovelace/cards/hui-history-graph-card.ts +++ b/src/panels/lovelace/cards/hui-history-graph-card.ts @@ -10,7 +10,7 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { throttle } from "../../../common/util/throttle"; import "../../../components/ha-card"; -import "../../../components/state-history-charts"; +import "../../../components/chart/state-history-charts"; import { CacheConfig, getRecentWithCache } from "../../../data/cached-history"; import { HistoryResult } from "../../../data/history"; import { HomeAssistant } from "../../../types"; @@ -139,8 +139,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { .isLoadingData=${!this._stateHistory} .historyData=${this._stateHistory} .names=${this._names} - .upToNow=${true} - .noSingle=${true} + up-to-now + no-single > diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index e6c825d9b9..88ccf2104c 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -25,23 +25,10 @@ import "../../../components/map/ha-map"; import { mdiImageFilterCenterFocus } from "@mdi/js"; import type { HaMap, HaMapPaths } from "../../../components/map/ha-map"; import memoizeOne from "memoize-one"; +import { getColorByIndex } from "../../../common/color/colors"; const MINUTE = 60000; -const COLORS = [ - "#0288D1", - "#00AA00", - "#984ea3", - "#00d2d5", - "#ff7f00", - "#af8d00", - "#7f80cd", - "#b3e900", - "#c42e60", - "#a65628", - "#f781bf", - "#8dd3c7", -]; @customElement("hui-map-card") class HuiMapCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; @@ -225,7 +212,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { if (color) { return color; } - color = COLORS[this._colorIndex % COLORS.length]; + color = getColorByIndex(this._colorIndex); this._colorIndex++; this._colorDict[entityId] = color; return color; diff --git a/src/panels/lovelace/cards/hui-thermostat-card.ts b/src/panels/lovelace/cards/hui-thermostat-card.ts index 1d50d04180..19ba06708a 100644 --- a/src/panels/lovelace/cards/hui-thermostat-card.ts +++ b/src/panels/lovelace/cards/hui-thermostat-card.ts @@ -447,47 +447,37 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard { --name-font-size: 1.2rem; --brightness-font-size: 1.2rem; --rail-border-color: transparent; - --auto-color: green; - --eco-color: springgreen; - --cool-color: #2b9af9; - --heat-color: #ff8100; - --manual-color: #44739e; - --off-color: #8a8a8a; - --fan_only-color: #8a8a8a; - --dry-color: #efbd07; - --idle-color: #8a8a8a; - --unknown-color: #bac; } .auto, .heat_cool { - --mode-color: var(--auto-color); + --mode-color: var(--state-climate-auto-color); } .cool { - --mode-color: var(--cool-color); + --mode-color: var(--state-climate-cool-color); } .heat { - --mode-color: var(--heat-color); + --mode-color: var(--state-climate-heat-color); } .manual { - --mode-color: var(--manual-color); + --mode-color: var(--state-climate-manual-color); } .off { - --mode-color: var(--off-color); + --mode-color: var(--state-climate-off-color); } .fan_only { - --mode-color: var(--fan_only-color); + --mode-color: var(--state-climate-fan_only-color); } .eco { - --mode-color: var(--eco-color); + --mode-color: var(--state-climate-eco-color); } .dry { - --mode-color: var(--dry-color); + --mode-color: var(--state-climate-dry-color); } .idle { - --mode-color: var(--idle-color); + --mode-color: var(--state-climate-idle-color); } .unknown-mode { - --mode-color: var(--unknown-color); + --mode-color: var(--state-unknown-color); } .more-info { diff --git a/src/resources/chartjs.ts b/src/resources/chartjs.ts new file mode 100644 index 0000000000..811addaa8b --- /dev/null +++ b/src/resources/chartjs.ts @@ -0,0 +1,35 @@ +import { + LineController, + TimeScale, + LinearScale, + PointElement, + LineElement, + Filler, + Legend, + Title, + Tooltip, + CategoryScale, + Chart, +} from "chart.js"; +import { TextBarElement } from "../components/chart/timeline-chart/textbar-element"; +import { TimelineController } from "../components/chart/timeline-chart/timeline-controller"; +import { TimeLineScale } from "../components/chart/timeline-chart/timeline-scale"; +import "../components/chart/chart-date-adapter"; + +export { Chart } from "chart.js"; + +Chart.register( + Tooltip, + Title, + Legend, + Filler, + TimeScale, + LinearScale, + LineController, + PointElement, + LineElement, + TextBarElement, + TimeLineScale, + TimelineController, + CategoryScale +); diff --git a/src/resources/ha-chart-scripts.js b/src/resources/ha-chart-scripts.js deleted file mode 100644 index 500c803788..0000000000 --- a/src/resources/ha-chart-scripts.js +++ /dev/null @@ -1,60 +0,0 @@ -import Chart from "chart.js"; -import "chartjs-chart-timeline"; - -// This function add a new interaction mode to Chart.js that -// returns one point for every dataset. -Chart.Interaction.modes.neareach = function (chart, e, options) { - const getRange = { - x: (a, b) => Math.abs(a.x - b.x), - y: (a, b) => Math.abs(a.y - b.y), - // eslint-disable-next-line no-restricted-properties - xy: (a, b) => Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2), - }; - const getRangeMax = { - x: (r) => r, - y: (r) => r, - xy: (r) => r * r, - }; - let position; - if (e.native) { - position = { - x: e.x, - y: e.y, - }; - } else { - position = Chart.helpers.getRelativePosition(e, chart); - } - const elements = []; - const elementsRange = []; - const datasets = chart.data.datasets; - let meta; - options.axis = options.axis || "xy"; - const rangeFunc = getRange[options.axis]; - const rangeMaxFunc = getRangeMax[options.axis]; - - for (let i = 0, ilen = datasets.length; i < ilen; ++i) { - if (!chart.isDatasetVisible(i)) { - continue; - } - - meta = chart.getDatasetMeta(i); - for (let j = 0, jlen = meta.data.length; j < jlen; ++j) { - const element = meta.data[j]; - if (!element._view.skip) { - const vm = element._view; - const range = rangeFunc(vm, position); - const oldRange = elementsRange[i]; - if (range < rangeMaxFunc(vm.radius + vm.hitRadius)) { - if (oldRange === undefined || oldRange > range) { - elementsRange[i] = range; - elements[i] = element; - } - } - } - } - } - const ret = elements.filter((n) => n !== undefined); - return ret; -}; - -export default Chart; diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index 82439c105e..fbfeb09eed 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -42,10 +42,6 @@ documentContainer.innerHTML = ` --success-color: #0f9d58; --info-color: #4285f4; - /* states and badges */ - --state-icon-color: #44739e; - --state-icon-active-color: #FDD835; - /* background and sidebar */ --card-background-color: #ffffff; --primary-background-color: #fafafa; @@ -60,6 +56,32 @@ documentContainer.innerHTML = ` --label-badge-green: #0DA035; --label-badge-yellow: #f4b400; + /* states and badges */ + --state-icon-color: #44739e; + /* an active state is anything that would require attention */ + --state-icon-active-color: #FDD835; + /* an error state is anything that would be considered an error */ + /* --state-icon-error-color: #db4437; derived from error-color */ + + --state-on-color: #66a61e; + --state-off-color: #ff0029; + --state-home-color: #66a61e; + --state-not_home-color: #ff0029; + /* --state-unavailable-color: #a0a0a0; derived from disabled-text-color */ + --state-unknown-color: #606060; + --state-idle-color: #377eb8; + + /* climate state colors */ + --state-climate-auto-color: #008000; + --state-climate-eco-color: #00ff7f; + --state-climate-cool-color: #2b9af9; + --state-climate-heat-color: #ff8100; + --state-climate-manual-color: #44739e; + --state-climate-off-color: #8a8a8a; + --state-climate-fan_only-color: #8a8a8a; + --state-climate-dry-color: #efbd07; + --state-climate-idle-color: #8a8a8a; + /* Paper-styles color.html dependency is stripped on build. When a default paper-style color is used, it needs to be copied diff --git a/src/resources/styles.ts b/src/resources/styles.ts index 54e298f1c2..035832e689 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -34,8 +34,9 @@ export const darkStyles = { }; export const derivedStyles = { - "error-state-color": "var(--error-color)", - "state-icon-unavailable-color": "var(--disabled-text-color)", + "state-icon-error-color": "var(--error-state-color, var(--error-color))", + "state-unavailable-color": + "var(--state-icon-unavailable-color, var(--disabled-text-color))", "sidebar-text-color": "var(--primary-text-color)", "sidebar-background-color": "var(--card-background-color)", "sidebar-selected-text-color": "var(--primary-color)", diff --git a/yarn.lock b/yarn.lock index eae3bc00de..1671fdf6e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4634,33 +4634,10 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -chart.js@^2.9.4: - version "2.9.4" - resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.4.tgz#0827f9563faffb2dc5c06562f8eb10337d5b9684" - integrity sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A== - dependencies: - chartjs-color "^2.1.0" - moment "^2.10.2" - -chartjs-chart-timeline@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/chartjs-chart-timeline/-/chartjs-chart-timeline-0.4.0.tgz#cbd25dc5ddb5c2b34289f8dd7a2a627d71e251e8" - integrity sha512-a3iOFgMUXgEK9zyDFXlL7cfhO6z4DkeuGqok1xnNVNg12ciSt/k1jDBFk8JKN+sVNZfoqeGAFBT9zvb++iEWnA== - -chartjs-color-string@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71" - integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A== - dependencies: - color-name "^1.0.0" - -chartjs-color@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.3.0.tgz#0e7e1e8dba37eae8415fd3db38bf572007dd958f" - integrity sha512-hEvVheqczsoHD+fZ+tfPUE+1+RbV6b+eksp2LwAhwRTVXEjCSEavvk+Hg3H6SZfGlPh/UfmWKGIvZbtobOEm3g== - dependencies: - chartjs-color-string "^0.6.0" - color-convert "^0.5.3" +chart.js@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.3.2.tgz#667f3a0b6371b9719d8949c04a5bcbaec0d8c615" + integrity sha512-H0hSO7xqTIrwxoACqnSoNromEMfXvfuVnrbuSt2TuXfBDDofbnto4zuZlRtRvC73/b37q3wGAWZyUU41QPvNbA== check-error@^1.0.2: version "1.0.2" @@ -4942,11 +4919,6 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" - integrity sha1-vbbGnOZg+t/+CwAHzER+G59ygr0= - color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -4966,7 +4938,7 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@^1.0.0, color-name@~1.1.4: +color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -5360,6 +5332,11 @@ d@1: dependencies: es5-ext "^0.10.9" +date-fns@^2.22.1: + version "2.22.1" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.22.1.tgz#1e5af959831ebb1d82992bf67b765052d8f0efc4" + integrity sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg== + dateformat@^1.0.7-1.2.3: version "1.0.12" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9" @@ -9425,11 +9402,6 @@ mocha@^8.4.0: yargs-parser "20.2.4" yargs-unparser "2.0.0" -moment@^2.10.2: - version "2.24.0" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" - integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== - ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"