unique_bubble_graph: name: Unique Bubble Multi History Background Graph version: 2.8.6 creator: Torsten supported: - button description: Shows up to three explicitly configured Home Assistant sensor history graphs as Bubble Card background with data-point cache, persistent cache, centered loading spinner, extend-to-now, peak-preserving downsampling and optional same-scale mode, fixed scale limits, percent-of-max value conversion and nested expandable editor settings support and RGB color picker array support and isolated per-graph color settings and cache-safe live style refresh. code: | ha-card { position: relative !important; overflow: hidden !important; border-radius: var(--bubble-history-border-radius, 28px) !important; background: var(--ha-card-background, var(--card-background-color)) !important; clip-path: inset(0 round var(--bubble-history-border-radius, 28px)) !important; contain: paint !important; } .bubble-button-card-container, .bubble-button-card, .bubble-card-background { background: transparent !important; background-color: transparent !important; } .bubble-history-background { position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important; z-index: 0 !important; pointer-events: none !important; opacity: 1 !important; border-radius: var(--bubble-history-border-radius, 28px) !important; overflow: hidden !important; clip-path: inset(0 round var(--bubble-history-border-radius, 28px)) !important; background: transparent !important; } .bubble-history-background svg { width: 100% !important; height: 100% !important; display: block !important; } .bubble-history-background path.area { stroke: none !important; } .bubble-history-background path.line { fill: none !important; stroke-linecap: round !important; stroke-linejoin: round !important; } .bubble-history-loader { position: absolute !important; left: 50%; top: 50%; z-index: 9 !important; width: 18px; height: 18px; margin-left: -9px; margin-top: -9px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: var(--primary-color); animation: bubble-history-spin 0.8s linear infinite; display: none; pointer-events: none; opacity: 0.9; } @keyframes bubble-history-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .bubble-history-tooltip { position: absolute !important; z-index: 10 !important; display: none; padding: 6px 8px; border-radius: 10px; background: rgba(20, 20, 20, 0.82); color: white; font-size: 11px; line-height: 1.35; white-space: nowrap; pointer-events: none; transform: translate(-50%, 0); backdrop-filter: blur(8px); } .bubble-history-tooltip-row { display: flex; gap: 6px; align-items: center; } .bubble-history-tooltip-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex: 0 0 auto; } .bubble-history-marker { position: absolute !important; z-index: 8 !important; display: none; top: 10%; bottom: 10%; width: 1px; background: rgba(255, 255, 255, 0.55); pointer-events: none; } .bubble-button-card-container, .bubble-button-card, .bubble-name-container, .bubble-sub-button-container, .bubble-icon-container, .bubble-sub-button, .bubble-icon, .bubble-state, .bubble-name { position: relative !important; z-index: 2 !important; } ${(() => { const rawCfg = this.config.unique_bubble_graph || this.config.history_background_graph || {}; const graphSettings = [ rawCfg.graph_1_settings || {}, rawCfg.graph_2_settings || {}, rawCfg.graph_3_settings || {} ]; const cfg = { ...(rawCfg.general_settings || {}), ...(rawCfg.scale_settings || {}), ...(rawCfg.advanced_settings || {}), ...rawCfg }; const getGraphSetting = (index, key, fallback = undefined) => { const prefix = `graph_${index + 1}`; const group = graphSettings[index] || {}; return group[`${prefix}_${key}`] ?? group[key] ?? cfg[`${prefix}_${key}`] ?? fallback; }; const hoursToShow = Number(cfg.hours_to_show ?? 24); const refreshMinutes = Number(cfg.refresh_minutes ?? 10); const maxPoints = Number(cfg.max_points ?? 120); const borderRadius = Number(cfg.border_radius ?? 28); const tooltipTop = Number(cfg.tooltip_top ?? 52); const paddingY = Number(cfg.padding_y ?? 12); const valuePaddingPercent = Number(cfg.value_padding_percent ?? 8); const showLoading = cfg.show_loading !== false; const persistentCache = cfg.persistent_cache !== false; const maxPersistentCacheAgeHours = Number(cfg.max_cache_age_hours ?? 24); const extendToNow = cfg.extend_to_now !== false; const extendThresholdMinutes = Number(cfg.extend_threshold_minutes ?? 2); const sameScale = cfg.same_scale === true; const numberOrNull = (value) => { if (value === undefined || value === null || value === "") return null; const number = Number(value); return Number.isFinite(number) ? number : null; }; const sameScaleMin = numberOrNull(cfg.same_scale_min); const sameScaleMax = numberOrNull(cfg.same_scale_max); const defaultLineWidth = Number(cfg.line_width ?? 2.5); const defaultLineOpacity = Number(cfg.line_opacity ?? 0.75); const defaultFillOpacity = Number(cfg.fill_opacity ?? 0.08); const normalizeColor = (color, fallback = "var(--primary-color)") => { if (Array.isArray(color) && color.length >= 3) { const clamp = (value) => Math.max(0, Math.min(255, Math.round(Number(value) || 0))); const r = clamp(color[0]); const g = clamp(color[1]); const b = clamp(color[2]); const a = color.length >= 4 ? Number(color[3]) : null; if (Number.isFinite(a)) { return `rgba(${r}, ${g}, ${b}, ${Math.max(0, Math.min(1, a))})`; } return `rgb(${r}, ${g}, ${b})`; } if (typeof color !== "string") return fallback; const trimmed = color.trim(); if (!trimmed) return fallback; if ( trimmed.startsWith("#") || trimmed.startsWith("rgb(") || trimmed.startsWith("rgba(") || trimmed.startsWith("hsl(") || trimmed.startsWith("hsla(") || trimmed.startsWith("var(") ) { return trimmed; } if (/^[0-9a-fA-F]{3}$/.test(trimmed) || /^[0-9a-fA-F]{6}$/.test(trimmed)) { return `#${trimmed}`; } return trimmed; }; const defaultColors = [ "var(--primary-color)", "#2196f3", "#4caf50" ]; const normalizeValueMode = (value) => { return value === "percent_of_max" ? "percent_of_max" : "default"; }; const getGraphScaleConfig = (graph, index) => { const valueMode = normalizeValueMode( graph?.value_mode ?? graph?.valueMode ?? getGraphSetting(index, "value_mode") ); const maxValue = numberOrNull( graph?.max_value ?? graph?.maxValue ?? getGraphSetting(index, "max_value") ); const effectiveValueMode = valueMode === "percent_of_max" && Number.isFinite(maxValue) && maxValue > 0 ? "percent_of_max" : "default"; return { valueMode: effectiveValueMode, maxValue }; }; const buildGraphs = () => { if (Array.isArray(cfg.graphs) && cfg.graphs.length) { return cfg.graphs .slice(0, 3) .map((graph, index) => { const scaleConfig = getGraphScaleConfig(graph, index); return { entity: graph.entity, label: graph.label || hass.states?.[graph.entity]?.attributes?.friendly_name || graph.entity || `Graph ${index + 1}`, color: normalizeColor(graph.color || graph.line_color, defaultColors[index]), lineWidth: Number(graph.line_width ?? defaultLineWidth), lineOpacity: Number(graph.line_opacity ?? defaultLineOpacity), fillOpacity: Number(graph.fill_opacity ?? defaultFillOpacity), rawUnit: hass.states?.[graph.entity]?.attributes?.unit_of_measurement || "", unit: scaleConfig.valueMode === "percent_of_max" ? "%" : (hass.states?.[graph.entity]?.attributes?.unit_of_measurement || ""), valueMode: scaleConfig.valueMode, maxValue: scaleConfig.maxValue }; }) .filter((graph) => graph.entity); } const graph1Entity = getGraphSetting(0, "entity"); const graph2Entity = getGraphSetting(1, "entity"); const graph3Entity = getGraphSetting(2, "entity"); return [ { entity: graph1Entity, label: getGraphSetting(0, "label") || cfg.label || hass.states?.[graph1Entity]?.attributes?.friendly_name || graph1Entity || "Graph 1", color: normalizeColor(getGraphSetting(0, "color") || cfg.line_color, defaultColors[0]), lineWidth: Number(getGraphSetting(0, "line_width", defaultLineWidth)), lineOpacity: Number(getGraphSetting(0, "line_opacity", defaultLineOpacity)), fillOpacity: Number(getGraphSetting(0, "fill_opacity", defaultFillOpacity)), rawUnit: hass.states?.[graph1Entity]?.attributes?.unit_of_measurement || "", unit: getGraphScaleConfig(null, 0).valueMode === "percent_of_max" ? "%" : (hass.states?.[graph1Entity]?.attributes?.unit_of_measurement || ""), valueMode: getGraphScaleConfig(null, 0).valueMode, maxValue: getGraphScaleConfig(null, 0).maxValue }, { entity: graph2Entity, label: getGraphSetting(1, "label") || hass.states?.[graph2Entity]?.attributes?.friendly_name || graph2Entity || "Graph 2", color: normalizeColor(getGraphSetting(1, "color"), defaultColors[1]), lineWidth: Number(getGraphSetting(1, "line_width", defaultLineWidth)), lineOpacity: Number(getGraphSetting(1, "line_opacity", defaultLineOpacity)), fillOpacity: Number(getGraphSetting(1, "fill_opacity", defaultFillOpacity)), rawUnit: hass.states?.[graph2Entity]?.attributes?.unit_of_measurement || "", unit: getGraphScaleConfig(null, 1).valueMode === "percent_of_max" ? "%" : (hass.states?.[graph2Entity]?.attributes?.unit_of_measurement || ""), valueMode: getGraphScaleConfig(null, 1).valueMode, maxValue: getGraphScaleConfig(null, 1).maxValue }, { entity: graph3Entity, label: getGraphSetting(2, "label") || hass.states?.[graph3Entity]?.attributes?.friendly_name || graph3Entity || "Graph 3", color: normalizeColor(getGraphSetting(2, "color"), defaultColors[2]), lineWidth: Number(getGraphSetting(2, "line_width", defaultLineWidth)), lineOpacity: Number(getGraphSetting(2, "line_opacity", defaultLineOpacity)), fillOpacity: Number(getGraphSetting(2, "fill_opacity", defaultFillOpacity)), rawUnit: hass.states?.[graph3Entity]?.attributes?.unit_of_measurement || "", unit: getGraphScaleConfig(null, 2).valueMode === "percent_of_max" ? "%" : (hass.states?.[graph3Entity]?.attributes?.unit_of_measurement || ""), valueMode: getGraphScaleConfig(null, 2).valueMode, maxValue: getGraphScaleConfig(null, 2).maxValue } ].filter((graph) => graph.entity); }; const graphs = buildGraphs(); const host = this.shadowRoot?.querySelector("ha-card") || this.querySelector?.("ha-card") || card?.querySelector?.("ha-card") || card; if (!host || !graphs.length) return ""; host.style.setProperty("--bubble-history-border-radius", `${borderRadius}px`); let bg = host.querySelector(".bubble-history-background"); let tooltip = host.querySelector(".bubble-history-tooltip"); let marker = host.querySelector(".bubble-history-marker"); let loader = host.querySelector(".bubble-history-loader"); if (!bg) { bg = document.createElement("div"); bg.className = "bubble-history-background"; host.prepend(bg); bg.innerHTML = ``; } if (!tooltip) { tooltip = document.createElement("div"); tooltip.className = "bubble-history-tooltip"; host.appendChild(tooltip); } if (!marker) { marker = document.createElement("div"); marker.className = "bubble-history-marker"; host.appendChild(marker); } if (!loader) { loader = document.createElement("div"); loader.className = "bubble-history-loader"; host.appendChild(loader); } const showLoader = () => { if (showLoading) loader.style.display = "block"; }; const hideLoader = () => { loader.style.display = "none"; }; const formatValue = (value, unit) => { if (unit === "W") { if (Math.abs(value) >= 1000) { return `${(value / 1000).toFixed(2)} kW`; } return `${Math.round(value)} W`; } if (unit === "%") { return `${Math.round(value)} %`; } if (Number.isInteger(value)) { return `${value} ${unit}`.trim(); } return `${value.toFixed(1)} ${unit}`.trim(); }; const formatTooltipValue = (point, series) => { const value = formatValue(point.v, series.unit); if ( series.valueMode === "percent_of_max" && Number.isFinite(point.rawV) ) { return `${value} · ${formatValue(point.rawV, series.rawUnit)}`; } return value; }; const transformValueForGraph = (value, graph) => { if ( graph.valueMode === "percent_of_max" && Number.isFinite(graph.maxValue) && graph.maxValue > 0 ) { return (value / graph.maxValue) * 100; } return value; }; const formatTime = (timestamp) => { return new Date(timestamp).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); }; const findNearestPoint = (points, targetTime) => { if (!points?.length) return null; let nearest = points[0]; let nearestDiff = Math.abs(points[0].t - targetTime); for (const point of points) { const diff = Math.abs(point.t - targetTime); if (diff < nearestDiff) { nearest = point; nearestDiff = diff; } } return nearest; }; const downsampleMinMax = (points, maxPoints) => { if (!Array.isArray(points) || points.length <= maxPoints) return points; const firstPoint = points[0]; const lastPoint = points[points.length - 1]; const bucketCount = Math.max(1, Math.floor((maxPoints - 2) / 2)); const bucketSize = Math.ceil(points.length / bucketCount); const sampled = [firstPoint]; for (let index = 0; index < points.length; index += bucketSize) { const bucket = points.slice(index, index + bucketSize); if (!bucket.length) continue; const min = bucket.reduce((a, b) => b.v < a.v ? b : a); const max = bucket.reduce((a, b) => b.v > a.v ? b : a); if (min.t <= max.t) { sampled.push(min, max); } else { sampled.push(max, min); } } sampled.push(lastPoint); return sampled .filter((point, index, array) => point && Number.isFinite(point.t) && Number.isFinite(point.v) && array.findIndex((item) => item.t === point.t && item.v === point.v) === index ) .sort((a, b) => a.t - b.t) .slice(0, maxPoints); }; const extendSeriesToNow = (seriesList) => { if (!extendToNow) return seriesList; const now = Date.now(); const thresholdMs = extendThresholdMinutes * 60 * 1000; return seriesList.map((series) => { const points = [...(series.points || [])]; const lastPoint = points[points.length - 1]; const currentRawValue = Number(hass.states?.[series.entity]?.state); const currentValue = transformValueForGraph(currentRawValue, series); if ( Number.isFinite(currentRawValue) && Number.isFinite(currentValue) && (!lastPoint || now - lastPoint.t > thresholdMs) ) { points.push({ t: now, v: currentValue, rawV: currentRawValue }); } return { ...series, points }; }); }; const attachTooltipOnce = () => { if (cfg.tooltip === false) return; host.__bubbleHistoryTooltip = tooltip; host.__bubbleHistoryMarker = marker; host.__bubbleHistoryTooltipTop = tooltipTop; if (host.__bubbleHistoryTooltipAttached) return; host.__bubbleHistoryTooltipAttached = true; host.onmousemove = (event) => { const usableSeries = (host.__bubbleHistorySeries || []) .filter((series) => series.points?.length); if (!usableSeries.length) return; const tooltipElement = host.__bubbleHistoryTooltip; const markerElement = host.__bubbleHistoryMarker; if (!tooltipElement || !markerElement) return; const rect = host.getBoundingClientRect(); const xRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); const globalMinTime = Number.isFinite(host.__bubbleHistoryMinTime) ? host.__bubbleHistoryMinTime : Math.min(...usableSeries.map((series) => series.points[0].t)); const globalMaxTime = Number.isFinite(host.__bubbleHistoryMaxTime) ? host.__bubbleHistoryMaxTime : Math.max(...usableSeries.map((series) => series.points[series.points.length - 1].t)); const targetTime = globalMinTime + xRatio * (globalMaxTime - globalMinTime); const rows = usableSeries.map((series) => { const nearest = findNearestPoint(series.points, targetTime); if (!nearest) return ""; return `