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 `
${series.label}: ${formatValue(nearest.v, series.unit)}
`; }).join(""); const firstNearest = findNearestPoint(usableSeries[0].points, targetTime); const tooltipTime = firstNearest ? firstNearest.t : targetTime; const x = ((targetTime - globalMinTime) / (globalMaxTime - globalMinTime || 1)) * rect.width; const safeX = Math.min(rect.width - 60, Math.max(60, x)); tooltip.innerHTML = ` ${rows}
${formatTime(tooltipTime)}
`; tooltip.style.display = "block"; marker.style.display = "block"; tooltip.style.left = `${safeX}px`; tooltip.style.top = `${tooltipTop}px`; marker.style.left = `${x}px`; }; host.onmouseleave = () => { tooltip.style.display = "none"; marker.style.display = "none"; }; }; const renderGraph = (seriesList) => { const usableSeries = extendSeriesToNow(seriesList) .filter((series) => series.points?.length >= 2); if (!usableSeries.length) return false; const width = 100; const height = 100; 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 buildPath = (series) => { const rawMinValue = Math.min(...series.points.map((point) => point.v)); const rawMaxValue = Math.max(...series.points.map((point) => point.v)); const rawRange = rawMaxValue - rawMinValue; const safeRange = rawRange || Math.max(Math.abs(rawMaxValue), 1); const valuePadding = safeRange * (valuePaddingPercent / 100); const minValue = rawMinValue - valuePadding; const maxValue = rawMaxValue + valuePadding; const timeRange = globalMaxTime - globalMinTime || 1; const valueRange = maxValue - minValue || 1; const xy = series.points.map((point) => { const x = ((point.t - globalMinTime) / timeRange) * width; const y = height - paddingY - ((point.v - minValue) / valueRange) * (height - paddingY * 2); return [x, y]; }); return xy .map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}` ) .join(" "); }; const paths = usableSeries.map((series, index) => { const linePath = buildPath(series); const areaPath = `${linePath} L ${width} ${height} L 0 ${height} Z`; const area = series.fillOpacity > 0 ? `` : ""; const line = ` `; return `${area}${line}`; }).join(""); bg.innerHTML = ` `; attachTooltip(usableSeries); return true; }; if (!hass?.fetchWithAuth) { console.warn("Bubble history graph: hass.fetchWithAuth not available"); hideLoader(); return ""; } const validGraphs = graphs.filter((graph) => { if (!hass?.states?.[graph.entity]) { console.warn(`Bubble history graph: entity not found: ${graph.entity}`); return false; } return true; }); if (!validGraphs.length) { hideLoader(); return ""; } const dataCacheParts = validGraphs.map((graph) => graph.entity).join("|"); const cacheKey = [ "unique-bubble-graph-data-v260", dataCacheParts, hoursToShow, maxPoints ].join("::"); const storageKey = `unique-bubble-graph-data-cache:${cacheKey}`; window.__uniqueBubbleGraphDataCache = window.__uniqueBubbleGraphDataCache || {}; const readPersistentCache = () => { if (!persistentCache) return null; try { const raw = localStorage.getItem(storageKey); if (!raw) return null; const parsed = JSON.parse(raw); if (!Array.isArray(parsed?.seriesList) || !parsed?.timestamp) { return null; } const maxAgeMs = maxPersistentCacheAgeHours * 60 * 60 * 1000; if (Date.now() - parsed.timestamp > maxAgeMs) { localStorage.removeItem(storageKey); return null; } return parsed; } catch (error) { console.warn("Bubble history graph persistent data cache read error:", error); return null; } }; const writePersistentCache = (data) => { if (!persistentCache) return; try { localStorage.setItem(storageKey, JSON.stringify({ timestamp: data.timestamp, seriesList: data.seriesList })); } catch (error) { console.warn("Bubble history graph persistent data cache write error:", error); } }; let cache = window.__uniqueBubbleGraphDataCache[cacheKey] || readPersistentCache(); if (cache?.seriesList) { renderGraph(cache.seriesList); window.__uniqueBubbleGraphDataCache[cacheKey] = { ...cache, loading: false }; } if (cache?.timestamp && Date.now() - cache.timestamp < refreshMinutes * 60 * 1000) { hideLoader(); return ""; } if (window.__uniqueBubbleGraphDataCache[cacheKey]?.loading) { if (!cache?.seriesList) showLoader(); return ""; } showLoader(); window.__uniqueBubbleGraphDataCache[cacheKey] = { ...(window.__uniqueBubbleGraphDataCache[cacheKey] || {}), ...(cache || {}), loading: true }; const end = new Date(); const start = new Date(end.getTime() - hoursToShow * 60 * 60 * 1000); const fetchHistory = (graph) => { const url = `/api/history/period/${encodeURIComponent(start.toISOString())}` + `?filter_entity_id=${encodeURIComponent(graph.entity)}` + `&end_time=${encodeURIComponent(end.toISOString())}` + `&minimal_response`; return hass.fetchWithAuth(url) .then((response) => response.json()) .then((history) => { const states = Array.isArray(history) ? history.flat() : []; let points = states .map((state) => ({ t: new Date(state.lu || state.lc || state.last_updated || state.last_changed).getTime(), v: Number(state.s ?? state.state) })) .filter((point) => Number.isFinite(point.t) && Number.isFinite(point.v)) .sort((a, b) => a.t - b.t); if (points.length > maxPoints) { const originalLastPoint = points[points.length - 1]; const step = Math.ceil(points.length / maxPoints); points = points.filter((_, index) => index % step === 0); if ( originalLastPoint && points[points.length - 1]?.t !== originalLastPoint.t ) { points.push(originalLastPoint); } } return { ...graph, points }; }); }; Promise.all(validGraphs.map(fetchHistory)) .then((seriesList) => { hideLoader(); const usableSeries = seriesList.filter((series) => series.points.length >= 2); if (!usableSeries.length) { console.warn("Bubble history graph: not enough points for all graphs"); window.__uniqueBubbleGraphDataCache[cacheKey] = { ...(window.__uniqueBubbleGraphDataCache[cacheKey] || {}), loading: false, timestamp: Date.now() }; return; } const newCache = { timestamp: Date.now(), loading: false, seriesList: usableSeries }; window.__uniqueBubbleGraphDataCache[cacheKey] = newCache; writePersistentCache(newCache); renderGraph(usableSeries); }) .catch((error) => { hideLoader(); window.__uniqueBubbleGraphDataCache[cacheKey] = { ...(window.__uniqueBubbleGraphDataCache[cacheKey] || {}), loading: false, timestamp: Date.now() }; console.warn("Bubble history graph error:", error); }); return ""; })()} editor: - name: hours_to_show label: Allgemein · Stunden anzeigen selector: number: min: 1 max: 168 step: 1 mode: box - name: refresh_minutes label: Allgemein · Aktualisierung Minuten selector: number: min: 1 max: 120 step: 1 mode: box - name: persistent_cache label: Allgemein · Cache nach F5 behalten selector: boolean: null - name: max_cache_age_hours label: Allgemein · Max. Cache-Alter Stunden selector: number: min: 1 max: 168 step: 1 mode: box - name: show_loading label: Allgemein · Lade-Kringel anzeigen selector: boolean: null - name: graph_1_entity label: ① Graph · Sensor selector: entity: domain: sensor - name: graph_1_label label: ① Graph · Label selector: text: null - name: graph_1_color label: ① Graph · Farbe selector: text: null - name: graph_1_line_width label: ① Graph · Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: graph_1_fill_opacity label: ① Graph · Fläche selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: graph_2_entity label: ② Graph · Sensor selector: entity: domain: sensor - name: graph_2_label label: ② Graph · Label selector: text: null - name: graph_2_color label: ② Graph · Farbe selector: text: null - name: graph_2_line_width label: ② Graph · Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: graph_2_fill_opacity label: ② Graph · Fläche selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: graph_3_entity label: ③ Graph · Sensor selector: entity: domain: sensor - name: graph_3_label label: ③ Graph · Label selector: text: null - name: graph_3_color label: ③ Graph · Farbe selector: text: null - name: graph_3_line_width label: ③ Graph · Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: graph_3_fill_opacity label: ③ Graph · Fläche selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: line_width label: Anzeige · Standard Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: line_opacity label: Anzeige · Linien Sichtbarkeit selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: fill_opacity label: Anzeige · Standard Fläche selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: value_padding_percent label: Anzeige · Abstand oben/unten Prozent selector: number: min: 0 max: 30 step: 1 mode: box - name: padding_y label: Anzeige · Graph Abstand oben/unten selector: number: min: 0 max: 40 step: 1 mode: box - name: border_radius label: Anzeige · Eckenradius selector: number: min: 0 max: 60 step: 1 mode: box - name: extend_to_now label: Erweitert · Linie bis jetzt verlängern selector: boolean: null - name: extend_threshold_minutes label: Erweitert · Verlängern nach Minuten selector: number: min: 0 max: 60 step: 1 mode: box - name: max_points label: Erweitert · Max. Datenpunkte selector: number: min: 20 max: 500 step: 10 mode: box - name: tooltip label: Erweitert · Tooltip anzeigen selector: boolean: null - name: tooltip_top label: Erweitert · Tooltip Position oben selector: number: min: 0 max: 200 step: 1 mode: box