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/build-scripts/gulp/webpack.js b/build-scripts/gulp/webpack.js index 6f0c450fac..e7083cd578 100644 --- a/build-scripts/gulp/webpack.js +++ b/build-scripts/gulp/webpack.js @@ -19,10 +19,12 @@ const bothBuilds = (createConfigFunc, params) => [ createConfigFunc({ ...params, latestBuild: false }), ]; -const isWsl = fs - .readFileSync("/proc/version", "utf-8") - .toLocaleLowerCase() - .includes("microsoft"); +const isWsl = + fs.existsSync("/proc/version") && + fs + .readFileSync("/proc/version", "utf-8") + .toLocaleLowerCase() + .includes("microsoft"); /** * @param {{ diff --git a/hassio/src/addon-view/config/hassio-addon-config.ts b/hassio/src/addon-view/config/hassio-addon-config.ts index 199a5f8af2..661f3a118c 100644 --- a/hassio/src/addon-view/config/hassio-addon-config.ts +++ b/hassio/src/addon-view/config/hassio-addon-config.ts @@ -134,7 +134,7 @@ class HassioAddonConfig extends LitElement { >` : html` `} ${this._error ? html`
${this._error}
` : ""} ${!this._yamlMode || diff --git a/hassio/src/addon-view/info/hassio-addon-info.ts b/hassio/src/addon-view/info/hassio-addon-info.ts index 0fe37230bb..42b6969dbc 100644 --- a/hassio/src/addon-view/info/hassio-addon-info.ts +++ b/hassio/src/addon-view/info/hassio-addon-info.ts @@ -892,10 +892,19 @@ class HassioAddonInfo extends LitElement { private async _openChangelog(): Promise { try { - const content = await fetchHassioAddonChangelog( - this.hass, - this.addon.slug - ); + let content = await fetchHassioAddonChangelog(this.hass, this.addon.slug); + if ( + content.includes(`# ${this.addon.version}`) && + content.includes(`# ${this.addon.version_latest}`) + ) { + const newcontent = content.split(`# ${this.addon.version}`)[0]; + if (newcontent.includes(`# ${this.addon.version_latest}`)) { + // Only change the content if the new version still exist + // if the changelog does not have the newests version on top + // this will not be true, and we don't modify the content + content = newcontent; + } + } showHassioMarkdownDialog(this, { title: this.supervisor.localize("addon.dashboard.changelog"), content, diff --git a/hassio/src/dialogs/update/dialog-supervisor-update.ts b/hassio/src/dialogs/update/dialog-supervisor-update.ts index e017c9d2f9..4e27303681 100644 --- a/hassio/src/dialogs/update/dialog-supervisor-update.ts +++ b/hassio/src/dialogs/update/dialog-supervisor-update.ts @@ -158,8 +158,8 @@ class DialogSupervisorUpdate extends LitElement { } catch (err) { if (this.hass.connection.connected && !ignoreSupervisorError(err)) { this._error = extractApiErrorMessage(err); + this._action = null; } - this._action = null; return; } diff --git a/package.json b/package.json index 5e99d75f81..9f4854d6f1 100644 --- a/package.json +++ b/package.json @@ -44,20 +44,21 @@ "@fullcalendar/list": "5.1.0", "@lit-labs/virtualizer": "^0.6.0", "@material/chips": "=12.0.0-canary.1a8d06483.0", - "@material/mwc-button": "canary", - "@material/mwc-checkbox": "canary", - "@material/mwc-circular-progress": "canary", - "@material/mwc-dialog": "canary", - "@material/mwc-fab": "canary", - "@material/mwc-formfield": "canary", - "@material/mwc-icon-button": "canary", - "@material/mwc-list": "canary", - "@material/mwc-menu": "canary", - "@material/mwc-radio": "canary", - "@material/mwc-ripple": "canary", - "@material/mwc-switch": "canary", - "@material/mwc-tab": "canary", - "@material/mwc-tab-bar": "canary", + "@material/mwc-button": "0.22.0-canary.cc04657a.0", + "@material/mwc-checkbox": "0.22.0-canary.cc04657a.0", + "@material/mwc-circular-progress": "0.22.0-canary.cc04657a.0", + "@material/mwc-dialog": "0.22.0-canary.cc04657a.0", + "@material/mwc-fab": "0.22.0-canary.cc04657a.0", + "@material/mwc-formfield": "0.22.0-canary.cc04657a.0", + "@material/mwc-icon-button": "0.22.0-canary.cc04657a.0", + "@material/mwc-linear-progress": "0.22.0-canary.cc04657a.0", + "@material/mwc-list": "0.22.0-canary.cc04657a.0", + "@material/mwc-menu": "0.22.0-canary.cc04657a.0", + "@material/mwc-radio": "0.22.0-canary.cc04657a.0", + "@material/mwc-ripple": "0.22.0-canary.cc04657a.0", + "@material/mwc-switch": "0.22.0-canary.cc04657a.0", + "@material/mwc-tab": "0.22.0-canary.cc04657a.0", + "@material/mwc-tab-bar": "0.22.0-canary.cc04657a.0", "@material/top-app-bar": "=12.0.0-canary.1a8d06483.0", "@mdi/js": "5.9.55", "@mdi/svg": "5.9.55", @@ -96,11 +97,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/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index 85dcf053e9..c04f91acc9 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -7,6 +7,7 @@ import { PropertyValues, TemplateResult, } from "lit"; +import "./ha-password-manager-polyfill"; import { property, state } from "lit/decorators"; import "../components/ha-form/ha-form"; import "../components/ha-markdown"; @@ -20,7 +21,7 @@ import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin"; type State = "loading" | "error" | "step"; class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { - @property() public authProvider?: AuthProvider; + @property({ attribute: false }) public authProvider?: AuthProvider; @property() public clientId?: string; @@ -37,7 +38,15 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { @state() private _errorMessage?: string; protected render() { - return html`
${this._renderForm()}
`; + return html` +
${this._renderForm()}
+ + `; } protected firstUpdated(changedProps: PropertyValues) { @@ -231,11 +240,17 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { await this.updateComplete; // 100ms to give all the form elements time to initialize. setTimeout(() => { - const form = this.shadowRoot!.querySelector("ha-form"); + const form = this.renderRoot.querySelector("ha-form"); if (form) { (form as any).focus(); } }, 100); + + setTimeout(() => { + this.renderRoot.querySelector( + "ha-password-manager-polyfill" + )!.boundingRect = this.getBoundingClientRect(); + }, 500); } private _stepDataChanged(ev: CustomEvent) { @@ -329,3 +344,9 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { } } customElements.define("ha-auth-flow", HaAuthFlow); + +declare global { + interface HTMLElementTagNameMap { + "ha-auth-flow": HaAuthFlow; + } +} diff --git a/src/auth/ha-password-manager-polyfill.ts b/src/auth/ha-password-manager-polyfill.ts new file mode 100644 index 0000000000..1a2e765e05 --- /dev/null +++ b/src/auth/ha-password-manager-polyfill.ts @@ -0,0 +1,110 @@ +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { HaFormSchema } from "../components/ha-form/ha-form"; +import { DataEntryFlowStep } from "../data/data_entry_flow"; + +declare global { + interface HTMLElementTagNameMap { + "ha-password-manager-polyfill": HaPasswordManagerPolyfill; + } + interface HASSDomEvents { + "form-submitted": undefined; + } +} + +const ENABLED_HANDLERS = [ + "homeassistant", + "legacy_api_password", + "command_line", +]; + +@customElement("ha-password-manager-polyfill") +export class HaPasswordManagerPolyfill extends LitElement { + @property({ attribute: false }) public step?: DataEntryFlowStep; + + @property({ attribute: false }) public stepData: any; + + @property({ attribute: false }) public boundingRect?: DOMRect; + + protected createRenderRoot() { + // Add under document body so the element isn't placed inside any shadow roots + return document.body; + } + + private get styles() { + return ` + .password-manager-polyfill { + position: absolute; + top: ${this.boundingRect?.y || 148}px; + left: calc(50% - ${(this.boundingRect?.width || 360) / 2}px); + width: ${this.boundingRect?.width || 360}px; + opacity: 0; + z-index: -1; + } + .password-manager-polyfill input { + width: 100%; + height: 62px; + padding: 0; + border: 0; + } + .password-manager-polyfill input[type="submit"] { + width: 0; + height: 0; + } + `; + } + + protected render(): TemplateResult { + if ( + this.step && + this.step.type === "form" && + this.step.step_id === "init" && + ENABLED_HANDLERS.includes(this.step.handler[0]) + ) { + return html` + + `; + } + return html``; + } + + private render_input(schema: HaFormSchema): TemplateResult | string { + const inputType = schema.name.includes("password") ? "password" : "text"; + if (schema.type !== "string") { + return ""; + } + return html` + + `; + } + + private _handleSubmit(ev: Event) { + ev.preventDefault(); + fireEvent(this, "form-submitted"); + } + + private _valueChanged(ev: Event) { + const target = ev.target! as HTMLInputElement; + this.stepData = { ...this.stepData, [target.id]: target.value }; + fireEvent(this, "value-changed", { + value: this.stepData, + }); + } +} 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/const.ts b/src/common/const.ts index 6ec749d662..40374552e0 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -42,6 +42,7 @@ export const FIXED_DOMAIN_ICONS = { remote: "hass:remote", scene: "hass:palette", script: "hass:script-text", + select: "hass:format-list-bulleted", sensor: "hass:eye", simple_alarm: "hass:bell", sun: "hass:white-balance-sunny", @@ -83,6 +84,7 @@ export const DOMAINS_WITH_CARD = [ "number", "scene", "script", + "select", "timer", "vacuum", "water_heater", @@ -121,6 +123,7 @@ export const DOMAINS_HIDE_MORE_INFO = [ "input_text", "number", "scene", + "select", ]; /** Domains that should have the history hidden in the more info dialog. */ 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/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 8165f6daf7..c5eb00c2d7 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -29,37 +29,61 @@ export const computeStateDisplay = ( const domain = computeStateDomain(stateObj); if (domain === "input_datetime") { - let date: Date; - if (!stateObj.attributes.has_time) { + if (state) { + // If trying to display an explicit state, need to parse the explict state to `Date` then format. + // Attributes aren't available, we have to use `state`. + try { + const components = state.split(" "); + if (components.length === 2) { + // Date and time. + return formatDateTime(new Date(components.join("T")), locale); + } + if (components.length === 1) { + if (state.includes("-")) { + // Date only. + return formatDate(new Date(`${state}T00:00`), locale); + } + if (state.includes(":")) { + // Time only. + const now = new Date(); + return formatTime( + new Date(`${now.toISOString().split("T")[0]}T${state}`), + locale + ); + } + } + return state; + } catch { + // Formatting methods may throw error if date parsing doesn't go well, + // just return the state string in that case. + return state; + } + } else { + // If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format. + let date: Date; + if (!stateObj.attributes.has_time) { + date = new Date( + stateObj.attributes.year, + stateObj.attributes.month - 1, + stateObj.attributes.day + ); + return formatDate(date, locale); + } + if (!stateObj.attributes.has_date) { + date = new Date(); + date.setHours(stateObj.attributes.hour, stateObj.attributes.minute); + return formatTime(date, locale); + } + date = new Date( stateObj.attributes.year, stateObj.attributes.month - 1, - stateObj.attributes.day - ); - return formatDate(date, locale); - } - if (!stateObj.attributes.has_date) { - const now = new Date(); - date = new Date( - // Due to bugs.chromium.org/p/chromium/issues/detail?id=797548 - // don't use artificial 1970 year. - now.getFullYear(), - now.getMonth(), - now.getDay(), + stateObj.attributes.day, stateObj.attributes.hour, stateObj.attributes.minute ); - return formatTime(date, locale); + return formatDateTime(date, locale); } - - date = new Date( - stateObj.attributes.year, - stateObj.attributes.month - 1, - stateObj.attributes.day, - stateObj.attributes.hour, - stateObj.attributes.minute - ); - return formatDateTime(date, locale); } if (domain === "humidifier") { 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..142f8252f7 --- /dev/null +++ b/src/components/chart/ha-chart-base.ts @@ -0,0 +1,315 @@ +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"; + +interface Tooltip extends TooltipModel { + top: string; + left: string; +} + +@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?: Tooltip; + + @state() private _height?: string; + + @state() private _hiddenDatasets: Set = new Set(); + + protected firstUpdated() { + this._setupChart(); + this.data.datasets.forEach((dataset, index) => { + if (dataset.hidden) { + this._hiddenDatasets.add(index); + } + }); + } + + 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.options?.plugins?.legend?.display === true + ? html`
+
    + ${this.data.datasets.map( + (dataset, index) => html`
  • +
    + ${dataset.label} +
  • ` + )} +
+
` + : ""} +
+ + ${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), + }, + legend: { + ...this.options?.plugins?.legend, + display: false, + }, + }, + }; + } + + private _legendClick(ev) { + if (!this.chart) { + return; + } + const index = ev.currentTarget.datasetIndex; + if (this.chart.isDatasetVisible(index)) { + this.chart.setDatasetVisibility(index, false); + this._hiddenDatasets.add(index); + } else { + this.chart.setDatasetVisibility(index, true); + this._hiddenDatasets.delete(index); + } + this.chart.update("none"); + this.requestUpdate("_hiddenDatasets"); + } + + private _handleTooltip(context: { + chart: Chart; + tooltip: TooltipModel; + }) { + if (context.tooltip.opacity === 0) { + this._tooltip = undefined; + return; + } + this._tooltip = { + ...context.tooltip, + top: this.chart!.canvas.offsetTop + context.tooltip.caretY + 12 + "px", + left: + this.chart!.canvas.offsetLeft + + clamp(context.tooltip.caretX, 100, this.clientWidth - 100) - + 100 + + "px", + }; + } + + public updateChart = (): void => { + if (this.chart) { + this.chart.update(); + } + }; + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + } + .chartContainer { + overflow: hidden; + height: 0; + transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .chartLegend { + text-align: center; + } + .chartLegend li { + cursor: pointer; + display: inline-flex; + padding: 0 8px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + box-sizing: border-box; + align-items: center; + color: var(--secondary-text-color); + } + .chartLegend .hidden { + text-decoration: line-through; + } + .chartLegend .bullet, + .chartTooltip .bullet { + border-width: 1px; + border-style: solid; + border-radius: 50%; + display: inline-block; + height: 16px; + margin-right: 4px; + width: 16px; + flex-shrink: 0; + box-sizing: border-box; + } + .chartTooltip .bullet { + align-self: baseline; + } + :host([rtl]) .chartTooltip .bullet { + margin-right: inherit; + margin-left: 4px; + } + .chartTooltip { + padding: 8px; + font-size: 90%; + position: absolute; + background: rgba(80, 80, 80, 0.9); + color: white; + border-radius: 4px; + pointer-events: none; + z-index: 1000; + width: 200px; + box-sizing: border-box; + } + :host([rtl]) .chartTooltip { + direction: rtl; + } + .chartLegend ul, + .chartTooltip ul { + display: inline-block; + padding: 0 0px; + margin: 8px 0 0 0; + width: 100%; + } + .chartTooltip ul { + margin: 0 4px; + } + .chartTooltip li { + display: flex; + white-space: pre-line; + align-items: center; + line-height: 16px; + } + .chartTooltip .title { + text-align: center; + font-weight: 500; + } + .chartTooltip .beforeBody { + text-align: center; + font-weight: 300; + word-break: break-all; + } + `; + } +} + +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/ha-form/ha-form-select.ts b/src/components/ha-form/ha-form-select.ts index 5cf9dc013d..c796bc8baf 100644 --- a/src/components/ha-form/ha-form-select.ts +++ b/src/components/ha-form/ha-form-select.ts @@ -1,14 +1,19 @@ +import "@material/mwc-icon-button/mwc-icon-button"; +import { mdiClose, mdiMenuDown } from "@mdi/js"; +import "@polymer/paper-input/paper-input"; import "@polymer/paper-item/paper-item"; import "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-menu-button/paper-menu-button"; +import "@polymer/paper-ripple/paper-ripple"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; -import "../ha-paper-dropdown-menu"; +import "../ha-svg-icon"; import { HaFormElement, HaFormSelectData, HaFormSelectSchema } from "./ha-form"; @customElement("ha-form-select") export class HaFormSelect extends LitElement implements HaFormElement { - @property() public schema!: HaFormSelectSchema; + @property({ attribute: false }) public schema!: HaFormSelectSchema; @property() public data!: HaFormSelectData; @@ -26,7 +31,33 @@ export class HaFormSelect extends LitElement implements HaFormElement { protected render(): TemplateResult { return html` - + + - + `; } @@ -57,6 +88,11 @@ export class HaFormSelect extends LitElement implements HaFormElement { return Array.isArray(item) ? item[1] || item[0] : item; } + private _clearValue(ev: CustomEvent) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { value: undefined }); + } + private _valueChanged(ev: CustomEvent) { if (!ev.detail.value) { return; @@ -68,8 +104,16 @@ export class HaFormSelect extends LitElement implements HaFormElement { static get styles(): CSSResultGroup { return css` - ha-paper-dropdown-menu { + paper-menu-button { display: block; + padding: 0; + } + paper-input > mwc-icon-button { + --mdc-icon-button-size: 24px; + padding: 2px; + } + .clear-button { + color: var(--secondary-text-color); } `; } diff --git a/src/components/ha-hls-player.ts b/src/components/ha-hls-player.ts index 6120e5aa91..17d3157884 100644 --- a/src/components/ha-hls-player.ts +++ b/src/components/ha-hls-player.ts @@ -7,7 +7,7 @@ import { PropertyValues, TemplateResult, } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; import { nextRender } from "../common/util/render-status"; import { getExternalConfig } from "../external_app/external_config"; @@ -42,27 +42,23 @@ class HaHLSPlayer extends LitElement { // don't cache this, as we remove it on disconnects @query("video") private _videoEl!: HTMLVideoElement; - @state() private _attached = false; - private _hlsPolyfillInstance?: HlsLite; - private _useExoPlayer = false; + private _exoPlayer = false; public connectedCallback() { super.connectedCallback(); - this._attached = true; + if (this.hasUpdated) { + this._startHls(); + } } public disconnectedCallback() { super.disconnectedCallback(); - this._attached = false; + this._cleanUp(); } protected render(): TemplateResult { - if (!this._attached) { - return html``; - } - return html`