unique_bubble_graph: name: Unique Bubble Multi History Background Graph version: 2.6.0 creator: Torsten supported: - button description: Shows up to three Home Assistant sensor history graphs as Bubble Card background with data-point cache, persistent cache, centered loading spinner and extend-to-now. 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 cfg = this.config.unique_bubble_graph || this.config.history_background_graph || {}; 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 defaultLineWidth = Number(cfg.line_width ?? 2.5); const defaultLineOpacity = Number(cfg.line_opacity ?? 0.75); const defaultFillOpacity = Number(cfg.fill_opacity ?? 0.08); const defaultColors = [ cfg.graph_1_color || cfg.line_color || "var(--primary-color)", cfg.graph_2_color || "#2196f3", cfg.graph_3_color || "#4caf50" ]; const buildGraphs = () => { if (Array.isArray(cfg.graphs) && cfg.graphs.length) { return cfg.graphs .slice(0, 3) .map((graph, index) => ({ entity: graph.entity, label: graph.label || hass.states?.[graph.entity]?.attributes?.friendly_name || graph.entity || `Graph ${index + 1}`, color: 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), unit: hass.states?.[graph.entity]?.attributes?.unit_of_measurement || "" })) .filter((graph) => graph.entity); } const graph1Entity = cfg.graph_1_entity || cfg.entity || this.config.entity || "sensor.sma_tripower_x_pv_power"; const graph2Entity = cfg.graph_2_entity; const graph3Entity = cfg.graph_3_entity; return [ { entity: graph1Entity, label: cfg.graph_1_label || cfg.label || hass.states?.[graph1Entity]?.attributes?.friendly_name || graph1Entity || "Graph 1", color: cfg.graph_1_color || cfg.line_color || defaultColors[0], lineWidth: Number(cfg.graph_1_line_width ?? defaultLineWidth), lineOpacity: Number(cfg.graph_1_line_opacity ?? defaultLineOpacity), fillOpacity: Number(cfg.graph_1_fill_opacity ?? defaultFillOpacity), unit: hass.states?.[graph1Entity]?.attributes?.unit_of_measurement || "" }, { entity: graph2Entity, label: cfg.graph_2_label || hass.states?.[graph2Entity]?.attributes?.friendly_name || graph2Entity || "Graph 2", color: cfg.graph_2_color || defaultColors[1], lineWidth: Number(cfg.graph_2_line_width ?? defaultLineWidth), lineOpacity: Number(cfg.graph_2_line_opacity ?? defaultLineOpacity), fillOpacity: Number(cfg.graph_2_fill_opacity ?? defaultFillOpacity), unit: hass.states?.[graph2Entity]?.attributes?.unit_of_measurement || "" }, { entity: graph3Entity, label: cfg.graph_3_label || hass.states?.[graph3Entity]?.attributes?.friendly_name || graph3Entity || "Graph 3", color: cfg.graph_3_color || defaultColors[2], lineWidth: Number(cfg.graph_3_line_width ?? defaultLineWidth), lineOpacity: Number(cfg.graph_3_line_opacity ?? defaultLineOpacity), fillOpacity: Number(cfg.graph_3_fill_opacity ?? defaultFillOpacity), unit: hass.states?.[graph3Entity]?.attributes?.unit_of_measurement || "" } ].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 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 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 currentValue = Number(hass.states?.[series.entity]?.state); if ( Number.isFinite(currentValue) && (!lastPoint || now - lastPoint.t > thresholdMs) ) { points.push({ t: now, v: currentValue }); } return { ...series, points }; }); }; const attachTooltip = (seriesList) => { if (cfg.tooltip === false) return; host.onmousemove = (event) => { const usableSeries = seriesList.filter((series) => series.points?.length); if (!usableSeries.length) return; const rect = host.getBoundingClientRect(); const xRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); const globalMinTime = Math.min(...usableSeries.map((series) => series.points[0].t)); const globalMaxTime = 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 `