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` `;
+ return html`
+
+
+ `;
}
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`
`
+ : ""}
+
+ `;
+ }
+
+ 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]]
-
- [[tooltip.beforeBody]]
-
-
-
-
- -
- [[item.text]]
-
-
-
-
-
-
- `;
- }
-
- 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`
-
+
+
+
+
+ ${this.data && this.schema.optional
+ ? 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`