-
Dev Datapoints
-
-
Currently recorded: — dev data points
+ }
+ };
+ _defineProperty(CardDevToolWindows, "styles", styles$56);
+ __decorate([n$1({ attribute: false })], CardDevToolWindows.prototype, "windows", null);
+ __decorate([r$1()], CardDevToolWindows.prototype, "_nextWindowId", null);
+ customElements.define("dev-tool-windows", CardDevToolWindows);
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/dev-tool/dev-tool.ts
+ var HassRecordsDevToolCard = class extends HTMLElement {
+ constructor() {
+ super();
+ _defineProperty(this, "_config", {});
+ _defineProperty(this, "_hass", null);
+ _defineProperty(this, "_rendered", false);
+ _defineProperty(this, "_entities", []);
+ _defineProperty(this, "_suppressEntityChange", false);
+ _defineProperty(this, "_results", []);
+ this.attachShadow({ mode: "open" });
+ }
+ setConfig(config) {
+ this._config = config || {};
+ }
+ set hass(hass) {
+ this._hass = hass;
+ if (!this._rendered) {
+ this._render();
+ this._refreshDevCount();
+ }
+ this._updateHassOnChildren();
+ }
+ _updateHassOnChildren() {
+ if (!this.shadowRoot || !this._hass) return;
+ const entityPicker = this.shadowRoot.getElementById("entity-picker");
+ if (!entityPicker) return;
+ this._suppressEntityChange = true;
+ entityPicker.hass = this._hass;
+ entityPicker.value = this._entities;
+ setTimeout(() => {
+ this._suppressEntityChange = false;
+ }, 100);
+ const resultsEl = this.shadowRoot.getElementById("results-container");
+ if (resultsEl) resultsEl.isAdmin = this._hass.user?.is_admin === true;
+ }
+ _render() {
+ this._rendered = true;
+ const cfg = this._config;
+ if (!this.shadowRoot.adoptedStyleSheets.length) {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(styles$58);
+ this.shadowRoot.adoptedStyleSheets = [sheet];
+ }
+ D(b`
+
+ ${cfg.title ? b`` : ""}
+ Analyze HA History
+
+
- Delete all dev datapoints
-
-
-
- `;
- const entityPicker = this.shadowRoot.getElementById(
- "entity-picker"
- );
- if (entityPicker) {
- entityPicker.selector = { entity: { multiple: true } };
- entityPicker.value = [];
- this._entities = [];
- this._suppressEntityChange = false;
- entityPicker.addEventListener("value-changed", (event) => {
- if (this._suppressEntityChange) {
- return;
- }
- const value = event.detail.value;
- if (Array.isArray(value)) {
- this._entities = value;
- } else if (value) {
- this._entities = [value];
- } else {
- this._entities = [];
- }
- });
- }
- this.shadowRoot.getElementById("analyze-btn").addEventListener(
- "click",
- () => {
- this._analyzeHistory();
- }
- );
- this.shadowRoot.getElementById("delete-dev-btn").addEventListener(
- "click",
- () => {
- this._deleteAllDev();
- }
- );
- this.shadowRoot.getElementById("results-container").addEventListener(
- "dp-record-selected-request",
- (event) => {
- const detail = event.detail;
- this._recordSelected(detail.items);
- }
- );
- }
- _readWindowConfigs() {
- const windowsEditor = this.shadowRoot.getElementById(
- "windows-editor"
- );
- return windowsEditor.getWindowConfigs().map((windowConfig, index) => ({
- ...windowConfig,
- label: windowConfig.label.trim() || `Window ${index + 1}`
- }));
- }
- async _analyzeHistory() {
- if (!this._entities.length) {
- this._showFeedback(
- "analyze-status",
- "err",
- "Please select at least one entity."
- );
- return;
- }
- const windowConfigs = this._readWindowConfigs();
- const button = this.shadowRoot.getElementById(
- "analyze-btn"
- );
- button.disabled = true;
- this._results = [];
- this._showFeedback(
- "analyze-status",
- "ok",
- `Fetching history for ${windowConfigs.length} window${windowConfigs.length === 1 ? "" : "s"}…`
- );
- try {
- const now = /* @__PURE__ */ new Date();
- this._results = await Promise.all(
- windowConfigs.map(async (windowConfig) => {
- const start = windowConfig.startDt ? new Date(windowConfig.startDt) : now;
- const end = windowConfig.endDt ? new Date(windowConfig.endDt) : now;
- const raw = await this._hass.connection.sendMessagePromise({
- type: "history/history_during_period",
- start_time: start.toISOString(),
- end_time: end.toISOString(),
- entity_ids: this._entities,
- include_start_time_state: false,
- significant_changes_only: false,
- no_attributes: false
- });
- const changes = this._detectChanges(
- raw || {}
- );
- return {
- id: windowConfig.id,
- label: windowConfig.label,
- startDt: windowConfig.startDt,
- endDt: windowConfig.endDt,
- changes,
- selected: changes.map((_2, index) => index)
- };
- })
- );
- this._renderResults();
- this._hideFeedback("analyze-status");
- } catch (err) {
- this._showFeedback(
- "analyze-status",
- "err",
- `Error: ${err.message || "Failed to fetch history"}`
- );
- logger$1.error("[hass-datapoints dev-tool]", err);
- }
- button.disabled = false;
- }
- _detectChanges(histResult) {
- const changes = [];
- for (const [entityId, statesRaw] of Object.entries(histResult)) {
- const states = statesRaw;
- if (!states?.length) {
- continue;
- }
- const domain = entityId.split(".")[0];
- const entityState = this._hass?.states?.[entityId];
- const deviceClass = entityState?.attributes?.device_class || "";
- const friendlyName = entityState?.attributes?.friendly_name || entityId;
- const unit = entityState?.attributes?.unit_of_measurement || "";
- for (let i2 = 0; i2 < states.length; i2 += 1) {
- const state = states[i2];
- const previous = i2 > 0 ? states[i2 - 1] : null;
- const currentValue = state.s;
- const previousValue = previous?.s ?? null;
- if (currentValue === "unavailable" || currentValue === "unknown") {
- continue;
- }
- if (previous && previousValue === currentValue) {
- if (domain !== "climate") {
- continue;
- }
- }
- const timestampRaw = state.lc ?? state.lu;
- const timestamp = timestampRaw != null ? new Date(timestampRaw * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
- let message = null;
- let icon = "mdi:bookmark";
- let color = "#03a9f4";
- if (domain === "binary_sensor" || domain === "input_boolean") {
- message = `${friendlyName}: ${this._binaryLabel(deviceClass, currentValue)}`;
- icon = currentValue === "on" ? "mdi:toggle-switch" : "mdi:toggle-switch-off";
- color = currentValue === "on" ? "#4caf50" : "#9e9e9e";
- } else if (domain === "switch") {
- message = `${friendlyName}: turned ${currentValue === "on" ? "on" : "off"}`;
- icon = currentValue === "on" ? "mdi:power-plug" : "mdi:power-plug-off";
- color = currentValue === "on" ? "#ff9800" : "#9e9e9e";
- } else if (domain === "light") {
- message = `${friendlyName}: ${currentValue === "on" ? "on" : "off"}`;
- icon = currentValue === "on" ? "mdi:lightbulb" : "mdi:lightbulb-off";
- color = currentValue === "on" ? "#ffee58" : "#9e9e9e";
- } else if (domain === "cover") {
- const labels = {
- open: "opened",
- closed: "closed",
- opening: "opening",
- closing: "closing"
- };
- if (!labels[currentValue]) {
- continue;
- }
- message = `${friendlyName}: ${labels[currentValue]}`;
- icon = currentValue === "open" || currentValue === "opening" ? "mdi:garage-open" : "mdi:garage";
- color = currentValue === "open" ? "#4caf50" : "#795548";
- } else if (domain === "climate") {
- const stateAttributes = state.a;
- const previousAttributes = previous?.a;
- const currentTemperature = stateAttributes?.temperature;
- const previousTemperature = previousAttributes?.temperature;
- if (currentTemperature != null && currentTemperature !== previousTemperature) {
- const temperatureUnit = stateAttributes?.temperature_unit || unit || "°";
- message = `${friendlyName}: setpoint → ${currentTemperature}${temperatureUnit}`;
- icon = "mdi:thermostat";
- color = "#ff5722";
- } else if (!previous || previousValue !== currentValue) {
- const modes = {
- heat: "heating",
- cool: "cooling",
- auto: "auto",
- off: "off",
- heat_cool: "heat/cool",
- fan_only: "fan only",
- dry: "dry"
- };
- message = `${friendlyName}: mode → ${modes[currentValue] || currentValue}`;
- icon = "mdi:thermostat";
- color = "#ff5722";
- } else {
- continue;
- }
- } else if (domain === "sensor") {
- const currentNumber = parseFloat(currentValue);
- const previousNumber = previousValue != null ? parseFloat(previousValue) : Number.NaN;
- if (Number.isNaN(currentNumber)) {
- continue;
- }
- if (!Number.isNaN(previousNumber) && Math.abs(currentNumber - previousNumber) < 0.5) {
- continue;
- }
- message = `${friendlyName}: ${currentValue}${unit}`;
- icon = "mdi:gauge";
- color = "#2196f3";
- } else if (domain === "input_number" || domain === "number") {
- const currentNumber = parseFloat(currentValue);
- const previousNumber = previousValue != null ? parseFloat(previousValue) : Number.NaN;
- if (Number.isNaN(currentNumber)) {
- continue;
- }
- if (!Number.isNaN(previousNumber) && currentNumber === previousNumber) {
- continue;
- }
- message = `${friendlyName}: → ${currentValue}${unit}`;
- icon = "mdi:numeric";
- color = "#9c27b0";
- } else if (domain === "input_select" || domain === "select") {
- if (!previous || previousValue === currentValue) {
- continue;
- }
- message = `${friendlyName}: → ${currentValue}`;
- icon = "mdi:form-select";
- color = "#009688";
- } else {
- if (!previous || previousValue === currentValue) {
- continue;
- }
- message = `${friendlyName}: ${previousValue} → ${currentValue}`;
- icon = "mdi:swap-horizontal";
- color = "#607d8b";
- }
- if (!message) {
- continue;
- }
- changes.push({
- timestamp,
- message,
- entity_id: entityId,
- icon,
- color
- });
- }
- }
- changes.sort((a2, b2) => a2.timestamp < b2.timestamp ? -1 : 1);
- return changes;
- }
- _binaryLabel(deviceClass, state) {
- const on = state === "on";
- const map = {
- door: ["opened", "closed"],
- window: ["opened", "closed"],
- garage_door: ["opened", "closed"],
- opening: ["opened", "closed"],
- lock: ["locked", "unlocked"],
- motion: ["motion detected", "motion cleared"],
- occupancy: ["occupied", "vacant"],
- presence: ["home", "away"],
- vibration: ["vibrating", "still"],
- plug: ["plugged in", "unplugged"],
- outlet: ["on", "off"],
- smoke: ["smoke detected", "smoke cleared"],
- moisture: ["wet", "dry"],
- running: ["running", "stopped"],
- connectivity: ["connected", "disconnected"],
- power: ["on", "off"],
- battery_charging: ["charging", "not charging"],
- battery: ["low battery", "battery normal"],
- cold: ["cold", "temperature normal"],
- heat: ["heat", "temperature normal"],
- light: ["light detected", "dark"],
- sound: ["sound detected", "quiet"]
- };
- const pair = map[deviceClass];
- if (pair) {
- return on ? pair[0] : pair[1];
- }
- return on ? "on" : "off";
- }
- _renderResults() {
- const resultsContainer = this.shadowRoot.getElementById(
- "results-container"
- );
- resultsContainer.results = [...this._results];
- resultsContainer.statusKind = "";
- resultsContainer.statusText = "";
- resultsContainer.statusVisible = false;
- }
- async _recordSelected(items) {
- if (!items.length) {
- this._showResultsStatus("err", "No items selected.");
- return;
- }
- this._showResultsStatus(
- "ok",
- `Recording ${items.length} data point${items.length === 1 ? "" : "s"}…`
- );
- const results = await Promise.allSettled(
- items.map(
- (item) => this._hass.callService(DOMAIN, "record", {
- message: item.message,
- entity_ids: [item.entity_id],
- icon: item.icon,
- color: item.color,
- date: item.timestamp,
- dev: true
- })
- )
- );
- const ok = results.filter((result) => result.status === "fulfilled").length;
- const fail = results.filter(
- (result) => result.status === "rejected"
- ).length;
- if (fail) {
- this._showResultsStatus("err", `Recorded ${ok}, failed ${fail}.`);
- } else {
- this._showResultsStatus(
- "ok",
- `Recorded ${ok} dev data point${ok === 1 ? "" : "s"}!`
- );
- }
- await this._refreshDevCount();
- window.dispatchEvent(new CustomEvent("hass-datapoints-event-recorded"));
- }
- async _deleteAllDev() {
- const devCountEl = this.shadowRoot.getElementById("dev-count");
- const count = parseInt(devCountEl?.textContent ?? "0", 10) || 0;
- if (count === 0) {
- this._showFeedback(
- "delete-status",
- "err",
- "No dev datapoints to delete."
- );
- return;
- }
- const confirmed = await confirmDestructiveAction(this, {
- title: "Delete dev datapoints",
- message: `Delete all ${count} dev data point${count === 1 ? "" : "s"}?`,
- confirmLabel: "Delete all"
- });
- if (!confirmed) {
- return;
- }
- const button = this.shadowRoot.getElementById(
- "delete-dev-btn"
- );
- button.disabled = true;
- try {
- const result = await this._hass.connection.sendMessagePromise({
- type: `${DOMAIN}/events/delete_dev`
- });
- const deleted = result.deleted;
- this._showFeedback(
- "delete-status",
- "ok",
- `Deleted ${deleted} dev data point${deleted === 1 ? "" : "s"}.`
- );
- await this._refreshDevCount();
- window.dispatchEvent(new CustomEvent("hass-datapoints-event-recorded"));
- } catch (err) {
- this._showFeedback(
- "delete-status",
- "err",
- `Error: ${err.message || "failed"}`
- );
- }
- button.disabled = false;
- }
- async _refreshDevCount() {
- try {
- const result = await this._hass.connection.sendMessagePromise({
- type: `${DOMAIN}/events`
- });
- const events = result.events || [];
- const count = events.filter((event) => event.dev).length;
- const countEl = this.shadowRoot.getElementById("dev-count");
- const pluralEl = this.shadowRoot.getElementById("dev-count-plural");
- if (countEl) {
- countEl.textContent = String(count);
- }
- if (pluralEl) {
- pluralEl.textContent = count === 1 ? "" : "s";
- }
- } catch (error) {
- logger$1.warn("[hass-datapoints dev-tool] refresh dev count failed", error);
- }
- }
- _showFeedback(id, kind, text) {
- const el = this.shadowRoot.getElementById(id);
- if (!el) {
- return;
- }
- el.kind = kind;
- el.text = text;
- el.visible = true;
- }
- _hideFeedback(id) {
- const el = this.shadowRoot.getElementById(id);
- if (!el) {
- return;
- }
- el.visible = false;
- }
- _showResultsStatus(kind, text) {
- const el = this.shadowRoot.getElementById(
- "results-container"
- );
- el.statusKind = kind;
- el.statusText = text;
- el.statusVisible = true;
- }
- static getStubConfig() {
- return { title: "Dev Tool" };
- }
- static getConfigElement() {
- return document.createElement("hass-datapoints-dev-tool-card-editor");
- }
- }
- var __defProp$X = Object.defineProperty;
- var __defNormalProp$X = (obj, key, value) => key in obj ? __defProp$X(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
- var __publicField$X = (obj, key, value) => __defNormalProp$X(obj, typeof key !== "symbol" ? key + "" : key, value);
- class ChartCardBase extends i$2 {
- constructor() {
- super(...arguments);
- __publicField$X(this, "_hass");
- __publicField$X(this, "_config", {});
- __publicField$X(this, "_loadRequestId", 0);
- __publicField$X(this, "_lastDrawArgs", []);
- __publicField$X(this, "_previousSeriesEndpoints", /* @__PURE__ */ new Map());
- __publicField$X(this, "_unsubscribe", null);
- __publicField$X(this, "_resizeObserver", null);
- __publicField$X(this, "_loadRaf", null);
- __publicField$X(this, "_loadInFlight", false);
- __publicField$X(this, "_hasStartedInitialLoad", false);
- __publicField$X(this, "_windowListener", null);
- __publicField$X(this, "_initialized", false);
- }
- // ── Lovelace API ──────────────────────────────────────────────────────────
- setConfig(config) {
- this._config = config ?? {};
- this.requestUpdate();
- }
- set hass(hass) {
- this._hass = hass;
- this.requestUpdate();
- if (this._hasStartedInitialLoad) {
- this._scheduleLoad();
- }
- }
- get hass() {
- return this._hass;
- }
- // ── Lifecycle ─────────────────────────────────────────────────────────────
- connectedCallback() {
- super.connectedCallback();
- if (this._initialized) {
- if (!this._unsubscribe || !this._windowListener) {
- this._setupAutoRefresh();
- }
- this._setupResizeObserver();
- }
- if (this._hass) {
- this._scheduleLoad();
- }
- }
- updated() {
- if (!this._initialized && this._hass) {
- this._initialized = true;
- this._setupAutoRefresh();
- this._setupResizeObserver();
- this._scheduleLoad();
- }
- }
- disconnectedCallback() {
- super.disconnectedCallback();
- this._cleanup();
- }
- // ── Scheduling ────────────────────────────────────────────────────────────
- _scheduleLoad() {
- if (!this._hass || this._loadRaf !== null || this._loadInFlight) return;
- this._loadRaf = window.requestAnimationFrame(() => {
- this._loadRaf = null;
- if (!this._hass || !this.isConnected || this._loadInFlight) return;
- this._hasStartedInitialLoad = true;
- this._loadInFlight = true;
- Promise.resolve(this._load()).catch((err) => {
- logger$1.error("[hass-datapoints chart-base] load failed", err);
- }).finally(() => {
- this._loadInFlight = false;
- });
- });
- }
- // ── Setup helpers ─────────────────────────────────────────────────────────
- _setupAutoRefresh() {
- if (!this._hass) return;
- if (this._unsubscribe || this._windowListener) return;
- this._hass.connection.subscribeEvents(() => {
- this._scheduleLoad();
- }, `${DOMAIN}_event_recorded`).then((unsub) => {
- this._unsubscribe = unsub;
- }).catch(() => {
- });
- this._windowListener = () => {
- this._scheduleLoad();
- };
- window.addEventListener(
- "hass-datapoints-event-recorded",
- this._windowListener
- );
- }
- _setupResizeObserver() {
- if (this._resizeObserver) return;
- const wrap = this.shadowRoot?.querySelector(".chart-wrap") ?? this.shadowRoot?.querySelector("hass-datapoints-history-chart");
- if (!wrap || !window.ResizeObserver) return;
- this._resizeObserver = new ResizeObserver(() => {
- if (this._lastDrawArgs.length) {
- this._drawChart(...this._lastDrawArgs);
- }
- });
- this._resizeObserver.observe(wrap);
- }
- // ── Cleanup ───────────────────────────────────────────────────────────────
- _cleanup() {
- if (this._loadRaf !== null) {
- window.cancelAnimationFrame(this._loadRaf);
- this._loadRaf = null;
- }
- if (this._unsubscribe) {
- this._unsubscribe();
- this._unsubscribe = null;
- }
- if (this._windowListener) {
- window.removeEventListener(
- "hass-datapoints-event-recorded",
- this._windowListener
- );
- this._windowListener = null;
- }
- if (this._resizeObserver) {
- this._resizeObserver.disconnect();
- this._resizeObserver = null;
- }
- }
- // ── Static API ────────────────────────────────────────────────────────────
- static getStubConfig() {
- return { title: "" };
- }
- }
- const styles$T = i$5`
+
+
+ Analyze all windows
+
+
+
+
+
+
Dev Datapoints
+
+ Currently recorded: — dev data points
+
+
Delete all dev datapoints
+
+
+
+ `, this.shadowRoot);
+ const entityPicker = this.shadowRoot.getElementById("entity-picker");
+ if (entityPicker) {
+ entityPicker.selector = { entity: { multiple: true } };
+ entityPicker.value = [];
+ this._entities = [];
+ this._suppressEntityChange = false;
+ entityPicker.addEventListener("value-changed", (event) => {
+ if (this._suppressEntityChange) return;
+ const value = event.detail.value;
+ if (Array.isArray(value)) this._entities = value;
+ else if (value) this._entities = [value];
+ else this._entities = [];
+ });
+ }
+ this.shadowRoot.getElementById("analyze-btn").addEventListener("click", () => {
+ this._analyzeHistory();
+ });
+ this.shadowRoot.getElementById("delete-dev-btn").addEventListener("click", () => {
+ this._deleteAllDev();
+ });
+ this.shadowRoot.getElementById("results-container").addEventListener("dp-record-selected-request", (event) => {
+ const detail = event.detail;
+ this._recordSelected(detail.items);
+ });
+ }
+ _readWindowConfigs() {
+ return this.shadowRoot.getElementById("windows-editor").getWindowConfigs().map((windowConfig, index) => ({
+ ...windowConfig,
+ label: windowConfig.label.trim() || `Window ${index + 1}`
+ }));
+ }
+ async _analyzeHistory() {
+ if (!this._entities.length) {
+ this._showFeedback("analyze-status", "err", "Please select at least one entity.");
+ return;
+ }
+ const windowConfigs = this._readWindowConfigs();
+ const button = this.shadowRoot.getElementById("analyze-btn");
+ button.disabled = true;
+ this._results = [];
+ this._showFeedback("analyze-status", "ok", `Fetching history for ${windowConfigs.length} window${windowConfigs.length === 1 ? "" : "s"}…`);
+ try {
+ const now = /* @__PURE__ */ new Date();
+ this._results = await Promise.all(windowConfigs.map(async (windowConfig) => {
+ const start = windowConfig.startDt ? new Date(windowConfig.startDt) : now;
+ const end = windowConfig.endDt ? new Date(windowConfig.endDt) : now;
+ const raw = await this._hass.connection.sendMessagePromise({
+ type: "history/history_during_period",
+ start_time: start.toISOString(),
+ end_time: end.toISOString(),
+ entity_ids: this._entities,
+ include_start_time_state: false,
+ significant_changes_only: false,
+ no_attributes: false
+ });
+ const changes = this._detectChanges(raw || {});
+ return {
+ id: windowConfig.id,
+ label: windowConfig.label,
+ startDt: windowConfig.startDt,
+ endDt: windowConfig.endDt,
+ changes,
+ selected: changes.map((_, index) => index)
+ };
+ }));
+ this._renderResults();
+ this._hideFeedback("analyze-status");
+ } catch (err) {
+ this._showFeedback("analyze-status", "err", `Error: ${err.message || "Failed to fetch history"}`);
+ logger$1.error("[hass-datapoints dev-tool]", err);
+ }
+ button.disabled = false;
+ }
+ _detectChanges(histResult) {
+ const changes = [];
+ for (const [entityId, statesRaw] of Object.entries(histResult)) {
+ const states = statesRaw;
+ if (!states?.length) continue;
+ const domain = entityId.split(".")[0];
+ const entityState = this._hass?.states?.[entityId];
+ const deviceClass = entityState?.attributes?.device_class || "";
+ const friendlyName = entityState?.attributes?.friendly_name || entityId;
+ const unit = entityState?.attributes?.unit_of_measurement || "";
+ for (let i = 0; i < states.length; i += 1) {
+ const state = states[i];
+ const previous = i > 0 ? states[i - 1] : null;
+ const currentValue = state.s;
+ const previousValue = previous?.s ?? null;
+ if (currentValue === "unavailable" || currentValue === "unknown") continue;
+ if (previous && previousValue === currentValue) {
+ if (domain !== "climate") continue;
+ }
+ const timestampRaw = state.lc ?? state.lu;
+ const timestamp = timestampRaw != null ? (/* @__PURE__ */ new Date(timestampRaw * 1e3)).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
+ let message;
+ let icon;
+ let color;
+ if (domain === "binary_sensor" || domain === "input_boolean") {
+ message = `${friendlyName}: ${this._binaryLabel(deviceClass, currentValue)}`;
+ icon = currentValue === "on" ? "mdi:toggle-switch" : "mdi:toggle-switch-off";
+ color = currentValue === "on" ? "#4caf50" : "#9e9e9e";
+ } else if (domain === "switch") {
+ message = `${friendlyName}: turned ${currentValue === "on" ? "on" : "off"}`;
+ icon = currentValue === "on" ? "mdi:power-plug" : "mdi:power-plug-off";
+ color = currentValue === "on" ? "#ff9800" : "#9e9e9e";
+ } else if (domain === "light") {
+ message = `${friendlyName}: ${currentValue === "on" ? "on" : "off"}`;
+ icon = currentValue === "on" ? "mdi:lightbulb" : "mdi:lightbulb-off";
+ color = currentValue === "on" ? "#ffee58" : "#9e9e9e";
+ } else if (domain === "cover") {
+ const labels = {
+ open: "opened",
+ closed: "closed",
+ opening: "opening",
+ closing: "closing"
+ };
+ if (!labels[currentValue]) continue;
+ message = `${friendlyName}: ${labels[currentValue]}`;
+ icon = currentValue === "open" || currentValue === "opening" ? "mdi:garage-open" : "mdi:garage";
+ color = currentValue === "open" ? "#4caf50" : "#795548";
+ } else if (domain === "climate") {
+ const stateAttributes = state.a;
+ const previousAttributes = previous?.a;
+ const currentTemperature = stateAttributes?.temperature;
+ const previousTemperature = previousAttributes?.temperature;
+ if (currentTemperature != null && currentTemperature !== previousTemperature) {
+ message = `${friendlyName}: setpoint → ${currentTemperature}${stateAttributes?.temperature_unit || unit || "°"}`;
+ icon = "mdi:thermostat";
+ color = "#ff5722";
+ } else if (!previous || previousValue !== currentValue) {
+ message = `${friendlyName}: mode → ${{
+ heat: "heating",
+ cool: "cooling",
+ auto: "auto",
+ off: "off",
+ heat_cool: "heat/cool",
+ fan_only: "fan only",
+ dry: "dry"
+ }[currentValue] || currentValue}`;
+ icon = "mdi:thermostat";
+ color = "#ff5722";
+ } else continue;
+ } else if (domain === "sensor") {
+ const currentNumber = parseFloat(currentValue);
+ const previousNumber = previousValue != null ? parseFloat(previousValue) : NaN;
+ if (Number.isNaN(currentNumber)) continue;
+ if (!Number.isNaN(previousNumber) && Math.abs(currentNumber - previousNumber) < .5) continue;
+ message = `${friendlyName}: ${currentValue}${unit}`;
+ icon = "mdi:gauge";
+ color = "#2196f3";
+ } else if (domain === "input_number" || domain === "number") {
+ const currentNumber = parseFloat(currentValue);
+ const previousNumber = previousValue != null ? parseFloat(previousValue) : NaN;
+ if (Number.isNaN(currentNumber)) continue;
+ if (!Number.isNaN(previousNumber) && currentNumber === previousNumber) continue;
+ message = `${friendlyName}: → ${currentValue}${unit}`;
+ icon = "mdi:numeric";
+ color = "#9c27b0";
+ } else if (domain === "input_select" || domain === "select") {
+ if (!previous || previousValue === currentValue) continue;
+ message = `${friendlyName}: → ${currentValue}`;
+ icon = "mdi:form-select";
+ color = "#009688";
+ } else {
+ if (!previous || previousValue === currentValue) continue;
+ message = `${friendlyName}: ${previousValue} → ${currentValue}`;
+ icon = "mdi:swap-horizontal";
+ color = "#607d8b";
+ }
+ if (!message) continue;
+ changes.push({
+ timestamp,
+ message,
+ entity_id: entityId,
+ icon,
+ color
+ });
+ }
+ }
+ changes.sort((a, b) => a.timestamp < b.timestamp ? -1 : 1);
+ return changes;
+ }
+ _binaryLabel(deviceClass, state) {
+ const on = state === "on";
+ const pair = {
+ door: ["opened", "closed"],
+ window: ["opened", "closed"],
+ garage_door: ["opened", "closed"],
+ opening: ["opened", "closed"],
+ lock: ["locked", "unlocked"],
+ motion: ["motion detected", "motion cleared"],
+ occupancy: ["occupied", "vacant"],
+ presence: ["home", "away"],
+ vibration: ["vibrating", "still"],
+ plug: ["plugged in", "unplugged"],
+ outlet: ["on", "off"],
+ smoke: ["smoke detected", "smoke cleared"],
+ moisture: ["wet", "dry"],
+ running: ["running", "stopped"],
+ connectivity: ["connected", "disconnected"],
+ power: ["on", "off"],
+ battery_charging: ["charging", "not charging"],
+ battery: ["low battery", "battery normal"],
+ cold: ["cold", "temperature normal"],
+ heat: ["heat", "temperature normal"],
+ light: ["light detected", "dark"],
+ sound: ["sound detected", "quiet"]
+ }[deviceClass];
+ if (pair) return on ? pair[0] : pair[1];
+ return on ? "on" : "off";
+ }
+ _renderResults() {
+ const resultsContainer = this.shadowRoot.getElementById("results-container");
+ resultsContainer.results = [...this._results];
+ resultsContainer.statusKind = "";
+ resultsContainer.statusText = "";
+ resultsContainer.statusVisible = false;
+ }
+ async _recordSelected(items) {
+ if (!items.length) {
+ this._showResultsStatus("err", "No items selected.");
+ return;
+ }
+ this._showResultsStatus("ok", `Recording ${items.length} data point${items.length === 1 ? "" : "s"}…`);
+ const results = await Promise.allSettled(items.map((item) => this._hass.callService(DOMAIN, "record", {
+ message: item.message,
+ entity_ids: [item.entity_id],
+ icon: item.icon,
+ color: item.color,
+ date: item.timestamp,
+ dev: true
+ })));
+ const ok = results.filter((result) => result.status === "fulfilled").length;
+ const fail = results.filter((result) => result.status === "rejected").length;
+ if (fail) this._showResultsStatus("err", `Recorded ${ok}, failed ${fail}.`);
+ else this._showResultsStatus("ok", `Recorded ${ok} dev data point${ok === 1 ? "" : "s"}!`);
+ await this._refreshDevCount();
+ window.dispatchEvent(new CustomEvent("hass-datapoints-event-recorded"));
+ }
+ async _deleteAllDev() {
+ const devCountEl = this.shadowRoot.getElementById("dev-count");
+ const count = parseInt(devCountEl?.textContent ?? "0", 10) || 0;
+ if (count === 0) {
+ this._showFeedback("delete-status", "err", "No dev datapoints to delete.");
+ return;
+ }
+ if (!await confirmDestructiveAction(this, {
+ title: "Delete dev datapoints",
+ message: `Delete all ${count} dev data point${count === 1 ? "" : "s"}?`,
+ confirmLabel: "Delete all"
+ })) return;
+ const button = this.shadowRoot.getElementById("delete-dev-btn");
+ button.disabled = true;
+ try {
+ const deleted = (await this._hass.connection.sendMessagePromise({ type: `${DOMAIN}/events/delete_dev` })).deleted;
+ this._showFeedback("delete-status", "ok", `Deleted ${deleted} dev data point${deleted === 1 ? "" : "s"}.`);
+ await this._refreshDevCount();
+ window.dispatchEvent(new CustomEvent("hass-datapoints-event-recorded"));
+ } catch (err) {
+ this._showFeedback("delete-status", "err", `Error: ${err.message || "failed"}`);
+ }
+ button.disabled = false;
+ }
+ async _refreshDevCount() {
+ try {
+ const count = ((await this._hass.connection.sendMessagePromise({ type: `hass_datapoints/events` })).events || []).filter((event) => event.dev).length;
+ const countEl = this.shadowRoot.getElementById("dev-count");
+ const pluralEl = this.shadowRoot.getElementById("dev-count-plural");
+ if (countEl) countEl.textContent = String(count);
+ if (pluralEl) pluralEl.textContent = count === 1 ? "" : "s";
+ } catch (error) {
+ logger$1.warn("[hass-datapoints dev-tool] refresh dev count failed", error);
+ }
+ }
+ _showFeedback(id, kind, text) {
+ const el = this.shadowRoot.getElementById(id);
+ if (!el) return;
+ el.kind = kind;
+ el.text = text;
+ el.visible = true;
+ }
+ _hideFeedback(id) {
+ const el = this.shadowRoot.getElementById(id);
+ if (!el) return;
+ el.visible = false;
+ }
+ _showResultsStatus(kind, text) {
+ const el = this.shadowRoot.getElementById("results-container");
+ el.statusKind = kind;
+ el.statusText = text;
+ el.statusVisible = true;
+ }
+ static getStubConfig() {
+ return { title: "Dev Tool" };
+ }
+ static getConfigElement() {
+ return document.createElement("hass-datapoints-dev-tool-card-editor");
+ }
+ };
+ //#endregion
+ //#region custom_components/hass_datapoints/src/charts/base/chart-card-base.ts
+ /**
+ * ChartCardBase – shared LitElement base class for history and statistics
+ * chart cards.
+ *
+ * Handles:
+ * • hass setter + requestUpdate plumbing
+ * • Auto-refresh via HA domain event subscription + window custom event
+ * • ResizeObserver to redraw the chart when the container resizes
+ * • _scheduleLoad() — rAF-deferred load with in-flight guard
+ *
+ * Subclasses must implement:
+ * • render() — Lit template (must include a `.chart-wrap` element)
+ * • _load() — async data fetch + draw
+ * • _drawChart() — (re-)draw the canvas chart; called by ResizeObserver
+ */
+ var ChartCardBase = class extends i$2 {
+ constructor(..._args) {
+ super(..._args);
+ _defineProperty(this, "_hass", void 0);
+ _defineProperty(this, "_config", {});
+ _defineProperty(this, "_loadRequestId", 0);
+ _defineProperty(
+ this,
+ /** Subclasses store their last draw call arguments here so the
+ * ResizeObserver can re-invoke _drawChart with the same data. */
+ "_lastDrawArgs",
+ []
+ );
+ _defineProperty(
+ this,
+ /** Tracks the last data point per series to detect new points for blip animations.
+ * Map of entityId → { t: timestamp, v: value } */
+ "_previousSeriesEndpoints",
+ /* @__PURE__ */ new Map()
+ );
+ _defineProperty(this, "_unsubscribe", null);
+ _defineProperty(this, "_resizeObserver", null);
+ _defineProperty(this, "_loadRaf", null);
+ _defineProperty(this, "_loadInFlight", false);
+ _defineProperty(this, "_hasStartedInitialLoad", false);
+ _defineProperty(this, "_windowListener", null);
+ _defineProperty(
+ this,
+ /** True once _setupAutoRefresh and _setupResizeObserver have been called. */
+ "_initialized",
+ false
+ );
+ }
+ setConfig(config) {
+ this._config = config ?? {};
+ this.requestUpdate();
+ }
+ set hass(hass) {
+ this._hass = hass;
+ this.requestUpdate();
+ if (this._hasStartedInitialLoad) this._scheduleLoad();
+ }
+ get hass() {
+ return this._hass;
+ }
+ connectedCallback() {
+ super.connectedCallback();
+ if (this._initialized) {
+ if (!this._unsubscribe || !this._windowListener) this._setupAutoRefresh();
+ this._setupResizeObserver();
+ }
+ if (this._hass) this._scheduleLoad();
+ }
+ updated() {
+ if (!this._initialized && this._hass) {
+ this._initialized = true;
+ this._setupAutoRefresh();
+ this._setupResizeObserver();
+ this._scheduleLoad();
+ }
+ }
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._cleanup();
+ }
+ _scheduleLoad() {
+ if (!this._hass || this._loadRaf !== null || this._loadInFlight) return;
+ this._loadRaf = window.requestAnimationFrame(() => {
+ this._loadRaf = null;
+ if (!this._hass || !this.isConnected || this._loadInFlight) return;
+ this._hasStartedInitialLoad = true;
+ this._loadInFlight = true;
+ Promise.resolve(this._load()).catch((err) => {
+ logger$1.error("[hass-datapoints chart-base] load failed", err);
+ }).finally(() => {
+ this._loadInFlight = false;
+ });
+ });
+ }
+ _setupAutoRefresh() {
+ if (!this._hass) return;
+ if (this._unsubscribe || this._windowListener) return;
+ this._hass.connection.subscribeEvents(() => {
+ this._scheduleLoad();
+ }, `${DOMAIN}_event_recorded`).then((unsub) => {
+ this._unsubscribe = unsub;
+ }).catch(() => {});
+ this._windowListener = () => {
+ this._scheduleLoad();
+ };
+ window.addEventListener("hass-datapoints-event-recorded", this._windowListener);
+ }
+ _setupResizeObserver() {
+ if (this._resizeObserver) return;
+ const wrap = this.shadowRoot?.querySelector(".chart-wrap") ?? this.shadowRoot?.querySelector("hass-datapoints-history-chart");
+ if (!wrap || !window.ResizeObserver) return;
+ this._resizeObserver = new ResizeObserver(() => {
+ if (this._lastDrawArgs.length) this._drawChart(...this._lastDrawArgs);
+ });
+ this._resizeObserver.observe(wrap);
+ }
+ _cleanup() {
+ if (this._loadRaf !== null) {
+ window.cancelAnimationFrame(this._loadRaf);
+ this._loadRaf = null;
+ }
+ if (this._unsubscribe) {
+ this._unsubscribe();
+ this._unsubscribe = null;
+ }
+ if (this._windowListener) {
+ window.removeEventListener("hass-datapoints-event-recorded", this._windowListener);
+ this._windowListener = null;
+ }
+ if (this._resizeObserver) {
+ this._resizeObserver.disconnect();
+ this._resizeObserver = null;
+ }
+ }
+ static getStubConfig() {
+ return { title: "" };
+ }
+ };
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/history.styles.ts
+ var styles$55 = i$5`
:host {
display: block;
height: 100%;
@@ -4383,4435 +7215,3665 @@
min-height: 0;
}
`;
- const SAMPLE_INTERVAL_MS = {
- "1s": 1e3,
- "5s": 5e3,
- "10s": 1e4,
- "15s": 15e3,
- "30s": 3e4,
- "1m": 6e4,
- "2m": 2 * 6e4,
- "5m": 5 * 6e4,
- "10m": 10 * 6e4,
- "15m": 15 * 6e4,
- "30m": 30 * 6e4,
- "1h": 60 * 6e4,
- "2h": 2 * 60 * 6e4,
- "3h": 3 * 60 * 6e4,
- "4h": 4 * 60 * 6e4,
- "6h": 6 * 60 * 6e4,
- "12h": 12 * 60 * 6e4,
- "24h": 24 * 60 * 6e4
- };
- function binaryOnLabel(deviceClass) {
- const labels = {
- battery: "low",
- battery_charging: "charging",
- carbon_monoxide: "detected",
- cold: "cold",
- connectivity: "connected",
- door: "open",
- garage_door: "open",
- gas: "detected",
- heat: "hot",
- lock: "unlocked",
- moisture: "wet",
- motion: "motion",
- moving: "moving",
- occupancy: "occupied",
- opening: "open",
- plug: "plugged in",
- power: "power",
- presence: "present",
- problem: "problem",
- running: "running",
- safety: "unsafe",
- smoke: "smoke",
- sound: "sound",
- tamper: "tampered",
- update: "update available",
- vibration: "vibration",
- window: "open"
- };
- return labels[deviceClass] || "on";
- }
- function binaryOffLabel(deviceClass) {
- const labels = {
- battery: "normal",
- battery_charging: "not charging",
- carbon_monoxide: "clear",
- cold: "normal",
- connectivity: "disconnected",
- door: "closed",
- garage_door: "closed",
- gas: "clear",
- heat: "normal",
- lock: "locked",
- moisture: "dry",
- motion: "clear",
- moving: "still",
- occupancy: "clear",
- opening: "closed",
- plug: "unplugged",
- power: "off",
- presence: "away",
- problem: "ok",
- running: "idle",
- safety: "safe",
- smoke: "clear",
- sound: "quiet",
- tamper: "clear",
- update: "up to date",
- vibration: "still",
- window: "closed"
- };
- return labels[deviceClass] || "off";
- }
- function normalizeNumericHistory(_entityId2, histStates) {
- return (Array.isArray(histStates) ? histStates : []).map((state) => {
- const value = parseFloat(state?.s ?? "");
- if (Number.isNaN(value)) {
- return null;
- }
- const rawTimestamp = state?.lu ?? state?.lc ?? state?.last_changed ?? state?.last_updated;
- const timeSec = typeof rawTimestamp === "number" ? rawTimestamp : new Date(rawTimestamp || 0).getTime() / 1e3;
- if (!Number.isFinite(timeSec)) {
- return null;
- }
- return {
- lu: Math.round(timeSec * 1e3) / 1e3,
- s: String(value)
- };
- }).filter((entry) => entry !== null);
- }
- function getHistoryStatesForEntity$1(histResult, entityId, entityIds = []) {
- if (!histResult) {
- return [];
- }
- const result = histResult;
- if (Array.isArray(result[entityId])) {
- return result[entityId];
- }
- if (Array.isArray(histResult)) {
- const entries = histResult;
- const entityIndex = entityIds.indexOf(entityId);
- if (entityIndex >= 0 && Array.isArray(entries[entityIndex])) {
- return entries[entityIndex];
- }
- if (entries.every(
- (entry) => entry && typeof entry === "object" && !Array.isArray(entry)
- )) {
- return entries.filter(
- (entry) => entry.entity_id === entityId
- );
- }
- }
- if (histResult && typeof histResult === "object") {
- const wrapped = histResult;
- if (Array.isArray(wrapped.result?.[entityId])) {
- return wrapped.result[entityId];
- }
- if (Array.isArray(wrapped.result)) {
- if (wrapped.result.every(
- (entry) => entry && typeof entry === "object" && !Array.isArray(entry)
- )) {
- return wrapped.result.filter(
- (entry) => entry.entity_id === entityId
- );
- }
- const entityIndex = entityIds.indexOf(entityId);
- if (entityIndex >= 0 && Array.isArray(wrapped.result[entityIndex])) {
- return wrapped.result[entityIndex];
- }
- }
- }
- return [];
- }
- function normalizeStatisticsHistory(entityId, statsData) {
- const statEntries = statsData && typeof statsData === "object" ? statsData[entityId] ?? [] : [];
- return (Array.isArray(statEntries) ? statEntries : []).map((entry) => {
- const value = Number(entry?.mean);
- if (!Number.isFinite(value)) {
- return null;
- }
- const rawTimestamp = entry?.start;
- let timestamp;
- if (typeof rawTimestamp === "number") {
- if (rawTimestamp > 1e11) {
- timestamp = rawTimestamp;
- } else {
- timestamp = rawTimestamp * 1e3;
- }
- } else {
- timestamp = new Date(rawTimestamp).getTime();
- }
- if (!Number.isFinite(timestamp)) {
- return null;
- }
- return {
- lu: Math.round(timestamp) / 1e3,
- s: String(value)
- };
- }).filter((entry) => entry !== null).sort((a2, b2) => a2.lu - b2.lu);
- }
- function mergeNumericHistoryWithStatistics(histPts, statsPts) {
- const raw = Array.isArray(histPts) ? histPts : [];
- const stats = Array.isArray(statsPts) ? statsPts : [];
- if (!raw.length) {
- return [...stats];
- }
- if (!stats.length) {
- return [...raw];
- }
- const firstRawMs = raw[0].lu * 1e3;
- const lastRawMs = raw[raw.length - 1].lu * 1e3;
- const merged2 = [
- ...stats.filter((entry) => {
- const timeMs = entry.lu * 1e3;
- return timeMs < firstRawMs || timeMs > lastRawMs;
- }),
- ...raw
- ];
- merged2.sort((a2, b2) => a2.lu - b2.lu);
- return merged2;
- }
- function getAxisValueExtent(allValues) {
- let min = Infinity;
- let max = -Infinity;
- for (const value of allValues) {
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) {
- continue;
- }
- if (numeric < min) {
- min = numeric;
- }
- if (numeric > max) {
- max = numeric;
- }
- }
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
- return null;
- }
- return { min, max };
- }
- function hexToRgba(hex, alpha) {
- const h2 = hex.replace("#", "");
- const r2 = Number.parseInt(h2.substring(0, 2), 16);
- const g2 = Number.parseInt(h2.substring(2, 4), 16);
- const b2 = Number.parseInt(h2.substring(4, 6), 16);
- return `rgba(${r2},${g2},${b2},${alpha})`;
- }
- function contrastColor(hex) {
- if (!hex || typeof hex !== "string") {
- return "#fff";
- }
- const h2 = hex.replace("#", "");
- if (h2.length !== 6) {
- return "#fff";
- }
- const r2 = Number.parseInt(h2.substring(0, 2), 16) / 255;
- const g2 = Number.parseInt(h2.substring(2, 4), 16) / 255;
- const b2 = Number.parseInt(h2.substring(4, 6), 16) / 255;
- const lin = (c2) => c2 <= 0.04045 ? c2 / 12.92 : ((c2 + 0.055) / 1.055) ** 2.4;
- const luminance = 0.2126 * lin(r2) + 0.7152 * lin(g2) + 0.0722 * lin(b2);
- return luminance > 0.179 ? "#000" : "#fff";
- }
- var __defProp$W = Object.defineProperty;
- var __defNormalProp$W = (obj, key, value) => key in obj ? __defProp$W(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
- var __publicField$W = (obj, key, value) => __defNormalProp$W(obj, typeof key !== "symbol" ? key + "" : key, value);
- function createFallbackCanvasContext() {
- return {
- beginPath() {
- },
- moveTo() {
- },
- lineTo() {
- },
- stroke() {
- },
- fill() {
- },
- fillRect() {
- },
- clearRect() {
- },
- rect() {
- },
- clip() {
- },
- save() {
- },
- restore() {
- },
- scale() {
- },
- setLineDash() {
- },
- fillText() {
- },
- closePath() {
- },
- bezierCurveTo() {
- },
- arc() {
- },
- measureText() {
- return { width: 0 };
- }
- };
- }
- class ChartRenderer {
- constructor(canvas, cssWidth, cssHeight) {
- __publicField$W(this, "canvas");
- __publicField$W(this, "ctx");
- __publicField$W(this, "cssW");
- __publicField$W(this, "cssH");
- __publicField$W(this, "basePad");
- __publicField$W(this, "pad");
- __publicField$W(this, "labelColor");
- __publicField$W(this, "_activeAxes", []);
- this.canvas = canvas;
- this.ctx = canvas.getContext("2d") || createFallbackCanvasContext();
- this.cssW = cssWidth;
- this.cssH = cssHeight;
- this.basePad = { top: 24, right: 12, bottom: 48, left: 12 };
- this.pad = { ...this.basePad };
- this.labelColor = "rgba(214,218,224,0.92)";
- }
- static get AXIS_SLOT_WIDTH() {
- return 30;
- }
- get cw() {
- return this.cssW - this.pad.left - this.pad.right;
- }
- get ch() {
- return this.cssH - this.pad.top - this.pad.bottom;
- }
- xOf(t2, t0, t1) {
- return this.pad.left + (t2 - t0) / (t1 - t0) * this.cw;
- }
- yOf(v2, vMin, vMax) {
- return this.pad.top + this.ch - (v2 - vMin) / (vMax - vMin) * this.ch;
- }
- clear() {
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
- }
- _normalizeAxes(vMinOrAxes, vMax) {
- const axisColumnWidth = ChartRenderer.AXIS_SLOT_WIDTH;
- const inputAxes = Array.isArray(vMinOrAxes) ? vMinOrAxes : [
- {
- key: "default",
- min: vMinOrAxes,
- max: vMax ?? vMinOrAxes,
- side: "left",
- unit: "",
- color: null
- }
- ];
- const leftAxes = [];
- const rightAxes = [];
- const axes = inputAxes.map((axis, index) => {
- const normalized = {
- key: axis.key || `axis-${index}`,
- min: axis.min,
- max: axis.max,
- side: axis.side === "right" ? "right" : "left",
- unit: axis.unit || "",
- color: axis.color || "rgba(128,128,128,0.85)"
- };
- const bucket = normalized.side === "right" ? rightAxes : leftAxes;
- normalized.slot = bucket.length;
- bucket.push(normalized);
- return normalized;
- });
- this.pad = {
- top: this.basePad.top,
- bottom: this.basePad.bottom,
- left: this.basePad.left + Math.max(1, leftAxes.length) * axisColumnWidth,
- right: this.basePad.right + rightAxes.length * axisColumnWidth
- };
- this._activeAxes = axes;
- return axes;
- }
- _formatAxisTick(v2, _unit2) {
- const numeric = Math.abs(v2) >= 1e3 ? `${(v2 / 1e3).toFixed(1).replace(/\.0$/, "")}k` : v2.toFixed(v2 % 1 !== 0 ? 1 : 0);
- return numeric;
- }
- _axisLabelX(axis) {
- const columnWidth = ChartRenderer.AXIS_SLOT_WIDTH;
- const leftAxisX = this.pad.left;
- const rightAxisX = this.pad.left + this.cw;
- if (axis.side === "right") {
- return rightAxisX + 10 + (axis.slot ?? 0) * columnWidth;
- }
- return leftAxisX - 10 - (axis.slot ?? 0) * columnWidth;
- }
- _formatTimeTick(t2, t0, t1, tickSpanMs = null) {
- const value = new Date(t2);
- const spanMs = Math.max(0, t1 - t0);
- const detailSpanMs = Number.isFinite(tickSpanMs) && (tickSpanMs ?? 0) > 0 ? tickSpanMs : spanMs;
- const start = new Date(t0);
- const end = new Date(t1);
- const sameDay = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth() && start.getDate() === end.getDate();
- const sameMonth = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth();
- if (detailSpanMs <= 2 * 60 * 60 * 1e3) {
- return value.toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit"
- });
- }
- if (detailSpanMs <= 12 * 60 * 60 * 1e3) {
- return value.toLocaleString([], {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit"
- });
- }
- if (detailSpanMs <= 2 * 24 * 60 * 60 * 1e3) {
- return value.toLocaleString([], {
- month: "short",
- day: "numeric",
- hour: "2-digit"
- });
- }
- if (sameDay) {
- return value.toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit"
- });
- }
- if (detailSpanMs <= 6 * 60 * 60 * 1e3) {
- return value.toLocaleString([], {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit"
- });
- }
- if (detailSpanMs <= 24 * 60 * 60 * 1e3) {
- return value.toLocaleString([], {
- month: "short",
- day: "numeric",
- hour: "2-digit"
- });
- }
- if (sameMonth && spanMs <= 14 * 24 * 60 * 60 * 1e3) {
- return value.toLocaleDateString([], { day: "numeric" });
- }
- if (spanMs >= 2 * 24 * 60 * 60 * 1e3) {
- return value.toLocaleDateString([], { month: "short", day: "numeric" });
- }
- if (spanMs >= 24 * 60 * 60 * 1e3) {
- return value.toLocaleString([], {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit"
- });
- }
- return fmtTime(value.toISOString());
- }
- _niceNumber(value, round) {
- if (!Number.isFinite(value) || value <= 0) {
- return 1;
- }
- const exponent = Math.floor(Math.log10(value));
- const fraction = value / 10 ** exponent;
- let niceFraction;
- if (round) {
- if (fraction < 1.5) {
- niceFraction = 1;
- } else if (fraction < 3) {
- niceFraction = 2;
- } else if (fraction < 7) {
- niceFraction = 5;
- } else {
- niceFraction = 10;
- }
- } else if (fraction <= 1) {
- niceFraction = 1;
- } else if (fraction <= 2) {
- niceFraction = 2;
- } else if (fraction <= 5) {
- niceFraction = 5;
- } else {
- niceFraction = 10;
- }
- return niceFraction * 10 ** exponent;
- }
- _buildNiceAxisScale(axis, tickCount) {
- const rawMin = Number.isFinite(axis.min) ? axis.min : 0;
- const rawMax = Number.isFinite(axis.max) ? axis.max : 1;
- if (rawMin === rawMax) {
- const pad = Math.abs(rawMin || 1);
- const step2 = this._niceNumber(pad * 2 / Math.max(1, tickCount), true);
- const niceMin2 = Math.floor((rawMin - pad) / step2) * step2;
- const niceMax2 = Math.ceil((rawMax + pad) / step2) * step2;
- const ticks2 = [];
- for (let value = niceMin2; value <= niceMax2 + step2 * 0.5; value += step2) {
- ticks2.push(Number(value.toFixed(10)));
- }
- return { min: niceMin2, max: niceMax2, step: step2, ticks: ticks2 };
- }
- const range = this._niceNumber(rawMax - rawMin, false);
- const step = this._niceNumber(range / Math.max(1, tickCount), true);
- const niceMin = Math.floor(rawMin / step) * step;
- const niceMax = Math.ceil(rawMax / step) * step;
- const ticks = [];
- for (let value = niceMin; value <= niceMax + step * 0.5; value += step) {
- ticks.push(Number(value.toFixed(10)));
- }
- return { min: niceMin, max: niceMax, step, ticks };
- }
- _alignTimeTick(timestamp, stepMs) {
- const date = new Date(timestamp);
- if (stepMs < 60 * 1e3) {
- return Math.floor(timestamp / stepMs) * stepMs;
- }
- if (stepMs < 60 * 60 * 1e3) {
- const minutes = Math.max(1, Math.round(stepMs / (60 * 1e3)));
- date.setSeconds(0, 0);
- date.setMinutes(Math.floor(date.getMinutes() / minutes) * minutes);
- return date.getTime();
- }
- if (stepMs < 24 * 60 * 60 * 1e3) {
- const hours = Math.max(1, Math.round(stepMs / (60 * 60 * 1e3)));
- date.setMinutes(0, 0, 0);
- date.setHours(Math.floor(date.getHours() / hours) * hours);
- return date.getTime();
- }
- if (stepMs < 7 * 24 * 60 * 60 * 1e3) {
- const days = Math.max(1, Math.round(stepMs / (24 * 60 * 60 * 1e3)));
- date.setHours(0, 0, 0, 0);
- const dayOfMonth = date.getDate();
- date.setDate(dayOfMonth - (dayOfMonth - 1) % days);
- return date.getTime();
- }
- if (stepMs < 30 * 24 * 60 * 60 * 1e3) {
- date.setHours(0, 0, 0, 0);
- const day = date.getDay();
- const offset = (day + 6) % 7;
- date.setDate(date.getDate() - offset);
- return date.getTime();
- }
- date.setHours(0, 0, 0, 0);
- date.setDate(1);
- return date.getTime();
- }
- _getTimeTickStep(targetStepMs) {
- const candidates = [
- 5 * 60 * 1e3,
- 10 * 60 * 1e3,
- 15 * 60 * 1e3,
- 30 * 60 * 1e3,
- 60 * 60 * 1e3,
- 2 * 60 * 60 * 1e3,
- 3 * 60 * 60 * 1e3,
- 6 * 60 * 60 * 1e3,
- 12 * 60 * 60 * 1e3,
- 24 * 60 * 60 * 1e3,
- 2 * 24 * 60 * 60 * 1e3,
- 7 * 24 * 60 * 60 * 1e3,
- 14 * 24 * 60 * 60 * 1e3,
- 30 * 24 * 60 * 60 * 1e3
- ];
- return candidates.find((step) => step >= targetStepMs) || candidates[candidates.length - 1];
- }
- _buildTimeTicks(t0, t1) {
- const approxTickCount = Math.max(
- 2,
- Math.min(96, Math.floor(this.cw / 120))
- );
- const stepMs = this._getTimeTickStep(
- (t1 - t0) / Math.max(1, approxTickCount)
- );
- const ticks = [];
- let tick = this._alignTimeTick(t0, stepMs);
- if (tick < t0) {
- tick += stepMs;
- }
- while (tick <= t1) {
- ticks.push(tick);
- tick += stepMs;
- }
- if (!ticks.length) {
- ticks.push(t0, t1);
- }
- return { ticks, stepMs };
- }
- drawGrid(t0, t1, vMin, vMax, yTicks = 5, options = {}) {
- const { ctx, pad } = this;
- const gridColor = "rgba(128,128,128,0.15)";
- const labelColor = this.labelColor;
- const fixedAxisOverlay = !!options.fixedAxisOverlay;
- const hideTimeLabels = !!options.hideTimeLabels;
- const axes = this._normalizeAxes(vMin, vMax);
- const unitCounts = axes.reduce((counts, axis) => {
- if (!axis.unit) {
- return counts;
- }
- counts.set(axis.unit, (counts.get(axis.unit) || 0) + 1);
- return counts;
- }, /* @__PURE__ */ new Map());
- const axisLabelColor = (axis) => {
- const duplicateUnit = !!axis?.unit && (unitCounts.get(axis.unit) || 0) > 1;
- if (!duplicateUnit || !axis?.color) {
- return labelColor;
- }
- return axis.color;
- };
- axes.forEach((axis) => {
- const scale = this._buildNiceAxisScale(axis, yTicks);
- axis.min = scale.min;
- axis.max = scale.max;
- axis.ticks = scale.ticks;
- });
- const primaryAxis = axes[0];
- ctx.font = "12px sans-serif";
- for (const v2 of primaryAxis.ticks || []) {
- const y2 = this.yOf(v2, primaryAxis.min, primaryAxis.max);
- ctx.strokeStyle = gridColor;
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(pad.left, y2);
- ctx.lineTo(pad.left + this.cw, y2);
- ctx.stroke();
- if (!fixedAxisOverlay) {
- ctx.fillStyle = axisLabelColor(primaryAxis);
- ctx.textAlign = "right";
- ctx.textBaseline = "middle";
- ctx.fillText(
- this._formatAxisTick(v2, primaryAxis.unit),
- this._axisLabelX(primaryAxis),
- y2
- );
- }
- }
- if (!fixedAxisOverlay) {
- for (const axis of axes.slice(1)) {
- for (const v2 of axis.ticks || []) {
- const y2 = this.yOf(v2, axis.min, axis.max);
- ctx.fillStyle = axisLabelColor(axis);
- ctx.textAlign = axis.side === "right" ? "left" : "right";
- ctx.textBaseline = "middle";
- ctx.fillText(
- this._formatAxisTick(v2, axis.unit),
- this._axisLabelX(axis),
- y2
- );
- }
- }
- }
- if (!fixedAxisOverlay) {
- for (const axis of axes) {
- if (!axis.unit) {
- continue;
- }
- ctx.fillStyle = axisLabelColor(axis);
- ctx.textAlign = axis.side === "right" ? "left" : "right";
- ctx.textBaseline = "bottom";
- ctx.fillText(axis.unit, this._axisLabelX(axis), pad.top - 6);
- }
- }
- const { ticks: timeTicks, stepMs: tickSpanMs } = this._buildTimeTicks(
- t0,
- t1
- );
- for (const t2 of timeTicks) {
- const x2 = this.xOf(t2, t0, t1);
- const label = this._formatTimeTick(t2, t0, t1, tickSpanMs);
- ctx.strokeStyle = "rgba(128,128,128,0.08)";
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(x2, pad.top);
- ctx.lineTo(x2, pad.top + this.ch);
- ctx.stroke();
- if (!hideTimeLabels) {
- ctx.fillStyle = labelColor;
- ctx.textAlign = "center";
- ctx.textBaseline = "top";
- const labelWidth = ctx.measureText(label).width;
- const labelX = Math.min(
- pad.left + this.cw - labelWidth / 2,
- Math.max(pad.left + labelWidth / 2, x2)
- );
- ctx.fillText(label, labelX, pad.top + this.ch + 6);
- }
- }
- ctx.strokeStyle = "rgba(128,128,128,0.35)";
- ctx.lineWidth = 1;
- ctx.beginPath();
- if (!fixedAxisOverlay) {
- ctx.moveTo(pad.left, pad.top);
- ctx.lineTo(pad.left, pad.top + this.ch);
- }
- ctx.moveTo(pad.left, pad.top + this.ch);
- ctx.lineTo(pad.left + this.cw, pad.top + this.ch);
- if (axes.some((axis) => axis.side === "right") && !fixedAxisOverlay) {
- ctx.moveTo(pad.left + this.cw, pad.top);
- ctx.lineTo(pad.left + this.cw, pad.top + this.ch);
- }
- ctx.stroke();
- }
- drawRowLabel(text, color = "rgba(214,218,224,0.85)") {
- if (!text) {
- return;
- }
- const { ctx, pad } = this;
- ctx.save();
- ctx.font = "bold 11px sans-serif";
- ctx.fillStyle = color;
- ctx.textAlign = "left";
- ctx.textBaseline = "top";
- ctx.fillText(text, pad.left + 6, pad.top + 5);
- ctx.restore();
- }
- /**
- * Append Catmull-Rom bezier segments to the current path, starting from pts[0]
- * (caller must have already called moveTo(pts[0])). Each segment passes exactly
- * through the data points with tangents derived from neighbouring points.
- */
- static _catmullRomPath(ctx, pts) {
- for (let i2 = 1; i2 < pts.length; i2++) {
- const pm1 = pts[Math.max(0, i2 - 2)];
- const p0 = pts[i2 - 1];
- const p1 = pts[i2];
- const p2 = pts[Math.min(pts.length - 1, i2 + 1)];
- const cp1x = p0[0] + (p1[0] - pm1[0]) / 6;
- const cp1y = p0[1] + (p1[1] - pm1[1]) / 6;
- const cp2x = p1[0] - (p2[0] - p0[0]) / 6;
- const cp2y = p1[1] - (p2[1] - p0[1]) / 6;
- ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p1[0], p1[1]);
- }
- }
- static _steppedPath(ctx, pts) {
- for (let i2 = 1; i2 < pts.length; i2++) {
- const previous = pts[i2 - 1];
- const current = pts[i2];
- ctx.lineTo(current[0], previous[1]);
- ctx.lineTo(current[0], current[1]);
- }
- }
- drawLine(points, color, t0, t1, vMin, vMax, options = {}) {
- if (!points.length) {
- return;
- }
- const { ctx, pad } = this;
- const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : 0;
- const smooth = !!options.smooth;
- const stepped = options.stepped === true;
- const dashed = !!options.dashed;
- const dotted = !!options.dotted;
- const dashPattern = Array.isArray(options.dashPattern) ? options.dashPattern.filter(
- (entry) => Number.isFinite(entry) && entry > 0
- ) : null;
- const lineOpacity = Number.isFinite(options.lineOpacity) ? options.lineOpacity : 1;
- const lineWidth = Number.isFinite(options.lineWidth) ? options.lineWidth : 1.75;
- const px = points.map(([t2, v2]) => [
- this.xOf(t2, t0, t1),
- this.yOf(v2, vMin, vMax)
- ]);
- const bottom = pad.top + this.ch;
- ctx.save();
- ctx.beginPath();
- ctx.rect(pad.left, pad.top, this.cw, this.ch);
- ctx.clip();
- if (dashPattern && dashPattern.length) {
- ctx.setLineDash(dashPattern);
- } else if (dotted) {
- ctx.setLineDash([1, 3]);
- ctx.lineCap = "round";
- } else if (dashed) {
- ctx.setLineDash([6, 4]);
- }
- if (lineOpacity < 1) {
- ctx.globalAlpha = lineOpacity;
- }
- if (fillAlpha > 0) {
- ctx.beginPath();
- ctx.moveTo(px[0][0], bottom);
- ctx.lineTo(px[0][0], px[0][1]);
- if (stepped) {
- ChartRenderer._steppedPath(ctx, px);
- } else if (smooth) {
- ChartRenderer._catmullRomPath(ctx, px);
- } else {
- for (let i2 = 1; i2 < px.length; i2++) {
- ctx.lineTo(px[i2][0], px[i2][1]);
- }
- }
- ctx.lineTo(px[px.length - 1][0], bottom);
- ctx.closePath();
- ctx.fillStyle = hexToRgba(color, fillAlpha);
- ctx.fill();
- }
- ctx.beginPath();
- ctx.moveTo(px[0][0], px[0][1]);
- if (stepped) {
- ChartRenderer._steppedPath(ctx, px);
- } else if (smooth) {
- ChartRenderer._catmullRomPath(ctx, px);
- } else {
- for (let i2 = 1; i2 < px.length; i2++) {
- ctx.lineTo(px[i2][0], px[i2][1]);
- }
- }
- ctx.strokeStyle = color;
- ctx.lineWidth = lineWidth;
- if (stepped) {
- ctx.lineJoin = "miter";
- ctx.lineCap = "butt";
- } else {
- ctx.lineJoin = "round";
- ctx.lineCap = "round";
- }
- ctx.stroke();
- ctx.restore();
- }
- drawBars(points, color, t0, t1, vMin, vMax, options = {}) {
- if (!points.length) {
- return;
- }
- const { ctx } = this;
- const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : 0.78;
- const widthFactor = Number.isFinite(options.widthFactor) ? options.widthFactor : 0.72;
- const baselineY = this.yOf(Math.max(vMin, 0), vMin, vMax);
- const xs = points.map(([t2]) => this.xOf(t2, t0, t1));
- let minGap = this.cw / Math.max(points.length, 1);
- for (let i2 = 1; i2 < xs.length; i2++) {
- minGap = Math.min(minGap, xs[i2] - xs[i2 - 1]);
- }
- const barWidth = Math.max(3, Math.min(28, minGap * widthFactor));
- ctx.save();
- ctx.fillStyle = hexToRgba(color, fillAlpha);
- ctx.strokeStyle = color;
- ctx.lineWidth = 1;
- for (let i2 = 0; i2 < points.length; i2++) {
- const [, v2] = points[i2];
- const x2 = xs[i2];
- const y2 = this.yOf(v2, vMin, vMax);
- const top = Math.min(y2, baselineY);
- const height = Math.max(1, Math.abs(baselineY - y2));
- const left = x2 - barWidth / 2;
- ctx.fillRect(left, top, barWidth, height);
- }
- ctx.restore();
- }
- drawStateBands(spans, t0, t1, color = "#03a9f4", alpha = 0.12) {
- if (!spans?.length) {
- return;
- }
- const { ctx, pad } = this;
- ctx.save();
- ctx.fillStyle = hexToRgba(color, alpha);
- for (const span of spans) {
- const start = Math.max(t0, span.start);
- const end = Math.min(t1, span.end);
- if (!(start < end)) {
- continue;
- }
- const x0 = this.xOf(start, t0, t1);
- const x1 = this.xOf(end, t0, t1);
- ctx.fillRect(x0, pad.top, Math.max(1, x1 - x0), this.ch);
- }
- ctx.restore();
- }
- drawAnnotations(events, t0, t1, options = {}) {
- const { ctx, pad } = this;
- const hits = [];
- const showLines = options.showLines !== false;
- const showMarkers = options.showMarkers !== false;
- for (const event of events) {
- const t2 = new Date(event.timestamp).getTime();
- if (t2 < t0 || t2 > t1) {
- continue;
- }
- const x2 = this.xOf(t2, t0, t1);
- const color = event.color || "#03a9f4";
- if (showLines) {
- ctx.save();
- ctx.setLineDash([4, 3]);
- ctx.strokeStyle = color;
- ctx.lineWidth = 1.5;
- ctx.globalAlpha = 0.75;
- ctx.beginPath();
- ctx.moveTo(x2, pad.top + 8);
- ctx.lineTo(x2, pad.top + this.ch);
- ctx.stroke();
- ctx.restore();
- }
- if (showMarkers) {
- const d2 = 5;
- ctx.save();
- ctx.fillStyle = color;
- ctx.strokeStyle = "rgba(255,255,255,0.8)";
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- ctx.moveTo(x2, pad.top - d2);
- ctx.lineTo(x2 + d2, pad.top);
- ctx.lineTo(x2, pad.top + d2);
- ctx.lineTo(x2 - d2, pad.top);
- ctx.closePath();
- ctx.fill();
- ctx.stroke();
- ctx.restore();
- }
- hits.push({ event, x: x2, y: pad.top });
- }
- return hits;
- }
- /**
- * Draw sensor-style vertical annotation lines that terminate on the data line
- * with a small circle marker.
- */
- drawAnnotationLinesOnLine(events, allSeries, t0, t1, vMin, vMax) {
- const { ctx, pad } = this;
- const firstPts = allSeries.length ? allSeries[0].pts : [];
- const hits = [];
- for (const event of events) {
- const t2 = new Date(event.timestamp).getTime();
- if (t2 < t0 || t2 > t1) {
- continue;
- }
- const x2 = this.xOf(t2, t0, t1);
- const value = this._interpolateValue(firstPts, t2);
- if (value === null) {
- continue;
- }
- const y2 = this.yOf(value, vMin, vMax);
- const color = event.color || "#03a9f4";
- ctx.save();
- ctx.setLineDash([4, 3]);
- ctx.strokeStyle = color;
- ctx.lineWidth = 1.5;
- ctx.globalAlpha = 0.75;
- ctx.beginPath();
- ctx.moveTo(x2, pad.top + this.ch);
- ctx.lineTo(x2, y2);
- ctx.stroke();
- ctx.restore();
- ctx.save();
- ctx.beginPath();
- ctx.arc(x2, y2, 4, 0, Math.PI * 2);
- ctx.fillStyle = color;
- ctx.fill();
- ctx.strokeStyle = "rgba(255,255,255,0.9)";
- ctx.lineWidth = 1.5;
- ctx.stroke();
- ctx.restore();
- hits.push({ event, x: x2, y: y2, value });
- }
- return hits;
- }
- /**
- * Interpolate the Y pixel position on a data series at a given timestamp.
- * Uses linear interpolation between surrounding data points.
- */
- _interpolateY(seriesPoints, t2, _t0, _t1, vMin, vMax) {
- if (!seriesPoints.length) {
- return null;
- }
- if (t2 <= seriesPoints[0][0]) {
- return this.yOf(seriesPoints[0][1], vMin, vMax);
- }
- if (t2 >= seriesPoints[seriesPoints.length - 1][0]) {
- return this.yOf(seriesPoints[seriesPoints.length - 1][1], vMin, vMax);
- }
- for (let i2 = 0; i2 < seriesPoints.length - 1; i2++) {
- const [t1p, v1p] = seriesPoints[i2];
- const [t2p, v2p] = seriesPoints[i2 + 1];
- if (t2 >= t1p && t2 <= t2p) {
- const frac = (t2 - t1p) / (t2p - t1p);
- const v2 = v1p + frac * (v2p - v1p);
- return this.yOf(v2, vMin, vMax);
- }
- }
- return null;
- }
- _interpolateValue(seriesPoints, t2) {
- const len = seriesPoints.length;
- if (!len) {
- return null;
- }
- if (t2 < seriesPoints[0][0]) {
- return null;
- }
- if (t2 > seriesPoints[len - 1][0]) {
- return null;
- }
- let lo = 0;
- let hi = len - 1;
- while (lo + 1 < hi) {
- const mid = Math.floor((lo + hi) / 2);
- if (seriesPoints[mid][0] <= t2) {
- lo = mid;
- } else {
- hi = mid;
- }
- }
- const [t0, v0] = seriesPoints[lo];
- const [t1, v1] = seriesPoints[hi];
- if (t0 === t1) return v0;
- return v0 + (v1 - v0) * ((t2 - t0) / (t1 - t0));
- }
- /**
- * Draw annotation markers directly on a sensor data line.
- * No vertical dotted line — only a coloured circle on the line.
- *
- * @param {Array} events Recorded events array
- * @param {Array} allSeries Array of {pts} objects — first series used for Y
- * @param {number} t0 Start time ms
- * @param {number} t1 End time ms
- * @param {number} vMin Y axis min
- * @param {number} vMax Y axis max
- * @returns {Array} Array of {event, x, y} for hit-testing
- */
- drawAnnotationsOnLine(events, allSeries, t0, t1, vMin, vMax) {
- const { ctx } = this;
- const firstPts = allSeries.length ? allSeries[0].pts : [];
- const hits = [];
- for (const event of events) {
- const t2 = new Date(event.timestamp).getTime();
- if (t2 < t0 || t2 > t1) {
- continue;
- }
- const x2 = this.xOf(t2, t0, t1);
- const value = this._interpolateValue(firstPts, t2);
- if (value === null) {
- continue;
- }
- const y2 = this.yOf(value, vMin, vMax);
- const color = event.color || "#03a9f4";
- const r2 = 10;
- ctx.save();
- ctx.beginPath();
- ctx.arc(x2, y2, r2 + 1.5, 0, Math.PI * 2);
- ctx.fillStyle = "rgba(255,255,255,0.9)";
- ctx.fill();
- ctx.restore();
- ctx.save();
- ctx.beginPath();
- ctx.arc(x2, y2, r2, 0, Math.PI * 2);
- ctx.fillStyle = color;
- ctx.fill();
- ctx.restore();
- hits.push({ event, x: x2, y: y2, value });
- }
- return hits;
- }
- /**
- * Draw a gradient-filled band between two data values, fading from the edge
- * value toward the midpoint value. Used for min/max shading that fades toward
- * the mean line.
- *
- * @param {number} valueEdge Data value at the opaque edge (the min or max line)
- * @param {number} valueMid Data value at the transparent end (the mean line)
- * @param {string} color Hex color string (e.g. "#03a9f4")
- * @param {number} t0 Render start time ms
- * @param {number} t1 Render end time ms
- * @param {number} vMin Y-axis minimum data value
- * @param {number} vMax Y-axis maximum data value
- * @param {object} options { fillAlpha }
- */
- drawGradientBand(valueEdge, valueMid, color, _t0, _t1, vMin, vMax, options = {}) {
- const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : 0.08;
- if (fillAlpha <= 0) {
- return;
- }
- const hexMatch = String(color || "").match(
- /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i
- );
- if (!hexMatch) {
- return;
- }
- const r2 = parseInt(hexMatch[1], 16);
- const g2 = parseInt(hexMatch[2], 16);
- const b2 = parseInt(hexMatch[3], 16);
- const yEdge = this.yOf(valueEdge, vMin, vMax);
- const yMid = this.yOf(valueMid, vMin, vMax);
- if (Math.abs(yMid - yEdge) < 1) {
- return;
- }
- const { ctx, pad } = this;
- const grad = ctx.createLinearGradient(0, yEdge, 0, yMid);
- grad.addColorStop(0, `rgba(${r2}, ${g2}, ${b2}, ${fillAlpha})`);
- grad.addColorStop(1, `rgba(${r2}, ${g2}, ${b2}, 0)`);
- ctx.save();
- ctx.beginPath();
- ctx.rect(pad.left, pad.top, this.cw, this.ch);
- ctx.clip();
- ctx.fillStyle = grad;
- ctx.fillRect(
- pad.left,
- Math.min(yEdge, yMid),
- this.cw,
- Math.abs(yMid - yEdge)
- );
- ctx.restore();
- }
- drawThresholdArea(points, thresholdValue, color, t0, t1, vMin, vMax, options = {}) {
- if (!Array.isArray(points) || points.length < 2) {
- return;
- }
- if (!Number.isFinite(thresholdValue)) {
- return;
- }
- const mode = options.mode === "below" ? "below" : "above";
- const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : 0.12;
- if (fillAlpha <= 0) {
- return;
- }
- const segments = [];
- let currentSegment = [];
- const isInside = (value) => {
- if (mode === "below") {
- return value <= thresholdValue;
- }
- return value >= thresholdValue;
- };
- const flushSegment = () => {
- if (currentSegment.length >= 2) {
- segments.push(currentSegment);
- }
- currentSegment = [];
- };
- for (let index = 0; index < points.length - 1; index += 1) {
- const startPoint = points[index];
- const endPoint = points[index + 1];
- const startInside = isInside(startPoint[1]);
- const endInside = isInside(endPoint[1]);
- if (startInside && currentSegment.length === 0) {
- currentSegment.push(startPoint);
- }
- if (startInside && endInside) {
- currentSegment.push(endPoint);
- continue;
- }
- if (startInside !== endInside) {
- const deltaValue = endPoint[1] - startPoint[1];
- if (deltaValue === 0) {
- flushSegment();
- continue;
- }
- const fraction = (thresholdValue - startPoint[1]) / deltaValue;
- const crossingTime = startPoint[0] + (endPoint[0] - startPoint[0]) * fraction;
- const crossingPoint = [crossingTime, thresholdValue];
- if (startInside) {
- currentSegment.push(crossingPoint);
- flushSegment();
- } else {
- currentSegment.push(crossingPoint);
- currentSegment.push(endPoint);
- }
- continue;
- }
- if (!startInside && !endInside) {
- flushSegment();
- }
- }
- flushSegment();
- if (!segments.length) {
- return;
- }
- const { ctx, pad } = this;
- const thresholdY = this.yOf(thresholdValue, vMin, vMax);
- ctx.save();
- ctx.beginPath();
- ctx.rect(pad.left, pad.top, this.cw, this.ch);
- ctx.clip();
- ctx.fillStyle = hexToRgba(color, fillAlpha);
- segments.forEach((segment) => {
- if (!Array.isArray(segment) || segment.length < 2) {
- return;
- }
- ctx.beginPath();
- const firstPoint = segment[0];
- ctx.moveTo(this.xOf(firstPoint[0], t0, t1), thresholdY);
- segment.forEach((point) => {
- ctx.lineTo(this.xOf(point[0], t0, t1), this.yOf(point[1], vMin, vMax));
- });
- const lastPoint = segment[segment.length - 1];
- ctx.lineTo(this.xOf(lastPoint[0], t0, t1), thresholdY);
- ctx.closePath();
- ctx.fill();
- });
- ctx.restore();
- }
- /**
- * Draw diagonal hash marks at gap boundary points to indicate the start/end
- * of contiguous data ranges.
- *
- * @param {Array} boundaryPoints Array of [timeMs, value] pairs at gap edges
- * @param {string} color Stroke colour
- * @param {number} t0 Start time ms
- * @param {number} t1 End time ms
- * @param {number} vMin Y axis min
- * @param {number} vMax Y axis max
- */
- drawGapMarkers(boundaryPoints, color, t0, t1, vMin, vMax) {
- if (!boundaryPoints.length) {
- return;
- }
- const { ctx, pad } = this;
- const h2 = 7;
- const w = 3;
- const gap = 2;
- ctx.save();
- ctx.beginPath();
- ctx.rect(pad.left, pad.top, this.cw, this.ch);
- ctx.clip();
- ctx.strokeStyle = color;
- ctx.lineWidth = 1.5;
- ctx.globalAlpha = 0.55;
- for (let i2 = 0; i2 < boundaryPoints.length; i2++) {
- const [t2, v2] = boundaryPoints[i2];
- const x2 = this.xOf(t2, t0, t1);
- const y2 = this.yOf(v2, vMin, vMax);
- const dir = i2 % 2 === 0 ? 1 : -1;
- for (let d2 = -gap; d2 <= gap; d2 += gap * 2) {
- ctx.beginPath();
- ctx.moveTo(x2 + d2 - w * dir, y2 - h2);
- ctx.lineTo(x2 + d2 + w * dir, y2 + h2);
- ctx.stroke();
- }
- }
- ctx.restore();
- }
- drawAnomalyClusters(clusters, color, t0, t1, vMin, vMax, options = {}) {
- if (!Array.isArray(clusters) || clusters.length === 0) {
- return;
- }
- const strokeAlpha = Number.isFinite(options.strokeAlpha) ? options.strokeAlpha : 0.92;
- const lineWidth = Number.isFinite(options.lineWidth) ? options.lineWidth : 2;
- const haloWidth = Number.isFinite(options.haloWidth) ? options.haloWidth : Math.max(2.5, lineWidth + 1.5);
- const haloColor = typeof options.haloColor === "string" && options.haloColor ? options.haloColor : "rgba(255,255,255,0.9)";
- const haloAlpha = Number.isFinite(options.haloAlpha) ? options.haloAlpha : 0.9;
- const fillColor = typeof options.fillColor === "string" && options.fillColor ? options.fillColor : null;
- const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : 0;
- const pointPadding = Number.isFinite(options.pointPadding) ? options.pointPadding : 10;
- const minRadiusX = Number.isFinite(options.minRadiusX) ? options.minRadiusX : 10;
- const minRadiusY = Number.isFinite(options.minRadiusY) ? options.minRadiusY : 10;
- const clusterRegions = this.getAnomalyClusterRegions(
- clusters,
- t0,
- t1,
- vMin,
- vMax,
- {
- pointPadding,
- minRadiusX,
- minRadiusY
- }
- );
- const { ctx, pad } = this;
- ctx.save();
- ctx.beginPath();
- ctx.rect(pad.left, pad.top, this.cw, this.ch);
- ctx.clip();
- clusterRegions.forEach((region) => {
- ctx.save();
- ctx.setLineDash([]);
- if (fillColor && fillAlpha > 0) {
- ctx.globalAlpha = fillAlpha;
- ctx.fillStyle = fillColor;
- ctx.beginPath();
- ctx.ellipse(
- region.centerX,
- region.centerY,
- region.radiusX,
- region.radiusY,
- 0,
- 0,
- Math.PI * 2
- );
- ctx.fill();
- }
- ctx.globalAlpha = haloAlpha;
- ctx.strokeStyle = haloColor;
- ctx.lineWidth = haloWidth;
- ctx.beginPath();
- ctx.ellipse(
- region.centerX,
- region.centerY,
- region.radiusX,
- region.radiusY,
- 0,
- 0,
- Math.PI * 2
- );
- ctx.stroke();
- ctx.globalAlpha = strokeAlpha;
- ctx.strokeStyle = color;
- ctx.lineWidth = lineWidth;
- ctx.beginPath();
- ctx.ellipse(
- region.centerX,
- region.centerY,
- region.radiusX,
- region.radiusY,
- 0,
- 0,
- Math.PI * 2
- );
- ctx.stroke();
- ctx.restore();
- });
- ctx.restore();
- }
- /**
- * Animate a "blip" circle at the given canvas coordinates.
- * The circle expands with a bouncy overshoot, holds briefly, then shrinks to nothing.
- * Uses a separate overlay canvas so it doesn't interfere with the main chart.
- */
- drawBlip(cx, cy, color, options = {}) {
- const resolvedOptions = options;
- const maxRadius = resolvedOptions.maxRadius || 6;
- const duration = resolvedOptions.duration || 600;
- const canvas = this.canvas;
- const parent = canvas.parentElement;
- if (!parent) {
- return;
- }
- const overlay = document.createElement("canvas");
- overlay.width = canvas.width;
- overlay.height = canvas.height;
- overlay.style.cssText = `position:absolute;top:0;left:0;width:${canvas.style.width || `${canvas.offsetWidth}px`};height:${canvas.style.height || `${canvas.offsetHeight}px`};pointer-events:none;z-index:2;`;
- parent.style.position = parent.style.position || "relative";
- parent.appendChild(overlay);
- const ctx = overlay.getContext("2d");
- if (!ctx) {
- overlay.remove();
- return;
- }
- const dpr = window.devicePixelRatio || 1;
- const pxCx = cx * dpr;
- const pxCy = cy * dpr;
- const pxMaxR = maxRadius * dpr;
- const start = performance.now();
- const animate = (now) => {
- const elapsed = now - start;
- const t2 = Math.min(elapsed / duration, 1);
- ctx.clearRect(0, 0, overlay.width, overlay.height);
- let radius;
- let alpha;
- if (t2 < 0.35) {
- const p2 = t2 / 0.35;
- const bounce = p2 < 0.6 ? p2 / 0.6 * 1.3 : 1.3 - 0.3 * ((p2 - 0.6) / 0.4);
- radius = pxMaxR * Math.min(bounce, 1.3);
- alpha = Math.min(p2 * 2.5, 0.85);
- } else if (t2 < 0.6) {
- radius = pxMaxR;
- alpha = 0.85;
- } else {
- const p2 = (t2 - 0.6) / 0.4;
- const ease = 1 - (1 - p2) ** 3;
- radius = pxMaxR * (1 - ease);
- alpha = 0.85 * (1 - ease);
- }
- if (radius > 0.2 && alpha > 0.01) {
- ctx.save();
- ctx.globalAlpha = alpha;
- ctx.beginPath();
- ctx.arc(pxCx, pxCy, radius, 0, Math.PI * 2);
- ctx.fillStyle = color;
- ctx.fill();
- ctx.beginPath();
- ctx.arc(pxCx, pxCy, radius * 1.6, 0, Math.PI * 2);
- ctx.strokeStyle = color;
- ctx.lineWidth = 1.2 * dpr;
- ctx.globalAlpha = alpha * 0.4;
- ctx.stroke();
- ctx.restore();
- }
- if (t2 < 1) {
- requestAnimationFrame(animate);
- } else {
- overlay.remove();
- }
- };
- requestAnimationFrame(animate);
- }
- getAnomalyClusterRegions(clusters, t0, t1, vMin, vMax, options = {}) {
- if (!Array.isArray(clusters) || clusters.length === 0) {
- return [];
- }
- const pointPadding = Number.isFinite(options.pointPadding) ? options.pointPadding : 10;
- const minRadiusX = Number.isFinite(options.minRadiusX) ? options.minRadiusX : 10;
- const minRadiusY = Number.isFinite(options.minRadiusY) ? options.minRadiusY : 10;
- return clusters.flatMap((cluster) => {
- if (!Array.isArray(cluster?.points) || cluster.points.length === 0) {
- return [];
- }
- const xs = [];
- const ys = [];
- cluster.points.forEach((point) => {
- xs.push(this.xOf(point.timeMs, t0, t1));
- ys.push(this.yOf(point.value, vMin, vMax));
- });
- const minX = Math.min(...xs);
- const maxX = Math.max(...xs);
- const minY = Math.min(...ys);
- const maxY = Math.max(...ys);
- return [
- {
- centerX: (minX + maxX) / 2,
- centerY: (minY + maxY) / 2,
- radiusX: Math.max(minRadiusX, (maxX - minX) / 2 + pointPadding),
- radiusY: Math.max(minRadiusY, (maxY - minY) / 2 + pointPadding),
- cluster
- }
- ];
- });
- }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/data/downsampling.ts
+ /**
+ * Map of human-readable interval strings to milliseconds.
+ * Used when configuring downsampling bucket widths.
+ */
+ var SAMPLE_INTERVAL_MS = {
+ "1s": 1e3,
+ "5s": 5e3,
+ "10s": 1e4,
+ "15s": 15e3,
+ "30s": 3e4,
+ "1m": 6e4,
+ "2m": 2 * 6e4,
+ "5m": 5 * 6e4,
+ "10m": 10 * 6e4,
+ "15m": 15 * 6e4,
+ "30m": 30 * 6e4,
+ "1h": 60 * 6e4,
+ "2h": 120 * 6e4,
+ "3h": 180 * 6e4,
+ "4h": 240 * 6e4,
+ "6h": 360 * 6e4,
+ "12h": 720 * 6e4,
+ "24h": 1440 * 6e4
+ };
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/data/binary-labels.ts
+ /**
+ * Returns the human-readable "on" label for a binary sensor device class.
+ * Pass the lower-cased `device_class` attribute value.
+ */
+ function binaryOnLabel(deviceClass) {
+ return {
+ battery: "low",
+ battery_charging: "charging",
+ carbon_monoxide: "detected",
+ cold: "cold",
+ connectivity: "connected",
+ door: "open",
+ garage_door: "open",
+ gas: "detected",
+ heat: "hot",
+ lock: "unlocked",
+ moisture: "wet",
+ motion: "motion",
+ moving: "moving",
+ occupancy: "occupied",
+ opening: "open",
+ plug: "plugged in",
+ power: "power",
+ presence: "present",
+ problem: "problem",
+ running: "running",
+ safety: "unsafe",
+ smoke: "smoke",
+ sound: "sound",
+ tamper: "tampered",
+ update: "update available",
+ vibration: "vibration",
+ window: "open"
+ }[deviceClass] || "on";
+ }
+ /**
+ * Returns the human-readable "off" label for a binary sensor device class.
+ * Pass the lower-cased `device_class` attribute value.
+ */
+ function binaryOffLabel(deviceClass) {
+ return {
+ battery: "normal",
+ battery_charging: "not charging",
+ carbon_monoxide: "clear",
+ cold: "normal",
+ connectivity: "disconnected",
+ door: "closed",
+ garage_door: "closed",
+ gas: "clear",
+ heat: "normal",
+ lock: "locked",
+ moisture: "dry",
+ motion: "clear",
+ moving: "still",
+ occupancy: "clear",
+ opening: "closed",
+ plug: "unplugged",
+ power: "off",
+ presence: "away",
+ problem: "ok",
+ running: "idle",
+ safety: "safe",
+ smoke: "clear",
+ sound: "quiet",
+ tamper: "clear",
+ update: "up to date",
+ vibration: "still",
+ window: "closed"
+ }[deviceClass] || "off";
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/data/history-normalization.ts
+ /**
+ * Normalises a raw numeric sensor state list from the HA history API into an
+ * array of `{ lu, s }` records (non-finite values filtered out).
+ */
+ function normalizeNumericHistory(_entityId, histStates) {
+ return (Array.isArray(histStates) ? histStates : []).map((state) => {
+ const value = parseFloat(state?.s ?? "");
+ if (Number.isNaN(value)) return null;
+ const rawTimestamp = state?.lu ?? state?.lc ?? state?.last_changed ?? state?.last_updated;
+ const timeSec = typeof rawTimestamp === "number" ? rawTimestamp : new Date(rawTimestamp || 0).getTime() / 1e3;
+ if (!Number.isFinite(timeSec)) return null;
+ return {
+ lu: Math.round(timeSec * 1e3) / 1e3,
+ s: String(value)
+ };
+ }).filter((entry) => entry !== null);
+ }
+ /**
+ * Extracts the state array for a given entity from the various response shapes
+ * returned by different versions of the HA history WebSocket API.
+ */
+ function getHistoryStatesForEntity$1(histResult, entityId, entityIds = []) {
+ if (!histResult) return [];
+ const result = histResult;
+ if (Array.isArray(result[entityId])) return result[entityId];
+ if (Array.isArray(histResult)) {
+ const entries = histResult;
+ const entityIndex = entityIds.indexOf(entityId);
+ if (entityIndex >= 0 && Array.isArray(entries[entityIndex])) return entries[entityIndex];
+ if (entries.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry))) return entries.filter((entry) => entry.entity_id === entityId);
+ }
+ if (histResult && typeof histResult === "object") {
+ const wrapped = histResult;
+ if (Array.isArray(wrapped.result?.[entityId])) return wrapped.result[entityId];
+ if (Array.isArray(wrapped.result)) {
+ if (wrapped.result.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry))) return wrapped.result.filter((entry) => entry.entity_id === entityId);
+ const entityIndex = entityIds.indexOf(entityId);
+ if (entityIndex >= 0 && Array.isArray(wrapped.result[entityIndex])) return wrapped.result[entityIndex];
+ }
+ }
+ return [];
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/data/statistics-normalization.ts
+ /**
+ * Normalises statistics data from the HA statistics API for a single entity
+ * into a sorted array of `{ lu, s }` records.
+ */
+ function normalizeStatisticsHistory(entityId, statsData) {
+ const statEntries = statsData && typeof statsData === "object" ? statsData[entityId] ?? [] : [];
+ return (Array.isArray(statEntries) ? statEntries : []).map((entry) => {
+ const value = Number(entry?.mean);
+ if (!Number.isFinite(value)) return null;
+ const rawTimestamp = entry?.start;
+ let timestamp;
+ if (typeof rawTimestamp === "number") if (rawTimestamp > 1e11) timestamp = rawTimestamp;
+ else timestamp = rawTimestamp * 1e3;
+ else timestamp = new Date(rawTimestamp).getTime();
+ if (!Number.isFinite(timestamp)) return null;
+ return {
+ lu: Math.round(timestamp) / 1e3,
+ s: String(value)
+ };
+ }).filter((entry) => entry !== null).sort((a, b) => a.lu - b.lu);
+ }
+ /**
+ * Merges a raw (high-resolution) history point list with a statistics
+ * (long-term) point list, preferring raw data for the period it covers and
+ * filling in statistics data outside that period.
+ */
+ function mergeNumericHistoryWithStatistics(histPts, statsPts) {
+ const raw = Array.isArray(histPts) ? histPts : [];
+ const stats = Array.isArray(statsPts) ? statsPts : [];
+ if (!raw.length) return [...stats];
+ if (!stats.length) return [...raw];
+ const firstRawMs = raw[0].lu * 1e3;
+ const lastRawMs = raw[raw.length - 1].lu * 1e3;
+ const merged = [...stats.filter((entry) => {
+ const timeMs = entry.lu * 1e3;
+ return timeMs < firstRawMs || timeMs > lastRawMs;
+ }), ...raw];
+ merged.sort((a, b) => a.lu - b.lu);
+ return merged;
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/data/axis-extent.ts
+ /**
+ * Computes the finite minimum and maximum of an array of values.
+ * Returns `null` if there are no finite values.
+ */
+ function getAxisValueExtent(allValues) {
+ let min = Infinity;
+ let max = -Infinity;
+ for (const value of allValues) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) continue;
+ if (numeric < min) min = numeric;
+ if (numeric > max) max = numeric;
+ }
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
+ return {
+ min,
+ max
+ };
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/util/color.ts
+ function hexToRgba(hex, alpha) {
+ const h = hex.replace("#", "");
+ return `rgba(${Number.parseInt(h.substring(0, 2), 16)},${Number.parseInt(h.substring(2, 4), 16)},${Number.parseInt(h.substring(4, 6), 16)},${alpha})`;
+ }
+ /**
+ * Return "#fff" or "#000" whichever has better contrast against the given hex
+ * background colour, using the WCAG relative-luminance formula.
+ */
+ function contrastColor(hex) {
+ if (!hex || typeof hex !== "string") return "#fff";
+ const h = hex.replace("#", "");
+ if (h.length !== 6) return "#fff";
+ const r = Number.parseInt(h.substring(0, 2), 16) / 255;
+ const g = Number.parseInt(h.substring(2, 4), 16) / 255;
+ const b = Number.parseInt(h.substring(4, 6), 16) / 255;
+ const lin = (c) => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
+ return .2126 * lin(r) + .7152 * lin(g) + .0722 * lin(b) > .179 ? "#000" : "#fff";
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts
+ function createFallbackCanvasContext() {
+ return {
+ beginPath() {},
+ moveTo() {},
+ lineTo() {},
+ stroke() {},
+ fill() {},
+ fillRect() {},
+ clearRect() {},
+ rect() {},
+ clip() {},
+ save() {},
+ restore() {},
+ scale() {},
+ setLineDash() {},
+ fillText() {},
+ closePath() {},
+ bezierCurveTo() {},
+ arc() {},
+ measureText() {
+ return { width: 0 };
+ }
+ };
+ }
+ /**
+ * Canvas-based chart renderer – grids, lines, annotations.
+ */
+ var ChartRenderer = class ChartRenderer {
+ constructor(canvas, cssWidth, cssHeight) {
+ _defineProperty(this, "canvas", void 0);
+ _defineProperty(this, "ctx", void 0);
+ _defineProperty(this, "cssW", void 0);
+ _defineProperty(this, "cssH", void 0);
+ _defineProperty(this, "basePad", void 0);
+ _defineProperty(this, "pad", void 0);
+ _defineProperty(this, "labelColor", void 0);
+ _defineProperty(this, "_activeAxes", []);
+ this.canvas = canvas;
+ this.ctx = canvas.getContext("2d") || createFallbackCanvasContext();
+ this.cssW = cssWidth;
+ this.cssH = cssHeight;
+ this.basePad = {
+ top: 24,
+ right: 12,
+ bottom: 48,
+ left: 12
+ };
+ this.pad = { ...this.basePad };
+ this.labelColor = "rgba(214,218,224,0.92)";
+ }
+ static get AXIS_SLOT_WIDTH() {
+ return 30;
+ }
+ get cw() {
+ return this.cssW - this.pad.left - this.pad.right;
+ }
+ get ch() {
+ return this.cssH - this.pad.top - this.pad.bottom;
+ }
+ xOf(t, t0, t1) {
+ return this.pad.left + (t - t0) / (t1 - t0) * this.cw;
+ }
+ yOf(v, vMin, vMax) {
+ return this.pad.top + this.ch - (v - vMin) / (vMax - vMin) * this.ch;
+ }
+ clear() {
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ }
+ _normalizeAxes(vMinOrAxes, vMax) {
+ const axisColumnWidth = ChartRenderer.AXIS_SLOT_WIDTH;
+ const inputAxes = Array.isArray(vMinOrAxes) ? vMinOrAxes : [{
+ key: "default",
+ min: vMinOrAxes,
+ max: vMax ?? vMinOrAxes,
+ side: "left",
+ unit: "",
+ color: null
+ }];
+ const leftAxes = [];
+ const rightAxes = [];
+ const axes = inputAxes.map((axis, index) => {
+ const normalized = {
+ key: axis.key || `axis-${index}`,
+ min: axis.min,
+ max: axis.max,
+ side: axis.side === "right" ? "right" : "left",
+ unit: axis.unit || "",
+ color: axis.color || "rgba(128,128,128,0.85)"
+ };
+ const bucket = normalized.side === "right" ? rightAxes : leftAxes;
+ normalized.slot = bucket.length;
+ bucket.push(normalized);
+ return normalized;
+ });
+ this.pad = {
+ top: this.basePad.top,
+ bottom: this.basePad.bottom,
+ left: this.basePad.left + Math.max(1, leftAxes.length) * axisColumnWidth,
+ right: this.basePad.right + rightAxes.length * axisColumnWidth
+ };
+ this._activeAxes = axes;
+ return axes;
+ }
+ _formatAxisTick(v, _unit) {
+ return Math.abs(v) >= 1e3 ? `${(v / 1e3).toFixed(1).replace(/\.0$/, "")}k` : v.toFixed(v % 1 !== 0 ? 1 : 0);
+ }
+ _axisLabelX(axis) {
+ const columnWidth = ChartRenderer.AXIS_SLOT_WIDTH;
+ const leftAxisX = this.pad.left;
+ const rightAxisX = this.pad.left + this.cw;
+ if (axis.side === "right") return rightAxisX + 10 + (axis.slot ?? 0) * columnWidth;
+ return leftAxisX - 10 - (axis.slot ?? 0) * columnWidth;
+ }
+ _formatTimeTick(t, t0, t1, tickSpanMs = null) {
+ const value = new Date(t);
+ const spanMs = Math.max(0, t1 - t0);
+ const detailSpanMs = Number.isFinite(tickSpanMs) && (tickSpanMs ?? 0) > 0 ? tickSpanMs : spanMs;
+ const start = new Date(t0);
+ const end = new Date(t1);
+ const sameDay = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth() && start.getDate() === end.getDate();
+ const sameMonth = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth();
+ if (detailSpanMs <= 7200 * 1e3) return value.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ if (detailSpanMs <= 720 * 60 * 1e3) return value.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ if (detailSpanMs <= 2880 * 60 * 1e3) return value.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit"
+ });
+ if (sameDay) return value.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ if (detailSpanMs <= 360 * 60 * 1e3) return value.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ if (detailSpanMs <= 1440 * 60 * 1e3) return value.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit"
+ });
+ if (sameMonth && spanMs <= 336 * 60 * 60 * 1e3) return value.toLocaleDateString([], { day: "numeric" });
+ if (spanMs >= 2880 * 60 * 1e3) return value.toLocaleDateString([], {
+ month: "short",
+ day: "numeric"
+ });
+ if (spanMs >= 1440 * 60 * 1e3) return value.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ return fmtTime(value.toISOString());
+ }
+ _niceNumber(value, round) {
+ if (!Number.isFinite(value) || value <= 0) return 1;
+ const exponent = Math.floor(Math.log10(value));
+ const fraction = value / 10 ** exponent;
+ let niceFraction;
+ if (round) if (fraction < 1.5) niceFraction = 1;
+ else if (fraction < 3) niceFraction = 2;
+ else if (fraction < 7) niceFraction = 5;
+ else niceFraction = 10;
+ else if (fraction <= 1) niceFraction = 1;
+ else if (fraction <= 2) niceFraction = 2;
+ else if (fraction <= 5) niceFraction = 5;
+ else niceFraction = 10;
+ return niceFraction * 10 ** exponent;
+ }
+ _buildNiceAxisScale(axis, tickCount) {
+ const rawMin = Number.isFinite(axis.min) ? axis.min : 0;
+ const rawMax = Number.isFinite(axis.max) ? axis.max : 1;
+ if (rawMin === rawMax) {
+ const pad = Math.abs(rawMin || 1);
+ const step = this._niceNumber(pad * 2 / Math.max(1, tickCount), true);
+ const niceMin = Math.floor((rawMin - pad) / step) * step;
+ const niceMax = Math.ceil((rawMax + pad) / step) * step;
+ const ticks = [];
+ for (let value = niceMin; value <= niceMax + step * .5; value += step) ticks.push(Number(value.toFixed(10)));
+ return {
+ min: niceMin,
+ max: niceMax,
+ step,
+ ticks
+ };
+ }
+ const range = this._niceNumber(rawMax - rawMin, false);
+ const step = this._niceNumber(range / Math.max(1, tickCount), true);
+ const niceMin = Math.floor(rawMin / step) * step;
+ const niceMax = Math.ceil(rawMax / step) * step;
+ const ticks = [];
+ for (let value = niceMin; value <= niceMax + step * .5; value += step) ticks.push(Number(value.toFixed(10)));
+ return {
+ min: niceMin,
+ max: niceMax,
+ step,
+ ticks
+ };
+ }
+ _alignTimeTick(timestamp, stepMs) {
+ const date = new Date(timestamp);
+ if (stepMs < 60 * 1e3) return Math.floor(timestamp / stepMs) * stepMs;
+ if (stepMs < 3600 * 1e3) {
+ const minutes = Math.max(1, Math.round(stepMs / (60 * 1e3)));
+ date.setSeconds(0, 0);
+ date.setMinutes(Math.floor(date.getMinutes() / minutes) * minutes);
+ return date.getTime();
+ }
+ if (stepMs < 1440 * 60 * 1e3) {
+ const hours = Math.max(1, Math.round(stepMs / (3600 * 1e3)));
+ date.setMinutes(0, 0, 0);
+ date.setHours(Math.floor(date.getHours() / hours) * hours);
+ return date.getTime();
+ }
+ if (stepMs < 10080 * 60 * 1e3) {
+ const days = Math.max(1, Math.round(stepMs / (1440 * 60 * 1e3)));
+ date.setHours(0, 0, 0, 0);
+ const dayOfMonth = date.getDate();
+ date.setDate(dayOfMonth - (dayOfMonth - 1) % days);
+ return date.getTime();
+ }
+ if (stepMs < 720 * 60 * 60 * 1e3) {
+ date.setHours(0, 0, 0, 0);
+ const offset = (date.getDay() + 6) % 7;
+ date.setDate(date.getDate() - offset);
+ return date.getTime();
+ }
+ date.setHours(0, 0, 0, 0);
+ date.setDate(1);
+ return date.getTime();
+ }
+ _getTimeTickStep(targetStepMs) {
+ const candidates = [
+ 300 * 1e3,
+ 600 * 1e3,
+ 900 * 1e3,
+ 1800 * 1e3,
+ 3600 * 1e3,
+ 7200 * 1e3,
+ 10800 * 1e3,
+ 360 * 60 * 1e3,
+ 720 * 60 * 1e3,
+ 1440 * 60 * 1e3,
+ 2880 * 60 * 1e3,
+ 10080 * 60 * 1e3,
+ 336 * 60 * 60 * 1e3,
+ 720 * 60 * 60 * 1e3
+ ];
+ return candidates.find((step) => step >= targetStepMs) || candidates[candidates.length - 1];
+ }
+ _buildTimeTicks(t0, t1) {
+ const approxTickCount = Math.max(2, Math.min(96, Math.floor(this.cw / 120)));
+ const stepMs = this._getTimeTickStep((t1 - t0) / Math.max(1, approxTickCount));
+ const ticks = [];
+ let tick = this._alignTimeTick(t0, stepMs);
+ if (tick < t0) tick += stepMs;
+ while (tick <= t1) {
+ ticks.push(tick);
+ tick += stepMs;
+ }
+ if (!ticks.length) ticks.push(t0, t1);
+ return {
+ ticks,
+ stepMs
+ };
+ }
+ drawGrid(t0, t1, vMin, vMax, yTicks = 5, options = {}) {
+ const { ctx, pad } = this;
+ const gridColor = "rgba(128,128,128,0.15)";
+ const labelColor = this.labelColor;
+ const fixedAxisOverlay = !!options.fixedAxisOverlay;
+ const hideTimeLabels = !!options.hideTimeLabels;
+ const axes = this._normalizeAxes(vMin, vMax);
+ const unitCounts = axes.reduce((counts, axis) => {
+ if (!axis.unit) return counts;
+ counts.set(axis.unit, (counts.get(axis.unit) || 0) + 1);
+ return counts;
+ }, /* @__PURE__ */ new Map());
+ const axisLabelColor = (axis) => {
+ if (!(!!axis?.unit && (unitCounts.get(axis.unit) || 0) > 1) || !axis?.color) return labelColor;
+ return axis.color;
+ };
+ axes.forEach((axis) => {
+ const scale = this._buildNiceAxisScale(axis, yTicks);
+ axis.min = scale.min;
+ axis.max = scale.max;
+ axis.ticks = scale.ticks;
+ });
+ const primaryAxis = axes[0];
+ ctx.font = "12px sans-serif";
+ for (const v of primaryAxis.ticks || []) {
+ const y = this.yOf(v, primaryAxis.min, primaryAxis.max);
+ ctx.strokeStyle = gridColor;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(pad.left, y);
+ ctx.lineTo(pad.left + this.cw, y);
+ ctx.stroke();
+ if (!fixedAxisOverlay) {
+ ctx.fillStyle = axisLabelColor(primaryAxis);
+ ctx.textAlign = "right";
+ ctx.textBaseline = "middle";
+ ctx.fillText(this._formatAxisTick(v, primaryAxis.unit), this._axisLabelX(primaryAxis), y);
+ }
+ }
+ if (!fixedAxisOverlay) for (const axis of axes.slice(1)) for (const v of axis.ticks || []) {
+ const y = this.yOf(v, axis.min, axis.max);
+ ctx.fillStyle = axisLabelColor(axis);
+ ctx.textAlign = axis.side === "right" ? "left" : "right";
+ ctx.textBaseline = "middle";
+ ctx.fillText(this._formatAxisTick(v, axis.unit), this._axisLabelX(axis), y);
+ }
+ if (!fixedAxisOverlay) for (const axis of axes) {
+ if (!axis.unit) continue;
+ ctx.fillStyle = axisLabelColor(axis);
+ ctx.textAlign = axis.side === "right" ? "left" : "right";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(axis.unit, this._axisLabelX(axis), pad.top - 6);
+ }
+ const { ticks: timeTicks, stepMs: tickSpanMs } = this._buildTimeTicks(t0, t1);
+ for (const t of timeTicks) {
+ const x = this.xOf(t, t0, t1);
+ const label = this._formatTimeTick(t, t0, t1, tickSpanMs);
+ ctx.strokeStyle = "rgba(128,128,128,0.08)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(x, pad.top);
+ ctx.lineTo(x, pad.top + this.ch);
+ ctx.stroke();
+ if (!hideTimeLabels) {
+ ctx.fillStyle = labelColor;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ const labelWidth = ctx.measureText(label).width;
+ const labelX = Math.min(pad.left + this.cw - labelWidth / 2, Math.max(pad.left + labelWidth / 2, x));
+ ctx.fillText(label, labelX, pad.top + this.ch + 6);
+ }
+ }
+ ctx.strokeStyle = "rgba(128,128,128,0.35)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ if (!fixedAxisOverlay) {
+ ctx.moveTo(pad.left, pad.top);
+ ctx.lineTo(pad.left, pad.top + this.ch);
+ }
+ ctx.moveTo(pad.left, pad.top + this.ch);
+ ctx.lineTo(pad.left + this.cw, pad.top + this.ch);
+ if (axes.some((axis) => axis.side === "right") && !fixedAxisOverlay) {
+ ctx.moveTo(pad.left + this.cw, pad.top);
+ ctx.lineTo(pad.left + this.cw, pad.top + this.ch);
+ }
+ ctx.stroke();
+ }
+ drawRowLabel(text, color = "rgba(214,218,224,0.85)") {
+ if (!text) return;
+ const { ctx, pad } = this;
+ ctx.save();
+ ctx.font = "bold 11px sans-serif";
+ ctx.fillStyle = color;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ ctx.fillText(text, pad.left + 6, pad.top + 5);
+ ctx.restore();
+ }
+ /**
+ * Append Catmull-Rom bezier segments to the current path, starting from pts[0]
+ * (caller must have already called moveTo(pts[0])). Each segment passes exactly
+ * through the data points with tangents derived from neighbouring points.
+ */
+ static _catmullRomPath(ctx, pts) {
+ for (let i = 1; i < pts.length; i++) {
+ const pm1 = pts[Math.max(0, i - 2)];
+ const p0 = pts[i - 1];
+ const p1 = pts[i];
+ const p2 = pts[Math.min(pts.length - 1, i + 1)];
+ const cp1x = p0[0] + (p1[0] - pm1[0]) / 6;
+ const cp1y = p0[1] + (p1[1] - pm1[1]) / 6;
+ const cp2x = p1[0] - (p2[0] - p0[0]) / 6;
+ const cp2y = p1[1] - (p2[1] - p0[1]) / 6;
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p1[0], p1[1]);
+ }
+ }
+ static _steppedPath(ctx, pts) {
+ for (let i = 1; i < pts.length; i++) {
+ const previous = pts[i - 1];
+ const current = pts[i];
+ ctx.lineTo(current[0], previous[1]);
+ ctx.lineTo(current[0], current[1]);
+ }
+ }
+ drawLine(points, color, t0, t1, vMin, vMax, options = {}) {
+ if (!points.length) return;
+ const { ctx, pad } = this;
+ const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : 0;
+ const smooth = !!options.smooth;
+ const stepped = options.stepped === true;
+ const dashed = !!options.dashed;
+ const dotted = !!options.dotted;
+ const dashPattern = Array.isArray(options.dashPattern) ? options.dashPattern.filter((entry) => Number.isFinite(entry) && entry > 0) : null;
+ const lineOpacity = Number.isFinite(options.lineOpacity) ? options.lineOpacity : 1;
+ const lineWidth = Number.isFinite(options.lineWidth) ? options.lineWidth : 1.75;
+ const px = points.map(([t, v]) => [this.xOf(t, t0, t1), this.yOf(v, vMin, vMax)]);
+ const bottom = pad.top + this.ch;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(pad.left, pad.top, this.cw, this.ch);
+ ctx.clip();
+ if (dashPattern && dashPattern.length) ctx.setLineDash(dashPattern);
+ else if (dotted) {
+ ctx.setLineDash([1, 3]);
+ ctx.lineCap = "round";
+ } else if (dashed) ctx.setLineDash([6, 4]);
+ if (lineOpacity < 1) ctx.globalAlpha = lineOpacity;
+ if (fillAlpha > 0) {
+ ctx.beginPath();
+ ctx.moveTo(px[0][0], bottom);
+ ctx.lineTo(px[0][0], px[0][1]);
+ if (stepped) ChartRenderer._steppedPath(ctx, px);
+ else if (smooth) ChartRenderer._catmullRomPath(ctx, px);
+ else for (let i = 1; i < px.length; i++) ctx.lineTo(px[i][0], px[i][1]);
+ ctx.lineTo(px[px.length - 1][0], bottom);
+ ctx.closePath();
+ ctx.fillStyle = hexToRgba(color, fillAlpha);
+ ctx.fill();
+ }
+ ctx.beginPath();
+ ctx.moveTo(px[0][0], px[0][1]);
+ if (stepped) ChartRenderer._steppedPath(ctx, px);
+ else if (smooth) ChartRenderer._catmullRomPath(ctx, px);
+ else for (let i = 1; i < px.length; i++) ctx.lineTo(px[i][0], px[i][1]);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = lineWidth;
+ if (stepped) {
+ ctx.lineJoin = "miter";
+ ctx.lineCap = "butt";
+ } else {
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ }
+ ctx.stroke();
+ ctx.restore();
+ }
+ drawBars(points, color, t0, t1, vMin, vMax, options = {}) {
+ if (!points.length) return;
+ const { ctx } = this;
+ const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : .78;
+ const widthFactor = Number.isFinite(options.widthFactor) ? options.widthFactor : .72;
+ const baselineY = this.yOf(Math.max(vMin, 0), vMin, vMax);
+ const xs = points.map(([t]) => this.xOf(t, t0, t1));
+ let minGap = this.cw / Math.max(points.length, 1);
+ for (let i = 1; i < xs.length; i++) minGap = Math.min(minGap, xs[i] - xs[i - 1]);
+ const barWidth = Math.max(3, Math.min(28, minGap * widthFactor));
+ ctx.save();
+ ctx.fillStyle = hexToRgba(color, fillAlpha);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+ for (let i = 0; i < points.length; i++) {
+ const [, v] = points[i];
+ const x = xs[i];
+ const y = this.yOf(v, vMin, vMax);
+ const top = Math.min(y, baselineY);
+ const height = Math.max(1, Math.abs(baselineY - y));
+ const left = x - barWidth / 2;
+ ctx.fillRect(left, top, barWidth, height);
+ }
+ ctx.restore();
+ }
+ drawStateBands(spans, t0, t1, color = "#03a9f4", alpha = .12) {
+ if (!spans?.length) return;
+ const { ctx, pad } = this;
+ ctx.save();
+ ctx.fillStyle = hexToRgba(color, alpha);
+ for (const span of spans) {
+ const start = Math.max(t0, span.start);
+ const end = Math.min(t1, span.end);
+ if (!(start < end)) continue;
+ const x0 = this.xOf(start, t0, t1);
+ const x1 = this.xOf(end, t0, t1);
+ ctx.fillRect(x0, pad.top, Math.max(1, x1 - x0), this.ch);
+ }
+ ctx.restore();
+ }
+ drawAnnotations(events, t0, t1, options = {}) {
+ const { ctx, pad } = this;
+ const hits = [];
+ const showLines = options.showLines !== false;
+ const showMarkers = options.showMarkers !== false;
+ for (const event of events) {
+ const t = new Date(event.timestamp).getTime();
+ if (t < t0 || t > t1) continue;
+ const x = this.xOf(t, t0, t1);
+ const color = event.color || "#03a9f4";
+ if (showLines) {
+ ctx.save();
+ ctx.setLineDash([4, 3]);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.globalAlpha = .75;
+ ctx.beginPath();
+ ctx.moveTo(x, pad.top + 8);
+ ctx.lineTo(x, pad.top + this.ch);
+ ctx.stroke();
+ ctx.restore();
+ }
+ if (showMarkers) {
+ const d = 5;
+ ctx.save();
+ ctx.fillStyle = color;
+ ctx.strokeStyle = "rgba(255,255,255,0.8)";
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(x, pad.top - d);
+ ctx.lineTo(x + d, pad.top);
+ ctx.lineTo(x, pad.top + d);
+ ctx.lineTo(x - d, pad.top);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+ }
+ hits.push({
+ event,
+ x,
+ y: pad.top
+ });
+ }
+ return hits;
+ }
+ /**
+ * Draw sensor-style vertical annotation lines that terminate on the data line
+ * with a small circle marker.
+ */
+ drawAnnotationLinesOnLine(events, allSeries, t0, t1, vMin, vMax) {
+ const { ctx, pad } = this;
+ const firstPts = allSeries.length ? allSeries[0].pts : [];
+ const hits = [];
+ for (const event of events) {
+ const t = new Date(event.timestamp).getTime();
+ if (t < t0 || t > t1) continue;
+ const x = this.xOf(t, t0, t1);
+ const value = this._interpolateValue(firstPts, t);
+ if (value === null) continue;
+ const y = this.yOf(value, vMin, vMax);
+ const color = event.color || "#03a9f4";
+ ctx.save();
+ ctx.setLineDash([4, 3]);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.globalAlpha = .75;
+ ctx.beginPath();
+ ctx.moveTo(x, pad.top + this.ch);
+ ctx.lineTo(x, y);
+ ctx.stroke();
+ ctx.restore();
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(x, y, 4, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ ctx.restore();
+ hits.push({
+ event,
+ x,
+ y,
+ value
+ });
+ }
+ return hits;
+ }
+ /**
+ * Interpolate the Y pixel position on a data series at a given timestamp.
+ * Uses linear interpolation between surrounding data points.
+ */
+ _interpolateY(seriesPoints, t, _t0, _t1, vMin, vMax) {
+ if (!seriesPoints.length) return null;
+ if (t <= seriesPoints[0][0]) return this.yOf(seriesPoints[0][1], vMin, vMax);
+ if (t >= seriesPoints[seriesPoints.length - 1][0]) return this.yOf(seriesPoints[seriesPoints.length - 1][1], vMin, vMax);
+ for (let i = 0; i < seriesPoints.length - 1; i++) {
+ const [t1p, v1p] = seriesPoints[i];
+ const [t2p, v2p] = seriesPoints[i + 1];
+ if (t >= t1p && t <= t2p) {
+ const v = v1p + (t - t1p) / (t2p - t1p) * (v2p - v1p);
+ return this.yOf(v, vMin, vMax);
+ }
+ }
+ return null;
+ }
+ _interpolateValue(seriesPoints, t) {
+ const len = seriesPoints.length;
+ if (!len) return null;
+ if (t < seriesPoints[0][0]) return null;
+ if (t > seriesPoints[len - 1][0]) return null;
+ let lo = 0;
+ let hi = len - 1;
+ while (lo + 1 < hi) {
+ const mid = Math.floor((lo + hi) / 2);
+ if (seriesPoints[mid][0] <= t) lo = mid;
+ else hi = mid;
+ }
+ const [t0, v0] = seriesPoints[lo];
+ const [t1, v1] = seriesPoints[hi];
+ if (t0 === t1) return v0;
+ return v0 + (v1 - v0) * ((t - t0) / (t1 - t0));
+ }
+ /**
+ * Draw annotation markers directly on a sensor data line.
+ * No vertical dotted line — only a coloured circle on the line.
+ *
+ * @param {Array} events Recorded events array
+ * @param {Array} allSeries Array of {pts} objects — first series used for Y
+ * @param {number} t0 Start time ms
+ * @param {number} t1 End time ms
+ * @param {number} vMin Y axis min
+ * @param {number} vMax Y axis max
+ * @returns {Array} Array of {event, x, y} for hit-testing
+ */
+ drawAnnotationsOnLine(events, allSeries, t0, t1, vMin, vMax) {
+ const { ctx } = this;
+ const firstPts = allSeries.length ? allSeries[0].pts : [];
+ const hits = [];
+ for (const event of events) {
+ const t = new Date(event.timestamp).getTime();
+ if (t < t0 || t > t1) continue;
+ const x = this.xOf(t, t0, t1);
+ const value = this._interpolateValue(firstPts, t);
+ if (value === null) continue;
+ const y = this.yOf(value, vMin, vMax);
+ const color = event.color || "#03a9f4";
+ const r = 10;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(x, y, r + 1.5, 0, Math.PI * 2);
+ ctx.fillStyle = "rgba(255,255,255,0.9)";
+ ctx.fill();
+ ctx.restore();
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(x, y, r, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.restore();
+ hits.push({
+ event,
+ x,
+ y,
+ value
+ });
+ }
+ return hits;
+ }
+ /**
+ * Draw a gradient-filled band between two data values, fading from the edge
+ * value toward the midpoint value. Used for min/max shading that fades toward
+ * the mean line.
+ *
+ * @param {number} valueEdge Data value at the opaque edge (the min or max line)
+ * @param {number} valueMid Data value at the transparent end (the mean line)
+ * @param {string} color Hex color string (e.g. "#03a9f4")
+ * @param {number} t0 Render start time ms
+ * @param {number} t1 Render end time ms
+ * @param {number} vMin Y-axis minimum data value
+ * @param {number} vMax Y-axis maximum data value
+ * @param {object} options { fillAlpha }
+ */
+ drawGradientBand(valueEdge, valueMid, color, _t0, _t1, vMin, vMax, options = {}) {
+ const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : .08;
+ if (fillAlpha <= 0) return;
+ const hexMatch = String(color || "").match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+ if (!hexMatch) return;
+ const r = parseInt(hexMatch[1], 16);
+ const g = parseInt(hexMatch[2], 16);
+ const b = parseInt(hexMatch[3], 16);
+ const yEdge = this.yOf(valueEdge, vMin, vMax);
+ const yMid = this.yOf(valueMid, vMin, vMax);
+ if (Math.abs(yMid - yEdge) < 1) return;
+ const { ctx, pad } = this;
+ const grad = ctx.createLinearGradient(0, yEdge, 0, yMid);
+ grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${fillAlpha})`);
+ grad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(pad.left, pad.top, this.cw, this.ch);
+ ctx.clip();
+ ctx.fillStyle = grad;
+ ctx.fillRect(pad.left, Math.min(yEdge, yMid), this.cw, Math.abs(yMid - yEdge));
+ ctx.restore();
+ }
+ drawThresholdArea(points, thresholdValue, color, t0, t1, vMin, vMax, options = {}) {
+ if (!Array.isArray(points) || points.length < 2) return;
+ if (!Number.isFinite(thresholdValue)) return;
+ const mode = options.mode === "below" ? "below" : "above";
+ const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : .12;
+ if (fillAlpha <= 0) return;
+ const segments = [];
+ let currentSegment = [];
+ const isInside = (value) => {
+ if (mode === "below") return value <= thresholdValue;
+ return value >= thresholdValue;
+ };
+ const flushSegment = () => {
+ if (currentSegment.length >= 2) segments.push(currentSegment);
+ currentSegment = [];
+ };
+ for (let index = 0; index < points.length - 1; index += 1) {
+ const startPoint = points[index];
+ const endPoint = points[index + 1];
+ const startInside = isInside(startPoint[1]);
+ const endInside = isInside(endPoint[1]);
+ if (startInside && currentSegment.length === 0) currentSegment.push(startPoint);
+ if (startInside && endInside) {
+ currentSegment.push(endPoint);
+ continue;
+ }
+ if (startInside !== endInside) {
+ const deltaValue = endPoint[1] - startPoint[1];
+ if (deltaValue === 0) {
+ flushSegment();
+ continue;
+ }
+ const fraction = (thresholdValue - startPoint[1]) / deltaValue;
+ const crossingPoint = [startPoint[0] + (endPoint[0] - startPoint[0]) * fraction, thresholdValue];
+ if (startInside) {
+ currentSegment.push(crossingPoint);
+ flushSegment();
+ } else {
+ currentSegment.push(crossingPoint);
+ currentSegment.push(endPoint);
+ }
+ continue;
+ }
+ if (!startInside && !endInside) flushSegment();
+ }
+ flushSegment();
+ if (!segments.length) return;
+ const { ctx, pad } = this;
+ const thresholdY = this.yOf(thresholdValue, vMin, vMax);
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(pad.left, pad.top, this.cw, this.ch);
+ ctx.clip();
+ ctx.fillStyle = hexToRgba(color, fillAlpha);
+ segments.forEach((segment) => {
+ if (!Array.isArray(segment) || segment.length < 2) return;
+ ctx.beginPath();
+ const firstPoint = segment[0];
+ ctx.moveTo(this.xOf(firstPoint[0], t0, t1), thresholdY);
+ segment.forEach((point) => {
+ ctx.lineTo(this.xOf(point[0], t0, t1), this.yOf(point[1], vMin, vMax));
+ });
+ const lastPoint = segment[segment.length - 1];
+ ctx.lineTo(this.xOf(lastPoint[0], t0, t1), thresholdY);
+ ctx.closePath();
+ ctx.fill();
+ });
+ ctx.restore();
+ }
+ /**
+ * Draw diagonal hash marks at gap boundary points to indicate the start/end
+ * of contiguous data ranges.
+ *
+ * @param {Array} boundaryPoints Array of [timeMs, value] pairs at gap edges
+ * @param {string} color Stroke colour
+ * @param {number} t0 Start time ms
+ * @param {number} t1 End time ms
+ * @param {number} vMin Y axis min
+ * @param {number} vMax Y axis max
+ */
+ drawGapMarkers(boundaryPoints, color, t0, t1, vMin, vMax) {
+ if (!boundaryPoints.length) return;
+ const { ctx, pad } = this;
+ const h = 7;
+ const w = 3;
+ const gap = 2;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(pad.left, pad.top, this.cw, this.ch);
+ ctx.clip();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.globalAlpha = .55;
+ for (let i = 0; i < boundaryPoints.length; i++) {
+ const [t, v] = boundaryPoints[i];
+ const x = this.xOf(t, t0, t1);
+ const y = this.yOf(v, vMin, vMax);
+ const dir = i % 2 === 0 ? 1 : -1;
+ for (let d = -gap; d <= gap; d += gap * 2) {
+ ctx.beginPath();
+ ctx.moveTo(x + d - w * dir, y - h);
+ ctx.lineTo(x + d + w * dir, y + h);
+ ctx.stroke();
+ }
+ }
+ ctx.restore();
+ }
+ drawAnomalyClusters(clusters, color, t0, t1, vMin, vMax, options = {}) {
+ if (!Array.isArray(clusters) || clusters.length === 0) return;
+ const strokeAlpha = Number.isFinite(options.strokeAlpha) ? options.strokeAlpha : .92;
+ const lineWidth = Number.isFinite(options.lineWidth) ? options.lineWidth : 2;
+ const haloWidth = Number.isFinite(options.haloWidth) ? options.haloWidth : Math.max(2.5, lineWidth + 1.5);
+ const haloColor = typeof options.haloColor === "string" && options.haloColor ? options.haloColor : "rgba(255,255,255,0.9)";
+ const haloAlpha = Number.isFinite(options.haloAlpha) ? options.haloAlpha : .9;
+ const fillColor = typeof options.fillColor === "string" && options.fillColor ? options.fillColor : null;
+ const fillAlpha = Number.isFinite(options.fillAlpha) ? options.fillAlpha : 0;
+ const pointPadding = Number.isFinite(options.pointPadding) ? options.pointPadding : 10;
+ const minRadiusX = Number.isFinite(options.minRadiusX) ? options.minRadiusX : 10;
+ const minRadiusY = Number.isFinite(options.minRadiusY) ? options.minRadiusY : 10;
+ const clusterRegions = this.getAnomalyClusterRegions(clusters, t0, t1, vMin, vMax, {
+ pointPadding,
+ minRadiusX,
+ minRadiusY
+ });
+ const { ctx, pad } = this;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(pad.left, pad.top, this.cw, this.ch);
+ ctx.clip();
+ clusterRegions.forEach((region) => {
+ ctx.save();
+ ctx.setLineDash([]);
+ if (fillColor && fillAlpha > 0) {
+ ctx.globalAlpha = fillAlpha;
+ ctx.fillStyle = fillColor;
+ ctx.beginPath();
+ ctx.ellipse(region.centerX, region.centerY, region.radiusX, region.radiusY, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = haloAlpha;
+ ctx.strokeStyle = haloColor;
+ ctx.lineWidth = haloWidth;
+ ctx.beginPath();
+ ctx.ellipse(region.centerX, region.centerY, region.radiusX, region.radiusY, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.globalAlpha = strokeAlpha;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = lineWidth;
+ ctx.beginPath();
+ ctx.ellipse(region.centerX, region.centerY, region.radiusX, region.radiusY, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ });
+ ctx.restore();
+ }
+ /**
+ * Animate a "blip" circle at the given canvas coordinates.
+ * The circle expands with a bouncy overshoot, holds briefly, then shrinks to nothing.
+ * Uses a separate overlay canvas so it doesn't interfere with the main chart.
+ */
+ drawBlip(cx, cy, color, options = {}) {
+ const resolvedOptions = options;
+ const maxRadius = resolvedOptions.maxRadius || 6;
+ const duration = resolvedOptions.duration || 600;
+ const canvas = this.canvas;
+ const parent = canvas.parentElement;
+ if (!parent) return;
+ const overlay = document.createElement("canvas");
+ overlay.width = canvas.width;
+ overlay.height = canvas.height;
+ overlay.style.cssText = `position:absolute;top:0;left:0;width:${canvas.style.width || `${canvas.offsetWidth}px`};height:${canvas.style.height || `${canvas.offsetHeight}px`};pointer-events:none;z-index:2;`;
+ parent.style.position = parent.style.position || "relative";
+ parent.appendChild(overlay);
+ const ctx = overlay.getContext("2d");
+ if (!ctx) {
+ overlay.remove();
+ return;
+ }
+ const dpr = window.devicePixelRatio || 1;
+ const pxCx = cx * dpr;
+ const pxCy = cy * dpr;
+ const pxMaxR = maxRadius * dpr;
+ const start = performance.now();
+ const animate = (now) => {
+ const elapsed = now - start;
+ const t = Math.min(elapsed / duration, 1);
+ ctx.clearRect(0, 0, overlay.width, overlay.height);
+ let radius;
+ let alpha;
+ if (t < .35) {
+ const p = t / .35;
+ const bounce = p < .6 ? p / .6 * 1.3 : 1.3 - .3 * ((p - .6) / .4);
+ radius = pxMaxR * Math.min(bounce, 1.3);
+ alpha = Math.min(p * 2.5, .85);
+ } else if (t < .6) {
+ radius = pxMaxR;
+ alpha = .85;
+ } else {
+ const ease = 1 - (1 - (t - .6) / .4) ** 3;
+ radius = pxMaxR * (1 - ease);
+ alpha = .85 * (1 - ease);
+ }
+ if (radius > .2 && alpha > .01) {
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.arc(pxCx, pxCy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(pxCx, pxCy, radius * 1.6, 0, Math.PI * 2);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.2 * dpr;
+ ctx.globalAlpha = alpha * .4;
+ ctx.stroke();
+ ctx.restore();
+ }
+ if (t < 1) requestAnimationFrame(animate);
+ else overlay.remove();
+ };
+ requestAnimationFrame(animate);
+ }
+ getAnomalyClusterRegions(clusters, t0, t1, vMin, vMax, options = {}) {
+ if (!Array.isArray(clusters) || clusters.length === 0) return [];
+ const pointPadding = Number.isFinite(options.pointPadding) ? options.pointPadding : 10;
+ const minRadiusX = Number.isFinite(options.minRadiusX) ? options.minRadiusX : 10;
+ const minRadiusY = Number.isFinite(options.minRadiusY) ? options.minRadiusY : 10;
+ return clusters.flatMap((cluster) => {
+ if (!Array.isArray(cluster?.points) || cluster.points.length === 0) return [];
+ const xs = [];
+ const ys = [];
+ cluster.points.forEach((point) => {
+ xs.push(this.xOf(point.timeMs, t0, t1));
+ ys.push(this.yOf(point.value, vMin, vMax));
+ });
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const minY = Math.min(...ys);
+ const maxY = Math.max(...ys);
+ return [{
+ centerX: (minX + maxX) / 2,
+ centerY: (minY + maxY) / 2,
+ radiusX: Math.max(minRadiusX, (maxX - minX) / 2 + pointPadding),
+ radiusY: Math.max(minRadiusY, (maxY - minY) / 2 + pointPadding),
+ cluster
+ }];
+ });
+ }
+ };
+ //#endregion
+ //#region custom_components/hass_datapoints/src/charts/utils/chart-dom.ts
+ function getRoot$1(card) {
+ const rootNode = card.shadowRoot ?? card.getRootNode();
+ if (rootNode instanceof ShadowRoot || rootNode instanceof Document) return rootNode;
+ return document;
+ }
+ /**
+ * Reads the HA `--secondary-text-color` CSS variable from the given element so
+ * canvas-drawn axis labels use the correct colour for the active light/dark theme.
+ * Falls back to the dark-mode default when the variable is unavailable.
+ */
+ function resolveChartLabelColor(el) {
+ if (!el) return "rgba(214,218,224,0.92)";
+ const raw = getComputedStyle(el).getPropertyValue("--secondary-text-color").trim();
+ if (raw) return raw;
+ return "rgba(214,218,224,0.92)";
+ }
+ function setupCanvas(canvas, container, cssHeight, cssWidth = null) {
+ const dpr = window.devicePixelRatio || 1;
+ const maxCssDim = Math.floor(16383 / dpr);
+ const styles = getComputedStyle(container);
+ const paddingX = (Number.parseFloat(styles.paddingLeft || "0") || 0) + (Number.parseFloat(styles.paddingRight || "0") || 0);
+ const paddingY = (Number.parseFloat(styles.paddingTop || "0") || 0) + (Number.parseFloat(styles.paddingBottom || "0") || 0);
+ const measuredWidth = cssWidth ?? (container.clientWidth || 360);
+ const w = Math.min(maxCssDim, Math.max(1, Math.round(measuredWidth - paddingX)));
+ const requestedHeight = cssHeight ?? container.clientHeight ?? 220;
+ const h = Math.min(maxCssDim, Math.max(40, Math.round(requestedHeight - paddingY)));
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ const ctx = canvas.getContext("2d");
+ if (ctx && typeof ctx.scale === "function") ctx.scale(dpr, dpr);
+ return {
+ w,
+ h
+ };
+ }
+ function renderChartAxisOverlays(card, renderer, axes = []) {
+ const leftEl = getRoot$1(card)?.getElementById("chart-axis-left");
+ const rightEl = getRoot$1(card)?.getElementById("chart-axis-right");
+ if (!leftEl || !rightEl || !renderer) return;
+ const leftWidth = Math.max(0, renderer.pad.left);
+ const rightWidth = Math.max(0, renderer.pad.right);
+ leftEl.style.width = `${leftWidth}px`;
+ rightEl.style.width = `${rightWidth}px`;
+ const chartWrap = getRoot$1(card).querySelector(".chart-wrap");
+ const chartWrapEl = chartWrap instanceof HTMLElement ? chartWrap : card;
+ if (chartWrapEl) {
+ chartWrapEl.style.setProperty("--dp-chart-axis-left-width", `${leftWidth}px`);
+ chartWrapEl.style.setProperty("--dp-chart-axis-right-width", `${rightWidth}px`);
+ }
+ const axisSlotWidth = ChartRenderer.AXIS_SLOT_WIDTH;
+ const axisOffset = (axis) => 10 + (axis.slot ?? 0) * axisSlotWidth;
+ const unitCounts = axes.reduce((counts, axis) => {
+ if (!axis?.unit) return counts;
+ counts.set(axis.unit, (counts.get(axis.unit) || 0) + 1);
+ return counts;
+ }, /* @__PURE__ */ new Map());
+ const axisTextStyle = (axis) => {
+ if (!(!!axis?.unit && (unitCounts.get(axis.unit) || 0) > 1) || !axis?.color) return "";
+ return `color:${axis.color};`;
+ };
+ const buildAxisMarkup = (axis) => {
+ return b`${(axis.ticks || []).map((tick) => {
+ const y = renderer.yOf(tick, axis.min, axis.max);
+ const sideStyle = axis.side === "left" ? `right:${axisOffset(axis)}px;text-align:right;` : `left:${axisOffset(axis)}px;text-align:left;`;
+ return b`
+ ${renderer._formatAxisTick(tick, axis.unit)}
+
`;
+ })}${axis.unit ? b`
+ ${axis.unit}
+
` : ""}`;
+ };
+ const leftAxes = axes.filter((axis) => axis.side !== "right");
+ const rightAxes = axes.filter((axis) => axis.side === "right");
+ D(leftAxes.length ? b`
+ ${leftAxes.map((axis) => buildAxisMarkup(axis))}` : b``, leftEl);
+ D(rightAxes.length ? b`
+ ${rightAxes.map((axis) => buildAxisMarkup(axis))}` : b``, rightEl);
+ leftEl.classList.toggle("visible", !!leftAxes.length);
+ rightEl.classList.toggle("visible", !!rightAxes.length);
+ }
+ function renderChartAxisHoverDots(card, hoverValues = []) {
+ const root = getRoot$1(card);
+ const leftEl = root.getElementById("chart-axis-left");
+ const rightEl = root.getElementById("chart-axis-right");
+ const scrollViewport = root.getElementById("chart-scroll-viewport");
+ if (!leftEl || !rightEl) return;
+ leftEl.querySelectorAll(".chart-axis-hover-dot").forEach((el) => el.remove());
+ rightEl.querySelectorAll(".chart-axis-hover-dot").forEach((el) => el.remove());
+ const verticalOffset = scrollViewport?.offsetTop || 0;
+ hoverValues.filter((entry) => entry?.hasValue !== false && Number.isFinite(entry?.y)).forEach((entry) => {
+ const target = entry.axisSide === "right" ? rightEl : leftEl;
+ const dot = document.createElement("span");
+ dot.className = `chart-axis-hover-dot ${entry.axisSide === "right" ? "right" : "left"}`;
+ dot.style.top = `${verticalOffset + entry.y}px`;
+ dot.style.background = entry.color || "#03a9f4";
+ dot.style.opacity = `${Number.isFinite(entry.opacity) ? entry.opacity : 1}`;
+ target.appendChild(dot);
+ });
+ }
+ function positionTooltip(tooltip, clientX, clientY, bounds = null) {
+ tooltip.style.display = "block";
+ const tipRect = tooltip.getBoundingClientRect();
+ const tipW = tipRect.width || 220;
+ const tipH = tipRect.height || 64;
+ const gap = 12;
+ const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
+ const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
+ const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
+ const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
+ let left = clientX + gap;
+ if (left + tipW > maxLeft) left = clientX - tipW - gap;
+ let top = clientY - tipH - gap;
+ if (top < minTop) top = clientY + gap;
+ if (top + tipH > maxTop) top = Math.max(minTop, clientY - tipH - gap);
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft - tipW));
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop - tipH));
+ tooltip.style.left = `${left}px`;
+ tooltip.style.top = `${top}px`;
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/chart/chart-shell.ts
+ function clampChartValue(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+ }
+ function formatTooltipValue(value, unit = "") {
+ if (value == null || value === "" || Number.isNaN(Number(value))) return "";
+ return `${Number(value).toFixed(2).replace(/\.00$/, "")}${unit ? ` ${unit}` : ""}`;
+ }
+ function formatTooltipDisplayValue(value, unit = "") {
+ if (value == null || value === "") return "No value";
+ if (typeof value === "string") return unit ? `${value} ${unit}` : value;
+ return formatTooltipValue(value, unit);
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/ha/entity-name.ts
+ function entityName(hass, entityId) {
+ if (!hass || !entityId) return entityId || "";
+ const state = hass.states?.[entityId];
+ return state && state.attributes && state.attributes.friendly_name || entityId;
+ }
+ function entityIcon(hass, entityId) {
+ if (!hass || !entityId) return "mdi:link-variant";
+ const state = hass.states?.[entityId];
+ if (state?.attributes?.icon) return state.attributes.icon;
+ const domain = String(entityId).split(".")[0];
+ const entry = hass.entities?.[entityId];
+ if (entry?.icon) return entry.icon;
+ switch (domain) {
+ case "light": return "mdi:lightbulb";
+ case "switch": return "mdi:toggle-switch";
+ case "binary_sensor": return "mdi:radiobox-marked";
+ case "sensor": return "mdi:chart-line";
+ case "climate": return "mdi:thermostat";
+ case "cover": return "mdi:window-shutter";
+ case "lock": return "mdi:lock";
+ case "media_player": return "mdi:play-box";
+ case "person": return "mdi:account";
+ case "device_tracker": return "mdi:crosshairs-gps";
+ default: return "mdi:link-variant";
+ }
+ }
+ function entityRegistryEntries(hass) {
+ return Object.entries(hass?.entities || {});
+ }
+ function firstRelatedEntityId(hass, matcher) {
+ return entityRegistryEntries(hass).find(([, entry]) => entry && typeof entry === "object" && matcher(entry))?.[0] || "";
+ }
+ function deviceName(hass, deviceId) {
+ if (!hass || !deviceId) return deviceId || "";
+ return hass.devices?.[deviceId]?.name ?? deviceId;
+ }
+ function deviceIcon(hass, deviceId) {
+ if (!hass || !deviceId) return "mdi:devices";
+ const entityId = firstRelatedEntityId(hass, (entry) => (entry.device_id || entry.deviceId) === deviceId);
+ return entityId ? entityIcon(hass, entityId) : "mdi:devices";
+ }
+ function areaName(hass, areaId) {
+ if (!hass || !areaId) return areaId || "";
+ return hass.areas?.[areaId]?.name ?? areaId;
+ }
+ function areaIcon(hass, areaId) {
+ if (!hass || !areaId) return "mdi:floor-plan";
+ const entityId = firstRelatedEntityId(hass, (entry) => (entry.area_id || entry.areaId) === areaId);
+ return entityId ? entityIcon(hass, entityId) : "mdi:floor-plan";
+ }
+ function labelName(hass, labelId) {
+ if (!hass || !labelId) return labelId || "";
+ return hass.labels?.[labelId]?.name ?? labelId;
+ }
+ function labelIcon(hass, labelId) {
+ if (!hass || !labelId) return "mdi:label-outline";
+ const entityId = firstRelatedEntityId(hass, (entry) => {
+ return [...Array.isArray(entry.labels) ? entry.labels : [], ...Array.isArray(entry.label_ids) ? entry.label_ids : []].includes(labelId);
+ });
+ return entityId ? entityIcon(hass, entityId) : "mdi:label-outline";
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts
+ /**
+ * Returns the DOM root that contains the chart's elements.
+ * For cards with a shadow root (legacy HTMLElement-based cards) this is
+ * `card.shadowRoot`. For sub-components that render into the parent's shadow
+ * DOM (like `hass-datapoints-history-chart`) there is no own shadow root, so we fall back
+ * to `getRootNode()` which returns the ancestor ShadowRoot.
+ */
+ function getRoot(card) {
+ const rootNode = card.shadowRoot ?? card.getRootNode();
+ if (rootNode instanceof ShadowRoot || rootNode instanceof Document) return rootNode;
+ return document;
+ }
+ function getInteractionState(card) {
+ return card;
+ }
+ function toChartBounds(bounds) {
+ if (!bounds) return null;
+ return {
+ left: bounds.left + 8,
+ right: bounds.right - 8,
+ top: bounds.top + 8,
+ bottom: bounds.bottom - 8
+ };
+ }
+ function formatTooltipDateTimeFromMs(timeMs) {
+ if (!Number.isFinite(timeMs)) return "";
+ return fmtDateTime(new Date(timeMs).toISOString());
+ }
+ function t$2(key, ...values) {
+ let s = msg(key);
+ values.forEach((v, i) => {
+ s = s.replace(new RegExp(`\\{${i}\\}`, "g"), v);
+ });
+ return s;
+ }
+ function getAnomalyMethodLabels() {
+ return {
+ trend_residual: msg("Trend deviation"),
+ rate_of_change: msg("Sudden change"),
+ iqr: msg("Statistical outlier (IQR)"),
+ rolling_zscore: msg("Rolling Z-score"),
+ persistence: msg("Flat-line / stuck"),
+ comparison_window: msg("Comparison window")
+ };
+ }
+ function buildAnomalyMethodSection(region) {
+ if (!region?.cluster?.points?.length) return null;
+ const points = region.cluster.points;
+ const startPoint = points[0];
+ const endPoint = points[points.length - 1];
+ const peakPoint = points.reduce((peak, p) => !peak || Math.abs(p.residual) > Math.abs(peak.residual) ? p : peak, null);
+ if (!peakPoint) return null;
+ const label = region.label || region.relatedEntityId || "Series";
+ const unit = region.unit || "";
+ const cluster = region.cluster;
+ const method = cluster.anomalyMethod ?? "trend_residual";
+ const methodLabel = getAnomalyMethodLabels()[method] || method;
+ let description;
+ let alert;
+ if (method === "rate_of_change") {
+ const rateUnit = unit ? `${unit}/h` : "units/h";
+ description = t$2("{0} shows an unusual rate of change between {1} and {2}.", label, formatTooltipDateTimeFromMs(startPoint.timeMs), formatTooltipDateTimeFromMs(endPoint.timeMs));
+ alert = t$2("Peak rate deviation: {0} from a typical rate of {1} at {2}.", formatTooltipValue(peakPoint.residual, rateUnit), formatTooltipValue(peakPoint.baselineValue, rateUnit), formatTooltipDateTimeFromMs(peakPoint.timeMs));
+ } else if (method === "iqr") {
+ description = t$2("{0} contains statistical outliers between {1} and {2}.", label, formatTooltipDateTimeFromMs(startPoint.timeMs), formatTooltipDateTimeFromMs(endPoint.timeMs));
+ alert = t$2("Peak value: {0}, deviating {1} from the median at {2}.", formatTooltipValue(peakPoint.value, unit), formatTooltipValue(Math.abs(peakPoint.residual), unit), formatTooltipDateTimeFromMs(peakPoint.timeMs));
+ } else if (method === "rolling_zscore") {
+ description = t$2("{0} shows statistically unusual values between {1} and {2}.", label, formatTooltipDateTimeFromMs(startPoint.timeMs), formatTooltipDateTimeFromMs(endPoint.timeMs));
+ alert = t$2("Peak deviation: {0} from a rolling mean of {1} at {2}.", formatTooltipValue(peakPoint.residual, unit), formatTooltipValue(peakPoint.baselineValue, unit), formatTooltipDateTimeFromMs(peakPoint.timeMs));
+ } else if (method === "persistence") {
+ const flatRange = typeof cluster.flatRange === "number" ? cluster.flatRange : null;
+ const rangeStr = flatRange !== null ? t$2(" (range: {0})", formatTooltipValue(flatRange, unit)) : "";
+ description = t$2("{0} appears stuck or flat between {1} and {2}{3}.", label, formatTooltipDateTimeFromMs(startPoint.timeMs), formatTooltipDateTimeFromMs(endPoint.timeMs), rangeStr);
+ alert = t$2("Value remained near {0} for an unusually long period.", formatTooltipValue(peakPoint.baselineValue, unit));
+ } else if (method === "comparison_window") {
+ description = t$2("{0} deviates significantly from the comparison window between {1} and {2}.", label, formatTooltipDateTimeFromMs(startPoint.timeMs), formatTooltipDateTimeFromMs(endPoint.timeMs));
+ alert = t$2("Peak deviation from comparison: {0} at {1}.", formatTooltipValue(peakPoint.residual, unit), formatTooltipDateTimeFromMs(peakPoint.timeMs));
+ } else {
+ description = t$2("{0} deviates from its expected trend between {1} and {2}.", label, formatTooltipDateTimeFromMs(startPoint.timeMs), formatTooltipDateTimeFromMs(endPoint.timeMs));
+ alert = t$2("Peak deviation: {0} from a baseline of {1} at {2}.", formatTooltipValue(peakPoint.residual, unit), formatTooltipValue(peakPoint.baselineValue, unit), formatTooltipDateTimeFromMs(peakPoint.timeMs));
+ }
+ return {
+ methodLabel,
+ description,
+ alert
+ };
+ }
+ function buildAnomalyTooltipContent(regions) {
+ let regionsArray;
+ if (Array.isArray(regions)) regionsArray = regions;
+ else if (regions) regionsArray = [regions];
+ else regionsArray = [];
+ if (regionsArray.length === 0) return null;
+ const sections = regionsArray.map(buildAnomalyMethodSection).filter((section) => section !== null);
+ if (sections.length === 0) return null;
+ const instruction = msg("Click the highlighted circle to add an annotation.", { id: "Click the highlighted circle to add an annotation." });
+ if (sections.length === 1) {
+ const section = sections[0];
+ const cluster = regionsArray[0]?.cluster;
+ const detectedByMethods = Array.isArray(cluster?.detectedByMethods) && cluster.detectedByMethods.length > 1 ? cluster.detectedByMethods : null;
+ const isMultiMethod = detectedByMethods !== null;
+ const title = isMultiMethod ? msg("⚠️ Multi-method Anomaly") : msg("⚠️ Anomaly Insight");
+ const labels = getAnomalyMethodLabels();
+ const confirmedNote = isMultiMethod ? `\n${msg("Confirmed by")} ${detectedByMethods.length} ${msg("methods:")} ${detectedByMethods.map((method) => labels[method] || method).join(", ")}.` : "";
+ return {
+ title,
+ description: section.description + confirmedNote,
+ alert: `${msg("Alert:")} ${section.alert}`,
+ instruction
+ };
+ }
+ const description = sections.map((s) => `${s.methodLabel}:\n${s.description}`).join("\n\n");
+ const alert = sections.map((s) => `${s.methodLabel}: ${s.alert}`).join("\n");
+ return {
+ title: msg("⚠️ Multi-method Anomaly"),
+ description,
+ alert,
+ instruction
+ };
+ }
+ function positionAnomalyTooltip(tooltip, clientX, clientY, mainTooltip, bounds = null) {
+ if (!tooltip) return;
+ tooltip.style.display = "block";
+ const tipRect = tooltip.getBoundingClientRect();
+ const tipW = tipRect.width || 220;
+ const tipH = tipRect.height || 64;
+ const gap = 12;
+ const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
+ const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
+ const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
+ const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
+ let left = clientX - gap - tipW;
+ if (left < minLeft) {
+ const mainRect = mainTooltip ? mainTooltip.getBoundingClientRect() : null;
+ left = mainRect ? mainRect.right + gap : clientX + gap;
+ }
+ const mainRect = mainTooltip ? mainTooltip.getBoundingClientRect() : null;
+ let top = mainRect ? mainRect.top : clientY - tipH - gap;
+ if (top + tipH > maxTop) top = Math.max(minTop, maxTop - tipH);
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft - tipW));
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop - tipH));
+ tooltip.style.left = `${left}px`;
+ tooltip.style.top = `${top}px`;
+ }
+ function positionSecondaryTooltip(tooltip, anchorTooltip, bounds = null) {
+ if (!tooltip || !anchorTooltip) return;
+ tooltip.style.display = "block";
+ const anchorRect = anchorTooltip.getBoundingClientRect();
+ const tipRect = tooltip.getBoundingClientRect();
+ const gap = 10;
+ const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
+ const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
+ const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
+ const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
+ let left = anchorRect.right + gap;
+ if (left + tipRect.width > maxLeft) left = anchorRect.left - tipRect.width - gap;
+ let top = anchorRect.top;
+ if (top + tipRect.height > maxTop) top = Math.max(minTop, maxTop - tipRect.height);
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft - tipRect.width));
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop - tipRect.height));
+ tooltip.style.left = `${left}px`;
+ tooltip.style.top = `${top}px`;
+ }
+ function positionTooltipBelow(tooltip, anchorTooltip, bounds = null) {
+ if (!tooltip || !anchorTooltip) return;
+ tooltip.style.display = "block";
+ const anchorRect = anchorTooltip.getBoundingClientRect();
+ const tipRect = tooltip.getBoundingClientRect();
+ const gap = 8;
+ const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
+ const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
+ const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
+ const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
+ let left = anchorRect.left;
+ if (left + tipRect.width > maxLeft) left = Math.max(minLeft, maxLeft - tipRect.width);
+ let top = anchorRect.bottom + gap;
+ if (top + tipRect.height > maxTop) top = Math.max(minTop, anchorRect.top - tipRect.height - gap);
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft - tipRect.width));
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop - tipRect.height));
+ tooltip.style.left = `${left}px`;
+ tooltip.style.top = `${top}px`;
+ }
+ function getAnnotationTooltipContainer(card) {
+ if (!card || !getRoot(card)) return null;
+ return getRoot(card).getElementById("annotation-tooltips");
+ }
+ function clearAnnotationTooltips(card) {
+ const container = getAnnotationTooltipContainer(card);
+ if (!container) return;
+ container.innerHTML = "";
+ }
+ function buildAnnotationTooltip(card, event) {
+ const interactionState = getInteractionState(card);
+ const tooltip = document.createElement("div");
+ tooltip.className = "tooltip secondary annotation-tooltip";
+ const hasValue = event?.chart_value != null && event.chart_value !== "";
+ const message = event?.message || "Data point";
+ const annotation = event?.annotation && event.annotation !== event.message ? event.annotation : "";
+ const chips = buildTooltipRelatedChips(interactionState._hass, event);
+ D(b`
+
${fmtDateTime(event.timestamp)}
+ ${hasValue ? b`
+ ${formatTooltipValue(event.chart_value, event.chart_unit)}
+
` : ""}
+
+
+ ${message}
+
+
+ ${annotation}
+
+
+ ${chips}
+
+ `, tooltip);
+ return tooltip;
+ }
+ function renderAnnotationTooltips(card, hover, anchorTooltip, bounds = null) {
+ const container = getAnnotationTooltipContainer(card);
+ if (!container) return [];
+ clearAnnotationTooltips(card);
+ const annotationEvents = Array.isArray(hover?.events) ? hover.events : [];
+ if (!annotationEvents.length) return [];
+ const renderedTooltips = [];
+ let anchorEl = anchorTooltip;
+ for (const event of annotationEvents) {
+ const tooltip = buildAnnotationTooltip(card, event);
+ container.appendChild(tooltip);
+ if (renderedTooltips.length === 0) positionSecondaryTooltip(tooltip, anchorEl, bounds);
+ else positionTooltipBelow(tooltip, anchorEl, bounds);
+ renderedTooltips.push(tooltip);
+ anchorEl = tooltip;
+ }
+ return renderedTooltips;
+ }
+ function hideTooltip(card) {
+ const tooltip = getRoot(card).getElementById("tooltip");
+ const anomalyTooltip = getRoot(card).getElementById("anomaly-tooltip");
+ if (tooltip) tooltip.style.display = "none";
+ if (anomalyTooltip) anomalyTooltip.style.display = "none";
+ clearAnnotationTooltips(card);
+ }
+ function resolveTooltipSeriesLabel(entry) {
+ const isSubordinate = entry.grouped === true && entry.rawVisible === true;
+ const isComparisonDerived = entry.comparisonDerived === true && entry.grouped === true;
+ if (entry.comparison === true) {
+ const windowLabel = String(entry.windowLabel || msg("Date window"));
+ if (entry.grouped === true) return windowLabel;
+ return `${windowLabel}: ${String(entry.label || "")}`;
+ }
+ if (entry.trend === true) {
+ const trendLabel = msg("Trend");
+ if (isSubordinate || isComparisonDerived) return trendLabel;
+ return `${trendLabel}: ${entry.baseLabel || entry.label || ""}`;
+ }
+ if (entry.rate === true) {
+ const rateLabel = msg("Rate");
+ if (isSubordinate || isComparisonDerived) return rateLabel;
+ return `${rateLabel}: ${entry.baseLabel || entry.label || ""}`;
+ }
+ if (entry.delta === true) {
+ const deltaLabel = msg("Delta");
+ if (isSubordinate || isComparisonDerived) return deltaLabel;
+ return `${deltaLabel}: ${entry.baseLabel || entry.label || ""}`;
+ }
+ if (entry.summary === true) {
+ const summaryLabel = String(entry.summaryType || "").toUpperCase();
+ if (isSubordinate || isComparisonDerived) return summaryLabel;
+ return `${summaryLabel}: ${entry.baseLabel || entry.label || ""}`;
+ }
+ if (entry.threshold === true) {
+ const thresholdLabel = msg("Threshold");
+ if (isSubordinate || isComparisonDerived) return thresholdLabel;
+ return `${thresholdLabel}: ${entry.baseLabel || entry.label || ""}`;
+ }
+ return String(entry.label || "");
+ }
+ function showLineChartTooltip(card, hover, clientX, clientY) {
+ const root = getRoot(card);
+ const tooltip = root.getElementById("tooltip");
+ const ttTime = root.getElementById("tt-time");
+ const ttValue = root.getElementById("tt-value");
+ const ttSeries = root.getElementById("tt-series");
+ const anomalyTooltip = root.getElementById("anomaly-tooltip");
+ const ttSecondaryTitle = root.getElementById("tt-secondary-title");
+ const ttSecondaryDescription = root.getElementById("tt-secondary-description");
+ const ttSecondaryAlert = root.getElementById("tt-secondary-alert");
+ const ttSecondaryInstruction = root.getElementById("tt-secondary-instruction");
+ const ttMessageRow = root.getElementById("tt-message-row");
+ const ttMsg = root.getElementById("tt-message");
+ const ttAnn = root.getElementById("tt-annotation");
+ const ttEntities = root.getElementById("tt-entities");
+ if (!tooltip || !ttTime || !ttValue || !ttMessageRow || !ttMsg || !ttAnn || !ttEntities) return;
+ const rangeStartMs = Number.isFinite(hover.rangeStartMs) ? hover.rangeStartMs : hover.timeMs;
+ const rangeEndMs = Number.isFinite(hover.rangeEndMs) ? hover.rangeEndMs : hover.timeMs;
+ ttTime.textContent = rangeStartMs === rangeEndMs ? fmtDateTime(new Date(hover.timeMs).toISOString()) : `${fmtDateTime(new Date(rangeStartMs).toISOString())} - ${fmtDateTime(new Date(rangeEndMs).toISOString())}`;
+ const values = Array.isArray(hover.values) ? hover.values : [];
+ const trendValues = Array.isArray(hover.trendValues) ? hover.trendValues : [];
+ const rateValues = Array.isArray(hover.rateValues) ? hover.rateValues : [];
+ const deltaValues = Array.isArray(hover.deltaValues) ? hover.deltaValues : [];
+ const summaryValues = Array.isArray(hover.summaryValues) ? hover.summaryValues : [];
+ const thresholdValues = Array.isArray(hover.thresholdValues) ? hover.thresholdValues : [];
+ const binaryValues = Array.isArray(hover.binaryValues) ? hover.binaryValues : [];
+ const comparisonValues = Array.isArray(hover.comparisonValues) ? hover.comparisonValues : [];
+ const displayRows = [];
+ const usedTrendRows = /* @__PURE__ */ new Set();
+ const usedRateRows = /* @__PURE__ */ new Set();
+ const usedDeltaRows = /* @__PURE__ */ new Set();
+ const usedSummaryRows = /* @__PURE__ */ new Set();
+ const usedThresholdRows = /* @__PURE__ */ new Set();
+ const usedComparisonRows = /* @__PURE__ */ new Set();
+ const pushComparisonDerivedRows = (comparisonEntry, comparisonIndex) => {
+ trendValues.forEach((trendEntry, trendIndex) => {
+ if (usedTrendRows.has(trendIndex)) return;
+ if (trendEntry.comparisonParentId !== comparisonEntry.entityId && !(trendEntry.relatedEntityId === comparisonEntry.relatedEntityId && trendEntry.windowLabel === comparisonEntry.windowLabel)) return;
+ usedTrendRows.add(trendIndex);
+ displayRows.push({
+ ...trendEntry,
+ rawVisible: true,
+ comparisonDerived: true,
+ grouped: true,
+ key: `comparison-trend-${comparisonIndex}-${trendIndex}`
+ });
+ });
+ rateValues.forEach((rateEntry, rateIndex) => {
+ if (usedRateRows.has(rateIndex)) return;
+ if (rateEntry.comparisonParentId !== comparisonEntry.entityId && !(rateEntry.relatedEntityId === comparisonEntry.relatedEntityId && rateEntry.windowLabel === comparisonEntry.windowLabel)) return;
+ usedRateRows.add(rateIndex);
+ displayRows.push({
+ ...rateEntry,
+ rawVisible: true,
+ comparisonDerived: true,
+ grouped: true,
+ key: `comparison-rate-${comparisonIndex}-${rateIndex}`
+ });
+ });
+ summaryValues.forEach((summaryEntry, summaryIndex) => {
+ if (usedSummaryRows.has(summaryIndex)) return;
+ if (summaryEntry.comparisonParentId !== comparisonEntry.entityId && !(summaryEntry.relatedEntityId === comparisonEntry.relatedEntityId && summaryEntry.windowLabel === comparisonEntry.windowLabel)) return;
+ usedSummaryRows.add(summaryIndex);
+ displayRows.push({
+ ...summaryEntry,
+ rawVisible: true,
+ comparisonDerived: true,
+ grouped: true,
+ key: `comparison-summary-${comparisonIndex}-${summaryIndex}`
+ });
+ });
+ thresholdValues.forEach((thresholdEntry, thresholdIndex) => {
+ if (usedThresholdRows.has(thresholdIndex)) return;
+ if (thresholdEntry.comparisonParentId !== comparisonEntry.entityId && !(thresholdEntry.relatedEntityId === comparisonEntry.relatedEntityId && thresholdEntry.windowLabel === comparisonEntry.windowLabel)) return;
+ usedThresholdRows.add(thresholdIndex);
+ displayRows.push({
+ ...thresholdEntry,
+ rawVisible: true,
+ comparisonDerived: true,
+ grouped: true,
+ key: `comparison-threshold-${comparisonIndex}-${thresholdIndex}`
+ });
+ });
+ };
+ values.forEach((entry, index) => {
+ displayRows.push(entry);
+ trendValues.forEach((trendEntry, trendIndex) => {
+ if (usedTrendRows.has(trendIndex)) return;
+ const sameEntity = trendEntry.relatedEntityId && trendEntry.relatedEntityId === entry.entityId;
+ const sameLabel = !trendEntry.relatedEntityId && trendEntry.baseLabel && trendEntry.baseLabel === entry.label;
+ if (!sameEntity && !sameLabel) return;
+ usedTrendRows.add(trendIndex);
+ displayRows.push({
+ ...trendEntry,
+ rawVisible: trendEntry.rawVisible !== false,
+ grouped: true,
+ key: `trend-${index}-${trendIndex}`
+ });
+ });
+ rateValues.forEach((rateEntry, rateIndex) => {
+ if (usedRateRows.has(rateIndex)) return;
+ const sameEntity = rateEntry.relatedEntityId && rateEntry.relatedEntityId === entry.entityId;
+ const sameLabel = !rateEntry.relatedEntityId && rateEntry.baseLabel && rateEntry.baseLabel === entry.label;
+ if (!sameEntity && !sameLabel) return;
+ usedRateRows.add(rateIndex);
+ displayRows.push({
+ ...rateEntry,
+ rawVisible: rateEntry.rawVisible !== false,
+ grouped: true,
+ key: `rate-${index}-${rateIndex}`
+ });
+ });
+ deltaValues.forEach((deltaEntry, deltaIndex) => {
+ if (usedDeltaRows.has(deltaIndex)) return;
+ const sameEntity = deltaEntry.relatedEntityId && deltaEntry.relatedEntityId === entry.entityId;
+ const sameLabel = !deltaEntry.relatedEntityId && deltaEntry.baseLabel && deltaEntry.baseLabel === entry.label;
+ if (!sameEntity && !sameLabel) return;
+ usedDeltaRows.add(deltaIndex);
+ displayRows.push({
+ ...deltaEntry,
+ rawVisible: deltaEntry.rawVisible !== false,
+ grouped: true,
+ key: `delta-${index}-${deltaIndex}`
+ });
+ });
+ summaryValues.forEach((summaryEntry, summaryIndex) => {
+ if (usedSummaryRows.has(summaryIndex)) return;
+ const sameEntity = summaryEntry.relatedEntityId && summaryEntry.relatedEntityId === entry.entityId;
+ const sameLabel = !summaryEntry.relatedEntityId && summaryEntry.baseLabel && summaryEntry.baseLabel === entry.label;
+ if (!sameEntity && !sameLabel) return;
+ usedSummaryRows.add(summaryIndex);
+ displayRows.push({
+ ...summaryEntry,
+ rawVisible: summaryEntry.rawVisible !== false,
+ grouped: true,
+ key: `summary-${index}-${summaryIndex}`
+ });
+ });
+ thresholdValues.forEach((thresholdEntry, thresholdIndex) => {
+ if (usedThresholdRows.has(thresholdIndex)) return;
+ const sameEntity = thresholdEntry.relatedEntityId && thresholdEntry.relatedEntityId === entry.entityId;
+ const sameLabel = !thresholdEntry.relatedEntityId && thresholdEntry.baseLabel && thresholdEntry.baseLabel === entry.label;
+ if (!sameEntity && !sameLabel) return;
+ usedThresholdRows.add(thresholdIndex);
+ displayRows.push({
+ ...thresholdEntry,
+ rawVisible: thresholdEntry.rawVisible !== false,
+ grouped: true,
+ key: `threshold-${index}-${thresholdIndex}`
+ });
+ });
+ comparisonValues.forEach((compEntry, compIndex) => {
+ if (usedComparisonRows.has(compIndex)) return;
+ if (!compEntry.relatedEntityId || compEntry.relatedEntityId !== entry.entityId) return;
+ usedComparisonRows.add(compIndex);
+ const groupedEntry = {
+ ...compEntry,
+ grouped: true,
+ comparison: true,
+ key: `comparison-${index}-${compIndex}`
+ };
+ displayRows.push(groupedEntry);
+ pushComparisonDerivedRows(groupedEntry, compIndex);
+ });
+ });
+ trendValues.forEach((trendEntry, trendIndex) => {
+ if (usedTrendRows.has(trendIndex)) return;
+ if (trendEntry.comparisonDerived === true || typeof trendEntry.comparisonParentId === "string") return;
+ displayRows.push({
+ ...trendEntry,
+ rawVisible: trendEntry.rawVisible !== false
+ });
+ });
+ rateValues.forEach((rateEntry, rateIndex) => {
+ if (usedRateRows.has(rateIndex)) return;
+ if (rateEntry.comparisonDerived === true || typeof rateEntry.comparisonParentId === "string") return;
+ displayRows.push({
+ ...rateEntry,
+ rawVisible: rateEntry.rawVisible !== false
+ });
+ });
+ deltaValues.forEach((deltaEntry, deltaIndex) => {
+ if (usedDeltaRows.has(deltaIndex)) return;
+ displayRows.push({
+ ...deltaEntry,
+ rawVisible: deltaEntry.rawVisible !== false
+ });
+ });
+ summaryValues.forEach((summaryEntry, summaryIndex) => {
+ if (usedSummaryRows.has(summaryIndex)) return;
+ if (summaryEntry.comparisonDerived === true || typeof summaryEntry.comparisonParentId === "string") return;
+ displayRows.push({
+ ...summaryEntry,
+ rawVisible: summaryEntry.rawVisible !== false
+ });
+ });
+ thresholdValues.forEach((thresholdEntry, thresholdIndex) => {
+ if (usedThresholdRows.has(thresholdIndex)) return;
+ if (thresholdEntry.comparisonDerived === true || typeof thresholdEntry.comparisonParentId === "string") return;
+ displayRows.push({
+ ...thresholdEntry,
+ rawVisible: thresholdEntry.rawVisible !== false
+ });
+ });
+ comparisonValues.forEach((compEntry, compIndex) => {
+ if (usedComparisonRows.has(compIndex)) return;
+ const groupedEntry = {
+ ...compEntry,
+ comparison: true
+ };
+ displayRows.push(groupedEntry);
+ pushComparisonDerivedRows(groupedEntry, compIndex);
+ });
+ displayRows.push(...binaryValues);
+ if (displayRows.length === 1 && trendValues.length === 0 && rateValues.length === 0 && deltaValues.length === 0 && summaryValues.length === 0 && thresholdValues.length === 0 && comparisonValues.length === 0 && binaryValues.length === 0 && displayRows[0]?.comparison !== true) {
+ const value = displayRows[0];
+ ttValue.textContent = value ? formatTooltipDisplayValue(value.value, value.unit) : "";
+ ttValue.style.display = value ? "block" : "none";
+ if (ttSeries) {
+ D(b``, ttSeries);
+ ttSeries.style.display = "none";
+ }
+ } else {
+ ttValue.textContent = "";
+ ttValue.style.display = "none";
+ if (ttSeries) {
+ D(b`${displayRows.map((entry) => b`
+
+
+ ${entry.grouped === true && entry.rawVisible === true ? "" : b` `}
+ ${resolveTooltipSeriesLabel(entry)}
+
+
${formatTooltipDisplayValue(entry.value, entry.unit)}
+
+ `)}`, ttSeries);
+ ttSeries.style.display = displayRows.length ? "grid" : "none";
+ }
+ }
+ ttMessageRow.style.display = "none";
+ ttMsg.textContent = "";
+ ttAnn.textContent = "";
+ ttAnn.style.display = "none";
+ D(b``, ttEntities);
+ ttEntities.style.display = "none";
+ if (anomalyTooltip && ttSecondaryTitle && ttSecondaryDescription && ttSecondaryAlert && ttSecondaryInstruction) {
+ const anomalyContent = buildAnomalyTooltipContent(hover.anomalyRegions);
+ if (anomalyContent) {
+ ttSecondaryTitle.textContent = anomalyContent.title;
+ ttSecondaryDescription.textContent = anomalyContent.description;
+ ttSecondaryAlert.textContent = anomalyContent.alert;
+ ttSecondaryInstruction.textContent = anomalyContent.instruction;
+ } else {
+ ttSecondaryTitle.textContent = "";
+ ttSecondaryDescription.textContent = "";
+ ttSecondaryAlert.textContent = "";
+ ttSecondaryInstruction.textContent = "";
+ anomalyTooltip.style.display = "none";
+ }
+ }
+ const chartBounds = (root.querySelector(".chart-wrap") ?? card).getBoundingClientRect();
+ positionTooltip(tooltip, clientX, clientY, toChartBounds(chartBounds));
+ if (anomalyTooltip && (hover.anomalyRegions?.length ?? 0) > 0) positionAnomalyTooltip(anomalyTooltip, clientX, clientY, tooltip, toChartBounds(chartBounds));
+ if (Array.isArray(hover.events) && hover.events.length > 0) renderAnnotationTooltips(card, hover, tooltip, toChartBounds(chartBounds));
+ else clearAnnotationTooltips(card);
+ }
+ function buildTooltipRelatedChips(hass, event) {
+ const entities = Array.isArray(event?.entity_ids) ? event.entity_ids : [];
+ const devices = Array.isArray(event?.device_ids) ? event.device_ids : [];
+ const areas = Array.isArray(event?.area_ids) ? event.area_ids : [];
+ const labels = Array.isArray(event?.label_ids) ? event.label_ids : [];
+ const chips = [
+ ...entities.map((id) => ({
+ icon: entityIcon(hass, id),
+ label: entityName(hass, id)
+ })),
+ ...devices.map((id) => ({
+ icon: deviceIcon(hass, id),
+ label: deviceName(hass, id)
+ })),
+ ...areas.map((id) => ({
+ icon: areaIcon(hass, id),
+ label: areaName(hass, id)
+ })),
+ ...labels.map((id) => ({
+ icon: labelIcon(hass, id),
+ label: labelName(hass, id)
+ }))
+ ].filter((chip) => chip.label);
+ if (!chips.length) return null;
+ return b`${chips.map((chip) => b`
+
+
+ ${chip.label}
+
+ `)}`;
+ }
+ function showLineChartCrosshair(card, renderer, hover) {
+ const overlay = getRoot(card).getElementById("chart-crosshair");
+ const vertical = getRoot(card).getElementById("crosshair-vertical");
+ const horizontal = getRoot(card).getElementById("crosshair-horizontal");
+ const points = getRoot(card).getElementById("crosshair-points");
+ const addButton = getRoot(card).getElementById("chart-add-annotation");
+ if (!overlay || !vertical || !horizontal || !points) return;
+ overlay.hidden = false;
+ vertical.style.left = `${hover.x}px`;
+ if (hover.splitVertical) {
+ vertical.style.top = `${hover.splitVertical.top}px`;
+ vertical.style.height = `${hover.splitVertical.height}px`;
+ } else {
+ vertical.style.top = `${renderer.pad.top}px`;
+ vertical.style.height = `${renderer.ch}px`;
+ }
+ horizontal.hidden = true;
+ const crosshairValues = [
+ ...hover.values || [],
+ ...hover.showTrendCrosshairs === true ? (hover.trendValues || []).filter((entry) => entry.showCrosshair === true) : [],
+ ...hover.showRateCrosshairs === true ? (hover.rateValues || []).filter((entry) => entry.showCrosshair === true) : [],
+ ...hover.comparisonValues || []
+ ];
+ D(b`
+ ${crosshairValues.filter((entry) => entry.hasValue !== false).map((entry) => b`
+
+ `)}
+ ${crosshairValues.filter((entry) => entry.hasValue !== false).map((entry) => b`
+
+ `)}
+ `, points);
+ renderChartAxisHoverDots(card, crosshairValues);
+ if (addButton && addButton.dataset.allowAddAnnotation !== "false") {
+ addButton.hidden = false;
+ addButton.style.left = `${hover.x}px`;
+ if (hover.splitVertical) addButton.style.top = `${hover.splitVertical.top + hover.splitVertical.height}px`;
+ else addButton.style.top = `${renderer.pad.top + renderer.ch}px`;
+ }
+ }
+ function dispatchLineChartHover(card, hover) {
+ card.dispatchEvent(new CustomEvent("hass-datapoints-chart-hover", {
+ bubbles: true,
+ composed: true,
+ detail: hover ? { timeMs: hover.timeMs } : { timeMs: null }
+ }));
+ }
+ function findNearestSeriesPointTime(seriesPoints, timeMs) {
+ if (!Array.isArray(seriesPoints) || seriesPoints.length === 0) return null;
+ let lo = 0;
+ let hi = seriesPoints.length - 1;
+ while (lo + 1 < hi) {
+ const mid = Math.floor((lo + hi) / 2);
+ if (seriesPoints[mid][0] <= timeMs) lo = mid;
+ else hi = mid;
+ }
+ const left = seriesPoints[lo]?.[0];
+ const right = seriesPoints[hi]?.[0];
+ if (!Number.isFinite(left) && !Number.isFinite(right)) return null;
+ if (!Number.isFinite(left)) return right;
+ if (!Number.isFinite(right)) return left;
+ return Math.abs(left - timeMs) <= Math.abs(right - timeMs) ? left : right;
+ }
+ function resolveLineChartHoverTime(series, timeMs, mode = "follow_series") {
+ if (mode !== "snap_to_data_points") return timeMs;
+ let bestTime = null;
+ let bestDistance = Infinity;
+ for (const seriesItem of Array.isArray(series) ? series : []) {
+ const candidateTime = findNearestSeriesPointTime(seriesItem?.pts, timeMs);
+ if (candidateTime == null || !Number.isFinite(candidateTime)) continue;
+ const distance = Math.abs(candidateTime - timeMs);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ bestTime = candidateTime;
+ }
+ }
+ return bestTime != null && Number.isFinite(bestTime) ? bestTime : timeMs;
+ }
+ function hideLineChartHover(card) {
+ dispatchLineChartHover(card, null);
+ hideTooltip(card);
+ const overlay = getRoot(card).getElementById("chart-crosshair");
+ const points = getRoot(card).getElementById("crosshair-points");
+ const addButton = getRoot(card).getElementById("chart-add-annotation");
+ if (overlay) overlay.hidden = true;
+ if (points) D(b``, points);
+ renderChartAxisHoverDots(card, []);
+ const horizontal = getRoot(card).getElementById("crosshair-horizontal");
+ if (horizontal) horizontal.hidden = true;
+ if (addButton) addButton.hidden = true;
+ }
+ function attachLineChartHover(card, canvas, renderer, series, events, t0, t1, vMin, vMax, axes = null, options = {}) {
+ const interactionState = getInteractionState(card);
+ if (!canvas || !renderer) return;
+ if (interactionState._chartHoverCleanup) {
+ interactionState._chartHoverCleanup();
+ interactionState._chartHoverCleanup = null;
+ }
+ const resolvedSeries = Array.isArray(series) ? series : [];
+ const eventThresholdMs = renderer.cw ? 14 * ((t1 - t0) / renderer.cw) : 0;
+ const binaryStates = Array.isArray(options.binaryStates) ? options.binaryStates : [];
+ const comparisonSeries = Array.isArray(options.comparisonSeries) ? options.comparisonSeries : [];
+ const trendSeries = Array.isArray(options.trendSeries) ? options.trendSeries : [];
+ const rateSeries = Array.isArray(options.rateSeries) ? options.rateSeries : [];
+ const deltaSeries = Array.isArray(options.deltaSeries) ? options.deltaSeries : [];
+ const summarySeries = Array.isArray(options.summarySeries) ? options.summarySeries : [];
+ const thresholdSeries = Array.isArray(options.thresholdSeries) ? options.thresholdSeries : [];
+ const anomalyRegions = Array.isArray(options.anomalyRegions) ? options.anomalyRegions : [];
+ if (!resolvedSeries.length && !binaryStates.length && !comparisonSeries.length && !trendSeries.length && !rateSeries.length && !deltaSeries.length && !summarySeries.length && !thresholdSeries.length && !anomalyRegions.length) return;
+ const hoverSurfaceEl = options.hoverSurfaceEl || null;
+ const addAnnotationButton = getRoot(card)?.getElementById("chart-add-annotation") || null;
+ const resolveHoverAxis = (seriesItem) => seriesItem.axis || axes && axes[0] || {
+ min: vMin,
+ max: vMax
+ };
+ const buildHoverValueEntry = (seriesItem, value, axis, extra = {}, entryOpts = {}) => {
+ const hasNumericValue = typeof value === "number" && Number.isFinite(value);
+ const includePosition = entryOpts.includePosition === true && hasNumericValue;
+ return {
+ entityId: seriesItem.entityId || "",
+ comparisonParentId: seriesItem.comparisonParentId || "",
+ relatedEntityId: seriesItem.relatedEntityId || "",
+ label: seriesItem.label || seriesItem.entityId || "",
+ baseLabel: seriesItem.baseLabel || "",
+ windowLabel: seriesItem.windowLabel || "",
+ value: hasNumericValue ? value : value ?? null,
+ unit: seriesItem.unit || "",
+ color: seriesItem.color,
+ opacity: Number.isFinite(seriesItem.hoverOpacity) ? seriesItem.hoverOpacity : 1,
+ hasValue: hasNumericValue || value != null,
+ x: includePosition ? entryOpts.x : void 0,
+ y: includePosition ? renderer.yOf(value, axis.min, axis.max) : void 0,
+ axisSide: axis.side === "right" ? "right" : "left",
+ axisSlot: Number.isFinite(axis.slot) ? axis.slot : 0,
+ rawVisible: seriesItem.rawVisible !== false,
+ comparisonDerived: seriesItem.comparisonDerived === true,
+ showCrosshair: seriesItem.showCrosshair === true,
+ ...extra
+ };
+ };
+ const findAnomalyRegions = (clientX, clientY) => {
+ const rect = canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) return [];
+ const localX = clientX - rect.left;
+ const localY = clientY - rect.top;
+ const hits = [];
+ for (const region of anomalyRegions) {
+ const radiusX = Number(region?.radiusX) || 0;
+ const radiusY = Number(region?.radiusY) || 0;
+ if (radiusX <= 0 || radiusY <= 0) continue;
+ const dx = (localX - region.centerX) / radiusX;
+ const dy = (localY - region.centerY) / radiusY;
+ if (dx * dx + dy * dy <= 1) hits.push(region);
+ }
+ return hits;
+ };
+ const buildHoverState = (clientX, clientY) => {
+ const rect = canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height || !renderer.cw || !renderer.ch) return null;
+ const localX = clampChartValue(clientX - rect.left, renderer.pad.left, renderer.pad.left + renderer.cw);
+ const localY = clampChartValue(clientY - rect.top, renderer.pad.top, renderer.pad.top + renderer.ch);
+ const timeMs = resolveLineChartHoverTime(resolvedSeries, t0 + (renderer.cw ? (localX - renderer.pad.left) / renderer.cw : 0) * (t1 - t0), options.hoverSnapMode || "follow_series");
+ const x = renderer.xOf(timeMs, t0, t1);
+ const values = resolvedSeries.map((seriesItem) => {
+ const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
+ return buildHoverValueEntry(seriesItem, value, resolveHoverAxis(seriesItem), {}, {
+ includePosition: value != null,
+ x
+ });
+ });
+ const comparisonValues = comparisonSeries.map((seriesItem) => {
+ const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
+ return buildHoverValueEntry(seriesItem, value, resolveHoverAxis(seriesItem), { comparison: true }, {
+ includePosition: value != null,
+ x
+ });
+ });
+ const trendValues = trendSeries.map((seriesItem) => {
+ const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
+ return buildHoverValueEntry(seriesItem, value, resolveHoverAxis(seriesItem), { trend: true }, {
+ includePosition: value != null,
+ x
+ });
+ });
+ const rateValues = rateSeries.map((seriesItem) => {
+ const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
+ return buildHoverValueEntry(seriesItem, value, resolveHoverAxis(seriesItem), { rate: true }, {
+ includePosition: value != null,
+ x
+ });
+ });
+ const deltaValues = deltaSeries.map((seriesItem) => {
+ const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
+ return buildHoverValueEntry(seriesItem, value, resolveHoverAxis(seriesItem), { delta: true }, {
+ includePosition: value != null,
+ x
+ });
+ });
+ const summaryValues = summarySeries.map((seriesItem) => {
+ const axis = resolveHoverAxis(seriesItem);
+ return buildHoverValueEntry(seriesItem, Number(seriesItem.value), axis, {
+ summary: true,
+ summaryType: seriesItem.summaryType || ""
+ });
+ });
+ const thresholdValues = thresholdSeries.map((seriesItem) => {
+ const axis = resolveHoverAxis(seriesItem);
+ return buildHoverValueEntry(seriesItem, Number(seriesItem.value), axis, { threshold: true });
+ });
+ const plottedValues = [
+ ...values.filter((entry) => entry?.hasValue !== false),
+ ...comparisonValues.filter((entry) => entry?.hasValue !== false),
+ ...rateValues.filter((entry) => entry?.hasValue !== false),
+ ...options.showTrendCrosshairs === true ? trendValues.filter((entry) => entry?.hasValue !== false && entry.showCrosshair === true) : []
+ ];
+ let rangeStartMs = timeMs;
+ let rangeEndMs = timeMs;
+ let primary = plottedValues[0] || null;
+ if (primary) {
+ for (const entry of plottedValues) if (Number.isFinite(entry.y) && Number.isFinite(primary.y) && Math.abs(entry.y - localY) < Math.abs(primary.y - localY)) primary = entry;
+ }
+ const activePrimarySeries = primary ? resolvedSeries.find((seriesItem) => seriesItem.entityId === primary.entityId) || null : null;
+ if (activePrimarySeries?.pts?.length) {
+ const pts = activePrimarySeries.pts;
+ const pLen = pts.length;
+ let lo = 0;
+ let hi = pLen - 1;
+ let previousIndex = -1;
+ if (pts[0][0] <= timeMs) {
+ while (lo + 1 < hi) {
+ const mid = Math.floor((lo + hi) / 2);
+ if (pts[mid][0] <= timeMs) lo = mid;
+ else hi = mid;
+ }
+ previousIndex = pts[hi][0] <= timeMs ? hi : lo;
+ }
+ const nextIndex = previousIndex < pLen - 1 ? previousIndex + 1 : -1;
+ const previous = previousIndex >= 0 ? pts[previousIndex] : null;
+ let next = null;
+ if (nextIndex >= 0) next = pts[nextIndex];
+ else if (previousIndex < 0) next = pts[0];
+ if (previous && next) {
+ const prevPrev = pts[Math.max(0, previousIndex - 1)] || previous;
+ const nextNext = pts[Math.min(pLen - 1, nextIndex + 1)] || next;
+ rangeStartMs = previous === next ? previous[0] : Math.round((previous[0] + prevPrev[0]) / 2);
+ rangeEndMs = previous === next ? next[0] : Math.round((next[0] + nextNext[0]) / 2);
+ } else if (previous) {
+ rangeStartMs = previous[0];
+ rangeEndMs = previous[0];
+ } else if (next) {
+ rangeStartMs = next[0];
+ rangeEndMs = next[0];
+ }
+ }
+ const binaryValues = binaryStates.map((entry) => {
+ const activeSpan = (entry.spans || []).find((span) => timeMs >= span.start && timeMs <= span.end);
+ return {
+ entityId: entry.entityId || "",
+ label: entry.label || entry.entityId || "",
+ value: activeSpan ? entry.onLabel || "on" : entry.offLabel || "off",
+ unit: "",
+ color: entry.color,
+ hasValue: true,
+ active: !!activeSpan
+ };
+ }).filter((entry) => Boolean(entry.label));
+ if (!values.length && !binaryValues.length && !trendValues.length && !rateValues.length && !deltaValues.length && !summaryValues.length && !thresholdValues.length && !comparisonValues.length) return null;
+ const fallbackY = renderer.pad.top + 12;
+ const hoverY = primary ? primary.y : fallbackY;
+ const hoveredEvents = [];
+ for (const event of events || []) {
+ const eventTime = new Date(event.timestamp).getTime();
+ if (eventTime < t0 || eventTime > t1) continue;
+ const distance = Math.abs(eventTime - timeMs);
+ if (distance <= eventThresholdMs) hoveredEvents.push({
+ ...event,
+ _hoverDistanceMs: distance
+ });
+ }
+ hoveredEvents.sort((left, right) => {
+ const distanceDelta = (left._hoverDistanceMs || 0) - (right._hoverDistanceMs || 0);
+ if (distanceDelta !== 0) return distanceDelta;
+ return new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime();
+ });
+ const normalizedHoveredEvents = hoveredEvents.map((event) => {
+ const { _hoverDistanceMs: _, ...normalizedEvent } = event;
+ return normalizedEvent;
+ });
+ return {
+ x,
+ y: hoverY,
+ timeMs,
+ rangeStartMs,
+ rangeEndMs,
+ values,
+ trendValues,
+ rateValues,
+ deltaValues: options.showDeltaTooltip === true ? deltaValues : [],
+ summaryValues,
+ thresholdValues,
+ comparisonValues,
+ binaryValues,
+ primary,
+ event: normalizedHoveredEvents[0] || null,
+ events: normalizedHoveredEvents,
+ emphasizeGuides: options.emphasizeHoverGuides === true,
+ showTrendCrosshairs: options.showTrendCrosshairs === true,
+ showRateCrosshairs: options.showRateCrosshairs === true,
+ hideRawData: options.hideRawData === true
+ };
+ };
+ const showFromPointer = (clientX, clientY) => {
+ if (interactionState._chartZoomDragging) return;
+ const anomalyRegionsHit = findAnomalyRegions(clientX, clientY);
+ const hover = buildHoverState(clientX, clientY);
+ if (!hover) {
+ interactionState._chartLastHover = null;
+ hideLineChartHover(card);
+ canvas.style.cursor = "default";
+ return;
+ }
+ hover.anomalyRegions = anomalyRegionsHit;
+ interactionState._chartLastHover = hover;
+ showLineChartCrosshair(card, renderer, hover);
+ if (options.showTooltip !== false || Array.isArray(hover.events) && hover.events.length > 0) showLineChartTooltip(card, hover, clientX, clientY);
+ else hideTooltip(card);
+ dispatchLineChartHover(card, hover);
+ canvas.style.cursor = anomalyRegionsHit.length > 0 ? "pointer" : "crosshair";
+ };
+ const hideHover = () => {
+ interactionState._chartLastHover = null;
+ hideLineChartHover(card);
+ canvas.style.cursor = "default";
+ };
+ let _rafHandle = null;
+ let _pendingX = 0;
+ let _pendingY = 0;
+ const onMouseMove = (ev) => {
+ _pendingX = ev.clientX;
+ _pendingY = ev.clientY;
+ if (_rafHandle !== null) return;
+ _rafHandle = requestAnimationFrame(() => {
+ _rafHandle = null;
+ showFromPointer(_pendingX, _pendingY);
+ });
+ };
+ const onMouseLeave = (ev) => {
+ const nextTarget = ev.relatedTarget;
+ if (nextTarget instanceof Node && hoverSurfaceEl && hoverSurfaceEl.contains(nextTarget)) return;
+ if (nextTarget instanceof Node && addAnnotationButton && addAnnotationButton.contains(nextTarget)) return;
+ hideHover();
+ };
+ const onOverlayMove = (ev) => {
+ showFromPointer(ev.clientX, ev.clientY);
+ };
+ const onOverlayLeave = (ev) => {
+ const nextTarget = ev.relatedTarget;
+ if (nextTarget instanceof Node && canvas.contains(nextTarget)) return;
+ if (nextTarget instanceof Node && addAnnotationButton && addAnnotationButton.contains(nextTarget)) return;
+ hideHover();
+ };
+ const onAddButtonLeave = (ev) => {
+ const nextTarget = ev.relatedTarget;
+ if (nextTarget instanceof Node && (canvas.contains(nextTarget) || hoverSurfaceEl && hoverSurfaceEl.contains(nextTarget))) return;
+ hideHover();
+ };
+ const onAddButtonClick = (ev) => {
+ if (typeof options.onAddAnnotation !== "function" || !interactionState._chartLastHover) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ options.onAddAnnotation(interactionState._chartLastHover, ev);
+ };
+ const onContextMenu = (ev) => {
+ if (typeof options.onContextMenu !== "function") return;
+ const hover = buildHoverState(ev.clientX, ev.clientY);
+ if (!hover) return;
+ ev.preventDefault();
+ interactionState._chartLastHover = hover;
+ showLineChartCrosshair(card, renderer, hover);
+ showLineChartTooltip(card, hover, ev.clientX, ev.clientY);
+ dispatchLineChartHover(card, hover);
+ options.onContextMenu(hover, ev);
+ };
+ const onClick = (ev) => {
+ if (typeof options.onAnomalyClick !== "function") return;
+ const regions = findAnomalyRegions(ev.clientX, ev.clientY);
+ if (!regions.length) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ options.onAnomalyClick(regions, ev);
+ };
+ let touchTimer = null;
+ const scheduleTouchHide = () => {
+ if (touchTimer) window.clearTimeout(touchTimer);
+ touchTimer = window.setTimeout(() => hideHover(), 1800);
+ };
+ const onTouchStart = (ev) => {
+ ev.preventDefault();
+ const touch = ev.touches[0];
+ if (!touch) return;
+ showFromPointer(touch.clientX, touch.clientY);
+ scheduleTouchHide();
+ };
+ const onTouchMove = (ev) => {
+ ev.preventDefault();
+ const touch = ev.touches[0];
+ if (!touch) return;
+ showFromPointer(touch.clientX, touch.clientY);
+ scheduleTouchHide();
+ };
+ const onTouchEnd = () => scheduleTouchHide();
+ canvas.addEventListener("mousemove", onMouseMove);
+ canvas.addEventListener("mouseleave", onMouseLeave);
+ canvas.addEventListener("click", onClick);
+ canvas.addEventListener("contextmenu", onContextMenu);
+ canvas.addEventListener("touchstart", onTouchStart, { passive: false });
+ canvas.addEventListener("touchmove", onTouchMove, { passive: false });
+ canvas.addEventListener("touchend", onTouchEnd);
+ canvas.addEventListener("touchcancel", onTouchEnd);
+ hoverSurfaceEl?.addEventListener("mousemove", onOverlayMove);
+ hoverSurfaceEl?.addEventListener("mouseleave", onOverlayLeave);
+ addAnnotationButton?.addEventListener("mouseleave", onAddButtonLeave);
+ addAnnotationButton?.addEventListener("click", onAddButtonClick);
+ interactionState._chartHoverCleanup = () => {
+ canvas.removeEventListener("mousemove", onMouseMove);
+ canvas.removeEventListener("mouseleave", onMouseLeave);
+ canvas.removeEventListener("click", onClick);
+ canvas.removeEventListener("contextmenu", onContextMenu);
+ canvas.removeEventListener("touchstart", onTouchStart);
+ canvas.removeEventListener("touchmove", onTouchMove);
+ canvas.removeEventListener("touchend", onTouchEnd);
+ canvas.removeEventListener("touchcancel", onTouchEnd);
+ hoverSurfaceEl?.removeEventListener("mousemove", onOverlayMove);
+ hoverSurfaceEl?.removeEventListener("mouseleave", onOverlayLeave);
+ addAnnotationButton?.removeEventListener("mouseleave", onAddButtonLeave);
+ addAnnotationButton?.removeEventListener("click", onAddButtonClick);
+ if (_rafHandle !== null) {
+ cancelAnimationFrame(_rafHandle);
+ _rafHandle = null;
+ }
+ if (touchTimer) {
+ window.clearTimeout(touchTimer);
+ touchTimer = null;
+ }
+ hideHover();
+ };
+ }
+ function attachLineChartRangeZoom(card, canvas, renderer, t0, t1, options = {}) {
+ const interactionState = getInteractionState(card);
+ if (!canvas || !renderer) return;
+ if (interactionState._chartZoomCleanup) {
+ interactionState._chartZoomCleanup();
+ interactionState._chartZoomCleanup = null;
+ }
+ const selection = getRoot(card).getElementById("chart-zoom-selection");
+ if (!selection) return;
+ let pointerId = null;
+ let startX = 0;
+ let currentX = 0;
+ let dragging = false;
+ const hideSelection = () => {
+ selection.hidden = true;
+ selection.classList.remove("visible");
+ };
+ const clientXToTime = (clientX) => {
+ const localX = clampChartValue(clientX - canvas.getBoundingClientRect().left, renderer.pad.left, renderer.pad.left + renderer.cw);
+ return t0 + (renderer.cw ? (localX - renderer.pad.left) / renderer.cw : 0) * (t1 - t0);
+ };
+ const inPlotBounds = (clientX, clientY) => {
+ const rect = canvas.getBoundingClientRect();
+ const localX = clientX - rect.left;
+ const localY = clientY - rect.top;
+ return localX >= renderer.pad.left && localX <= renderer.pad.left + renderer.cw && localY >= renderer.pad.top && localY <= renderer.pad.top + renderer.ch;
+ };
+ const renderSelection = () => {
+ const left = Math.min(startX, currentX);
+ const width = Math.abs(currentX - startX);
+ selection.style.left = `${left}px`;
+ selection.style.top = `${renderer.pad.top}px`;
+ selection.style.width = `${width}px`;
+ selection.style.height = `${renderer.ch}px`;
+ selection.hidden = false;
+ selection.classList.add("visible");
+ };
+ const emitPreview = () => {
+ if (!dragging || Math.abs(currentX - startX) < 8) {
+ options.onPreview?.(null);
+ return;
+ }
+ const rectLeft = canvas.getBoundingClientRect().left;
+ const startTime = Math.min(clientXToTime(rectLeft + startX), clientXToTime(rectLeft + currentX));
+ const endTime = Math.max(clientXToTime(rectLeft + startX), clientXToTime(rectLeft + currentX));
+ options.onPreview?.({
+ startTime,
+ endTime
+ });
+ };
+ const resetDragging = (clearPreview = true) => {
+ pointerId = null;
+ dragging = false;
+ interactionState._chartZoomDragging = false;
+ hideSelection();
+ if (clearPreview) options.onPreview?.(null);
+ };
+ const onPointerMove = (ev) => {
+ if (pointerId == null || ev.pointerId !== pointerId) return;
+ currentX = clampChartValue(ev.clientX - canvas.getBoundingClientRect().left, renderer.pad.left, renderer.pad.left + renderer.cw);
+ const movedPx = Math.abs(currentX - startX);
+ if (!dragging && movedPx < 6) return;
+ dragging = true;
+ interactionState._chartZoomDragging = true;
+ hideLineChartHover(card);
+ renderSelection();
+ emitPreview();
+ ev.preventDefault();
+ };
+ const finish = (ev) => {
+ if (pointerId == null || ev.pointerId !== pointerId) return;
+ const didDrag = dragging;
+ const endX = currentX;
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", finish);
+ window.removeEventListener("pointercancel", finish);
+ if (!didDrag || Math.abs(endX - startX) < 8) {
+ resetDragging(true);
+ return;
+ }
+ const rectLeft = canvas.getBoundingClientRect().left;
+ const startTime = Math.min(clientXToTime(rectLeft + startX), clientXToTime(rectLeft + endX));
+ const endTime = Math.max(clientXToTime(rectLeft + startX), clientXToTime(rectLeft + endX));
+ options.onZoom?.({
+ startTime,
+ endTime
+ });
+ resetDragging(false);
+ };
+ const onPointerDown = (ev) => {
+ if (ev.button !== 0 || !inPlotBounds(ev.clientX, ev.clientY)) return;
+ pointerId = ev.pointerId;
+ const rect = canvas.getBoundingClientRect();
+ startX = clampChartValue(ev.clientX - rect.left, renderer.pad.left, renderer.pad.left + renderer.cw);
+ currentX = startX;
+ dragging = false;
+ interactionState._chartZoomDragging = false;
+ options.onPreview?.(null);
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("pointerup", finish);
+ window.addEventListener("pointercancel", finish);
+ };
+ const onDoubleClick = (ev) => {
+ if (!inPlotBounds(ev.clientX, ev.clientY)) return;
+ if (!options.onReset) return;
+ ev.preventDefault();
+ options.onReset();
+ };
+ canvas.addEventListener("pointerdown", onPointerDown);
+ canvas.addEventListener("dblclick", onDoubleClick);
+ interactionState._chartZoomCleanup = () => {
+ canvas.removeEventListener("pointerdown", onPointerDown);
+ canvas.removeEventListener("dblclick", onDoubleClick);
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", finish);
+ window.removeEventListener("pointercancel", finish);
+ resetDragging();
+ };
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/data/cache.ts
+ /**
+ * Shared cache utilities for history/statistics/event lookups.
+ */
+ var DATA_RANGE_CACHE_TTL_MS = 600 * 1e3;
+ var DATA_RANGE_CACHE_LIVE_EDGE_MS = 300 * 1e3;
+ var dataRangeCache = /* @__PURE__ */ new Map();
+ function normalizeCacheIdList(values) {
+ return [...new Set((Array.isArray(values) ? values : []).filter(Boolean))].sort();
+ }
+ function shouldUseStableRangeCache(endTime) {
+ const endMs = new Date(endTime || 0).getTime();
+ if (!Number.isFinite(endMs)) return false;
+ return endMs < Date.now() - DATA_RANGE_CACHE_LIVE_EDGE_MS;
+ }
+ function getCachedRangePromise(key) {
+ const entry = dataRangeCache.get(key);
+ if (!entry) return null;
+ if (entry.expiresAt <= Date.now()) {
+ dataRangeCache.delete(key);
+ return null;
+ }
+ return entry.promise;
+ }
+ function setCachedRangePromise(key, promise) {
+ dataRangeCache.set(key, {
+ promise,
+ expiresAt: Date.now() + DATA_RANGE_CACHE_TTL_MS
+ });
+ return promise;
+ }
+ function withStableRangeCache(key, endTime, loader) {
+ if (!shouldUseStableRangeCache(endTime)) return Promise.resolve().then(loader);
+ const cached = getCachedRangePromise(key);
+ if (cached) return cached;
+ return setCachedRangePromise(key, Promise.resolve().then(loader).catch((err) => {
+ dataRangeCache.delete(key);
+ throw err;
+ }));
+ }
+ function clearStableRangeCacheMatching(predicate) {
+ if (typeof predicate !== "function") return 0;
+ let deletedCount = 0;
+ [...dataRangeCache.keys()].forEach((key) => {
+ if (predicate(key) === true) {
+ dataRangeCache.delete(key);
+ deletedCount += 1;
+ }
+ });
+ return deletedCount;
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/data/history-api.ts
+ var MAX_DOWNSAMPLED_HISTORY_RANGE_MS = 2160 * 60 * 60 * 1e3;
+ function parseIsoTimeMs(value) {
+ const timeMs = Date.parse(value);
+ if (!Number.isFinite(timeMs)) return null;
+ return timeMs;
+ }
+ function buildDownsampledHistoryChunks(startTime, endTime) {
+ const startTimeMs = parseIsoTimeMs(startTime);
+ const endTimeMs = parseIsoTimeMs(endTime);
+ if (startTimeMs == null || endTimeMs == null || endTimeMs <= startTimeMs || endTimeMs - startTimeMs <= MAX_DOWNSAMPLED_HISTORY_RANGE_MS) return [{
+ startTime,
+ endTime
+ }];
+ const chunks = [];
+ let chunkStartMs = startTimeMs;
+ while (chunkStartMs < endTimeMs) {
+ const chunkEndMs = Math.min(endTimeMs, chunkStartMs + MAX_DOWNSAMPLED_HISTORY_RANGE_MS);
+ chunks.push({
+ startTime: new Date(chunkStartMs).toISOString(),
+ endTime: new Date(chunkEndMs).toISOString()
+ });
+ if (chunkEndMs >= endTimeMs) break;
+ chunkStartMs = chunkEndMs + 1;
+ }
+ return chunks;
+ }
+ function fetchDownsampledHistory(hass, entityId, startTime, endTime, interval, aggregate) {
+ return withStableRangeCache(JSON.stringify({
+ type: "hass_datapoints/history",
+ entity_id: entityId,
+ start_time: startTime,
+ end_time: endTime,
+ interval,
+ aggregate
+ }), endTime, async () => {
+ const chunks = buildDownsampledHistoryChunks(startTime, endTime);
+ const mergedPoints = (await Promise.all(chunks.map(async (chunk) => hass.connection.sendMessagePromise({
+ type: "hass_datapoints/history",
+ entity_id: entityId,
+ start_time: chunk.startTime,
+ end_time: chunk.endTime,
+ interval,
+ aggregate
+ })))).flatMap((result) => result.pts || []);
+ if (!mergedPoints.length) return [];
+ const dedupedPoints = /* @__PURE__ */ new Map();
+ for (const point of mergedPoints) if (Array.isArray(point) && point.length > 0) dedupedPoints.set(String(point[0]), point);
+ else dedupedPoints.set(JSON.stringify(point), point);
+ return [...dedupedPoints.values()];
+ });
+ }
+ function fetchAnomaliesFromBackend(hass, entityId, startTime, endTime, config) {
+ return hass.connection.sendMessagePromise({
+ type: "hass_datapoints/anomalies",
+ entity_id: entityId,
+ start_time: startTime,
+ end_time: endTime,
+ anomaly_methods: config.anomaly_methods || [],
+ anomaly_sensitivity: config.anomaly_sensitivity || "medium",
+ anomaly_overlap_mode: config.anomaly_overlap_mode || "all",
+ anomaly_rate_window: config.anomaly_rate_window || "1h",
+ anomaly_zscore_window: config.anomaly_zscore_window || "24h",
+ anomaly_persistence_window: config.anomaly_persistence_window || "1h",
+ trend_method: config.trend_method || "rolling_average",
+ trend_window: config.trend_window || "24h",
+ ...config.anomaly_use_sampled_data !== false && config.sample_interval && config.sample_interval !== "raw" ? {
+ sample_interval: config.sample_interval,
+ sample_aggregate: config.sample_aggregate || "mean"
+ } : {},
+ ...config.comparison_entity_id ? {
+ comparison_entity_id: config.comparison_entity_id,
+ comparison_start_time: config.comparison_start_time,
+ comparison_end_time: config.comparison_end_time,
+ comparison_time_offset_ms: config.comparison_time_offset_ms || 0
+ } : {}
+ }).then((result) => result.anomaly_clusters || []);
+ }
+ async function fetchHistoryDuringPeriod(hass, startTime, endTime, entityIds, options = {}) {
+ const normalizedEntityIds = normalizeCacheIdList(entityIds);
+ return withStableRangeCache(JSON.stringify({
+ type: "history/history_during_period",
+ start_time: startTime,
+ end_time: endTime,
+ entity_ids: normalizedEntityIds,
+ include_start_time_state: options.include_start_time_state !== false,
+ significant_changes_only: !!options.significant_changes_only,
+ no_attributes: options.no_attributes !== false
+ }), endTime, () => hass.connection.sendMessagePromise({
+ type: "history/history_during_period",
+ start_time: startTime,
+ end_time: endTime,
+ entity_ids: normalizedEntityIds,
+ include_start_time_state: options.include_start_time_state !== false,
+ significant_changes_only: !!options.significant_changes_only,
+ no_attributes: options.no_attributes !== false
+ }));
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/domain/history-series.ts
+ var VALID_ANOMALY_METHODS = [
+ "trend_residual",
+ "rate_of_change",
+ "iqr",
+ "rolling_zscore",
+ "persistence",
+ "comparison_window"
+ ];
+ var VALID_SAMPLE_INTERVALS = [
+ "raw",
+ "1s",
+ "5s",
+ "10s",
+ "15s",
+ "30s",
+ "1m",
+ "2m",
+ "5m",
+ "10m",
+ "15m",
+ "30m",
+ "1h",
+ "2h",
+ "3h",
+ "4h",
+ "6h",
+ "12h",
+ "24h"
+ ];
+ var VALID_SAMPLE_AGGREGATES = [
+ "mean",
+ "min",
+ "max",
+ "median",
+ "first",
+ "last"
+ ];
+ function normalizeHistorySeriesAnalysis(analysis) {
+ const source = analysis && typeof analysis === "object" ? analysis : {};
+ return {
+ expanded: source.expanded === true,
+ show_trend_lines: source.show_trend_lines === true,
+ trend_method: source.trend_method === "linear_trend" ? "linear_trend" : "rolling_average",
+ trend_window: typeof source.trend_window === "string" && source.trend_window ? source.trend_window : "24h",
+ show_trend_crosshairs: source.show_trend_crosshairs !== false,
+ show_summary_stats: source.show_summary_stats === true,
+ show_summary_stats_shading: source.show_summary_stats_shading === true,
+ show_rate_of_change: source.show_rate_of_change === true,
+ show_rate_crosshairs: source.show_rate_crosshairs !== false,
+ rate_window: typeof source.rate_window === "string" && source.rate_window ? source.rate_window : "1h",
+ show_threshold_analysis: source.show_threshold_analysis === true,
+ show_threshold_shading: source.show_threshold_shading === true,
+ threshold_value: typeof source.threshold_value === "string" || typeof source.threshold_value === "number" ? String(source.threshold_value).trim() : "",
+ threshold_direction: source.threshold_direction === "below" ? "below" : "above",
+ show_anomalies: source.show_anomalies === true,
+ anomaly_methods: (() => {
+ if (Array.isArray(source.anomaly_methods)) return source.anomaly_methods.filter((method) => typeof method === "string" && VALID_ANOMALY_METHODS.includes(method));
+ const legacy = typeof source.anomaly_method === "string" && VALID_ANOMALY_METHODS.includes(source.anomaly_method) ? source.anomaly_method : null;
+ return legacy ? [legacy] : [];
+ })(),
+ anomaly_overlap_mode: source.anomaly_overlap_mode === "only" ? "only" : "all",
+ anomaly_sensitivity: typeof source.anomaly_sensitivity === "string" && source.anomaly_sensitivity ? source.anomaly_sensitivity : "medium",
+ anomaly_rate_window: typeof source.anomaly_rate_window === "string" && source.anomaly_rate_window ? source.anomaly_rate_window : "1h",
+ anomaly_zscore_window: typeof source.anomaly_zscore_window === "string" && source.anomaly_zscore_window ? source.anomaly_zscore_window : "24h",
+ anomaly_persistence_window: typeof source.anomaly_persistence_window === "string" && source.anomaly_persistence_window ? source.anomaly_persistence_window : "1h",
+ anomaly_comparison_window_id: typeof source.anomaly_comparison_window_id === "string" && source.anomaly_comparison_window_id ? source.anomaly_comparison_window_id : null,
+ show_delta_analysis: source.show_delta_analysis === true,
+ show_delta_tooltip: source.show_delta_tooltip !== false,
+ show_delta_lines: source.show_delta_lines === true,
+ hide_source_series: source.hide_source_series === true,
+ sample_interval: typeof source.sample_interval === "string" && VALID_SAMPLE_INTERVALS.includes(source.sample_interval) ? source.sample_interval : "1m",
+ sample_aggregate: typeof source.sample_aggregate === "string" && VALID_SAMPLE_AGGREGATES.includes(source.sample_aggregate) ? source.sample_aggregate : "mean",
+ stepped_series: source.stepped_series === true,
+ anomaly_use_sampled_data: source.anomaly_use_sampled_data !== false
+ };
+ }
+ function normalizeHistorySeriesRows(rows) {
+ if (!Array.isArray(rows)) return [];
+ const seen = /* @__PURE__ */ new Set();
+ const normalized = [];
+ rows.forEach((row, index) => {
+ const entityId = typeof row?.entity_id === "string" ? row.entity_id.trim() : "";
+ if (!entityId || seen.has(entityId)) return;
+ seen.add(entityId);
+ normalized.push({
+ entity_id: entityId,
+ color: typeof row?.color === "string" && /^#[0-9a-f]{6}$/i.test(row.color) ? row.color : COLORS[index % COLORS.length],
+ visible: row?.visible !== false,
+ analysis: normalizeHistorySeriesAnalysis(row?.analysis)
+ });
+ });
+ return normalized;
+ }
+ function buildHistorySeriesRows(entityIds, previousRows = []) {
+ const normalizedPrevious = normalizeHistorySeriesRows(previousRows);
+ const previousMap = new Map(normalizedPrevious.map((row) => [row.entity_id, row]));
+ const intervals = normalizedPrevious.map((row) => row.analysis.sample_interval);
+ const inheritedSampleSettings = intervals.length > 0 && intervals.every((interval) => interval === intervals[0]) ? {
+ sample_interval: intervals[0],
+ sample_aggregate: normalizedPrevious[0].analysis.sample_aggregate
+ } : null;
+ return normalizeEntityIds(entityIds).map((entityId, index) => {
+ const existing = previousMap.get(entityId);
+ if (existing) return existing;
+ return {
+ entity_id: entityId,
+ color: COLORS[index % COLORS.length],
+ visible: true,
+ analysis: normalizeHistorySeriesAnalysis(inheritedSampleSettings)
+ };
+ });
+ }
+ function slugifySeriesName(value) {
+ return String(value || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
+ }
+ function parseSeriesColorsParam(value) {
+ if (!value || typeof value !== "string") return {};
+ return value.split(",").reduce((acc, entry) => {
+ const [rawKey, rawColor] = entry.split(":");
+ const key = decodeURIComponent(rawKey || "").trim();
+ const color = String(rawColor || "").trim();
+ if (!key || !/^#[0-9a-f]{6}$/i.test(color)) return acc;
+ acc[key] = color;
+ return acc;
+ }, {});
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/workers/history-analysis.worker.ts?worker&inline
+ var jsContent$1 = "(function() {\n //#region custom_components/hass_datapoints/src/lib/workers/history-analysis.worker.ts\n const HOUR_MS = 3600 * 1e3;\n function getTrendWindowMs(value) {\n const windows = {\n \"1h\": 3600 * 1e3,\n \"6h\": 360 * 60 * 1e3,\n \"24h\": 1440 * 60 * 1e3,\n \"7d\": 10080 * 60 * 1e3,\n \"14d\": 336 * 60 * 60 * 1e3,\n \"21d\": 504 * 60 * 60 * 1e3,\n \"28d\": 672 * 60 * 60 * 1e3\n };\n return windows[value] || windows[\"24h\"];\n }\n function buildRollingAverageTrend(points, windowMs) {\n if (!Array.isArray(points) || points.length < 2 || !Number.isFinite(windowMs) || windowMs <= 0) return [];\n const trendPoints = [];\n let windowStartIndex = 0;\n let windowSum = 0;\n for (let index = 0; index < points.length; index += 1) {\n const [time, value] = points[index];\n windowSum += value;\n while (windowStartIndex < index && time - points[windowStartIndex][0] > windowMs) {\n windowSum -= points[windowStartIndex][1];\n windowStartIndex += 1;\n }\n const count = index - windowStartIndex + 1;\n if (count > 0) trendPoints.push([time, windowSum / count]);\n }\n return trendPoints;\n }\n function buildLinearTrend(points) {\n if (!Array.isArray(points) || points.length < 2) return [];\n const origin = points[0][0];\n let sumX = 0;\n let sumY = 0;\n let sumXX = 0;\n let sumXY = 0;\n for (const [time, value] of points) {\n const x = (time - origin) / HOUR_MS;\n sumX += x;\n sumY += value;\n sumXX += x * x;\n sumXY += x * value;\n }\n const count = points.length;\n const denominator = count * sumXX - sumX * sumX;\n if (!Number.isFinite(denominator) || Math.abs(denominator) < 1e-9) return [];\n const slope = (count * sumXY - sumX * sumY) / denominator;\n const intercept = (sumY - slope * sumX) / count;\n const firstTime = points[0][0];\n const lastTime = points[points.length - 1][0];\n const firstX = (firstTime - origin) / HOUR_MS;\n const lastX = (lastTime - origin) / HOUR_MS;\n return [[firstTime, intercept + slope * firstX], [lastTime, intercept + slope * lastX]];\n }\n function buildTrendPoints(points, method, trendWindow) {\n if (!Array.isArray(points) || points.length < 2) return [];\n if (method === \"linear_trend\") return buildLinearTrend(points);\n return buildRollingAverageTrend(points, getTrendWindowMs(trendWindow));\n }\n function normalizeSeriesAnalysis(analysis) {\n const source = analysis && typeof analysis === \"object\" ? analysis : {};\n return {\n show_trend_lines: source.show_trend_lines === true,\n trend_method: source.trend_method === \"linear_trend\" ? \"linear_trend\" : \"rolling_average\",\n trend_window: typeof source.trend_window === \"string\" && source.trend_window ? source.trend_window : \"24h\",\n show_summary_stats: source.show_summary_stats === true,\n show_rate_of_change: source.show_rate_of_change === true,\n rate_window: typeof source.rate_window === \"string\" && source.rate_window ? source.rate_window : \"1h\",\n show_delta_analysis: source.show_delta_analysis === true\n };\n }\n function interpolateSeriesValue(points, timeMs) {\n if (!Array.isArray(points) || points.length === 0) return null;\n if (timeMs < points[0][0] || timeMs > points[points.length - 1][0]) return null;\n if (timeMs === points[0][0]) return points[0][1];\n if (timeMs === points[points.length - 1][0]) return points[points.length - 1][1];\n for (let index = 0; index < points.length - 1; index += 1) {\n const [startTime, startValue] = points[index];\n const [endTime, endValue] = points[index + 1];\n if (timeMs >= startTime && timeMs <= endTime) {\n const fraction = (timeMs - startTime) / (endTime - startTime);\n return startValue + (endValue - startValue) * fraction;\n }\n }\n return null;\n }\n function buildRateOfChangePoints(points, rateWindow) {\n if (!Array.isArray(points) || points.length < 2) return [];\n const ratePoints = [];\n for (let index = 1; index < points.length; index += 1) {\n const [timeMs, value] = points[index];\n let comparisonPoint = null;\n if (rateWindow === \"point_to_point\") comparisonPoint = points[index - 1];\n else {\n const windowMs = getTrendWindowMs(rateWindow);\n if (!Number.isFinite(windowMs) || windowMs <= 0) continue;\n for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) {\n const candidatePoint = points[candidateIndex];\n if (timeMs - candidatePoint[0] >= windowMs) {\n comparisonPoint = candidatePoint;\n break;\n }\n }\n if (!comparisonPoint) comparisonPoint = points[0];\n }\n if (!Array.isArray(comparisonPoint) || comparisonPoint.length < 2) continue;\n const deltaMs = timeMs - comparisonPoint[0];\n if (!Number.isFinite(deltaMs) || deltaMs <= 0) continue;\n const deltaHours = deltaMs / HOUR_MS;\n if (!Number.isFinite(deltaHours) || deltaHours <= 0) continue;\n const rateValue = (value - comparisonPoint[1]) / deltaHours;\n if (!Number.isFinite(rateValue)) continue;\n ratePoints.push([timeMs, rateValue]);\n }\n return ratePoints;\n }\n function buildDeltaPoints(sourcePoints, comparisonPoints) {\n if (!Array.isArray(sourcePoints) || sourcePoints.length < 2 || !Array.isArray(comparisonPoints) || comparisonPoints.length < 2) return [];\n const deltaPoints = [];\n for (const [timeMs, value] of sourcePoints) {\n const comparisonValue = interpolateSeriesValue(comparisonPoints, timeMs);\n if (comparisonValue == null) continue;\n deltaPoints.push([timeMs, value - comparisonValue]);\n }\n return deltaPoints;\n }\n function buildSummaryStats(points) {\n if (!Array.isArray(points) || points.length === 0) return null;\n let min = Infinity;\n let max = -Infinity;\n let sum = 0;\n let count = 0;\n for (const point of points) {\n const value = Number(point?.[1]);\n if (!Number.isFinite(value)) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n sum += value;\n count += 1;\n }\n if (!Number.isFinite(min) || !Number.isFinite(max) || count === 0) return null;\n return {\n min,\n max,\n mean: sum / count\n };\n }\n function computeHistoryAnalysis(payload) {\n const series = (Array.isArray(payload?.series) ? payload.series : []).map((seriesItem) => ({\n ...seriesItem,\n analysis: normalizeSeriesAnalysis(seriesItem?.analysis)\n }));\n const comparisonSeries = new Map((Array.isArray(payload?.comparisonSeries) ? payload.comparisonSeries : []).filter((entry) => entry?.entityId).map((entry) => [entry.entityId, entry]));\n const result = {\n trendSeries: [],\n rateSeries: [],\n deltaSeries: [],\n summaryStats: [],\n anomalySeries: [],\n comparisonWindowResults: {}\n };\n for (const seriesItem of series) {\n const points = Array.isArray(seriesItem?.pts) ? seriesItem.pts : [];\n const analysis = normalizeSeriesAnalysis(seriesItem?.analysis);\n if (points.length < 2) continue;\n if (analysis.show_trend_lines === true) {\n const trendPoints = buildTrendPoints(points, analysis.trend_method, analysis.trend_window);\n if (trendPoints.length >= 2) result.trendSeries.push({\n entityId: seriesItem.entityId,\n pts: trendPoints\n });\n }\n if (analysis.show_rate_of_change === true) {\n const ratePoints = buildRateOfChangePoints(points, analysis.rate_window);\n if (ratePoints.length >= 2) result.rateSeries.push({\n entityId: seriesItem.entityId,\n pts: ratePoints\n });\n }\n if (analysis.show_summary_stats === true) {\n const summaryStats = buildSummaryStats(points);\n if (summaryStats) result.summaryStats.push({\n entityId: seriesItem.entityId,\n ...summaryStats\n });\n }\n if (analysis.show_delta_analysis === true && payload?.hasSelectedComparisonWindow === true) {\n const comparisonPoints = comparisonSeries.get(seriesItem.entityId)?.pts ?? [];\n if (comparisonPoints.length >= 2) {\n const deltaPoints = buildDeltaPoints(points, comparisonPoints);\n if (deltaPoints.length >= 2) result.deltaSeries.push({\n entityId: seriesItem.entityId,\n pts: deltaPoints\n });\n }\n }\n }\n const seriesAnalysisConfigs = typeof payload?.seriesAnalysisConfigs === \"object\" && payload.seriesAnalysisConfigs !== null ? payload.seriesAnalysisConfigs : {};\n const allComparisonWindowsData = typeof payload?.allComparisonWindowsData === \"object\" && payload.allComparisonWindowsData !== null ? payload.allComparisonWindowsData : {};\n for (const [windowId, entityPtsMap] of Object.entries(allComparisonWindowsData)) {\n result.comparisonWindowResults[windowId] = {};\n for (const [entityId, pts] of Object.entries(entityPtsMap)) {\n const winAnalysis = normalizeSeriesAnalysis(seriesAnalysisConfigs[entityId]);\n result.comparisonWindowResults[windowId][entityId] = {\n trendPts: winAnalysis.show_trend_lines && pts.length >= 2 ? buildTrendPoints(pts, winAnalysis.trend_method, winAnalysis.trend_window) : [],\n ratePts: winAnalysis.show_rate_of_change && pts.length >= 2 ? buildRateOfChangePoints(pts, winAnalysis.rate_window) : [],\n summaryStats: winAnalysis.show_summary_stats ? buildSummaryStats(pts) : null\n };\n }\n }\n return result;\n }\n const workerScope = globalThis;\n workerScope.onmessage = (event) => {\n const { id, payload } = event.data || {};\n try {\n const result = computeHistoryAnalysis(payload);\n workerScope.postMessage({\n id,\n result\n });\n } catch (error) {\n workerScope.postMessage({\n id,\n error: error instanceof Error ? error.message : String(error)\n });\n }\n };\n //#endregion\n})();\n";
+ var blob$1 = typeof self !== "undefined" && self.Blob && new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);", jsContent$1], { type: "text/javascript;charset=utf-8" });
+ function WorkerWrapper$1(options) {
+ let objURL;
+ try {
+ objURL = blob$1 && (self.URL || self.webkitURL).createObjectURL(blob$1);
+ if (!objURL) throw "";
+ const worker = new Worker(objURL, { name: options?.name });
+ worker.addEventListener("error", () => {
+ (self.URL || self.webkitURL).revokeObjectURL(objURL);
+ });
+ return worker;
+ } catch (e) {
+ return new Worker("data:text/javascript;charset=utf-8," + encodeURIComponent(jsContent$1), { name: options?.name });
+ }
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/workers/history-analysis-client.ts
+ var workerInstance$1 = null;
+ var requestId$1 = 0;
+ var pending$1 = /* @__PURE__ */ new Map();
+ function getHistoryAnalysisWorker() {
+ if (workerInstance$1) return workerInstance$1;
+ workerInstance$1 = new WorkerWrapper$1();
+ workerInstance$1.addEventListener("message", (event) => {
+ const { id, result, error } = event.data || {};
+ const handlers = pending$1.get(id || -1);
+ if (!handlers) return;
+ pending$1.delete(id || -1);
+ if (error) {
+ handlers.reject(new Error(error));
+ return;
+ }
+ handlers.resolve(result);
+ });
+ workerInstance$1.addEventListener("error", (error) => {
+ pending$1.forEach((handlers) => {
+ handlers.reject(error);
+ });
+ pending$1.clear();
+ workerInstance$1 = null;
+ });
+ return workerInstance$1;
+ }
+ /**
+ * Abort all in-flight analysis requests and terminate the worker.
+ * Called when a new draw request supersedes an in-progress one so the worker
+ * is not left computing a result that will be thrown away.
+ */
+ function terminateHistoryAnalysisWorker() {
+ if (pending$1.size > 0) {
+ pending$1.forEach(({ reject }) => {
+ reject(/* @__PURE__ */ new Error("Aborted: superseded by newer analysis"));
+ });
+ pending$1.clear();
+ }
+ if (workerInstance$1) {
+ workerInstance$1.terminate();
+ workerInstance$1 = null;
+ }
+ }
+ function computeHistoryAnalysisInWorker(payload) {
+ const worker = getHistoryAnalysisWorker();
+ return new Promise((resolve, reject) => {
+ const id = ++requestId$1;
+ pending$1.set(id, {
+ resolve,
+ reject
+ });
+ worker.postMessage({
+ id,
+ payload
+ });
+ });
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/workers/chart-data.worker.ts?worker&inline
+ var jsContent = "(function() {\n //#region custom_components/hass_datapoints/src/lib/workers/chart-data.worker.ts\n function downsamplePts(pts, intervalMs, aggregate) {\n if (!pts.length || intervalMs <= 0) return pts;\n const buckets = /* @__PURE__ */ new Map();\n const bucketRepTime = /* @__PURE__ */ new Map();\n for (const [time, value] of pts) {\n const idx = Math.floor(time / intervalMs);\n if (!buckets.has(idx)) {\n buckets.set(idx, []);\n bucketRepTime.set(idx, time);\n }\n buckets.get(idx)?.push(value);\n }\n const result = [];\n for (const idx of [...buckets.keys()].sort((a, b) => a - b)) {\n const values = buckets.get(idx) || [];\n const repTime = bucketRepTime.get(idx) || 0;\n let agg;\n if (aggregate === \"min\") agg = Math.min(...values);\n else if (aggregate === \"max\") agg = Math.max(...values);\n else if (aggregate === \"median\") {\n const sorted = [...values].sort((a, b) => a - b);\n const mid = Math.floor(sorted.length / 2);\n agg = sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;\n } else if (aggregate === \"first\") agg = values[0];\n else if (aggregate === \"last\") agg = values[values.length - 1];\n else agg = values.reduce((sum, current) => sum + current, 0) / values.length;\n result.push([repTime, agg]);\n }\n return result;\n }\n self.onmessage = ({ data }) => {\n const { id, type, payload } = data || {};\n try {\n let result;\n if (type === \"downsample\") result = downsamplePts(payload.pts, payload.intervalMs, payload.aggregate);\n else throw new Error(`Unknown message type: ${type}`);\n self.postMessage({\n id,\n result\n });\n } catch (err) {\n self.postMessage({\n id,\n error: String(err)\n });\n }\n };\n //#endregion\n})();\n";
+ var blob = typeof self !== "undefined" && self.Blob && new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);", jsContent], { type: "text/javascript;charset=utf-8" });
+ function WorkerWrapper(options) {
+ let objURL;
+ try {
+ objURL = blob && (self.URL || self.webkitURL).createObjectURL(blob);
+ if (!objURL) throw "";
+ const worker = new Worker(objURL, { name: options?.name });
+ worker.addEventListener("error", () => {
+ (self.URL || self.webkitURL).revokeObjectURL(objURL);
+ });
+ return worker;
+ } catch (e) {
+ return new Worker("data:text/javascript;charset=utf-8," + encodeURIComponent(jsContent), { name: options?.name });
+ }
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/workers/chart-data-client.ts
+ var workerInstance;
+ var requestId = 0;
+ var pending = /* @__PURE__ */ new Map();
+ function getChartDataWorker() {
+ if (workerInstance !== void 0) return workerInstance;
+ try {
+ workerInstance = new WorkerWrapper();
+ workerInstance.addEventListener("message", (event) => {
+ const { id, result, error } = event.data || {};
+ const handlers = pending.get(id || -1);
+ if (!handlers) return;
+ pending.delete(id || -1);
+ if (error) handlers.reject(new Error(error));
+ else handlers.resolve(result || []);
+ });
+ workerInstance.addEventListener("error", (err) => {
+ pending.forEach(({ reject }) => {
+ reject(err);
+ });
+ pending.clear();
+ workerInstance = null;
+ });
+ } catch {
+ workerInstance = null;
+ }
+ return workerInstance;
+ }
+ /**
+ * Downsample [[timeMs, value], ...] pts in a worker.
+ * Returns a Promise that resolves with the downsampled pts array.
+ */
+ function downsampleInWorker(pts, intervalMs, aggregate) {
+ if (pts.length === 0) return Promise.resolve([]);
+ const worker = getChartDataWorker();
+ if (!worker) return Promise.reject(/* @__PURE__ */ new Error("Worker not available"));
+ return new Promise((resolve, reject) => {
+ const id = ++requestId;
+ pending.set(id, {
+ resolve,
+ reject
+ });
+ worker.postMessage({
+ id,
+ type: "downsample",
+ payload: {
+ pts,
+ intervalMs,
+ aggregate
+ }
+ });
+ });
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/analysis/windows.ts
+ var HOUR_MS$1 = 3600 * 1e3;
+ function getTrendWindowMs(value) {
+ const windows = {
+ "1h": HOUR_MS$1,
+ "6h": 6 * HOUR_MS$1,
+ "24h": 24 * HOUR_MS$1,
+ "7d": 168 * HOUR_MS$1,
+ "14d": 336 * HOUR_MS$1,
+ "21d": 504 * HOUR_MS$1,
+ "28d": 672 * HOUR_MS$1
+ };
+ return windows[value] ?? windows["24h"];
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/analysis/series.ts
+ function buildRollingAverageTrend(points, windowMs) {
+ if (!Array.isArray(points) || points.length < 2 || !Number.isFinite(windowMs) || windowMs <= 0) return [];
+ const trendPoints = [];
+ let windowStartIndex = 0;
+ let windowSum = 0;
+ for (let index = 0; index < points.length; index += 1) {
+ const [time, value] = points[index];
+ windowSum += value;
+ while (windowStartIndex < index && time - points[windowStartIndex][0] > windowMs) {
+ windowSum -= points[windowStartIndex][1];
+ windowStartIndex += 1;
+ }
+ const count = index - windowStartIndex + 1;
+ if (count > 0) trendPoints.push([time, windowSum / count]);
+ }
+ return trendPoints;
+ }
+ function buildLinearTrend(points) {
+ if (!Array.isArray(points) || points.length < 2) return [];
+ const origin = points[0][0];
+ let sumX = 0;
+ let sumY = 0;
+ let sumXX = 0;
+ let sumXY = 0;
+ for (const [time, value] of points) {
+ const x = (time - origin) / (3600 * 1e3);
+ sumX += x;
+ sumY += value;
+ sumXX += x * x;
+ sumXY += x * value;
+ }
+ const count = points.length;
+ const denominator = count * sumXX - sumX * sumX;
+ if (!Number.isFinite(denominator) || Math.abs(denominator) < 1e-9) return [];
+ const slope = (count * sumXY - sumX * sumY) / denominator;
+ const intercept = (sumY - slope * sumX) / count;
+ const firstTime = points[0][0];
+ const lastTime = points[points.length - 1][0];
+ const firstX = (firstTime - origin) / (3600 * 1e3);
+ const lastX = (lastTime - origin) / (3600 * 1e3);
+ return [[firstTime, intercept + slope * firstX], [lastTime, intercept + slope * lastX]];
+ }
+ function interpolateSeriesValue(points, timeMs) {
+ if (!Array.isArray(points) || !points.length) return null;
+ if (timeMs < points[0][0] || timeMs > points[points.length - 1][0]) return null;
+ if (timeMs === points[0][0]) return points[0][1];
+ if (timeMs === points[points.length - 1][0]) return points[points.length - 1][1];
+ for (let index = 0; index < points.length - 1; index += 1) {
+ const [startTime, startValue] = points[index];
+ const [endTime, endValue] = points[index + 1];
+ if (timeMs >= startTime && timeMs <= endTime) return startValue + (timeMs - startTime) / (endTime - startTime) * (endValue - startValue);
+ }
+ return null;
+ }
+ function buildRateOfChangePoints(points, rateWindow = "1h") {
+ if (!Array.isArray(points) || points.length < 2) return [];
+ const ratePoints = [];
+ for (let index = 1; index < points.length; index += 1) {
+ const [timeMs, value] = points[index];
+ let comparisonPoint = null;
+ if (rateWindow === "point_to_point") comparisonPoint = points[index - 1];
+ else {
+ const windowMs = getTrendWindowMs(rateWindow);
+ if (!Number.isFinite(windowMs) || windowMs <= 0) continue;
+ for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) {
+ const candidatePoint = points[candidateIndex];
+ if (timeMs - candidatePoint[0] >= windowMs) {
+ comparisonPoint = candidatePoint;
+ break;
+ }
+ }
+ if (!comparisonPoint) comparisonPoint = points[0];
+ }
+ if (!Array.isArray(comparisonPoint) || comparisonPoint.length < 2) continue;
+ const deltaMs = timeMs - comparisonPoint[0];
+ if (!Number.isFinite(deltaMs) || deltaMs <= 0) continue;
+ const deltaHours = deltaMs / (3600 * 1e3);
+ if (!Number.isFinite(deltaHours) || deltaHours <= 0) continue;
+ const rateValue = (value - comparisonPoint[1]) / deltaHours;
+ if (!Number.isFinite(rateValue)) continue;
+ ratePoints.push([timeMs, rateValue]);
+ }
+ return ratePoints;
+ }
+ function buildDeltaPoints(sourcePoints, comparisonPoints) {
+ if (!Array.isArray(sourcePoints) || sourcePoints.length < 2 || !Array.isArray(comparisonPoints) || comparisonPoints.length < 2) return [];
+ const deltaPoints = [];
+ for (const [timeMs, value] of sourcePoints) {
+ const comparisonValue = interpolateSeriesValue(comparisonPoints, timeMs);
+ if (comparisonValue == null) continue;
+ deltaPoints.push([timeMs, value - comparisonValue]);
+ }
+ return deltaPoints;
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/analysis/summary.ts
+ function buildSummaryStats(points) {
+ if (!Array.isArray(points) || !points.length) return null;
+ let min = Infinity;
+ let max = -Infinity;
+ let sum = 0;
+ let count = 0;
+ for (const point of points) {
+ const value = Number(point?.[1]);
+ if (!Number.isFinite(value)) continue;
+ if (value < min) min = value;
+ if (value > max) max = value;
+ sum += value;
+ count += 1;
+ }
+ if (!Number.isFinite(min) || !Number.isFinite(max) || count === 0) return null;
+ return {
+ min,
+ max,
+ mean: sum / count
+ };
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/domain/chart-zoom.ts
+ /**
+ * Date parsing and zoom state helpers shared by chart/timeline subsystems.
+ */
+ function parseDateValue(value) {
+ if (!value) return null;
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ }
+ function createChartZoomRange(startValue, endValue) {
+ const startDate = parseDateValue(startValue);
+ const endDate = parseDateValue(endValue);
+ const start = startDate?.getTime();
+ const end = endDate?.getTime();
+ if (typeof start === "number" && Number.isFinite(start) && typeof end === "number" && Number.isFinite(end) && start < end) return {
+ start,
+ end
+ };
+ return null;
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/history-page/history-url-state.ts
+ function makeDateWindowId(label, existingIds = /* @__PURE__ */ new Set()) {
+ const base = slugifySeriesName(label) || "date-window";
+ let candidate = base;
+ let suffix = 2;
+ while (existingIds.has(candidate)) {
+ candidate = `${base}-${suffix}`;
+ suffix += 1;
+ }
+ return candidate;
+ }
+ function normalizeDateWindows(windows) {
+ if (!Array.isArray(windows)) return [];
+ const seen = /* @__PURE__ */ new Set();
+ const normalized = [];
+ windows.forEach((window, index) => {
+ const label = String(window?.label || window?.name || "").trim();
+ const start = parseDateValue(window?.start_time || window?.start);
+ const end = parseDateValue(window?.end_time || window?.end);
+ if (!label || !start || !end || start >= end) return;
+ const id = String(window?.id || "").trim() || makeDateWindowId(`${label}-${index + 1}`, seen);
+ if (seen.has(id)) return;
+ seen.add(id);
+ normalized.push({
+ id,
+ label,
+ start_time: start.toISOString(),
+ end_time: end.toISOString()
+ });
+ });
+ return normalized;
+ }
+ function parseDateWindowsParam(value) {
+ if (!value || typeof value !== "string") return [];
+ return normalizeDateWindows(value.split("|").map((entry) => {
+ const [rawId, rawLabel, rawStart, rawEnd] = String(entry).split("~");
+ return {
+ id: decodeURIComponent(rawId || ""),
+ label: decodeURIComponent(rawLabel || ""),
+ start_time: decodeURIComponent(rawStart || ""),
+ end_time: decodeURIComponent(rawEnd || "")
+ };
+ }));
+ }
+ function serializeDateWindowsParam(windows) {
+ const normalized = normalizeDateWindows(windows);
+ if (!normalized.length) return "";
+ return normalized.map((window) => [
+ encodeURIComponent(window.id),
+ encodeURIComponent(window.label ?? ""),
+ encodeURIComponent(window.start_time),
+ encodeURIComponent(window.end_time)
+ ].join("~")).join("|");
+ }
+ function parseHistoryPageStateParam(value) {
+ if (!value || typeof value !== "string") return null;
+ try {
+ const parsed = JSON.parse(value);
+ return parsed && typeof parsed === "object" ? parsed : null;
+ } catch {
+ return null;
+ }
+ }
+ function serializeHistoryPageStateParam(state) {
+ if (!state || typeof state !== "object") return "";
+ try {
+ return JSON.stringify(state);
+ } catch {
+ return "";
+ }
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/lib/ha/navigation.ts
+ function buildDataPointsHistoryPath(target = {}, options = {}) {
+ const normalizedTarget = {
+ entity_id: [...new Set((target.entity_id || []).filter(Boolean))],
+ device_id: [...new Set((target.device_id || []).filter(Boolean))],
+ area_id: [...new Set((target.area_id || []).filter(Boolean))],
+ label_id: [...new Set((target.label_id || []).filter(Boolean))]
+ };
+ const params = new URLSearchParams();
+ if (normalizedTarget.entity_id.length) params.set("entity_id", normalizedTarget.entity_id.join(","));
+ if (normalizedTarget.device_id.length) params.set("device_id", normalizedTarget.device_id.join(","));
+ if (normalizedTarget.area_id.length) params.set("area_id", normalizedTarget.area_id.join(","));
+ if (normalizedTarget.label_id.length) params.set("label_id", normalizedTarget.label_id.join(","));
+ if (options.datapoint_scope === "all") params.set("datapoints_scope", "all");
+ const start = options.start_time ? new Date(options.start_time) : null;
+ const end = options.end_time ? new Date(options.end_time) : null;
+ if (start && end && Number.isFinite(start.getTime()) && Number.isFinite(end.getTime()) && start < end) {
+ params.set("start_time", start.toISOString());
+ params.set("end_time", end.toISOString());
+ params.set("hours_to_show", String(Math.max(1, Math.round((end.getTime() - start.getTime()) / 36e5))));
+ }
+ const zoomStart = options.zoom_start_time ? new Date(options.zoom_start_time) : null;
+ const zoomEnd = options.zoom_end_time ? new Date(options.zoom_end_time) : null;
+ if (zoomStart && zoomEnd && Number.isFinite(zoomStart.getTime()) && Number.isFinite(zoomEnd.getTime()) && zoomStart < zoomEnd) {
+ params.set("zoom_start_time", zoomStart.toISOString());
+ params.set("zoom_end_time", zoomEnd.toISOString());
+ }
+ const pageStateParam = serializeHistoryPageStateParam(options.page_state);
+ if (pageStateParam) params.set("page_state", pageStateParam);
+ return `/${PANEL_URL_PATH}?${params.toString()}`;
+ }
+ function navigateToDataPointsHistory(_card, target = {}, options = {}) {
+ const path = buildDataPointsHistoryPath(target, options);
+ if (window.history && window.history.pushState) {
+ window.history.pushState(null, "", path);
+ window.dispatchEvent(new Event("location-changed"));
+ return;
+ }
+ window.location.assign(path);
+ }
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.styles.ts
+ var styles$54 = `
+ hass-datapoints-history-chart {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ --dp-spacing-xs: calc(var(--spacing, 8px) * 0.5);
+ --dp-spacing-sm: var(--spacing, 8px);
+ --dp-spacing-md: calc(var(--spacing, 8px) * 1.5);
+ --dp-spacing-lg: calc(var(--spacing, 8px) * 2);
+ --dp-spacing-xl: calc(var(--spacing, 8px) * 2.5);
+ --ha-tooltip-background-color: color-mix(in srgb, #0f1218 96%, transparent);
+ --ha-tooltip-text-color: rgba(255, 255, 255, 0.96);
+ --ha-tooltip-padding: calc(var(--dp-spacing-sm) + 2px) calc(var(--dp-spacing-md) + 2px);
+ --ha-tooltip-border-radius: 10px;
+ --ha-tooltip-arrow-size: 10px;
+ --ha-tooltip-font-size: 0.86rem;
+ --ha-tooltip-line-height: 1.1;
}
- function getRoot$1(card) {
- const rootNode = card.shadowRoot ?? card.getRootNode();
- if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
- return rootNode;
- }
- return document;
+ ha-card { padding: 0; overflow: visible; height: 100%; display: flex; flex-direction: column; }
+ .card-header {
+ padding: var(--dp-spacing-lg);
+ font-size: 1.1em;
+ font-weight: 500;
+ color: var(--primary-text-color);
+ flex: 0 0 auto;
+ line-height: 1.3;
}
- function resolveChartLabelColor(el) {
- if (!el) {
- return "rgba(214,218,224,0.92)";
- }
- const raw = getComputedStyle(el).getPropertyValue("--secondary-text-color").trim();
- if (raw) {
- return raw;
- }
- return "rgba(214,218,224,0.92)";
- }
- function setupCanvas(canvas, container, cssHeight, cssWidth = null) {
- const dpr = window.devicePixelRatio || 1;
- const maxCssDim = Math.floor(16383 / dpr);
- const styles2 = getComputedStyle(container);
- const paddingX = (Number.parseFloat(styles2.paddingLeft || "0") || 0) + (Number.parseFloat(styles2.paddingRight || "0") || 0);
- const paddingY = (Number.parseFloat(styles2.paddingTop || "0") || 0) + (Number.parseFloat(styles2.paddingBottom || "0") || 0);
- const measuredWidth = cssWidth ?? (container.clientWidth || 360);
- const w = Math.min(
- maxCssDim,
- Math.max(1, Math.round(measuredWidth - paddingX))
- );
- const requestedHeight = cssHeight ?? container.clientHeight ?? 220;
- const h2 = Math.min(
- maxCssDim,
- Math.max(40, Math.round(requestedHeight - paddingY))
- );
- canvas.width = w * dpr;
- canvas.height = h2 * dpr;
- canvas.style.width = `${w}px`;
- canvas.style.height = `${h2}px`;
- const ctx = canvas.getContext("2d");
- if (ctx && typeof ctx.scale === "function") {
- ctx.scale(dpr, dpr);
- }
- return { w, h: h2 };
+ .chart-top-slot[hidden] {
+ display: none;
}
- function renderChartAxisOverlays(card, renderer, axes = []) {
- const leftEl = getRoot$1(card)?.getElementById("chart-axis-left");
- const rightEl = getRoot$1(card)?.getElementById("chart-axis-right");
- if (!leftEl || !rightEl || !renderer) {
- return;
- }
- const leftWidth = Math.max(0, renderer.pad.left);
- const rightWidth = Math.max(0, renderer.pad.right);
- leftEl.style.width = `${leftWidth}px`;
- rightEl.style.width = `${rightWidth}px`;
- const chartWrap = getRoot$1(card).querySelector(".chart-wrap");
- const chartWrapEl = chartWrap instanceof HTMLElement ? chartWrap : card;
- if (chartWrapEl) {
- chartWrapEl.style.setProperty(
- "--dp-chart-axis-left-width",
- `${leftWidth}px`
- );
- chartWrapEl.style.setProperty(
- "--dp-chart-axis-right-width",
- `${rightWidth}px`
- );
- }
- const axisSlotWidth = ChartRenderer.AXIS_SLOT_WIDTH;
- const axisOffset = (axis) => 10 + (axis.slot ?? 0) * axisSlotWidth;
- const unitCounts = axes.reduce((counts, axis) => {
- if (!axis?.unit) {
- return counts;
- }
- counts.set(axis.unit, (counts.get(axis.unit) || 0) + 1);
- return counts;
- }, /* @__PURE__ */ new Map());
- const axisTextStyle = (axis) => {
- const duplicateUnit = !!axis?.unit && (unitCounts.get(axis.unit) || 0) > 1;
- if (!duplicateUnit || !axis?.color) {
- return "";
- }
- return `color:${esc(axis.color)};`;
- };
- const buildAxisMarkup = (axis) => {
- const labels = (axis.ticks || []).map((tick) => {
- const y2 = renderer.yOf(tick, axis.min, axis.max);
- return `
${esc(renderer._formatAxisTick(tick, axis.unit))}
`;
- }).join("");
- const unit = axis.unit ? `
${esc(axis.unit)}
` : "";
- return `${labels}${unit}`;
- };
- const leftAxes = axes.filter((axis) => axis.side !== "right");
- const rightAxes = axes.filter((axis) => axis.side === "right");
- leftEl.innerHTML = leftAxes.length ? `
${leftAxes.map((axis) => buildAxisMarkup(axis)).join("")}` : "";
- rightEl.innerHTML = rightAxes.length ? `
${rightAxes.map((axis) => buildAxisMarkup(axis)).join("")}` : "";
- leftEl.classList.toggle("visible", !!leftAxes.length);
- rightEl.classList.toggle("visible", !!rightAxes.length);
- }
- function renderChartAxisHoverDots(card, hoverValues = []) {
- const root = getRoot$1(card);
- const leftEl = root.getElementById("chart-axis-left");
- const rightEl = root.getElementById("chart-axis-right");
- const scrollViewport = root.getElementById("chart-scroll-viewport");
- if (!leftEl || !rightEl) {
- return;
- }
- leftEl.querySelectorAll(".chart-axis-hover-dot").forEach((el) => el.remove());
- rightEl.querySelectorAll(".chart-axis-hover-dot").forEach((el) => el.remove());
- const verticalOffset = scrollViewport?.offsetTop || 0;
- hoverValues.filter(
- (entry) => entry?.hasValue !== false && Number.isFinite(entry?.y)
- ).forEach((entry) => {
- const target = entry.axisSide === "right" ? rightEl : leftEl;
- const dot = document.createElement("span");
- dot.className = `chart-axis-hover-dot ${entry.axisSide === "right" ? "right" : "left"}`;
- dot.style.top = `${verticalOffset + entry.y}px`;
- dot.style.background = entry.color || "#03a9f4";
- dot.style.opacity = `${Number.isFinite(entry.opacity) ? entry.opacity : 1}`;
- target.appendChild(dot);
- });
- }
- function positionTooltip(tooltip, clientX, clientY, bounds = null) {
- tooltip.style.display = "block";
- const tipRect = tooltip.getBoundingClientRect();
- const tipW = tipRect.width || 220;
- const tipH = tipRect.height || 64;
- const gap = 12;
- const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
- const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
- const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
- const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
- let left = clientX + gap;
- if (left + tipW > maxLeft) {
- left = clientX - tipW - gap;
- }
- let top = clientY - tipH - gap;
- if (top < minTop) {
- top = clientY + gap;
- }
- if (top + tipH > maxTop) {
- top = Math.max(minTop, clientY - tipH - gap);
- }
- left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft - tipW));
- top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop - tipH));
- tooltip.style.left = `${left}px`;
- tooltip.style.top = `${top}px`;
+ .chart-top-slot {
+ position: relative;
+ flex: 0 0 auto;
+ min-width: 0;
+ margin-left: calc(var(--dp-spacing-md) * -1);
+ margin-right: calc(var(--dp-spacing-md) * -1);
+ margin-top: -5px;
+ z-index: 1;
}
- function clampChartValue(value, min, max) {
- return Math.min(max, Math.max(min, value));
+ .chart-wrap {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: var(--dp-spacing-sm) var(--dp-spacing-md) var(--dp-spacing-md);
+ box-sizing: border-box;
+ overflow: visible;
+ isolation: isolate;
+ z-index: 3;
}
- function formatTooltipValue(value, unit = "") {
- if (value == null || value === "" || Number.isNaN(Number(value))) {
- return "";
- }
- return `${Number(value).toFixed(2).replace(/\.00$/, "")}${unit ? ` ${unit}` : ""}`;
+ .chart-preview-overlay[hidden] {
+ display: none;
}
- function formatTooltipDisplayValue(value, unit = "") {
- if (value == null || value === "") {
- return "No value";
- }
- if (typeof value === "string") {
- return unit ? `${value} ${unit}` : value;
- }
- return formatTooltipValue(value, unit);
+ .chart-preview-overlay {
+ position: absolute;
+ top: calc(var(--dp-chart-top-slot-height, 0px) + var(--dp-spacing-sm));
+ left: var(--dp-spacing-md);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ max-width: min(340px, calc(100% - (var(--dp-spacing-lg) * 2)));
+ padding: 8px 12px;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--card-background-color, #fff) 90%, transparent);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
+ backdrop-filter: blur(4px);
+ pointer-events: none;
+ z-index: 4;
}
- function entityName(hass, entityId) {
- if (!hass || !entityId) {
- return entityId || "";
- }
- const state = hass.states?.[entityId];
- return state && state.attributes && state.attributes.friendly_name || entityId;
+ .chart-preview-kicker {
+ font-size: 0.68rem;
+ line-height: 1.15;
+ color: color-mix(in srgb, var(--warning-color, #f59e0b) 72%, var(--secondary-text-color, #6b7280));
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
}
- function entityIcon(hass, entityId) {
- if (!hass || !entityId) {
- return "mdi:link-variant";
- }
- const state = hass.states?.[entityId];
- if (state?.attributes?.icon) {
- return state.attributes.icon;
- }
- const domain = String(entityId).split(".")[0];
- const entry = hass.entities?.[entityId];
- if (entry?.icon) {
- return entry.icon;
- }
- switch (domain) {
- case "light":
- return "mdi:lightbulb";
- case "switch":
- return "mdi:toggle-switch";
- case "binary_sensor":
- return "mdi:radiobox-marked";
- case "sensor":
- return "mdi:chart-line";
- case "climate":
- return "mdi:thermostat";
- case "cover":
- return "mdi:window-shutter";
- case "lock":
- return "mdi:lock";
- case "media_player":
- return "mdi:play-box";
- case "person":
- return "mdi:account";
- case "device_tracker":
- return "mdi:crosshairs-gps";
- default:
- return "mdi:link-variant";
- }
+ .chart-preview-title {
+ font-size: 0.84rem;
+ line-height: 1.2;
+ color: var(--primary-text-color);
+ font-weight: 600;
}
- function entityRegistryEntries(hass) {
- return Object.entries(hass?.entities || {});
+ .chart-preview-line {
+ font-size: 0.74rem;
+ line-height: 1.2;
+ color: var(--secondary-text-color);
}
- function firstRelatedEntityId(hass, matcher) {
- return entityRegistryEntries(hass).find(
- ([, entry]) => entry && typeof entry === "object" && matcher(entry)
- )?.[0] || "";
+ .chart-preview-line strong {
+ color: color-mix(in srgb, var(--warning-color, #f59e0b) 72%, var(--primary-text-color, #111));
+ font-weight: 600;
}
- function deviceName(hass, deviceId) {
- if (!hass || !deviceId) {
- return deviceId || "";
- }
- return hass.devices?.[deviceId]?.name ?? deviceId;
+ .chart-scroll-viewport {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-gutter: stable both-edges;
+ -webkit-overflow-scrolling: touch;
}
- function deviceIcon(hass, deviceId) {
- if (!hass || !deviceId) {
- return "mdi:devices";
- }
- const entityId = firstRelatedEntityId(
- hass,
- (entry) => (entry.device_id || entry.deviceId) === deviceId
- );
- return entityId ? entityIcon(hass, entityId) : "mdi:devices";
+ .chart-stage {
+ position: relative;
+ min-height: 100%;
}
- function areaName(hass, areaId) {
- if (!hass || !areaId) {
- return areaId || "";
- }
- return hass.areas?.[areaId]?.name ?? areaId;
+ .chart-icon-overlay {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 2;
}
- function areaIcon(hass, areaId) {
- if (!hass || !areaId) {
- return "mdi:floor-plan";
- }
- const entityId = firstRelatedEntityId(
- hass,
- (entry) => (entry.area_id || entry.areaId) === areaId
- );
- return entityId ? entityIcon(hass, entityId) : "mdi:floor-plan";
+ .chart-event-icon {
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ transform: translate(-50%, -50%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ cursor: pointer;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ border-radius: 50%;
}
- function labelName(hass, labelId) {
- if (!hass || !labelId) {
- return labelId || "";
- }
- return hass.labels?.[labelId]?.name ?? labelId;
+ .chart-event-icon ha-icon {
+ --mdc-icon-size: 14px;
+ pointer-events: none;
}
- function labelIcon(hass, labelId) {
- if (!hass || !labelId) {
- return "mdi:label-outline";
- }
- const entityId = firstRelatedEntityId(hass, (entry) => {
- const labels = [
- ...Array.isArray(entry.labels) ? entry.labels : [],
- ...Array.isArray(entry.label_ids) ? entry.label_ids : []
- ];
- return labels.includes(labelId);
- });
- return entityId ? entityIcon(hass, entityId) : "mdi:label-outline";
- }
- function getRoot(card) {
- const rootNode = card.shadowRoot ?? card.getRootNode();
- if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
- return rootNode;
- }
- return document;
+ .chart-axis-overlay {
+ position: absolute;
+ top: calc(var(--dp-chart-top-slot-height, 0px) + 5px);
+ bottom: 0;
+ display: none;
+ pointer-events: none;
+ background: var(--card-background-color, var(--primary-background-color, #fff));
+ overflow: hidden;
+ z-index: 3;
+ border-bottom-left-radius: 11px;
}
- function getInteractionState(card) {
- return card;
+ .chart-axis-overlay.visible {
+ display: block;
}
- function toChartBounds(bounds) {
- if (!bounds) {
- return null;
- }
- return {
- left: bounds.left + 8,
- right: bounds.right - 8,
- top: bounds.top + 8,
- bottom: bounds.bottom - 8
- };
- }
- function formatTooltipDateTimeFromMs(timeMs) {
- if (!Number.isFinite(timeMs)) {
- return "";
- }
- return fmtDateTime(new Date(timeMs).toISOString());
- }
- function t$2(key, ...values) {
- let s2 = msg(key);
- values.forEach((v2, i2) => {
- s2 = s2.replace(new RegExp(`\\{${i2}\\}`, "g"), v2);
- });
- return s2;
- }
- function getAnomalyMethodLabels() {
- return {
- trend_residual: msg("Trend deviation"),
- rate_of_change: msg("Sudden change"),
- iqr: msg("Statistical outlier (IQR)"),
- rolling_zscore: msg("Rolling Z-score"),
- persistence: msg("Flat-line / stuck"),
- comparison_window: msg("Comparison window")
- };
- }
- function buildAnomalyMethodSection(region) {
- if (!region?.cluster?.points?.length) {
- return null;
- }
- const points = region.cluster.points;
- const startPoint = points[0];
- const endPoint = points[points.length - 1];
- const peakPoint = points.reduce(
- (peak, p2) => !peak || Math.abs(p2.residual) > Math.abs(peak.residual) ? p2 : peak,
- null
- );
- if (!peakPoint) {
- return null;
- }
- const label = region.label || region.relatedEntityId || "Series";
- const unit = region.unit || "";
- const cluster = region.cluster;
- const method = cluster.anomalyMethod ?? "trend_residual";
- const methodLabel = getAnomalyMethodLabels()[method] || method;
- let description;
- let alert;
- if (method === "rate_of_change") {
- const rateUnit = unit ? `${unit}/h` : "units/h";
- description = t$2(
- "{0} shows an unusual rate of change between {1} and {2}.",
- label,
- formatTooltipDateTimeFromMs(startPoint.timeMs),
- formatTooltipDateTimeFromMs(endPoint.timeMs)
- );
- alert = t$2(
- "Peak rate deviation: {0} from a typical rate of {1} at {2}.",
- formatTooltipValue(peakPoint.residual, rateUnit),
- formatTooltipValue(peakPoint.baselineValue, rateUnit),
- formatTooltipDateTimeFromMs(peakPoint.timeMs)
- );
- } else if (method === "iqr") {
- description = t$2(
- "{0} contains statistical outliers between {1} and {2}.",
- label,
- formatTooltipDateTimeFromMs(startPoint.timeMs),
- formatTooltipDateTimeFromMs(endPoint.timeMs)
- );
- alert = t$2(
- "Peak value: {0}, deviating {1} from the median at {2}.",
- formatTooltipValue(peakPoint.value, unit),
- formatTooltipValue(Math.abs(peakPoint.residual), unit),
- formatTooltipDateTimeFromMs(peakPoint.timeMs)
- );
- } else if (method === "rolling_zscore") {
- description = t$2(
- "{0} shows statistically unusual values between {1} and {2}.",
- label,
- formatTooltipDateTimeFromMs(startPoint.timeMs),
- formatTooltipDateTimeFromMs(endPoint.timeMs)
- );
- alert = t$2(
- "Peak deviation: {0} from a rolling mean of {1} at {2}.",
- formatTooltipValue(peakPoint.residual, unit),
- formatTooltipValue(peakPoint.baselineValue, unit),
- formatTooltipDateTimeFromMs(peakPoint.timeMs)
- );
- } else if (method === "persistence") {
- const flatRange = typeof cluster.flatRange === "number" ? cluster.flatRange : null;
- const rangeStr = flatRange !== null ? t$2(" (range: {0})", formatTooltipValue(flatRange, unit)) : "";
- description = t$2(
- "{0} appears stuck or flat between {1} and {2}{3}.",
- label,
- formatTooltipDateTimeFromMs(startPoint.timeMs),
- formatTooltipDateTimeFromMs(endPoint.timeMs),
- rangeStr
- );
- alert = t$2(
- "Value remained near {0} for an unusually long period.",
- formatTooltipValue(peakPoint.baselineValue, unit)
- );
- } else if (method === "comparison_window") {
- description = t$2(
- "{0} deviates significantly from the comparison window between {1} and {2}.",
- label,
- formatTooltipDateTimeFromMs(startPoint.timeMs),
- formatTooltipDateTimeFromMs(endPoint.timeMs)
- );
- alert = t$2(
- "Peak deviation from comparison: {0} at {1}.",
- formatTooltipValue(peakPoint.residual, unit),
- formatTooltipDateTimeFromMs(peakPoint.timeMs)
- );
- } else {
- description = t$2(
- "{0} deviates from its expected trend between {1} and {2}.",
- label,
- formatTooltipDateTimeFromMs(startPoint.timeMs),
- formatTooltipDateTimeFromMs(endPoint.timeMs)
- );
- alert = t$2(
- "Peak deviation: {0} from a baseline of {1} at {2}.",
- formatTooltipValue(peakPoint.residual, unit),
- formatTooltipValue(peakPoint.baselineValue, unit),
- formatTooltipDateTimeFromMs(peakPoint.timeMs)
- );
- }
- return { methodLabel, description, alert };
- }
- function buildAnomalyTooltipContent(regions) {
- let regionsArray;
- if (Array.isArray(regions)) {
- regionsArray = regions;
- } else if (regions) {
- regionsArray = [regions];
- } else {
- regionsArray = [];
- }
- if (regionsArray.length === 0) {
- return null;
- }
- const sections = regionsArray.map(buildAnomalyMethodSection).filter((section) => section !== null);
- if (sections.length === 0) {
- return null;
- }
- const instruction = msg(
- "Click the highlighted circle to add an annotation.",
- { id: "Click the highlighted circle to add an annotation." }
- );
- if (sections.length === 1) {
- const section = sections[0];
- const cluster = regionsArray[0]?.cluster;
- const detectedByMethods = Array.isArray(cluster?.detectedByMethods) && cluster.detectedByMethods.length > 1 ? cluster.detectedByMethods : null;
- const isMultiMethod = detectedByMethods !== null;
- const title = isMultiMethod ? msg("⚠️ Multi-method Anomaly") : msg("⚠️ Anomaly Insight");
- const labels = getAnomalyMethodLabels();
- const confirmedNote = isMultiMethod ? `
-${msg("Confirmed by")} ${detectedByMethods.length} ${msg("methods:")} ${detectedByMethods.map(
- (method) => labels[method] || method
- ).join(", ")}.` : "";
- return {
- title,
- description: section.description + confirmedNote,
- alert: `${msg("Alert:")} ${section.alert}`,
- instruction
- };
- }
- const description = sections.map((s2) => `${s2.methodLabel}:
-${s2.description}`).join("\n\n");
- const alert = sections.map((s2) => `${s2.methodLabel}: ${s2.alert}`).join("\n");
- return {
- title: msg("⚠️ Multi-method Anomaly"),
- description,
- alert,
- instruction
- };
- }
- function positionAnomalyTooltip(tooltip, clientX, clientY, mainTooltip, bounds = null) {
- if (!tooltip) {
- return;
- }
- tooltip.style.display = "block";
- const tipRect = tooltip.getBoundingClientRect();
- const tipW = tipRect.width || 220;
- const tipH = tipRect.height || 64;
- const gap = 12;
- const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
- const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
- const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
- const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
- let left = clientX - gap - tipW;
- if (left < minLeft) {
- const mainRect2 = mainTooltip ? mainTooltip.getBoundingClientRect() : null;
- left = mainRect2 ? mainRect2.right + gap : clientX + gap;
- }
- const mainRect = mainTooltip ? mainTooltip.getBoundingClientRect() : null;
- let top = mainRect ? mainRect.top : clientY - tipH - gap;
- if (top + tipH > maxTop) {
- top = Math.max(minTop, maxTop - tipH);
- }
- left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft - tipW));
- top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop - tipH));
- tooltip.style.left = `${left}px`;
- tooltip.style.top = `${top}px`;
- }
- function positionSecondaryTooltip(tooltip, anchorTooltip, bounds = null) {
- if (!tooltip || !anchorTooltip) {
- return;
- }
- tooltip.style.display = "block";
- const anchorRect = anchorTooltip.getBoundingClientRect();
- const tipRect = tooltip.getBoundingClientRect();
- const gap = 10;
- const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
- const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
- const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
- const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
- let left = anchorRect.right + gap;
- if (left + tipRect.width > maxLeft) {
- left = anchorRect.left - tipRect.width - gap;
- }
- let top = anchorRect.top;
- if (top + tipRect.height > maxTop) {
- top = Math.max(minTop, maxTop - tipRect.height);
- }
- left = Math.min(
- Math.max(left, minLeft),
- Math.max(minLeft, maxLeft - tipRect.width)
- );
- top = Math.min(
- Math.max(top, minTop),
- Math.max(minTop, maxTop - tipRect.height)
- );
- tooltip.style.left = `${left}px`;
- tooltip.style.top = `${top}px`;
+ .chart-axis-overlay.left {
+ left: 0;
}
- function positionTooltipBelow(tooltip, anchorTooltip, bounds = null) {
- if (!tooltip || !anchorTooltip) {
- return;
- }
- tooltip.style.display = "block";
- const anchorRect = anchorTooltip.getBoundingClientRect();
- const tipRect = tooltip.getBoundingClientRect();
- const gap = 8;
- const minLeft = Number.isFinite(bounds?.left) ? bounds?.left : gap;
- const maxLeft = Number.isFinite(bounds?.right) ? bounds?.right : window.innerWidth - gap;
- const minTop = Number.isFinite(bounds?.top) ? bounds?.top : gap;
- const maxTop = Number.isFinite(bounds?.bottom) ? bounds?.bottom : window.innerHeight - gap;
- let left = anchorRect.left;
- if (left + tipRect.width > maxLeft) {
- left = Math.max(minLeft, maxLeft - tipRect.width);
- }
- let top = anchorRect.bottom + gap;
- if (top + tipRect.height > maxTop) {
- top = Math.max(minTop, anchorRect.top - tipRect.height - gap);
- }
- left = Math.min(
- Math.max(left, minLeft),
- Math.max(minLeft, maxLeft - tipRect.width)
- );
- top = Math.min(
- Math.max(top, minTop),
- Math.max(minTop, maxTop - tipRect.height)
- );
- tooltip.style.left = `${left}px`;
- tooltip.style.top = `${top}px`;
+ .chart-axis-overlay.right {
+ right: 0;
}
- function getAnnotationTooltipContainer(card) {
- if (!card || !getRoot(card)) {
- return null;
- }
- return getRoot(card).getElementById("annotation-tooltips");
+ .chart-axis-divider {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: rgba(128,128,128,0.35);
}
- function clearAnnotationTooltips(card) {
- const container = getAnnotationTooltipContainer(card);
- if (!container) {
- return;
- }
- container.innerHTML = "";
- }
- function buildAnnotationTooltip(card, event) {
- const interactionState = getInteractionState(card);
- const tooltip = document.createElement("div");
- tooltip.className = "tooltip secondary annotation-tooltip";
- const hasValue = event?.chart_value != null && event.chart_value !== "";
- const valueMarkup = hasValue ? `
${esc(formatTooltipValue(event.chart_value, event.chart_unit))}
` : "";
- const message = event?.message || "Data point";
- const annotation = event?.annotation && event.annotation !== event.message ? event.annotation : "";
- const relatedMarkup = buildTooltipRelatedChips(interactionState._hass, event);
- tooltip.innerHTML = `
-
${esc(fmtDateTime(event.timestamp))}
- ${valueMarkup}
-
-
- ${esc(message)}
-
-
${esc(annotation)}
-
${relatedMarkup}
- `;
- return tooltip;
- }
- function renderAnnotationTooltips(card, hover, anchorTooltip, bounds = null) {
- const container = getAnnotationTooltipContainer(card);
- if (!container) {
- return [];
- }
- clearAnnotationTooltips(card);
- const annotationEvents = Array.isArray(hover?.events) ? hover.events : [];
- if (!annotationEvents.length) {
- return [];
- }
- const renderedTooltips = [];
- let anchorEl = anchorTooltip;
- for (const event of annotationEvents) {
- const tooltip = buildAnnotationTooltip(card, event);
- container.appendChild(tooltip);
- if (renderedTooltips.length === 0) {
- positionSecondaryTooltip(tooltip, anchorEl, bounds);
- } else {
- positionTooltipBelow(tooltip, anchorEl, bounds);
- }
- renderedTooltips.push(tooltip);
- anchorEl = tooltip;
- }
- return renderedTooltips;
+ .chart-axis-overlay.left .chart-axis-divider {
+ right: 0;
}
- function hideTooltip(card) {
- const tooltip = getRoot(card).getElementById("tooltip");
- const anomalyTooltip = getRoot(card).getElementById("anomaly-tooltip");
- if (tooltip) {
- tooltip.style.display = "none";
- }
- if (anomalyTooltip) {
- anomalyTooltip.style.display = "none";
- }
- clearAnnotationTooltips(card);
- }
- function resolveTooltipSeriesLabel(entry) {
- const isSubordinate = entry.grouped === true && entry.rawVisible === true;
- const isComparisonDerived = entry.comparisonDerived === true && entry.grouped === true;
- if (entry.comparison === true) {
- const windowLabel = String(entry.windowLabel || msg("Date window"));
- if (entry.grouped === true) {
- return windowLabel;
- }
- return `${windowLabel}: ${String(entry.label || "")}`;
- }
- if (entry.trend === true) {
- const trendLabel = msg("Trend");
- if (isSubordinate || isComparisonDerived) {
- return trendLabel;
- }
- return `${trendLabel}: ${entry.baseLabel || entry.label || ""}`;
- }
- if (entry.rate === true) {
- const rateLabel = msg("Rate");
- if (isSubordinate || isComparisonDerived) {
- return rateLabel;
- }
- return `${rateLabel}: ${entry.baseLabel || entry.label || ""}`;
- }
- if (entry.delta === true) {
- const deltaLabel = msg("Delta");
- if (isSubordinate || isComparisonDerived) {
- return deltaLabel;
- }
- return `${deltaLabel}: ${entry.baseLabel || entry.label || ""}`;
- }
- if (entry.summary === true) {
- const summaryLabel = String(entry.summaryType || "").toUpperCase();
- if (isSubordinate || isComparisonDerived) {
- return summaryLabel;
- }
- return `${summaryLabel}: ${entry.baseLabel || entry.label || ""}`;
- }
- if (entry.threshold === true) {
- const thresholdLabel = msg("Threshold");
- if (isSubordinate || isComparisonDerived) {
- return thresholdLabel;
- }
- return `${thresholdLabel}: ${entry.baseLabel || entry.label || ""}`;
- }
- return String(entry.label || "");
- }
- function showLineChartTooltip(card, hover, clientX, clientY) {
- const root = getRoot(card);
- const tooltip = root.getElementById("tooltip");
- const ttTime = root.getElementById("tt-time");
- const ttValue = root.getElementById("tt-value");
- const ttSeries = root.getElementById("tt-series");
- const anomalyTooltip = root.getElementById(
- "anomaly-tooltip"
- );
- const ttSecondaryTitle = root.getElementById("tt-secondary-title");
- const ttSecondaryDescription = root.getElementById(
- "tt-secondary-description"
- );
- const ttSecondaryAlert = root.getElementById("tt-secondary-alert");
- const ttSecondaryInstruction = root.getElementById(
- "tt-secondary-instruction"
- );
- const ttMessageRow = root.getElementById(
- "tt-message-row"
- );
- const ttMsg = root.getElementById("tt-message");
- const ttAnn = root.getElementById("tt-annotation");
- const ttEntities = root.getElementById(
- "tt-entities"
- );
- if (!tooltip || !ttTime || !ttValue || !ttMessageRow || !ttMsg || !ttAnn || !ttEntities) {
- return;
- }
- const rangeStartMs = Number.isFinite(hover.rangeStartMs) ? hover.rangeStartMs : hover.timeMs;
- const rangeEndMs = Number.isFinite(hover.rangeEndMs) ? hover.rangeEndMs : hover.timeMs;
- ttTime.textContent = rangeStartMs === rangeEndMs ? fmtDateTime(new Date(hover.timeMs).toISOString()) : `${fmtDateTime(new Date(rangeStartMs).toISOString())} - ${fmtDateTime(new Date(rangeEndMs).toISOString())}`;
- const values = Array.isArray(hover.values) ? hover.values : [];
- const trendValues = Array.isArray(hover.trendValues) ? hover.trendValues : [];
- const rateValues = Array.isArray(hover.rateValues) ? hover.rateValues : [];
- const deltaValues = Array.isArray(hover.deltaValues) ? hover.deltaValues : [];
- const summaryValues = Array.isArray(hover.summaryValues) ? hover.summaryValues : [];
- const thresholdValues = Array.isArray(hover.thresholdValues) ? hover.thresholdValues : [];
- const binaryValues = Array.isArray(hover.binaryValues) ? hover.binaryValues : [];
- const comparisonValues = Array.isArray(hover.comparisonValues) ? hover.comparisonValues : [];
- const displayRows = [];
- const usedTrendRows = /* @__PURE__ */ new Set();
- const usedRateRows = /* @__PURE__ */ new Set();
- const usedDeltaRows = /* @__PURE__ */ new Set();
- const usedSummaryRows = /* @__PURE__ */ new Set();
- const usedThresholdRows = /* @__PURE__ */ new Set();
- const usedComparisonRows = /* @__PURE__ */ new Set();
- const pushComparisonDerivedRows = (comparisonEntry, comparisonIndex) => {
- trendValues.forEach((trendEntry, trendIndex) => {
- if (usedTrendRows.has(trendIndex)) {
- return;
- }
- if (trendEntry.comparisonParentId !== comparisonEntry.entityId && !(trendEntry.relatedEntityId === comparisonEntry.relatedEntityId && trendEntry.windowLabel === comparisonEntry.windowLabel)) {
- return;
- }
- usedTrendRows.add(trendIndex);
- displayRows.push({
- ...trendEntry,
- rawVisible: true,
- comparisonDerived: true,
- grouped: true,
- key: `comparison-trend-${comparisonIndex}-${trendIndex}`
- });
- });
- rateValues.forEach((rateEntry, rateIndex) => {
- if (usedRateRows.has(rateIndex)) {
- return;
- }
- if (rateEntry.comparisonParentId !== comparisonEntry.entityId && !(rateEntry.relatedEntityId === comparisonEntry.relatedEntityId && rateEntry.windowLabel === comparisonEntry.windowLabel)) {
- return;
- }
- usedRateRows.add(rateIndex);
- displayRows.push({
- ...rateEntry,
- rawVisible: true,
- comparisonDerived: true,
- grouped: true,
- key: `comparison-rate-${comparisonIndex}-${rateIndex}`
- });
- });
- summaryValues.forEach((summaryEntry, summaryIndex) => {
- if (usedSummaryRows.has(summaryIndex)) {
- return;
- }
- if (summaryEntry.comparisonParentId !== comparisonEntry.entityId && !(summaryEntry.relatedEntityId === comparisonEntry.relatedEntityId && summaryEntry.windowLabel === comparisonEntry.windowLabel)) {
- return;
- }
- usedSummaryRows.add(summaryIndex);
- displayRows.push({
- ...summaryEntry,
- rawVisible: true,
- comparisonDerived: true,
- grouped: true,
- key: `comparison-summary-${comparisonIndex}-${summaryIndex}`
- });
- });
- thresholdValues.forEach((thresholdEntry, thresholdIndex) => {
- if (usedThresholdRows.has(thresholdIndex)) {
- return;
- }
- if (thresholdEntry.comparisonParentId !== comparisonEntry.entityId && !(thresholdEntry.relatedEntityId === comparisonEntry.relatedEntityId && thresholdEntry.windowLabel === comparisonEntry.windowLabel)) {
- return;
- }
- usedThresholdRows.add(thresholdIndex);
- displayRows.push({
- ...thresholdEntry,
- rawVisible: true,
- comparisonDerived: true,
- grouped: true,
- key: `comparison-threshold-${comparisonIndex}-${thresholdIndex}`
- });
- });
- };
- values.forEach((entry, index) => {
- displayRows.push(entry);
- trendValues.forEach((trendEntry, trendIndex) => {
- if (usedTrendRows.has(trendIndex)) {
- return;
- }
- const sameEntity = trendEntry.relatedEntityId && trendEntry.relatedEntityId === entry.entityId;
- const sameLabel = !trendEntry.relatedEntityId && trendEntry.baseLabel && trendEntry.baseLabel === entry.label;
- if (!sameEntity && !sameLabel) {
- return;
- }
- usedTrendRows.add(trendIndex);
- displayRows.push({
- ...trendEntry,
- rawVisible: trendEntry.rawVisible !== false,
- grouped: true,
- key: `trend-${index}-${trendIndex}`
- });
- });
- rateValues.forEach((rateEntry, rateIndex) => {
- if (usedRateRows.has(rateIndex)) {
- return;
- }
- const sameEntity = rateEntry.relatedEntityId && rateEntry.relatedEntityId === entry.entityId;
- const sameLabel = !rateEntry.relatedEntityId && rateEntry.baseLabel && rateEntry.baseLabel === entry.label;
- if (!sameEntity && !sameLabel) {
- return;
- }
- usedRateRows.add(rateIndex);
- displayRows.push({
- ...rateEntry,
- rawVisible: rateEntry.rawVisible !== false,
- grouped: true,
- key: `rate-${index}-${rateIndex}`
- });
- });
- deltaValues.forEach((deltaEntry, deltaIndex) => {
- if (usedDeltaRows.has(deltaIndex)) {
- return;
- }
- const sameEntity = deltaEntry.relatedEntityId && deltaEntry.relatedEntityId === entry.entityId;
- const sameLabel = !deltaEntry.relatedEntityId && deltaEntry.baseLabel && deltaEntry.baseLabel === entry.label;
- if (!sameEntity && !sameLabel) {
- return;
- }
- usedDeltaRows.add(deltaIndex);
- displayRows.push({
- ...deltaEntry,
- rawVisible: deltaEntry.rawVisible !== false,
- grouped: true,
- key: `delta-${index}-${deltaIndex}`
- });
- });
- summaryValues.forEach((summaryEntry, summaryIndex) => {
- if (usedSummaryRows.has(summaryIndex)) {
- return;
- }
- const sameEntity = summaryEntry.relatedEntityId && summaryEntry.relatedEntityId === entry.entityId;
- const sameLabel = !summaryEntry.relatedEntityId && summaryEntry.baseLabel && summaryEntry.baseLabel === entry.label;
- if (!sameEntity && !sameLabel) {
- return;
- }
- usedSummaryRows.add(summaryIndex);
- displayRows.push({
- ...summaryEntry,
- rawVisible: summaryEntry.rawVisible !== false,
- grouped: true,
- key: `summary-${index}-${summaryIndex}`
- });
- });
- thresholdValues.forEach((thresholdEntry, thresholdIndex) => {
- if (usedThresholdRows.has(thresholdIndex)) {
- return;
- }
- const sameEntity = thresholdEntry.relatedEntityId && thresholdEntry.relatedEntityId === entry.entityId;
- const sameLabel = !thresholdEntry.relatedEntityId && thresholdEntry.baseLabel && thresholdEntry.baseLabel === entry.label;
- if (!sameEntity && !sameLabel) {
- return;
- }
- usedThresholdRows.add(thresholdIndex);
- displayRows.push({
- ...thresholdEntry,
- rawVisible: thresholdEntry.rawVisible !== false,
- grouped: true,
- key: `threshold-${index}-${thresholdIndex}`
- });
- });
- comparisonValues.forEach((compEntry, compIndex) => {
- if (usedComparisonRows.has(compIndex)) {
- return;
- }
- if (!compEntry.relatedEntityId || compEntry.relatedEntityId !== entry.entityId) {
- return;
- }
- usedComparisonRows.add(compIndex);
- const groupedEntry = {
- ...compEntry,
- grouped: true,
- comparison: true,
- key: `comparison-${index}-${compIndex}`
- };
- displayRows.push(groupedEntry);
- pushComparisonDerivedRows(groupedEntry, compIndex);
- });
- });
- trendValues.forEach((trendEntry, trendIndex) => {
- if (usedTrendRows.has(trendIndex)) {
- return;
- }
- if (trendEntry.comparisonDerived === true || typeof trendEntry.comparisonParentId === "string") {
- return;
- }
- displayRows.push({
- ...trendEntry,
- rawVisible: trendEntry.rawVisible !== false
- });
- });
- rateValues.forEach((rateEntry, rateIndex) => {
- if (usedRateRows.has(rateIndex)) {
- return;
- }
- if (rateEntry.comparisonDerived === true || typeof rateEntry.comparisonParentId === "string") {
- return;
- }
- displayRows.push({
- ...rateEntry,
- rawVisible: rateEntry.rawVisible !== false
- });
- });
- deltaValues.forEach((deltaEntry, deltaIndex) => {
- if (usedDeltaRows.has(deltaIndex)) {
- return;
- }
- displayRows.push({
- ...deltaEntry,
- rawVisible: deltaEntry.rawVisible !== false
- });
- });
- summaryValues.forEach((summaryEntry, summaryIndex) => {
- if (usedSummaryRows.has(summaryIndex)) {
- return;
- }
- if (summaryEntry.comparisonDerived === true || typeof summaryEntry.comparisonParentId === "string") {
- return;
- }
- displayRows.push({
- ...summaryEntry,
- rawVisible: summaryEntry.rawVisible !== false
- });
- });
- thresholdValues.forEach((thresholdEntry, thresholdIndex) => {
- if (usedThresholdRows.has(thresholdIndex)) {
- return;
- }
- if (thresholdEntry.comparisonDerived === true || typeof thresholdEntry.comparisonParentId === "string") {
- return;
- }
- displayRows.push({
- ...thresholdEntry,
- rawVisible: thresholdEntry.rawVisible !== false
- });
- });
- comparisonValues.forEach((compEntry, compIndex) => {
- if (usedComparisonRows.has(compIndex)) {
- return;
- }
- const groupedEntry = { ...compEntry, comparison: true };
- displayRows.push(groupedEntry);
- pushComparisonDerivedRows(groupedEntry, compIndex);
- });
- displayRows.push(...binaryValues);
- const useSingleValueMode = displayRows.length === 1 && trendValues.length === 0 && rateValues.length === 0 && deltaValues.length === 0 && summaryValues.length === 0 && thresholdValues.length === 0 && comparisonValues.length === 0 && binaryValues.length === 0 && displayRows[0]?.comparison !== true;
- if (useSingleValueMode) {
- const value = displayRows[0];
- ttValue.textContent = value ? formatTooltipDisplayValue(value.value, value.unit) : "";
- ttValue.style.display = value ? "block" : "none";
- if (ttSeries) {
- ttSeries.innerHTML = "";
- ttSeries.style.display = "none";
- }
- } else {
- ttValue.textContent = "";
- ttValue.style.display = "none";
- if (ttSeries) {
- ttSeries.innerHTML = displayRows.map(
- (entry) => `
-
-
- ${entry.grouped === true && entry.rawVisible === true ? "" : ` `}
- ${esc(resolveTooltipSeriesLabel(entry))}
-
-
${esc(formatTooltipDisplayValue(entry.value, entry.unit))}
-
- `
- ).join("");
- ttSeries.style.display = displayRows.length ? "grid" : "none";
- }
- }
- ttMessageRow.style.display = "none";
- ttMsg.textContent = "";
- ttAnn.textContent = "";
- ttAnn.style.display = "none";
- ttEntities.innerHTML = "";
- ttEntities.style.display = "none";
- if (anomalyTooltip && ttSecondaryTitle && ttSecondaryDescription && ttSecondaryAlert && ttSecondaryInstruction) {
- const anomalyContent = buildAnomalyTooltipContent(hover.anomalyRegions);
- if (anomalyContent) {
- ttSecondaryTitle.textContent = anomalyContent.title;
- ttSecondaryDescription.textContent = anomalyContent.description;
- ttSecondaryAlert.textContent = anomalyContent.alert;
- ttSecondaryInstruction.textContent = anomalyContent.instruction;
- } else {
- ttSecondaryTitle.textContent = "";
- ttSecondaryDescription.textContent = "";
- ttSecondaryAlert.textContent = "";
- ttSecondaryInstruction.textContent = "";
- anomalyTooltip.style.display = "none";
- }
- }
- const chartBounds = (root.querySelector(".chart-wrap") ?? card).getBoundingClientRect();
- positionTooltip(tooltip, clientX, clientY, toChartBounds(chartBounds));
- if (anomalyTooltip && (hover.anomalyRegions?.length ?? 0) > 0) {
- positionAnomalyTooltip(
- anomalyTooltip,
- clientX,
- clientY,
- tooltip,
- toChartBounds(chartBounds)
- );
- }
- if (Array.isArray(hover.events) && hover.events.length > 0) {
- renderAnnotationTooltips(card, hover, tooltip, toChartBounds(chartBounds));
- } else {
- clearAnnotationTooltips(card);
- }
+ .chart-axis-overlay.right .chart-axis-divider {
+ left: 0;
}
- function buildTooltipRelatedChips(hass, event) {
- const entities = Array.isArray(event?.entity_ids) ? event.entity_ids : [];
- const devices = Array.isArray(event?.device_ids) ? event.device_ids : [];
- const areas = Array.isArray(event?.area_ids) ? event.area_ids : [];
- const labels = Array.isArray(event?.label_ids) ? event.label_ids : [];
- const chips = [
- ...entities.map((id) => ({
- icon: entityIcon(hass, id),
- label: entityName(hass, id)
- })),
- ...devices.map((id) => ({
- icon: deviceIcon(hass, id),
- label: deviceName(hass, id)
- })),
- ...areas.map((id) => ({
- icon: areaIcon(hass, id),
- label: areaName(hass, id)
- })),
- ...labels.map((id) => ({
- icon: labelIcon(hass, id),
- label: labelName(hass, id)
- }))
- ].filter((chip) => chip.label);
- if (!chips.length) return "";
- return chips.map(
- (chip) => `
-
-
- ${esc(chip.label)}
-
- `
- ).join("");
- }
- function showLineChartCrosshair(card, renderer, hover) {
- const overlay = getRoot(card).getElementById("chart-crosshair");
- const vertical = getRoot(card).getElementById("crosshair-vertical");
- const horizontal = getRoot(card).getElementById("crosshair-horizontal");
- const points = getRoot(card).getElementById("crosshair-points");
- const addButton = getRoot(card).getElementById("chart-add-annotation");
- if (!overlay || !vertical || !horizontal || !points) return;
- overlay.hidden = false;
- vertical.style.left = `${hover.x}px`;
- if (hover.splitVertical) {
- vertical.style.top = `${hover.splitVertical.top}px`;
- vertical.style.height = `${hover.splitVertical.height}px`;
- } else {
- vertical.style.top = `${renderer.pad.top}px`;
- vertical.style.height = `${renderer.ch}px`;
- }
- horizontal.hidden = true;
- const crosshairValues = [
- ...hover.values || [],
- ...hover.showTrendCrosshairs === true ? (hover.trendValues || []).filter(
- (entry) => entry.showCrosshair === true
- ) : [],
- ...hover.showRateCrosshairs === true ? (hover.rateValues || []).filter((entry) => entry.showCrosshair === true) : [],
- ...hover.comparisonValues || []
- ];
- points.innerHTML = `
- ${crosshairValues.filter((entry) => entry.hasValue !== false).map(
- (entry) => `
-
- `
- ).join("")}
- ${crosshairValues.filter((entry) => entry.hasValue !== false).map(
- (entry) => `
-
- `
- ).join("")}
- `;
- renderChartAxisHoverDots(card, crosshairValues);
- if (addButton && addButton.dataset.allowAddAnnotation !== "false") {
- addButton.hidden = false;
- addButton.style.left = `${hover.x}px`;
- if (hover.splitVertical) {
- addButton.style.top = `${hover.splitVertical.top + hover.splitVertical.height}px`;
- } else {
- addButton.style.top = `${renderer.pad.top + renderer.ch}px`;
- }
- }
+ .chart-axis-label,
+ .chart-axis-unit {
+ position: absolute;
+ color: var(--secondary-text-color);
+ font: 12px sans-serif;
+ line-height: 1;
+ white-space: nowrap;
}
- function dispatchLineChartHover(card, hover) {
- card.dispatchEvent(
- new CustomEvent("hass-datapoints-chart-hover", {
- bubbles: true,
- composed: true,
- detail: hover ? { timeMs: hover.timeMs } : { timeMs: null }
- })
- );
+ .chart-axis-label {
+ transform: translateY(calc(-50% + 6px));
}
- function findNearestSeriesPointTime(seriesPoints, timeMs) {
- if (!Array.isArray(seriesPoints) || seriesPoints.length === 0) {
- return null;
- }
- let lo = 0;
- let hi = seriesPoints.length - 1;
- while (lo + 1 < hi) {
- const mid = Math.floor((lo + hi) / 2);
- if (seriesPoints[mid][0] <= timeMs) {
- lo = mid;
- } else {
- hi = mid;
- }
- }
- const left = seriesPoints[lo]?.[0];
- const right = seriesPoints[hi]?.[0];
- if (!Number.isFinite(left) && !Number.isFinite(right)) {
- return null;
- }
- if (!Number.isFinite(left)) {
- return right;
- }
- if (!Number.isFinite(right)) {
- return left;
- }
- return Math.abs(left - timeMs) <= Math.abs(right - timeMs) ? left : right;
+ .chart-axis-unit {
+ font-weight: 500;
}
- function resolveLineChartHoverTime(series, timeMs, mode = "follow_series") {
- if (mode !== "snap_to_data_points") {
- return timeMs;
- }
- let bestTime = null;
- let bestDistance = Infinity;
- for (const seriesItem of Array.isArray(series) ? series : []) {
- const candidateTime = findNearestSeriesPointTime(seriesItem?.pts, timeMs);
- if (candidateTime == null || !Number.isFinite(candidateTime)) {
- continue;
- }
- const distance = Math.abs(candidateTime - timeMs);
- if (distance < bestDistance) {
- bestDistance = distance;
- bestTime = candidateTime;
- }
- }
- return bestTime != null && Number.isFinite(bestTime) ? bestTime : timeMs;
- }
- function hideLineChartHover(card) {
- dispatchLineChartHover(card, null);
- hideTooltip(card);
- const overlay = getRoot(card).getElementById("chart-crosshair");
- const points = getRoot(card).getElementById("crosshair-points");
- const addButton = getRoot(card).getElementById("chart-add-annotation");
- if (overlay) overlay.hidden = true;
- if (points) points.innerHTML = "";
- renderChartAxisHoverDots(card, []);
- const horizontal = getRoot(card).getElementById("crosshair-horizontal");
- if (horizontal) horizontal.hidden = true;
- if (addButton) addButton.hidden = true;
- }
- function attachLineChartHover(card, canvas, renderer, series, events, t0, t1, vMin, vMax, axes = null, options = {}) {
- const interactionState = getInteractionState(card);
- if (!canvas || !renderer) {
- return;
- }
- if (interactionState._chartHoverCleanup) {
- interactionState._chartHoverCleanup();
- interactionState._chartHoverCleanup = null;
- }
- const resolvedSeries = Array.isArray(series) ? series : [];
- const eventThresholdMs = renderer.cw ? 14 * ((t1 - t0) / renderer.cw) : 0;
- const binaryStates = Array.isArray(options.binaryStates) ? options.binaryStates : [];
- const comparisonSeries = Array.isArray(options.comparisonSeries) ? options.comparisonSeries : [];
- const trendSeries = Array.isArray(options.trendSeries) ? options.trendSeries : [];
- const rateSeries = Array.isArray(options.rateSeries) ? options.rateSeries : [];
- const deltaSeries = Array.isArray(options.deltaSeries) ? options.deltaSeries : [];
- const summarySeries = Array.isArray(options.summarySeries) ? options.summarySeries : [];
- const thresholdSeries = Array.isArray(options.thresholdSeries) ? options.thresholdSeries : [];
- const anomalyRegions = Array.isArray(options.anomalyRegions) ? options.anomalyRegions : [];
- if (!resolvedSeries.length && !binaryStates.length && !comparisonSeries.length && !trendSeries.length && !rateSeries.length && !deltaSeries.length && !summarySeries.length && !thresholdSeries.length && !anomalyRegions.length) {
- return;
- }
- const hoverSurfaceEl = options.hoverSurfaceEl || null;
- const addAnnotationButton = getRoot(card)?.getElementById("chart-add-annotation") || null;
- const resolveHoverAxis = (seriesItem) => seriesItem.axis || axes && axes[0] || { min: vMin, max: vMax };
- const buildHoverValueEntry = (seriesItem, value, axis, extra = {}, entryOpts = {}) => {
- const hasNumericValue = typeof value === "number" && Number.isFinite(value);
- const includePosition = entryOpts.includePosition === true && hasNumericValue;
- return {
- entityId: seriesItem.entityId || "",
- comparisonParentId: seriesItem.comparisonParentId || "",
- relatedEntityId: seriesItem.relatedEntityId || "",
- label: seriesItem.label || seriesItem.entityId || "",
- baseLabel: seriesItem.baseLabel || "",
- windowLabel: seriesItem.windowLabel || "",
- value: hasNumericValue ? value : value ?? null,
- unit: seriesItem.unit || "",
- color: seriesItem.color,
- opacity: Number.isFinite(seriesItem.hoverOpacity) ? seriesItem.hoverOpacity : 1,
- hasValue: hasNumericValue || value != null,
- x: includePosition ? entryOpts.x : void 0,
- y: includePosition ? renderer.yOf(value, axis.min, axis.max) : void 0,
- axisSide: axis.side === "right" ? "right" : "left",
- axisSlot: Number.isFinite(axis.slot) ? axis.slot : 0,
- rawVisible: seriesItem.rawVisible !== false,
- comparisonDerived: seriesItem.comparisonDerived === true,
- showCrosshair: seriesItem.showCrosshair === true,
- ...extra
- };
- };
- const findAnomalyRegions = (clientX, clientY) => {
- const rect = canvas.getBoundingClientRect();
- if (!rect.width || !rect.height) {
- return [];
- }
- const localX = clientX - rect.left;
- const localY = clientY - rect.top;
- const hits = [];
- for (const region of anomalyRegions) {
- const radiusX = Number(region?.radiusX) || 0;
- const radiusY = Number(region?.radiusY) || 0;
- if (radiusX <= 0 || radiusY <= 0) {
- continue;
- }
- const dx = (localX - region.centerX) / radiusX;
- const dy = (localY - region.centerY) / radiusY;
- if (dx * dx + dy * dy <= 1) {
- hits.push(region);
- }
- }
- return hits;
- };
- const buildHoverState = (clientX, clientY) => {
- const rect = canvas.getBoundingClientRect();
- if (!rect.width || !rect.height || !renderer.cw || !renderer.ch) {
- return null;
- }
- const localX = clampChartValue(
- clientX - rect.left,
- renderer.pad.left,
- renderer.pad.left + renderer.cw
- );
- const localY = clampChartValue(
- clientY - rect.top,
- renderer.pad.top,
- renderer.pad.top + renderer.ch
- );
- const ratio = renderer.cw ? (localX - renderer.pad.left) / renderer.cw : 0;
- const rawTimeMs = t0 + ratio * (t1 - t0);
- const timeMs = resolveLineChartHoverTime(
- resolvedSeries,
- rawTimeMs,
- options.hoverSnapMode || "follow_series"
- );
- const x2 = renderer.xOf(timeMs, t0, t1);
- const values = resolvedSeries.map((seriesItem) => {
- const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
- const axis = resolveHoverAxis(seriesItem);
- return buildHoverValueEntry(
- seriesItem,
- value,
- axis,
- {},
- {
- includePosition: value != null,
- x: x2
- }
- );
- });
- const comparisonValues = comparisonSeries.map((seriesItem) => {
- const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
- const axis = resolveHoverAxis(seriesItem);
- return buildHoverValueEntry(
- seriesItem,
- value,
- axis,
- { comparison: true },
- { includePosition: value != null, x: x2 }
- );
- });
- const trendValues = trendSeries.map((seriesItem) => {
- const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
- const axis = resolveHoverAxis(seriesItem);
- return buildHoverValueEntry(
- seriesItem,
- value,
- axis,
- { trend: true },
- { includePosition: value != null, x: x2 }
- );
- });
- const rateValues = rateSeries.map((seriesItem) => {
- const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
- const axis = resolveHoverAxis(seriesItem);
- return buildHoverValueEntry(
- seriesItem,
- value,
- axis,
- { rate: true },
- { includePosition: value != null, x: x2 }
- );
- });
- const deltaValues = deltaSeries.map((seriesItem) => {
- const value = renderer._interpolateValue(seriesItem.pts || [], timeMs);
- const axis = resolveHoverAxis(seriesItem);
- return buildHoverValueEntry(
- seriesItem,
- value,
- axis,
- { delta: true },
- { includePosition: value != null, x: x2 }
- );
- });
- const summaryValues = summarySeries.map((seriesItem) => {
- const axis = resolveHoverAxis(seriesItem);
- const value = Number(seriesItem.value);
- return buildHoverValueEntry(seriesItem, value, axis, {
- summary: true,
- summaryType: seriesItem.summaryType || ""
- });
- });
- const thresholdValues = thresholdSeries.map((seriesItem) => {
- const axis = resolveHoverAxis(seriesItem);
- const value = Number(seriesItem.value);
- return buildHoverValueEntry(seriesItem, value, axis, {
- threshold: true
- });
- });
- const plottedValues = [
- ...values.filter((entry) => entry?.hasValue !== false),
- ...comparisonValues.filter((entry) => entry?.hasValue !== false),
- ...rateValues.filter((entry) => entry?.hasValue !== false),
- ...options.showTrendCrosshairs === true ? trendValues.filter(
- (entry) => entry?.hasValue !== false && entry.showCrosshair === true
- ) : []
- ];
- let rangeStartMs = timeMs;
- let rangeEndMs = timeMs;
- let primary = plottedValues[0] || null;
- if (primary) {
- for (const entry of plottedValues) {
- if (Number.isFinite(entry.y) && Number.isFinite(primary.y) && Math.abs(entry.y - localY) < Math.abs(primary.y - localY)) {
- primary = entry;
- }
- }
- }
- const activePrimarySeries = primary ? resolvedSeries.find(
- (seriesItem) => seriesItem.entityId === primary.entityId
- ) || null : null;
- if (activePrimarySeries?.pts?.length) {
- const pts = activePrimarySeries.pts;
- const pLen = pts.length;
- let lo = 0;
- let hi = pLen - 1;
- let previousIndex = -1;
- if (pts[0][0] <= timeMs) {
- while (lo + 1 < hi) {
- const mid = Math.floor((lo + hi) / 2);
- if (pts[mid][0] <= timeMs) {
- lo = mid;
- } else {
- hi = mid;
- }
- }
- previousIndex = pts[hi][0] <= timeMs ? hi : lo;
- }
- const nextIndex = previousIndex < pLen - 1 ? previousIndex + 1 : -1;
- const previous = previousIndex >= 0 ? pts[previousIndex] : null;
- let next = null;
- if (nextIndex >= 0) {
- next = pts[nextIndex];
- } else if (previousIndex < 0) {
- next = pts[0];
- }
- if (previous && next) {
- const prevPrev = pts[Math.max(0, previousIndex - 1)] || previous;
- const nextNext = pts[Math.min(pLen - 1, nextIndex + 1)] || next;
- rangeStartMs = previous === next ? previous[0] : Math.round((previous[0] + prevPrev[0]) / 2);
- rangeEndMs = previous === next ? next[0] : Math.round((next[0] + nextNext[0]) / 2);
- } else if (previous) {
- rangeStartMs = previous[0];
- rangeEndMs = previous[0];
- } else if (next) {
- rangeStartMs = next[0];
- rangeEndMs = next[0];
- }
- }
- const binaryValues = binaryStates.map((entry) => {
- const activeSpan = (entry.spans || []).find(
- (span) => timeMs >= span.start && timeMs <= span.end
- );
- return {
- entityId: entry.entityId || "",
- label: entry.label || entry.entityId || "",
- value: activeSpan ? entry.onLabel || "on" : entry.offLabel || "off",
- unit: "",
- color: entry.color,
- hasValue: true,
- active: !!activeSpan
- };
- }).filter((entry) => Boolean(entry.label));
- if (!values.length && !binaryValues.length && !trendValues.length && !rateValues.length && !deltaValues.length && !summaryValues.length && !thresholdValues.length && !comparisonValues.length) {
- return null;
- }
- const fallbackY = renderer.pad.top + 12;
- const hoverY = primary ? primary.y : fallbackY;
- const hoveredEvents = [];
- for (const event of events || []) {
- const eventTime = new Date(event.timestamp).getTime();
- if (eventTime < t0 || eventTime > t1) {
- continue;
- }
- const distance = Math.abs(eventTime - timeMs);
- if (distance <= eventThresholdMs) {
- hoveredEvents.push({
- ...event,
- _hoverDistanceMs: distance
- });
- }
- }
- hoveredEvents.sort((left, right) => {
- const distanceDelta = (left._hoverDistanceMs || 0) - (right._hoverDistanceMs || 0);
- if (distanceDelta !== 0) {
- return distanceDelta;
- }
- return new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime();
- });
- const normalizedHoveredEvents = hoveredEvents.map(
- (event) => {
- const { _hoverDistanceMs: _2, ...normalizedEvent } = event;
- return normalizedEvent;
- }
- );
- return {
- x: x2,
- y: hoverY,
- timeMs,
- rangeStartMs,
- rangeEndMs,
- values,
- trendValues,
- rateValues,
- deltaValues: options.showDeltaTooltip === true ? deltaValues : [],
- summaryValues,
- thresholdValues,
- comparisonValues,
- binaryValues,
- primary,
- event: normalizedHoveredEvents[0] || null,
- events: normalizedHoveredEvents,
- emphasizeGuides: options.emphasizeHoverGuides === true,
- showTrendCrosshairs: options.showTrendCrosshairs === true,
- showRateCrosshairs: options.showRateCrosshairs === true,
- hideRawData: options.hideRawData === true
- };
- };
- const showFromPointer = (clientX, clientY) => {
- if (interactionState._chartZoomDragging) {
- return;
- }
- const anomalyRegionsHit = findAnomalyRegions(clientX, clientY);
- const hover = buildHoverState(clientX, clientY);
- if (!hover) {
- interactionState._chartLastHover = null;
- hideLineChartHover(card);
- canvas.style.cursor = "default";
- return;
- }
- hover.anomalyRegions = anomalyRegionsHit;
- interactionState._chartLastHover = hover;
- showLineChartCrosshair(card, renderer, hover);
- if (options.showTooltip !== false || Array.isArray(hover.events) && hover.events.length > 0) {
- showLineChartTooltip(card, hover, clientX, clientY);
- } else {
- hideTooltip(card);
- }
- dispatchLineChartHover(card, hover);
- canvas.style.cursor = anomalyRegionsHit.length > 0 ? "pointer" : "crosshair";
- };
- const hideHover = () => {
- interactionState._chartLastHover = null;
- hideLineChartHover(card);
- canvas.style.cursor = "default";
- };
- let _rafHandle = null;
- let _pendingX = 0;
- let _pendingY = 0;
- const onMouseMove = (ev) => {
- _pendingX = ev.clientX;
- _pendingY = ev.clientY;
- if (_rafHandle !== null) {
- return;
- }
- _rafHandle = requestAnimationFrame(() => {
- _rafHandle = null;
- showFromPointer(_pendingX, _pendingY);
- });
- };
- const onMouseLeave = (ev) => {
- const nextTarget = ev.relatedTarget;
- if (nextTarget instanceof Node && hoverSurfaceEl && hoverSurfaceEl.contains(nextTarget)) {
- return;
- }
- if (nextTarget instanceof Node && addAnnotationButton && addAnnotationButton.contains(nextTarget)) {
- return;
- }
- hideHover();
- };
- const onOverlayMove = (ev) => {
- showFromPointer(ev.clientX, ev.clientY);
- };
- const onOverlayLeave = (ev) => {
- const nextTarget = ev.relatedTarget;
- if (nextTarget instanceof Node && canvas.contains(nextTarget)) {
- return;
- }
- if (nextTarget instanceof Node && addAnnotationButton && addAnnotationButton.contains(nextTarget)) {
- return;
- }
- hideHover();
- };
- const onAddButtonLeave = (ev) => {
- const nextTarget = ev.relatedTarget;
- if (nextTarget instanceof Node && (canvas.contains(nextTarget) || hoverSurfaceEl && hoverSurfaceEl.contains(nextTarget))) {
- return;
- }
- hideHover();
- };
- const onAddButtonClick = (ev) => {
- if (typeof options.onAddAnnotation !== "function" || !interactionState._chartLastHover) {
- return;
- }
- ev.preventDefault();
- ev.stopPropagation();
- options.onAddAnnotation(interactionState._chartLastHover, ev);
- };
- const onContextMenu = (ev) => {
- if (typeof options.onContextMenu !== "function") {
- return;
- }
- const hover = buildHoverState(ev.clientX, ev.clientY);
- if (!hover) {
- return;
- }
- ev.preventDefault();
- interactionState._chartLastHover = hover;
- showLineChartCrosshair(card, renderer, hover);
- showLineChartTooltip(card, hover, ev.clientX, ev.clientY);
- dispatchLineChartHover(card, hover);
- options.onContextMenu(hover, ev);
- };
- const onClick = (ev) => {
- if (typeof options.onAnomalyClick !== "function") {
- return;
- }
- const regions = findAnomalyRegions(ev.clientX, ev.clientY);
- if (!regions.length) {
- return;
- }
- ev.preventDefault();
- ev.stopPropagation();
- options.onAnomalyClick(regions, ev);
- };
- let touchTimer = null;
- const scheduleTouchHide = () => {
- if (touchTimer) {
- window.clearTimeout(touchTimer);
- }
- touchTimer = window.setTimeout(() => hideHover(), 1800);
- };
- const onTouchStart = (ev) => {
- ev.preventDefault();
- const touch = ev.touches[0];
- if (!touch) {
- return;
- }
- showFromPointer(touch.clientX, touch.clientY);
- scheduleTouchHide();
- };
- const onTouchMove = (ev) => {
- ev.preventDefault();
- const touch = ev.touches[0];
- if (!touch) {
- return;
- }
- showFromPointer(touch.clientX, touch.clientY);
- scheduleTouchHide();
- };
- const onTouchEnd = () => scheduleTouchHide();
- canvas.addEventListener("mousemove", onMouseMove);
- canvas.addEventListener("mouseleave", onMouseLeave);
- canvas.addEventListener("click", onClick);
- canvas.addEventListener("contextmenu", onContextMenu);
- canvas.addEventListener("touchstart", onTouchStart, { passive: false });
- canvas.addEventListener("touchmove", onTouchMove, { passive: false });
- canvas.addEventListener("touchend", onTouchEnd);
- canvas.addEventListener("touchcancel", onTouchEnd);
- hoverSurfaceEl?.addEventListener("mousemove", onOverlayMove);
- hoverSurfaceEl?.addEventListener("mouseleave", onOverlayLeave);
- addAnnotationButton?.addEventListener("mouseleave", onAddButtonLeave);
- addAnnotationButton?.addEventListener("click", onAddButtonClick);
- interactionState._chartHoverCleanup = () => {
- canvas.removeEventListener("mousemove", onMouseMove);
- canvas.removeEventListener("mouseleave", onMouseLeave);
- canvas.removeEventListener("click", onClick);
- canvas.removeEventListener("contextmenu", onContextMenu);
- canvas.removeEventListener("touchstart", onTouchStart);
- canvas.removeEventListener("touchmove", onTouchMove);
- canvas.removeEventListener("touchend", onTouchEnd);
- canvas.removeEventListener("touchcancel", onTouchEnd);
- hoverSurfaceEl?.removeEventListener("mousemove", onOverlayMove);
- hoverSurfaceEl?.removeEventListener("mouseleave", onOverlayLeave);
- addAnnotationButton?.removeEventListener("mouseleave", onAddButtonLeave);
- addAnnotationButton?.removeEventListener("click", onAddButtonClick);
- if (_rafHandle !== null) {
- cancelAnimationFrame(_rafHandle);
- _rafHandle = null;
- }
- if (touchTimer) {
- window.clearTimeout(touchTimer);
- touchTimer = null;
- }
- hideHover();
- };
- }
- function attachLineChartRangeZoom(card, canvas, renderer, t0, t1, options = {}) {
- const interactionState = getInteractionState(card);
- if (!canvas || !renderer) {
- return;
- }
- if (interactionState._chartZoomCleanup) {
- interactionState._chartZoomCleanup();
- interactionState._chartZoomCleanup = null;
- }
- const selection = getRoot(card).getElementById(
- "chart-zoom-selection"
- );
- if (!selection) {
- return;
- }
- let pointerId = null;
- let startX = 0;
- let currentX = 0;
- let dragging = false;
- const hideSelection = () => {
- selection.hidden = true;
- selection.classList.remove("visible");
- };
- const clientXToTime = (clientX) => {
- const rect = canvas.getBoundingClientRect();
- const localX = clampChartValue(
- clientX - rect.left,
- renderer.pad.left,
- renderer.pad.left + renderer.cw
- );
- const ratio = renderer.cw ? (localX - renderer.pad.left) / renderer.cw : 0;
- return t0 + ratio * (t1 - t0);
- };
- const inPlotBounds = (clientX, clientY) => {
- const rect = canvas.getBoundingClientRect();
- const localX = clientX - rect.left;
- const localY = clientY - rect.top;
- return localX >= renderer.pad.left && localX <= renderer.pad.left + renderer.cw && localY >= renderer.pad.top && localY <= renderer.pad.top + renderer.ch;
- };
- const renderSelection = () => {
- const left = Math.min(startX, currentX);
- const width = Math.abs(currentX - startX);
- selection.style.left = `${left}px`;
- selection.style.top = `${renderer.pad.top}px`;
- selection.style.width = `${width}px`;
- selection.style.height = `${renderer.ch}px`;
- selection.hidden = false;
- selection.classList.add("visible");
- };
- const emitPreview = () => {
- if (!dragging || Math.abs(currentX - startX) < 8) {
- options.onPreview?.(null);
- return;
- }
- const rectLeft = canvas.getBoundingClientRect().left;
- const startTime = Math.min(
- clientXToTime(rectLeft + startX),
- clientXToTime(rectLeft + currentX)
- );
- const endTime = Math.max(
- clientXToTime(rectLeft + startX),
- clientXToTime(rectLeft + currentX)
- );
- options.onPreview?.({ startTime, endTime });
- };
- const resetDragging = (clearPreview = true) => {
- pointerId = null;
- dragging = false;
- interactionState._chartZoomDragging = false;
- hideSelection();
- if (clearPreview) {
- options.onPreview?.(null);
- }
- };
- const onPointerMove = (ev) => {
- if (pointerId == null || ev.pointerId !== pointerId) {
- return;
- }
- currentX = clampChartValue(
- ev.clientX - canvas.getBoundingClientRect().left,
- renderer.pad.left,
- renderer.pad.left + renderer.cw
- );
- const movedPx = Math.abs(currentX - startX);
- if (!dragging && movedPx < 6) {
- return;
- }
- dragging = true;
- interactionState._chartZoomDragging = true;
- hideLineChartHover(card);
- renderSelection();
- emitPreview();
- ev.preventDefault();
- };
- const finish = (ev) => {
- if (pointerId == null || ev.pointerId !== pointerId) {
- return;
- }
- const didDrag = dragging;
- const endX = currentX;
- window.removeEventListener("pointermove", onPointerMove);
- window.removeEventListener("pointerup", finish);
- window.removeEventListener("pointercancel", finish);
- if (!didDrag || Math.abs(endX - startX) < 8) {
- resetDragging(true);
- return;
- }
- const rectLeft = canvas.getBoundingClientRect().left;
- const startTime = Math.min(
- clientXToTime(rectLeft + startX),
- clientXToTime(rectLeft + endX)
- );
- const endTime = Math.max(
- clientXToTime(rectLeft + startX),
- clientXToTime(rectLeft + endX)
- );
- options.onZoom?.({ startTime, endTime });
- resetDragging(false);
- };
- const onPointerDown = (ev) => {
- if (ev.button !== 0 || !inPlotBounds(ev.clientX, ev.clientY)) {
- return;
- }
- pointerId = ev.pointerId;
- const rect = canvas.getBoundingClientRect();
- startX = clampChartValue(
- ev.clientX - rect.left,
- renderer.pad.left,
- renderer.pad.left + renderer.cw
- );
- currentX = startX;
- dragging = false;
- interactionState._chartZoomDragging = false;
- options.onPreview?.(null);
- window.addEventListener("pointermove", onPointerMove);
- window.addEventListener("pointerup", finish);
- window.addEventListener("pointercancel", finish);
- };
- const onDoubleClick = (ev) => {
- if (!inPlotBounds(ev.clientX, ev.clientY)) {
- return;
- }
- if (!options.onReset) {
- return;
- }
- ev.preventDefault();
- options.onReset();
- };
- canvas.addEventListener("pointerdown", onPointerDown);
- canvas.addEventListener("dblclick", onDoubleClick);
- interactionState._chartZoomCleanup = () => {
- canvas.removeEventListener("pointerdown", onPointerDown);
- canvas.removeEventListener("dblclick", onDoubleClick);
- window.removeEventListener("pointermove", onPointerMove);
- window.removeEventListener("pointerup", finish);
- window.removeEventListener("pointercancel", finish);
- resetDragging();
- };
- }
- const DATA_RANGE_CACHE_TTL_MS = 10 * 60 * 1e3;
- const DATA_RANGE_CACHE_LIVE_EDGE_MS = 5 * 60 * 1e3;
- const dataRangeCache = /* @__PURE__ */ new Map();
- function normalizeCacheIdList(values) {
- return [
- ...new Set(
- (Array.isArray(values) ? values : []).filter(Boolean)
- )
- ].sort();
+ canvas { display: block; }
+ .chart-loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ gap: var(--dp-spacing-sm);
+ min-width: calc(var(--spacing, 8px) * 12);
+ min-height: calc(var(--spacing, 8px) * 5);
+ padding: var(--dp-spacing-sm) var(--dp-spacing-md);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--card-background-color, #fff) 92%, transparent);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+ z-index: 6;
+ pointer-events: none;
+ transform: translate(-50%, -50%);
}
- function shouldUseStableRangeCache(endTime) {
- const endMs = new Date(endTime || 0).getTime();
- if (!Number.isFinite(endMs)) {
- return false;
- }
- return endMs < Date.now() - DATA_RANGE_CACHE_LIVE_EDGE_MS;
+ .chart-loading.active {
+ display: inline-flex;
}
- function getCachedRangePromise(key) {
- const entry = dataRangeCache.get(key);
- if (!entry) {
- return null;
- }
- if (entry.expiresAt <= Date.now()) {
- dataRangeCache.delete(key);
- return null;
- }
- return entry.promise;
- }
- function setCachedRangePromise(key, promise) {
- dataRangeCache.set(key, {
- promise,
- expiresAt: Date.now() + DATA_RANGE_CACHE_TTL_MS
- });
- return promise;
- }
- function withStableRangeCache(key, endTime, loader) {
- if (!shouldUseStableRangeCache(endTime)) {
- return Promise.resolve().then(loader);
- }
- const cached = getCachedRangePromise(key);
- if (cached) {
- return cached;
- }
- const promise = Promise.resolve().then(loader).catch((err) => {
- dataRangeCache.delete(key);
- throw err;
- });
- return setCachedRangePromise(key, promise);
- }
- function clearStableRangeCacheMatching(predicate) {
- if (typeof predicate !== "function") {
- return 0;
- }
- let deletedCount = 0;
- [...dataRangeCache.keys()].forEach((key) => {
- if (predicate(key) === true) {
- dataRangeCache.delete(key);
- deletedCount += 1;
- }
- });
- return deletedCount;
- }
- const MAX_DOWNSAMPLED_HISTORY_RANGE_MS = 90 * 24 * 60 * 60 * 1e3;
- function parseIsoTimeMs(value) {
- const timeMs = Date.parse(value);
- if (!Number.isFinite(timeMs)) {
- return null;
- }
- return timeMs;
+ .chart-loading-spinner {
+ width: calc(var(--spacing, 8px) * 2);
+ height: calc(var(--spacing, 8px) * 2);
+ border-radius: 50%;
+ border: 2px solid color-mix(in srgb, var(--primary-color, #03a9f4) 22%, transparent);
+ border-top-color: var(--primary-color, #03a9f4);
+ animation: chart-spinner 0.9s linear infinite;
}
- function buildDownsampledHistoryChunks(startTime, endTime) {
- const startTimeMs = parseIsoTimeMs(startTime);
- const endTimeMs = parseIsoTimeMs(endTime);
- if (startTimeMs == null || endTimeMs == null || endTimeMs <= startTimeMs || endTimeMs - startTimeMs <= MAX_DOWNSAMPLED_HISTORY_RANGE_MS) {
- return [{ startTime, endTime }];
- }
- const chunks = [];
- let chunkStartMs = startTimeMs;
- while (chunkStartMs < endTimeMs) {
- const chunkEndMs = Math.min(
- endTimeMs,
- chunkStartMs + MAX_DOWNSAMPLED_HISTORY_RANGE_MS
- );
- chunks.push({
- startTime: new Date(chunkStartMs).toISOString(),
- endTime: new Date(chunkEndMs).toISOString()
- });
- if (chunkEndMs >= endTimeMs) {
- break;
- }
- chunkStartMs = chunkEndMs + 1;
- }
- return chunks;
- }
- function fetchDownsampledHistory(hass, entityId, startTime, endTime, interval, aggregate) {
- const cacheKey = JSON.stringify({
- type: "hass_datapoints/history",
- entity_id: entityId,
- start_time: startTime,
- end_time: endTime,
- interval,
- aggregate
- });
- return withStableRangeCache(cacheKey, endTime, async () => {
- const chunks = buildDownsampledHistoryChunks(startTime, endTime);
- const responses = await Promise.all(
- chunks.map(
- async (chunk) => hass.connection.sendMessagePromise({
- type: "hass_datapoints/history",
- entity_id: entityId,
- start_time: chunk.startTime,
- end_time: chunk.endTime,
- interval,
- aggregate
- })
- )
- );
- const mergedPoints = responses.flatMap(
- (result) => result.pts || []
- );
- if (!mergedPoints.length) {
- return [];
- }
- const dedupedPoints = /* @__PURE__ */ new Map();
- for (const point of mergedPoints) {
- if (Array.isArray(point) && point.length > 0) {
- dedupedPoints.set(String(point[0]), point);
- } else {
- dedupedPoints.set(JSON.stringify(point), point);
- }
- }
- return [...dedupedPoints.values()];
- });
- }
- function fetchAnomaliesFromBackend(hass, entityId, startTime, endTime, config) {
- return hass.connection.sendMessagePromise({
- type: "hass_datapoints/anomalies",
- entity_id: entityId,
- start_time: startTime,
- end_time: endTime,
- anomaly_methods: config.anomaly_methods || [],
- anomaly_sensitivity: config.anomaly_sensitivity || "medium",
- anomaly_overlap_mode: config.anomaly_overlap_mode || "all",
- anomaly_rate_window: config.anomaly_rate_window || "1h",
- anomaly_zscore_window: config.anomaly_zscore_window || "24h",
- anomaly_persistence_window: config.anomaly_persistence_window || "1h",
- trend_method: config.trend_method || "rolling_average",
- trend_window: config.trend_window || "24h",
- ...config.anomaly_use_sampled_data !== false && config.sample_interval && config.sample_interval !== "raw" ? {
- sample_interval: config.sample_interval,
- sample_aggregate: config.sample_aggregate || "mean"
- } : {},
- ...config.comparison_entity_id ? {
- comparison_entity_id: config.comparison_entity_id,
- comparison_start_time: config.comparison_start_time,
- comparison_end_time: config.comparison_end_time,
- comparison_time_offset_ms: config.comparison_time_offset_ms || 0
- } : {}
- }).then(
- (result) => result.anomaly_clusters || []
- );
+ .chart-loading::after {
+ content: none;
}
- async function fetchHistoryDuringPeriod(hass, startTime, endTime, entityIds, options = {}) {
- const normalizedEntityIds = normalizeCacheIdList(entityIds);
- const cacheKey = JSON.stringify({
- type: "history/history_during_period",
- start_time: startTime,
- end_time: endTime,
- entity_ids: normalizedEntityIds,
- include_start_time_state: options.include_start_time_state !== false,
- significant_changes_only: !!options.significant_changes_only,
- no_attributes: options.no_attributes !== false
- });
- return withStableRangeCache(
- cacheKey,
- endTime,
- () => hass.connection.sendMessagePromise({
- type: "history/history_during_period",
- start_time: startTime,
- end_time: endTime,
- entity_ids: normalizedEntityIds,
- include_start_time_state: options.include_start_time_state !== false,
- significant_changes_only: !!options.significant_changes_only,
- no_attributes: options.no_attributes !== false
- })
- );
+ .chart-loading-label {
+ color: var(--secondary-text-color);
+ font-size: 0.85rem;
+ font-weight: 500;
}
- const VALID_ANOMALY_METHODS = [
- "trend_residual",
- "rate_of_change",
- "iqr",
- "rolling_zscore",
- "persistence",
- "comparison_window"
- ];
- const VALID_SAMPLE_INTERVALS = [
- "raw",
- "1s",
- "5s",
- "10s",
- "15s",
- "30s",
- "1m",
- "2m",
- "5m",
- "10m",
- "15m",
- "30m",
- "1h",
- "2h",
- "3h",
- "4h",
- "6h",
- "12h",
- "24h"
- ];
- const VALID_SAMPLE_AGGREGATES = [
- "mean",
- "min",
- "max",
- "median",
- "first",
- "last"
- ];
- function normalizeHistorySeriesAnalysis(analysis) {
- const source = analysis && typeof analysis === "object" ? analysis : {};
- return {
- expanded: source.expanded === true,
- show_trend_lines: source.show_trend_lines === true,
- trend_method: source.trend_method === "linear_trend" ? "linear_trend" : "rolling_average",
- trend_window: typeof source.trend_window === "string" && source.trend_window ? source.trend_window : "24h",
- show_trend_crosshairs: source.show_trend_crosshairs !== false,
- show_summary_stats: source.show_summary_stats === true,
- show_summary_stats_shading: source.show_summary_stats_shading === true,
- show_rate_of_change: source.show_rate_of_change === true,
- show_rate_crosshairs: source.show_rate_crosshairs !== false,
- rate_window: typeof source.rate_window === "string" && source.rate_window ? source.rate_window : "1h",
- show_threshold_analysis: source.show_threshold_analysis === true,
- show_threshold_shading: source.show_threshold_shading === true,
- threshold_value: typeof source.threshold_value === "string" || typeof source.threshold_value === "number" ? String(source.threshold_value).trim() : "",
- threshold_direction: source.threshold_direction === "below" ? "below" : "above",
- show_anomalies: source.show_anomalies === true,
- anomaly_methods: (() => {
- if (Array.isArray(source.anomaly_methods)) {
- return source.anomaly_methods.filter(
- (method) => typeof method === "string" && VALID_ANOMALY_METHODS.includes(method)
- );
- }
- const legacy = typeof source.anomaly_method === "string" && VALID_ANOMALY_METHODS.includes(source.anomaly_method) ? source.anomaly_method : null;
- return legacy ? [legacy] : [];
- })(),
- anomaly_overlap_mode: source.anomaly_overlap_mode === "only" ? "only" : "all",
- anomaly_sensitivity: typeof source.anomaly_sensitivity === "string" && source.anomaly_sensitivity ? source.anomaly_sensitivity : "medium",
- anomaly_rate_window: typeof source.anomaly_rate_window === "string" && source.anomaly_rate_window ? source.anomaly_rate_window : "1h",
- anomaly_zscore_window: typeof source.anomaly_zscore_window === "string" && source.anomaly_zscore_window ? source.anomaly_zscore_window : "24h",
- anomaly_persistence_window: typeof source.anomaly_persistence_window === "string" && source.anomaly_persistence_window ? source.anomaly_persistence_window : "1h",
- anomaly_comparison_window_id: typeof source.anomaly_comparison_window_id === "string" && source.anomaly_comparison_window_id ? source.anomaly_comparison_window_id : null,
- show_delta_analysis: source.show_delta_analysis === true,
- show_delta_tooltip: source.show_delta_tooltip !== false,
- show_delta_lines: source.show_delta_lines === true,
- hide_source_series: source.hide_source_series === true,
- sample_interval: typeof source.sample_interval === "string" && VALID_SAMPLE_INTERVALS.includes(source.sample_interval) ? source.sample_interval : "1m",
- sample_aggregate: typeof source.sample_aggregate === "string" && VALID_SAMPLE_AGGREGATES.includes(source.sample_aggregate) ? source.sample_aggregate : "mean",
- stepped_series: source.stepped_series === true,
- anomaly_use_sampled_data: source.anomaly_use_sampled_data !== false
- };
- }
- function normalizeHistorySeriesRows(rows) {
- if (!Array.isArray(rows)) {
- return [];
- }
- const seen = /* @__PURE__ */ new Set();
- const normalized = [];
- rows.forEach((row, index) => {
- const entityId = typeof row?.entity_id === "string" ? row.entity_id.trim() : "";
- if (!entityId || seen.has(entityId)) {
- return;
- }
- seen.add(entityId);
- normalized.push({
- entity_id: entityId,
- color: typeof row?.color === "string" && /^#[0-9a-f]{6}$/i.test(row.color) ? row.color : COLORS[index % COLORS.length],
- visible: row?.visible !== false,
- analysis: normalizeHistorySeriesAnalysis(row?.analysis)
- });
- });
- return normalized;
- }
- function buildHistorySeriesRows(entityIds, previousRows = []) {
- const normalizedPrevious = normalizeHistorySeriesRows(previousRows);
- const previousMap = new Map(
- normalizedPrevious.map((row) => [row.entity_id, row])
- );
- const intervals = normalizedPrevious.map(
- (row) => row.analysis.sample_interval
- );
- const allSame = intervals.length > 0 && intervals.every((interval) => interval === intervals[0]);
- const inheritedSampleSettings = allSame ? {
- sample_interval: intervals[0],
- sample_aggregate: normalizedPrevious[0].analysis.sample_aggregate
- } : null;
- return normalizeEntityIds(entityIds).map((entityId, index) => {
- const existing = previousMap.get(entityId);
- if (existing) {
- return existing;
- }
- return {
- entity_id: entityId,
- color: COLORS[index % COLORS.length],
- visible: true,
- analysis: normalizeHistorySeriesAnalysis(inheritedSampleSettings)
- };
- });
- }
- function slugifySeriesName(value) {
- return String(value || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
- }
- function parseSeriesColorsParam(value) {
- if (!value || typeof value !== "string") {
- return {};
- }
- return value.split(",").reduce((acc, entry) => {
- const [rawKey, rawColor] = entry.split(":");
- const key = decodeURIComponent(rawKey || "").trim();
- const color = String(rawColor || "").trim();
- if (!key || !/^#[0-9a-f]{6}$/i.test(color)) {
- return acc;
- }
- acc[key] = color;
- return acc;
- }, {});
- }
- const jsContent$1 = '(function() {\n "use strict";\n const HOUR_MS = 60 * 60 * 1e3;\n function getTrendWindowMs(value) {\n const windows = {\n "1h": 60 * 60 * 1e3,\n "6h": 6 * 60 * 60 * 1e3,\n "24h": 24 * 60 * 60 * 1e3,\n "7d": 7 * 24 * 60 * 60 * 1e3,\n "14d": 14 * 24 * 60 * 60 * 1e3,\n "21d": 21 * 24 * 60 * 60 * 1e3,\n "28d": 28 * 24 * 60 * 60 * 1e3\n };\n return windows[value] || windows["24h"];\n }\n function buildRollingAverageTrend(points, windowMs) {\n if (!Array.isArray(points) || points.length < 2 || !Number.isFinite(windowMs) || windowMs <= 0) {\n return [];\n }\n const trendPoints = [];\n let windowStartIndex = 0;\n let windowSum = 0;\n for (let index = 0; index < points.length; index += 1) {\n const [time, value] = points[index];\n windowSum += value;\n while (windowStartIndex < index && time - points[windowStartIndex][0] > windowMs) {\n windowSum -= points[windowStartIndex][1];\n windowStartIndex += 1;\n }\n const count = index - windowStartIndex + 1;\n if (count > 0) {\n trendPoints.push([time, windowSum / count]);\n }\n }\n return trendPoints;\n }\n function buildLinearTrend(points) {\n if (!Array.isArray(points) || points.length < 2) {\n return [];\n }\n const origin = points[0][0];\n let sumX = 0;\n let sumY = 0;\n let sumXX = 0;\n let sumXY = 0;\n for (const [time, value] of points) {\n const x = (time - origin) / HOUR_MS;\n sumX += x;\n sumY += value;\n sumXX += x * x;\n sumXY += x * value;\n }\n const count = points.length;\n const denominator = count * sumXX - sumX * sumX;\n if (!Number.isFinite(denominator) || Math.abs(denominator) < 1e-9) {\n return [];\n }\n const slope = (count * sumXY - sumX * sumY) / denominator;\n const intercept = (sumY - slope * sumX) / count;\n const firstTime = points[0][0];\n const lastTime = points[points.length - 1][0];\n const firstX = (firstTime - origin) / HOUR_MS;\n const lastX = (lastTime - origin) / HOUR_MS;\n return [\n [firstTime, intercept + slope * firstX],\n [lastTime, intercept + slope * lastX]\n ];\n }\n function buildTrendPoints(points, method, trendWindow) {\n if (!Array.isArray(points) || points.length < 2) {\n return [];\n }\n if (method === "linear_trend") {\n return buildLinearTrend(points);\n }\n return buildRollingAverageTrend(points, getTrendWindowMs(trendWindow));\n }\n function normalizeSeriesAnalysis(analysis) {\n const source = analysis && typeof analysis === "object" ? analysis : {};\n return {\n show_trend_lines: source.show_trend_lines === true,\n trend_method: source.trend_method === "linear_trend" ? "linear_trend" : "rolling_average",\n trend_window: typeof source.trend_window === "string" && source.trend_window ? source.trend_window : "24h",\n show_summary_stats: source.show_summary_stats === true,\n show_rate_of_change: source.show_rate_of_change === true,\n rate_window: typeof source.rate_window === "string" && source.rate_window ? source.rate_window : "1h",\n show_delta_analysis: source.show_delta_analysis === true\n };\n }\n function interpolateSeriesValue(points, timeMs) {\n if (!Array.isArray(points) || points.length === 0) {\n return null;\n }\n if (timeMs < points[0][0] || timeMs > points[points.length - 1][0]) {\n return null;\n }\n if (timeMs === points[0][0]) {\n return points[0][1];\n }\n if (timeMs === points[points.length - 1][0]) {\n return points[points.length - 1][1];\n }\n for (let index = 0; index < points.length - 1; index += 1) {\n const [startTime, startValue] = points[index];\n const [endTime, endValue] = points[index + 1];\n if (timeMs >= startTime && timeMs <= endTime) {\n const fraction = (timeMs - startTime) / (endTime - startTime);\n return startValue + (endValue - startValue) * fraction;\n }\n }\n return null;\n }\n function buildRateOfChangePoints(points, rateWindow) {\n if (!Array.isArray(points) || points.length < 2) {\n return [];\n }\n const ratePoints = [];\n for (let index = 1; index < points.length; index += 1) {\n const [timeMs, value] = points[index];\n let comparisonPoint = null;\n if (rateWindow === "point_to_point") {\n comparisonPoint = points[index - 1];\n } else {\n const windowMs = getTrendWindowMs(rateWindow);\n if (!Number.isFinite(windowMs) || windowMs <= 0) {\n continue;\n }\n for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) {\n const candidatePoint = points[candidateIndex];\n if (timeMs - candidatePoint[0] >= windowMs) {\n comparisonPoint = candidatePoint;\n break;\n }\n }\n if (!comparisonPoint) {\n comparisonPoint = points[0];\n }\n }\n if (!Array.isArray(comparisonPoint) || comparisonPoint.length < 2) {\n continue;\n }\n const deltaMs = timeMs - comparisonPoint[0];\n if (!Number.isFinite(deltaMs) || deltaMs <= 0) {\n continue;\n }\n const deltaHours = deltaMs / HOUR_MS;\n if (!Number.isFinite(deltaHours) || deltaHours <= 0) {\n continue;\n }\n const rateValue = (value - comparisonPoint[1]) / deltaHours;\n if (!Number.isFinite(rateValue)) {\n continue;\n }\n ratePoints.push([timeMs, rateValue]);\n }\n return ratePoints;\n }\n function buildDeltaPoints(sourcePoints, comparisonPoints) {\n if (!Array.isArray(sourcePoints) || sourcePoints.length < 2 || !Array.isArray(comparisonPoints) || comparisonPoints.length < 2) {\n return [];\n }\n const deltaPoints = [];\n for (const [timeMs, value] of sourcePoints) {\n const comparisonValue = interpolateSeriesValue(comparisonPoints, timeMs);\n if (comparisonValue == null) {\n continue;\n }\n deltaPoints.push([timeMs, value - comparisonValue]);\n }\n return deltaPoints;\n }\n function buildSummaryStats(points) {\n if (!Array.isArray(points) || points.length === 0) {\n return null;\n }\n let min = Infinity;\n let max = -Infinity;\n let sum = 0;\n let count = 0;\n for (const point of points) {\n const value = Number(point?.[1]);\n if (!Number.isFinite(value)) {\n continue;\n }\n if (value < min) {\n min = value;\n }\n if (value > max) {\n max = value;\n }\n sum += value;\n count += 1;\n }\n if (!Number.isFinite(min) || !Number.isFinite(max) || count === 0) {\n return null;\n }\n return {\n min,\n max,\n mean: sum / count\n };\n }\n function computeHistoryAnalysis(payload) {\n const series = (Array.isArray(payload?.series) ? payload.series : []).map(\n (seriesItem) => ({\n ...seriesItem,\n analysis: normalizeSeriesAnalysis(seriesItem?.analysis)\n })\n );\n const comparisonSeries = new Map(\n (Array.isArray(payload?.comparisonSeries) ? payload.comparisonSeries : []).filter((entry) => entry?.entityId).map((entry) => [entry.entityId, entry])\n );\n const result = {\n trendSeries: [],\n rateSeries: [],\n deltaSeries: [],\n summaryStats: [],\n anomalySeries: [],\n comparisonWindowResults: {}\n };\n for (const seriesItem of series) {\n const points = Array.isArray(seriesItem?.pts) ? seriesItem.pts : [];\n const analysis = normalizeSeriesAnalysis(seriesItem?.analysis);\n if (points.length < 2) {\n continue;\n }\n if (analysis.show_trend_lines === true) {\n const trendPoints = buildTrendPoints(\n points,\n analysis.trend_method,\n analysis.trend_window\n );\n if (trendPoints.length >= 2) {\n result.trendSeries.push({\n entityId: seriesItem.entityId,\n pts: trendPoints\n });\n }\n }\n if (analysis.show_rate_of_change === true) {\n const ratePoints = buildRateOfChangePoints(points, analysis.rate_window);\n if (ratePoints.length >= 2) {\n result.rateSeries.push({\n entityId: seriesItem.entityId,\n pts: ratePoints\n });\n }\n }\n if (analysis.show_summary_stats === true) {\n const summaryStats = buildSummaryStats(points);\n if (summaryStats) {\n result.summaryStats.push({\n entityId: seriesItem.entityId,\n ...summaryStats\n });\n }\n }\n if (analysis.show_delta_analysis === true && payload?.hasSelectedComparisonWindow === true) {\n const comparisonEntry = comparisonSeries.get(seriesItem.entityId);\n const comparisonPoints = comparisonEntry?.pts ?? [];\n if (comparisonPoints.length >= 2) {\n const deltaPoints = buildDeltaPoints(points, comparisonPoints);\n if (deltaPoints.length >= 2) {\n result.deltaSeries.push({\n entityId: seriesItem.entityId,\n pts: deltaPoints\n });\n }\n }\n }\n }\n const seriesAnalysisConfigs = typeof payload?.seriesAnalysisConfigs === "object" && payload.seriesAnalysisConfigs !== null ? payload.seriesAnalysisConfigs : {};\n const allComparisonWindowsData = typeof payload?.allComparisonWindowsData === "object" && payload.allComparisonWindowsData !== null ? payload.allComparisonWindowsData : {};\n for (const [windowId, entityPtsMap] of Object.entries(\n allComparisonWindowsData\n )) {\n result.comparisonWindowResults[windowId] = {};\n for (const [entityId, pts] of Object.entries(entityPtsMap)) {\n const winAnalysis = normalizeSeriesAnalysis(\n seriesAnalysisConfigs[entityId]\n );\n result.comparisonWindowResults[windowId][entityId] = {\n trendPts: winAnalysis.show_trend_lines && pts.length >= 2 ? buildTrendPoints(\n pts,\n winAnalysis.trend_method,\n winAnalysis.trend_window\n ) : [],\n ratePts: winAnalysis.show_rate_of_change && pts.length >= 2 ? buildRateOfChangePoints(pts, winAnalysis.rate_window) : [],\n summaryStats: winAnalysis.show_summary_stats ? buildSummaryStats(pts) : null\n };\n }\n }\n return result;\n }\n const workerScope = globalThis;\n workerScope.onmessage = (event) => {\n const { id, payload } = event.data || {};\n try {\n const result = computeHistoryAnalysis(payload);\n workerScope.postMessage({ id, result });\n } catch (error) {\n workerScope.postMessage({\n id,\n error: error instanceof Error ? error.message : String(error)\n });\n }\n };\n})();\n';
- const blob$1 = typeof self !== "undefined" && self.Blob && new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);", jsContent$1], { type: "text/javascript;charset=utf-8" });
- function WorkerWrapper$1(options) {
- let objURL;
- try {
- objURL = blob$1 && (self.URL || self.webkitURL).createObjectURL(blob$1);
- if (!objURL) throw "";
- const worker = new Worker(objURL, {
- name: options?.name
- });
- worker.addEventListener("error", () => {
- (self.URL || self.webkitURL).revokeObjectURL(objURL);
- });
- return worker;
- } catch (e2) {
- return new Worker(
- "data:text/javascript;charset=utf-8," + encodeURIComponent(jsContent$1),
- {
- name: options?.name
- }
- );
+ @keyframes chart-spinner {
+ to {
+ transform: rotate(360deg);
}
}
- let workerInstance$1 = null;
- let requestId$1 = 0;
- const pending$1 = /* @__PURE__ */ new Map();
- function getHistoryAnalysisWorker() {
- if (workerInstance$1) {
- return workerInstance$1;
- }
- workerInstance$1 = new WorkerWrapper$1();
- workerInstance$1.addEventListener(
- "message",
- (event) => {
- const { id, result, error } = event.data || {};
- const handlers = pending$1.get(id || -1);
- if (!handlers) {
- return;
- }
- pending$1.delete(id || -1);
- if (error) {
- handlers.reject(new Error(error));
- return;
- }
- handlers.resolve(result);
- }
- );
- workerInstance$1.addEventListener("error", (error) => {
- pending$1.forEach((handlers) => {
- handlers.reject(error);
- });
- pending$1.clear();
- workerInstance$1 = null;
- });
- return workerInstance$1;
- }
- function terminateHistoryAnalysisWorker() {
- if (pending$1.size > 0) {
- pending$1.forEach(({ reject }) => {
- reject(new Error("Aborted: superseded by newer analysis"));
- });
- pending$1.clear();
- }
- if (workerInstance$1) {
- workerInstance$1.terminate();
- workerInstance$1 = null;
- }
+ .chart-message {
+ position: absolute;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: calc(var(--spacing, 8px) * 5) var(--dp-spacing-lg);
+ text-align: center;
+ color: var(--secondary-text-color);
+ font-size: 0.95rem;
+ pointer-events: none;
+ z-index: 2;
}
- function computeHistoryAnalysisInWorker(payload) {
- const worker = getHistoryAnalysisWorker();
- return new Promise((resolve, reject) => {
- const id = ++requestId$1;
- pending$1.set(id, { resolve, reject });
- worker.postMessage({ id, payload });
- });
- }
- const jsContent = '(function() {\n "use strict";\n function downsamplePts(pts, intervalMs, aggregate) {\n if (!pts.length || intervalMs <= 0) {\n return pts;\n }\n const buckets = /* @__PURE__ */ new Map();\n const bucketRepTime = /* @__PURE__ */ new Map();\n for (const [time, value] of pts) {\n const idx = Math.floor(time / intervalMs);\n if (!buckets.has(idx)) {\n buckets.set(idx, []);\n bucketRepTime.set(idx, time);\n }\n buckets.get(idx)?.push(value);\n }\n const result = [];\n for (const idx of [...buckets.keys()].sort((a, b) => a - b)) {\n const values = buckets.get(idx) || [];\n const repTime = bucketRepTime.get(idx) || 0;\n let agg;\n if (aggregate === "min") {\n agg = Math.min(...values);\n } else if (aggregate === "max") {\n agg = Math.max(...values);\n } else if (aggregate === "median") {\n const sorted = [...values].sort((a, b) => a - b);\n const mid = Math.floor(sorted.length / 2);\n agg = sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;\n } else if (aggregate === "first") {\n agg = values[0];\n } else if (aggregate === "last") {\n agg = values[values.length - 1];\n } else {\n agg = values.reduce((sum, current) => sum + current, 0) / values.length;\n }\n result.push([repTime, agg]);\n }\n return result;\n }\n self.onmessage = ({ data }) => {\n const { id, type, payload } = data || {};\n try {\n let result;\n if (type === "downsample") {\n result = downsamplePts(\n payload.pts,\n payload.intervalMs,\n payload.aggregate\n );\n } else {\n throw new Error(`Unknown message type: ${type}`);\n }\n self.postMessage({ id, result });\n } catch (err) {\n self.postMessage({\n id,\n error: String(err)\n });\n }\n };\n})();\n';
- const blob = typeof self !== "undefined" && self.Blob && new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);", jsContent], { type: "text/javascript;charset=utf-8" });
- function WorkerWrapper(options) {
- let objURL;
- try {
- objURL = blob && (self.URL || self.webkitURL).createObjectURL(blob);
- if (!objURL) throw "";
- const worker = new Worker(objURL, {
- name: options?.name
- });
- worker.addEventListener("error", () => {
- (self.URL || self.webkitURL).revokeObjectURL(objURL);
- });
- return worker;
- } catch (e2) {
- return new Worker(
- "data:text/javascript;charset=utf-8," + encodeURIComponent(jsContent),
- {
- name: options?.name
- }
- );
- }
+ .chart-message.visible {
+ display: flex;
}
- let workerInstance;
- let requestId = 0;
- const pending = /* @__PURE__ */ new Map();
- function getChartDataWorker() {
- if (workerInstance !== void 0) {
- return workerInstance;
- }
- try {
- workerInstance = new WorkerWrapper();
- workerInstance.addEventListener(
- "message",
- (event) => {
- const { id, result, error } = event.data || {};
- const handlers = pending.get(id || -1);
- if (!handlers) {
- return;
- }
- pending.delete(id || -1);
- if (error) {
- handlers.reject(new Error(error));
- } else {
- handlers.resolve(result || []);
- }
- }
- );
- workerInstance.addEventListener("error", (err) => {
- pending.forEach(({ reject }) => {
- reject(err);
- });
- pending.clear();
- workerInstance = null;
- });
- } catch {
- workerInstance = null;
- }
- return workerInstance;
+ .chart-crosshair {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
}
- function downsampleInWorker(pts, intervalMs, aggregate) {
- if (pts.length === 0) {
- return Promise.resolve([]);
- }
- const worker = getChartDataWorker();
- if (!worker) {
- return Promise.reject(new Error("Worker not available"));
- }
- return new Promise((resolve, reject) => {
- const id = ++requestId;
- pending.set(id, { resolve, reject });
- worker.postMessage({
- id,
- type: "downsample",
- payload: { pts, intervalMs, aggregate }
- });
- });
- }
- const HOUR_MS$1 = 60 * 60 * 1e3;
- function getTrendWindowMs(value) {
- const windows = {
- "1h": HOUR_MS$1,
- "6h": 6 * HOUR_MS$1,
- "24h": 24 * HOUR_MS$1,
- "7d": 7 * 24 * HOUR_MS$1,
- "14d": 14 * 24 * HOUR_MS$1,
- "21d": 21 * 24 * HOUR_MS$1,
- "28d": 28 * 24 * HOUR_MS$1
- };
- return windows[value] ?? windows["24h"];
- }
- function buildRollingAverageTrend(points, windowMs) {
- if (!Array.isArray(points) || points.length < 2 || !Number.isFinite(windowMs) || windowMs <= 0) {
- return [];
- }
- const trendPoints = [];
- let windowStartIndex = 0;
- let windowSum = 0;
- for (let index = 0; index < points.length; index += 1) {
- const [time, value] = points[index];
- windowSum += value;
- while (windowStartIndex < index && time - points[windowStartIndex][0] > windowMs) {
- windowSum -= points[windowStartIndex][1];
- windowStartIndex += 1;
- }
- const count = index - windowStartIndex + 1;
- if (count > 0) {
- trendPoints.push([time, windowSum / count]);
- }
- }
- return trendPoints;
+ .chart-crosshair[hidden] {
+ display: none;
}
- function buildLinearTrend(points) {
- if (!Array.isArray(points) || points.length < 2) {
- return [];
- }
- const origin = points[0][0];
- let sumX = 0;
- let sumY = 0;
- let sumXX = 0;
- let sumXY = 0;
- for (const [time, value] of points) {
- const x2 = (time - origin) / (60 * 60 * 1e3);
- sumX += x2;
- sumY += value;
- sumXX += x2 * x2;
- sumXY += x2 * value;
- }
- const count = points.length;
- const denominator = count * sumXX - sumX * sumX;
- if (!Number.isFinite(denominator) || Math.abs(denominator) < 1e-9) {
- return [];
- }
- const slope = (count * sumXY - sumX * sumY) / denominator;
- const intercept = (sumY - slope * sumX) / count;
- const firstTime = points[0][0];
- const lastTime = points[points.length - 1][0];
- const firstX = (firstTime - origin) / (60 * 60 * 1e3);
- const lastX = (lastTime - origin) / (60 * 60 * 1e3);
- return [
- [firstTime, intercept + slope * firstX],
- [lastTime, intercept + slope * lastX]
- ];
- }
- function interpolateSeriesValue(points, timeMs) {
- if (!Array.isArray(points) || !points.length) {
- return null;
- }
- if (timeMs < points[0][0] || timeMs > points[points.length - 1][0]) {
- return null;
- }
- if (timeMs === points[0][0]) {
- return points[0][1];
- }
- if (timeMs === points[points.length - 1][0]) {
- return points[points.length - 1][1];
- }
- for (let index = 0; index < points.length - 1; index += 1) {
- const [startTime, startValue] = points[index];
- const [endTime, endValue] = points[index + 1];
- if (timeMs >= startTime && timeMs <= endTime) {
- const fraction = (timeMs - startTime) / (endTime - startTime);
- return startValue + fraction * (endValue - startValue);
- }
- }
- return null;
+ .crosshair-line {
+ position: absolute;
+ background: color-mix(in srgb, var(--primary-text-color, #111) 24%, transparent);
}
- function buildRateOfChangePoints(points, rateWindow = "1h") {
- if (!Array.isArray(points) || points.length < 2) {
- return [];
- }
- const ratePoints = [];
- for (let index = 1; index < points.length; index += 1) {
- const [timeMs, value] = points[index];
- let comparisonPoint = null;
- if (rateWindow === "point_to_point") {
- comparisonPoint = points[index - 1];
- } else {
- const windowMs = getTrendWindowMs(rateWindow);
- if (!Number.isFinite(windowMs) || windowMs <= 0) {
- continue;
- }
- for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) {
- const candidatePoint = points[candidateIndex];
- if (timeMs - candidatePoint[0] >= windowMs) {
- comparisonPoint = candidatePoint;
- break;
- }
- }
- if (!comparisonPoint) {
- comparisonPoint = points[0];
- }
- }
- if (!Array.isArray(comparisonPoint) || comparisonPoint.length < 2) {
- continue;
- }
- const deltaMs = timeMs - comparisonPoint[0];
- if (!Number.isFinite(deltaMs) || deltaMs <= 0) {
- continue;
- }
- const deltaHours = deltaMs / (60 * 60 * 1e3);
- if (!Number.isFinite(deltaHours) || deltaHours <= 0) {
- continue;
- }
- const rateValue = (value - comparisonPoint[1]) / deltaHours;
- if (!Number.isFinite(rateValue)) {
- continue;
- }
- ratePoints.push([timeMs, rateValue]);
- }
- return ratePoints;
+ .crosshair-line.vertical {
+ width: 1px;
+ transform: translateX(-50%);
}
- function buildDeltaPoints(sourcePoints, comparisonPoints) {
- if (!Array.isArray(sourcePoints) || sourcePoints.length < 2 || !Array.isArray(comparisonPoints) || comparisonPoints.length < 2) {
- return [];
- }
- const deltaPoints = [];
- for (const [timeMs, value] of sourcePoints) {
- const comparisonValue = interpolateSeriesValue(comparisonPoints, timeMs);
- if (comparisonValue == null) {
- continue;
- }
- deltaPoints.push([timeMs, value - comparisonValue]);
- }
- return deltaPoints;
+ .crosshair-line.horizontal {
+ height: 1px;
+ transform: translateY(-50%);
}
- function buildSummaryStats(points) {
- if (!Array.isArray(points) || !points.length) {
- return null;
- }
- let min = Infinity;
- let max = -Infinity;
- let sum = 0;
- let count = 0;
- for (const point of points) {
- const value = Number(point?.[1]);
- if (!Number.isFinite(value)) {
- continue;
- }
- if (value < min) {
- min = value;
- }
- if (value > max) {
- max = value;
- }
- sum += value;
- count += 1;
- }
- if (!Number.isFinite(min) || !Number.isFinite(max) || count === 0) {
- return null;
- }
- return {
- min,
- max,
- mean: sum / count
- };
- }
- function parseDateValue(value) {
- if (!value) {
- return null;
- }
- if (value instanceof Date) {
- return Number.isNaN(value.getTime()) ? null : value;
- }
- const parsed = new Date(value);
- return Number.isNaN(parsed.getTime()) ? null : parsed;
- }
- function createChartZoomRange(startValue, endValue) {
- const startDate = parseDateValue(startValue);
- const endDate = parseDateValue(endValue);
- const start = startDate?.getTime();
- const end = endDate?.getTime();
- if (typeof start === "number" && Number.isFinite(start) && typeof end === "number" && Number.isFinite(end) && start < end) {
- return { start, end };
- }
- return null;
- }
- function makeDateWindowId(label, existingIds = /* @__PURE__ */ new Set()) {
- const base = slugifySeriesName(label) || "date-window";
- let candidate = base;
- let suffix = 2;
- while (existingIds.has(candidate)) {
- candidate = `${base}-${suffix}`;
- suffix += 1;
- }
- return candidate;
+ .crosshair-line.horizontal.series {
+ left: 0;
+ width: 100%;
}
- function normalizeDateWindows(windows) {
- if (!Array.isArray(windows)) {
- return [];
- }
- const seen = /* @__PURE__ */ new Set();
- const normalized = [];
- windows.forEach((window2, index) => {
- const label = String(window2?.label || window2?.name || "").trim();
- const start = parseDateValue(
- window2?.start_time || window2?.start
- );
- const end = parseDateValue(
- window2?.end_time || window2?.end
- );
- if (!label || !start || !end || start >= end) {
- return;
- }
- const id = String(window2?.id || "").trim() || makeDateWindowId(`${label}-${index + 1}`, seen);
- if (seen.has(id)) {
- return;
- }
- seen.add(id);
- normalized.push({
- id,
- label,
- start_time: start.toISOString(),
- end_time: end.toISOString()
- });
- });
- return normalized;
- }
- function parseDateWindowsParam(value) {
- if (!value || typeof value !== "string") {
- return [];
- }
- return normalizeDateWindows(
- value.split("|").map((entry) => {
- const [rawId, rawLabel, rawStart, rawEnd] = String(entry).split("~");
- return {
- id: decodeURIComponent(rawId || ""),
- label: decodeURIComponent(rawLabel || ""),
- start_time: decodeURIComponent(rawStart || ""),
- end_time: decodeURIComponent(rawEnd || "")
- };
- })
- );
+ .crosshair-line.horizontal.series.subtle {
+ background: currentColor;
+ opacity: 0.22;
}
- function serializeDateWindowsParam(windows) {
- const normalized = normalizeDateWindows(windows);
- if (!normalized.length) {
- return "";
- }
- return normalized.map(
- (window2) => [
- encodeURIComponent(window2.id),
- encodeURIComponent(window2.label ?? ""),
- encodeURIComponent(window2.start_time),
- encodeURIComponent(window2.end_time)
- ].join("~")
- ).join("|");
- }
- function parseHistoryPageStateParam(value) {
- if (!value || typeof value !== "string") {
- return null;
- }
- try {
- const parsed = JSON.parse(value);
- return parsed && typeof parsed === "object" ? parsed : null;
- } catch {
- return null;
- }
+ .crosshair-line.horizontal.series.emphasized {
+ height: 0;
+ background: transparent;
+ border-top: 1px dashed currentColor;
+ opacity: 0.9;
}
- function serializeHistoryPageStateParam(state) {
- if (!state || typeof state !== "object") {
- return "";
- }
- try {
- return JSON.stringify(state);
- } catch {
- return "";
- }
+ .crosshair-points {
+ position: absolute;
+ inset: 0;
}
- function buildDataPointsHistoryPath(target = {}, options = {}) {
- const normalizedTarget = {
- entity_id: [...new Set((target.entity_id || []).filter(Boolean))],
- device_id: [...new Set((target.device_id || []).filter(Boolean))],
- area_id: [...new Set((target.area_id || []).filter(Boolean))],
- label_id: [...new Set((target.label_id || []).filter(Boolean))]
- };
- const params = new URLSearchParams();
- if (normalizedTarget.entity_id.length) {
- params.set("entity_id", normalizedTarget.entity_id.join(","));
- }
- if (normalizedTarget.device_id.length) {
- params.set("device_id", normalizedTarget.device_id.join(","));
- }
- if (normalizedTarget.area_id.length) {
- params.set("area_id", normalizedTarget.area_id.join(","));
- }
- if (normalizedTarget.label_id.length) {
- params.set("label_id", normalizedTarget.label_id.join(","));
- }
- if (options.datapoint_scope === "all") {
- params.set("datapoints_scope", "all");
- }
- const start = options.start_time ? new Date(options.start_time) : null;
- const end = options.end_time ? new Date(options.end_time) : null;
- if (start && end && Number.isFinite(start.getTime()) && Number.isFinite(end.getTime()) && start < end) {
- params.set("start_time", start.toISOString());
- params.set("end_time", end.toISOString());
- params.set(
- "hours_to_show",
- String(
- Math.max(1, Math.round((end.getTime() - start.getTime()) / 36e5))
- )
- );
- }
- const zoomStart = options.zoom_start_time ? new Date(options.zoom_start_time) : null;
- const zoomEnd = options.zoom_end_time ? new Date(options.zoom_end_time) : null;
- if (zoomStart && zoomEnd && Number.isFinite(zoomStart.getTime()) && Number.isFinite(zoomEnd.getTime()) && zoomStart < zoomEnd) {
- params.set("zoom_start_time", zoomStart.toISOString());
- params.set("zoom_end_time", zoomEnd.toISOString());
- }
- const pageStateParam = serializeHistoryPageStateParam(options.page_state);
- if (pageStateParam) {
- params.set("page_state", pageStateParam);
- }
- return `/${PANEL_URL_PATH}?${params.toString()}`;
- }
- function navigateToDataPointsHistory(_card, target = {}, options = {}) {
- const path = buildDataPointsHistoryPath(target, options);
- if (window.history && window.history.pushState) {
- window.history.pushState(null, "", path);
- window.dispatchEvent(new Event("location-changed"));
- return;
- }
- window.location.assign(path);
+ .crosshair-point {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ border: 2px solid var(--card-background-color, #fff);
+ box-shadow: 0 2px 6px rgba(0,0,0,0.18);
+ transform: translate(-50%, -50%);
}
- const styles$S = `
- hass-datapoints-history-chart {
- position: relative;
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- --dp-spacing-xs: calc(var(--spacing, 8px) * 0.5);
- --dp-spacing-sm: var(--spacing, 8px);
- --dp-spacing-md: calc(var(--spacing, 8px) * 1.5);
- --dp-spacing-lg: calc(var(--spacing, 8px) * 2);
- --dp-spacing-xl: calc(var(--spacing, 8px) * 2.5);
- --ha-tooltip-background-color: color-mix(in srgb, #0f1218 96%, transparent);
- --ha-tooltip-text-color: rgba(255, 255, 255, 0.96);
- --ha-tooltip-padding: calc(var(--dp-spacing-sm) + 2px) calc(var(--dp-spacing-md) + 2px);
- --ha-tooltip-border-radius: 10px;
- --ha-tooltip-arrow-size: 10px;
- --ha-tooltip-font-size: 0.86rem;
- --ha-tooltip-line-height: 1.1;
- }
- ha-card { padding: 0; overflow: visible; height: 100%; display: flex; flex-direction: column; }
- .card-header {
- padding: var(--dp-spacing-lg);
- font-size: 1.1em;
- font-weight: 500;
- color: var(--primary-text-color);
- flex: 0 0 auto;
- line-height: 1.3;
- }
- .chart-top-slot[hidden] {
- display: none;
- }
- .chart-top-slot {
- position: relative;
- flex: 0 0 auto;
- min-width: 0;
- margin-left: calc(var(--dp-spacing-md) * -1);
- margin-right: calc(var(--dp-spacing-md) * -1);
- margin-top: -5px;
- z-index: 1;
- }
- .chart-wrap {
- position: relative;
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- min-height: 0;
- padding: var(--dp-spacing-sm) var(--dp-spacing-md) var(--dp-spacing-md);
- box-sizing: border-box;
- overflow: visible;
- isolation: isolate;
- z-index: 3;
- }
- .chart-preview-overlay[hidden] {
- display: none;
- }
- .chart-preview-overlay {
+ .crosshair-axis-dot {
position: absolute;
- top: calc(var(--dp-chart-top-slot-height, 0px) + var(--dp-spacing-sm));
- left: var(--dp-spacing-md);
- display: flex;
- flex-direction: column;
- gap: 2px;
- max-width: min(340px, calc(100% - (var(--dp-spacing-lg) * 2)));
- padding: 8px 12px;
- border-radius: 10px;
- background: color-mix(in srgb, var(--card-background-color, #fff) 90%, transparent);
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
- backdrop-filter: blur(4px);
- pointer-events: none;
- z-index: 4;
- }
- .chart-preview-kicker {
- font-size: 0.68rem;
- line-height: 1.15;
- color: color-mix(in srgb, var(--warning-color, #f59e0b) 72%, var(--secondary-text-color, #6b7280));
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- }
- .chart-preview-title {
- font-size: 0.84rem;
- line-height: 1.2;
- color: var(--primary-text-color);
- font-weight: 600;
- }
- .chart-preview-line {
- font-size: 0.74rem;
- line-height: 1.2;
- color: var(--secondary-text-color);
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ border: 2px solid var(--card-background-color, #fff);
+ box-shadow: 0 1px 4px rgba(0,0,0,0.28);
+ transform: translate(-50%, -50%);
}
- .chart-preview-line strong {
- color: color-mix(in srgb, var(--warning-color, #f59e0b) 72%, var(--primary-text-color, #111));
- font-weight: 600;
+ .chart-axis-hover-dot {
+ position: absolute;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ border: 2px solid var(--card-background-color, #fff);
+ box-shadow: 0 1px 4px rgba(0,0,0,0.28);
+ top: 0;
+ transform: translateY(-50%);
}
- .chart-scroll-viewport {
- position: relative;
- flex: 1 1 auto;
- min-height: 0;
- overflow-x: auto;
- overflow-y: hidden;
- scrollbar-gutter: stable both-edges;
- -webkit-overflow-scrolling: touch;
+ .chart-axis-hover-dot.left {
+ right: 0;
+ transform: translate(50%, -50%);
}
- .chart-stage {
- position: relative;
- min-height: 100%;
+ .chart-axis-hover-dot.right {
+ left: 0;
+ transform: translate(-50%, -50%);
}
- .chart-icon-overlay {
+ .chart-zoom-selection {
position: absolute;
- inset: 0;
+ border-radius: 6px;
+ border: 1px solid color-mix(in srgb, var(--primary-color, #03a9f4) 78%, transparent);
+ background: color-mix(in srgb, var(--primary-color, #03a9f4) 18%, transparent);
pointer-events: none;
- z-index: 2;
+ opacity: 0;
+ transition: opacity 120ms ease;
}
- .chart-event-icon {
+ .chart-zoom-selection.visible {
+ opacity: 1;
+ }
+ .chart-add-annotation {
position: absolute;
- width: 18px;
- height: 18px;
- transform: translate(-50%, -50%);
+ width: 24px;
+ height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
- pointer-events: auto;
- cursor: pointer;
- border: 0;
padding: 0;
margin: 0;
- background: transparent;
- border-radius: 50%;
+ border: 1px solid color-mix(in srgb, var(--secondary-text-color, #616161) 22%, transparent);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--secondary-background-color, #f3f4f6) 94%, transparent);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16);
+ color: var(--secondary-text-color, #616161);
+ cursor: pointer;
+ z-index: 4;
+ transform: translate(-50%, -50%);
}
- .chart-event-icon ha-icon {
+ .chart-add-annotation ha-icon {
--mdc-icon-size: 14px;
pointer-events: none;
}
- .chart-axis-overlay {
- position: absolute;
- top: calc(var(--dp-chart-top-slot-height, 0px) + 5px);
- bottom: 0;
- display: none;
- pointer-events: none;
- background: var(--card-background-color, var(--primary-background-color, #fff));
- overflow: hidden;
- z-index: 3;
- border-bottom-left-radius: 11px;
- }
- .chart-axis-overlay.visible {
- display: block;
- }
- .chart-axis-overlay.left {
- left: 0;
+ .chart-add-annotation:hover,
+ .chart-add-annotation:focus-visible {
+ background: color-mix(in srgb, var(--secondary-background-color, #f3f4f6) 82%, transparent);
+ color: var(--primary-text-color);
+ outline: none;
}
- .chart-axis-overlay.right {
- right: 0;
+ .chart-add-annotation[hidden] {
+ display: none;
}
- .chart-axis-divider {
+ .chart-zoom-out {
position: absolute;
- top: 0;
- bottom: 0;
- width: 1px;
- background: rgba(128,128,128,0.35);
- }
- .chart-axis-overlay.left .chart-axis-divider {
- right: 0;
+ top: calc(var(--dp-chart-top-slot-height, 0px) + var(--dp-spacing-sm));
+ right: var(--dp-spacing-lg);
+ display: inline-flex;
+ align-items: center;
+ gap: calc(var(--spacing, 8px) * 0.75);
+ padding: calc(var(--spacing, 8px) * 0.875) var(--dp-spacing-md);
+ border: 1px solid color-mix(in srgb, var(--primary-color, #03a9f4) 26%, transparent);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, var(--card-background-color, #fff));
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
+ color: var(--primary-color, #03a9f4);
+ font: inherit;
+ font-size: 0.82rem;
+ font-weight: 500;
+ cursor: pointer;
+ z-index: 4;
}
- .chart-axis-overlay.right .chart-axis-divider {
- left: 0;
+ .chart-zoom-out ha-icon {
+ --mdc-icon-size: 16px;
}
- .chart-axis-label,
- .chart-axis-unit {
- position: absolute;
- color: var(--secondary-text-color);
- font: 12px sans-serif;
- line-height: 1;
- white-space: nowrap;
+ .chart-zoom-out[hidden] {
+ display: none;
}
- .chart-axis-label {
- transform: translateY(calc(-50% + 6px));
+ .chart-zoom-out:hover,
+ .chart-zoom-out:focus-visible {
+ background: color-mix(in srgb, var(--primary-color, #03a9f4) 18%, var(--card-background-color, #fff));
+ outline: none;
}
- .chart-axis-unit {
+ .chart-adjust-axis {
+ position: absolute;
+ left: calc(var(--dp-chart-axis-left-width, 0px) + var(--dp-spacing-sm, 8px));
+ bottom: calc(var(--dp-chart-axis-bottom-height, 50px) + var(--dp-spacing-sm, 8px));
+ display: inline-flex;
+ align-items: center;
+ gap: calc(var(--spacing, 8px) * 0.75);
+ padding: calc(var(--spacing, 8px) * 0.875) var(--dp-spacing-md);
+ border: 1px solid color-mix(in srgb, var(--primary-color, #03a9f4) 26%, transparent);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, var(--card-background-color, #fff));
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
+ color: var(--primary-color, #03a9f4);
+ font: inherit;
+ font-size: 0.82rem;
font-weight: 500;
+ cursor: pointer;
+ z-index: 4;
}
- canvas { display: block; }
- .chart-loading {
- position: absolute;
- top: 50%;
- left: 50%;
+ .chart-adjust-axis[hidden] {
display: none;
- align-items: center;
- justify-content: center;
- gap: var(--dp-spacing-sm);
- min-width: calc(var(--spacing, 8px) * 12);
- min-height: calc(var(--spacing, 8px) * 5);
- padding: var(--dp-spacing-sm) var(--dp-spacing-md);
- border-radius: 999px;
- background: color-mix(in srgb, var(--card-background-color, #fff) 92%, transparent);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
- z-index: 6;
- pointer-events: none;
- transform: translate(-50%, -50%);
- }
- .chart-loading.active {
- display: inline-flex;
- }
- .chart-loading-spinner {
- width: calc(var(--spacing, 8px) * 2);
- height: calc(var(--spacing, 8px) * 2);
- border-radius: 50%;
- border: 2px solid color-mix(in srgb, var(--primary-color, #03a9f4) 22%, transparent);
- border-top-color: var(--primary-color, #03a9f4);
- animation: chart-spinner 0.9s linear infinite;
- }
- .chart-loading::after {
- content: none;
- }
- .chart-loading-label {
- color: var(--secondary-text-color);
- font-size: 0.85rem;
- font-weight: 500;
- }
- @keyframes chart-spinner {
- to {
- transform: rotate(360deg);
- }
- }
- .chart-message {
- position: absolute;
- inset: 0;
- display: none;
- align-items: center;
- justify-content: center;
- padding: calc(var(--spacing, 8px) * 5) var(--dp-spacing-lg);
- text-align: center;
- color: var(--secondary-text-color);
- font-size: 0.95rem;
- pointer-events: none;
- z-index: 2;
- }
- .chart-message.visible {
- display: flex;
- }
- .chart-crosshair {
- position: absolute;
- inset: 0;
- pointer-events: none;
- }
- .chart-crosshair[hidden] {
- display: none;
- }
- .crosshair-line {
- position: absolute;
- background: color-mix(in srgb, var(--primary-text-color, #111) 24%, transparent);
- }
- .crosshair-line.vertical {
- width: 1px;
- transform: translateX(-50%);
- }
- .crosshair-line.horizontal {
- height: 1px;
- transform: translateY(-50%);
- }
- .crosshair-line.horizontal.series {
- left: 0;
- width: 100%;
- }
- .crosshair-line.horizontal.series.subtle {
- background: currentColor;
- opacity: 0.22;
- }
- .crosshair-line.horizontal.series.emphasized {
- height: 0;
- background: transparent;
- border-top: 1px dashed currentColor;
- opacity: 0.9;
- }
- .crosshair-points {
- position: absolute;
- inset: 0;
- }
- .crosshair-point {
- position: absolute;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- border: 2px solid var(--card-background-color, #fff);
- box-shadow: 0 2px 6px rgba(0,0,0,0.18);
- transform: translate(-50%, -50%);
- }
- .crosshair-axis-dot {
- position: absolute;
- width: 5px;
- height: 5px;
- border-radius: 50%;
- border: 2px solid var(--card-background-color, #fff);
- box-shadow: 0 1px 4px rgba(0,0,0,0.28);
- transform: translate(-50%, -50%);
- }
- .chart-axis-hover-dot {
- position: absolute;
- width: 5px;
- height: 5px;
- border-radius: 50%;
- border: 2px solid var(--card-background-color, #fff);
- box-shadow: 0 1px 4px rgba(0,0,0,0.28);
- top: 0;
- transform: translateY(-50%);
- }
- .chart-axis-hover-dot.left {
- right: 0;
- transform: translate(50%, -50%);
- }
- .chart-axis-hover-dot.right {
- left: 0;
- transform: translate(-50%, -50%);
- }
- .chart-zoom-selection {
- position: absolute;
- border-radius: 6px;
- border: 1px solid color-mix(in srgb, var(--primary-color, #03a9f4) 78%, transparent);
- background: color-mix(in srgb, var(--primary-color, #03a9f4) 18%, transparent);
- pointer-events: none;
- opacity: 0;
- transition: opacity 120ms ease;
- }
- .chart-zoom-selection.visible {
- opacity: 1;
- }
- .chart-add-annotation {
- position: absolute;
- width: 24px;
- height: 24px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- margin: 0;
- border: 1px solid color-mix(in srgb, var(--secondary-text-color, #616161) 22%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--secondary-background-color, #f3f4f6) 94%, transparent);
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16);
- color: var(--secondary-text-color, #616161);
- cursor: pointer;
- z-index: 4;
- transform: translate(-50%, -50%);
- }
- .chart-add-annotation ha-icon {
- --mdc-icon-size: 14px;
- pointer-events: none;
- }
- .chart-add-annotation:hover,
- .chart-add-annotation:focus-visible {
- background: color-mix(in srgb, var(--secondary-background-color, #f3f4f6) 82%, transparent);
- color: var(--primary-text-color);
- outline: none;
- }
- .chart-add-annotation[hidden] {
- display: none;
- }
- .chart-zoom-out {
- position: absolute;
- top: calc(var(--dp-chart-top-slot-height, 0px) + var(--dp-spacing-sm));
- right: var(--dp-spacing-lg);
- display: inline-flex;
- align-items: center;
- gap: calc(var(--spacing, 8px) * 0.75);
- padding: calc(var(--spacing, 8px) * 0.875) var(--dp-spacing-md);
- border: 1px solid color-mix(in srgb, var(--primary-color, #03a9f4) 26%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, var(--card-background-color, #fff));
- box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
- color: var(--primary-color, #03a9f4);
- font: inherit;
- font-size: 0.82rem;
- font-weight: 500;
- cursor: pointer;
- z-index: 4;
- }
- .chart-zoom-out ha-icon {
- --mdc-icon-size: 16px;
- }
- .chart-zoom-out[hidden] {
- display: none;
- }
- .chart-zoom-out:hover,
- .chart-zoom-out:focus-visible {
- background: color-mix(in srgb, var(--primary-color, #03a9f4) 18%, var(--card-background-color, #fff));
- outline: none;
- }
- .chart-adjust-axis {
- position: absolute;
- left: calc(var(--dp-chart-axis-left-width, 0px) + var(--dp-spacing-sm, 8px));
- bottom: calc(var(--dp-chart-axis-bottom-height, 50px) + var(--dp-spacing-sm, 8px));
- display: inline-flex;
- align-items: center;
- gap: calc(var(--spacing, 8px) * 0.75);
- padding: calc(var(--spacing, 8px) * 0.875) var(--dp-spacing-md);
- border: 1px solid color-mix(in srgb, var(--primary-color, #03a9f4) 26%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, var(--card-background-color, #fff));
- box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
- color: var(--primary-color, #03a9f4);
- font: inherit;
- font-size: 0.82rem;
- font-weight: 500;
- cursor: pointer;
- z-index: 4;
- }
- .chart-adjust-axis[hidden] {
- display: none;
- }
- .chart-adjust-axis:hover,
- .chart-adjust-axis:focus-visible {
- background: color-mix(in srgb, var(--primary-color, #03a9f4) 18%, var(--card-background-color, #fff));
- outline: none;
- }
- .legend {
- display: flex;
- flex-wrap: nowrap;
+ }
+ .chart-adjust-axis:hover,
+ .chart-adjust-axis:focus-visible {
+ background: color-mix(in srgb, var(--primary-color, #03a9f4) 18%, var(--card-background-color, #fff));
+ outline: none;
+ }
+ .legend {
+ display: flex;
+ flex-wrap: nowrap;
align-items: center;
gap: var(--dp-spacing-sm);
padding: var(--dp-spacing-sm) var(--dp-spacing-md) var(--dp-spacing-md);
@@ -9004,100 +11066,298 @@ ${s2.description}`).join("\n\n");
text-overflow: ellipsis;
}
`;
- var __defProp$V = Object.defineProperty;
- var __defNormalProp$V = (obj, key, value) => key in obj ? __defProp$V(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
- var __publicField$V = (obj, key, value) => __defNormalProp$V(obj, typeof key !== "symbol" ? key + "" : key, value);
- const HISTORY_LEGEND_WRAP_ENABLE_HEIGHT_PX = 500;
- const HISTORY_LEGEND_WRAP_DISABLE_HEIGHT_PX = 440;
- const HISTORY_CHART_MAX_CANVAS_WIDTH_PX = Math.floor(
- 16383 / (window.devicePixelRatio || 1)
- );
- const HISTORY_CHART_MAX_ZOOM_MULTIPLIER = 365;
- let HistoryChart$1 = class HistoryChart extends HTMLElement {
- constructor() {
- super(...arguments);
- __publicField$V(this, "_hass", null);
- __publicField$V(this, "_config", {});
- __publicField$V(this, "_legendWrapRows", false);
- __publicField$V(this, "_adjustComparisonAxisScale", false);
- __publicField$V(this, "_drawRequestId", 0);
- __publicField$V(this, "_analysisCache", null);
- __publicField$V(this, "_backendAnomalyByEntity", /* @__PURE__ */ new Map());
- __publicField$V(this, "_backendComparisonAnomalyByKey", /* @__PURE__ */ new Map());
- __publicField$V(this, "_pendingAnomalyEntityIds", /* @__PURE__ */ new Set());
- __publicField$V(this, "_pendingComparisonAnomalyKeys", /* @__PURE__ */ new Set());
- __publicField$V(this, "_chartHoverCleanup", null);
- __publicField$V(this, "_chartZoomCleanup", null);
- __publicField$V(this, "_chartZoomDragging", false);
- __publicField$V(this, "_chartLastHover", null);
- __publicField$V(this, "_scrollSyncSuspended", false);
- __publicField$V(this, "_lastProgrammaticScrollLeft", null);
- __publicField$V(this, "_ignoreNextProgrammaticScrollEvent", false);
- __publicField$V(this, "_skipNextScrollViewportSync", false);
- __publicField$V(this, "_creatingContextAnnotation", false);
- __publicField$V(this, "_lastComparisonResults", null);
- __publicField$V(this, "_hiddenSeries", /* @__PURE__ */ new Set());
- __publicField$V(this, "_entityIds", []);
- __publicField$V(this, "_previousSeriesEndpoints", /* @__PURE__ */ new Map());
- __publicField$V(this, "_zoomRange", null);
- __publicField$V(this, "_chartScrollViewportEl", null);
- __publicField$V(this, "_chartStageEl", null);
- __publicField$V(this, "_annotationDialog", null);
- __publicField$V(this, "_scrollZoomApplyTimer", null);
- __publicField$V(this, "_onChartScroll", () => {
- if (this._scrollSyncSuspended || this._ignoreNextProgrammaticScrollEvent) {
- this._ignoreNextProgrammaticScrollEvent = false;
- return;
- }
- if (!this._chartScrollViewportEl || !this._zoomRange) return;
- const viewport = this._chartScrollViewportEl;
- const scrollLeft = viewport.scrollLeft;
- const maxScrollLeft = Math.max(
- 1,
- viewport.scrollWidth - viewport.clientWidth
- );
- const ratio = scrollLeft / maxScrollLeft;
- const totalMs = Math.max(1, this._lastT1 - this._lastT0);
- const spanMs = this._zoomRange.end - this._zoomRange.start;
- const maxStartOffsetMs = Math.max(0, totalMs - spanMs);
- const newStart = this._lastT0 + ratio * maxStartOffsetMs;
- this._zoomRange = { start: newStart, end: newStart + spanMs };
- this._dispatchZoomPreview({
- startTime: newStart,
- endTime: newStart + spanMs
- });
- if (this._scrollZoomApplyTimer !== null) {
- clearTimeout(this._scrollZoomApplyTimer);
- }
- this._scrollZoomApplyTimer = setTimeout(() => {
- this._scrollZoomApplyTimer = null;
- if (!this._zoomRange) return;
- this.dispatchEvent(
- new CustomEvent("hass-datapoints-zoom-apply", {
- bubbles: true,
- composed: true,
- detail: { start: this._zoomRange.start, end: this._zoomRange.end }
- })
- );
- }, 300);
- });
- __publicField$V(this, "_lastAnomalyRegions", []);
- __publicField$V(this, "_lastHistResult", null);
- __publicField$V(this, "_lastStatsResult", null);
- __publicField$V(this, "_lastEvents", null);
- __publicField$V(this, "_hiddenEventIds", /* @__PURE__ */ new Set());
- __publicField$V(this, "_lastT0", 0);
- __publicField$V(this, "_lastT1", 0);
- __publicField$V(this, "_lastDrawArgs", []);
- }
- // ── No shadow DOM ───────────────────────────────────────────────────────────
- // Renders synchronously into its own children so the canvas and legend are
- // accessible from the parent card's shadow root (required by existing tests).
- /** Called once when the element is inserted into the DOM. */
- connectedCallback() {
- this.style.cssText = "position:relative;display:flex;flex-direction:column;height:100%;min-height:0;padding:var(--dp-spacing-sm,8px) var(--dp-spacing-md,12px) var(--dp-spacing-md,12px);box-sizing:border-box;overflow:visible;isolation:isolate;z-index:3;";
- if (this.querySelector("#chart")) return;
- this.innerHTML = `
+ //#endregion
+ //#region custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts
+ /**
+ * history-chart.ts
+ *
+ * LitElement sub-component that owns the canvas, the chart DOM shell
+ * (loading spinner, tooltips, crosshair, legend, axis overlays), and all
+ * drawing/interaction state for the history chart card.
+ *
+ * IMPORTANT: No shadow DOM — renders into its own children so the canvas and
+ * legend remain accessible from the parent card's shadow root.
+ */
+ var HISTORY_LEGEND_WRAP_ENABLE_HEIGHT_PX = 500;
+ var HISTORY_LEGEND_WRAP_DISABLE_HEIGHT_PX = 440;
+ var HISTORY_CHART_MAX_CANVAS_WIDTH_PX = Math.floor(16383 / (window.devicePixelRatio || 1));
+ var HISTORY_CHART_MAX_ZOOM_MULTIPLIER = 365;
+ var HistoryChart$1 = class extends HTMLElement {
+ constructor(..._args) {
+ super(..._args);
+ _defineProperty(this, "_hass", null);
+ _defineProperty(this, "_config", {});
+ _defineProperty(
+ this,
+ /** Whether the legend should wrap to multiple rows. */
+ "_legendWrapRows",
+ false
+ );
+ _defineProperty(
+ this,
+ /** When true, the comparison axis scale has been manually adjusted by the user. */
+ "_adjustComparisonAxisScale",
+ false
+ );
+ _defineProperty(
+ this,
+ /** Monotonically-incrementing request ID — stale draws are discarded. */
+ "_drawRequestId",
+ 0
+ );
+ _defineProperty(
+ this,
+ /** Cached analysis result keyed by a content hash. */
+ "_analysisCache",
+ null
+ );
+ _defineProperty(
+ this,
+ /** Backend anomaly data keyed by entity ID. */
+ "_backendAnomalyByEntity",
+ /* @__PURE__ */ new Map()
+ );
+ _defineProperty(
+ this,
+ /** Backend anomaly data keyed by comparison window ID + entity ID. */
+ "_backendComparisonAnomalyByKey",
+ /* @__PURE__ */ new Map()
+ );
+ _defineProperty(
+ this,
+ /** Entity IDs whose anomaly fetch is currently in-flight. */
+ "_pendingAnomalyEntityIds",
+ /* @__PURE__ */ new Set()
+ );
+ _defineProperty(
+ this,
+ /** Comparison window + entity keys whose anomaly fetch is currently in-flight. */
+ "_pendingComparisonAnomalyKeys",
+ /* @__PURE__ */ new Set()
+ );
+ _defineProperty(
+ this,
+ /**
+ * Cleanup function returned by attachLineChartHover.
+ * Must be called before re-attaching hover or before unmount.
+ */
+ "_chartHoverCleanup",
+ null
+ );
+ _defineProperty(
+ this,
+ /**
+ * Cleanup function returned by attachLineChartRangeZoom.
+ * Must be called before re-attaching zoom or before unmount.
+ */
+ "_chartZoomCleanup",
+ null
+ );
+ _defineProperty(
+ this,
+ /** True while the user is drag-zooming on the split chart overlay. */
+ "_chartZoomDragging",
+ false
+ );
+ _defineProperty(
+ this,
+ /** Last computed hover object — used by split-chart crosshair/tooltip. */
+ "_chartLastHover",
+ null
+ );
+ _defineProperty(
+ this,
+ /**
+ * When true, scroll sync events from the secondary (comparison) chart
+ * viewport are temporarily ignored to prevent feedback loops.
+ */
+ "_scrollSyncSuspended",
+ false
+ );
+ _defineProperty(
+ this,
+ /**
+ * The scrollLeft value of the last programmatic scroll, used to detect
+ * and ignore the scroll event that the browser fires for that scroll.
+ */
+ "_lastProgrammaticScrollLeft",
+ null
+ );
+ _defineProperty(
+ this,
+ /**
+ * Set to true immediately before a programmatic scroll so the resulting
+ * scroll event can be identified and skipped by the scroll handler.
+ */
+ "_ignoreNextProgrammaticScrollEvent",
+ false
+ );
+ _defineProperty(
+ this,
+ /**
+ * When true, the next call to _syncChartViewportScroll will skip
+ * repositioning the viewport and clear this flag. Used to prevent
+ * a snap-back immediately after the user commits a zoom via scroll.
+ */
+ "_skipNextScrollViewportSync",
+ false
+ );
+ _defineProperty(
+ this,
+ /**
+ * True while the user is in the process of creating a context annotation
+ * (i.e. after clicking the "+" button but before the dialog closes).
+ */
+ "_creatingContextAnnotation",
+ false
+ );
+ _defineProperty(
+ this,
+ /** Last comparison results fetched by the card. */
+ "_lastComparisonResults",
+ null
+ );
+ _defineProperty(
+ this,
+ /** Series entity IDs that are currently hidden from the chart. */
+ "_hiddenSeries",
+ /* @__PURE__ */ new Set()
+ );
+ _defineProperty(
+ this,
+ /** Entity IDs tracked by this card instance. */
+ "_entityIds",
+ []
+ );
+ _defineProperty(
+ this,
+ /** Previous drawn endpoints per entity, used for live-update logging. */
+ "_previousSeriesEndpoints",
+ /* @__PURE__ */ new Map()
+ );
+ _defineProperty(
+ this,
+ /** The currently active zoom range, or null if not zoomed. */
+ "_zoomRange",
+ null
+ );
+ _defineProperty(
+ this,
+ /** The chart scroll viewport element (set during draw). */
+ "_chartScrollViewportEl",
+ null
+ );
+ _defineProperty(
+ this,
+ /** The chart stage element (set during draw). */
+ "_chartStageEl",
+ null
+ );
+ _defineProperty(
+ this,
+ /** The annotation dialog controller. */
+ "_annotationDialog",
+ null
+ );
+ _defineProperty(
+ this,
+ /** Debounce timer for dispatching zoom-apply after a user scroll. */
+ "_scrollZoomApplyTimer",
+ null
+ );
+ _defineProperty(
+ this,
+ /** Bound scroll handler — wired to _chartScrollViewportEl. */
+ "_onChartScroll",
+ () => {
+ if (this._scrollSyncSuspended || this._ignoreNextProgrammaticScrollEvent) {
+ this._ignoreNextProgrammaticScrollEvent = false;
+ return;
+ }
+ if (!this._chartScrollViewportEl || !this._zoomRange) return;
+ const viewport = this._chartScrollViewportEl;
+ const ratio = viewport.scrollLeft / Math.max(1, viewport.scrollWidth - viewport.clientWidth);
+ const totalMs = Math.max(1, this._lastT1 - this._lastT0);
+ const spanMs = this._zoomRange.end - this._zoomRange.start;
+ const maxStartOffsetMs = Math.max(0, totalMs - spanMs);
+ const newStart = this._lastT0 + ratio * maxStartOffsetMs;
+ this._zoomRange = {
+ start: newStart,
+ end: newStart + spanMs
+ };
+ this._dispatchZoomPreview({
+ startTime: newStart,
+ endTime: newStart + spanMs
+ });
+ if (this._scrollZoomApplyTimer !== null) clearTimeout(this._scrollZoomApplyTimer);
+ this._scrollZoomApplyTimer = setTimeout(() => {
+ this._scrollZoomApplyTimer = null;
+ if (!this._zoomRange) return;
+ this.dispatchEvent(new CustomEvent("hass-datapoints-zoom-apply", {
+ bubbles: true,
+ composed: true,
+ detail: {
+ start: this._zoomRange.start,
+ end: this._zoomRange.end
+ }
+ }));
+ }, 300);
+ }
+ );
+ _defineProperty(
+ this,
+ /** Last drawn anomaly regions (for hover hit-testing). */
+ "_lastAnomalyRegions",
+ []
+ );
+ _defineProperty(
+ this,
+ /** Cache of last drawn history result. */
+ "_lastHistResult",
+ null
+ );
+ _defineProperty(
+ this,
+ /** Cache of last drawn statistics result. */
+ "_lastStatsResult",
+ null
+ );
+ _defineProperty(
+ this,
+ /** Cache of last drawn events list. */
+ "_lastEvents",
+ null
+ );
+ _defineProperty(
+ this,
+ /** IDs of datapoint events currently hidden from the chart. */
+ "_hiddenEventIds",
+ /* @__PURE__ */ new Set()
+ );
+ _defineProperty(
+ this,
+ /** Cache of last draw time range start (ms). */
+ "_lastT0",
+ 0
+ );
+ _defineProperty(
+ this,
+ /** Cache of last draw time range end (ms). */
+ "_lastT1",
+ 0
+ );
+ _defineProperty(
+ this,
+ /** Cache of last _drawChart argument list. */
+ "_lastDrawArgs",
+ []
+ );
+ }
+ /** Called once when the element is inserted into the DOM. */
+ connectedCallback() {
+ this.style.cssText = "position:relative;display:flex;flex-direction:column;height:100%;min-height:0;padding:var(--dp-spacing-sm,8px) var(--dp-spacing-md,12px) var(--dp-spacing-md,12px);box-sizing:border-box;overflow:visible;isolation:isolate;z-index:3;";
+ if (this.querySelector("#chart")) return;
+ this.innerHTML = `
-
- Adjust Y-Axis
-
-
-