unique_bubble_graph: name: Unique Bubble History Background Graph version: 1.3.1 creator: Torsten supported: - button description: Shows a Home Assistant sensor history graph as Bubble Card background. 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 { fill: var(--bubble-history-fill-color, var(--primary-color)) !important; opacity: var(--bubble-history-fill-opacity, 0.10) !important; } .bubble-history-background path.line { fill: none !important; stroke: var(--bubble-history-line-color, var(--primary-color)) !important; stroke-width: var(--bubble-history-line-width, 2.5) !important; stroke-linecap: round !important; stroke-linejoin: round !important; opacity: var(--bubble-history-line-opacity, 0.75) !important; } .bubble-history-background path.loading-line { opacity: 0.35 !important; stroke-dasharray: 6 6 !important; } .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.25; white-space: nowrap; pointer-events: none; transform: translate(-50%, 0); backdrop-filter: blur(8px); } .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 entity = cfg.entity || this.config.entity || "sensor.sma_tripower_x_pv_power"; const entityLabel = cfg.label || hass.states?.[entity]?.attributes?.friendly_name || entity || "Sensor"; const unit = hass.states?.[entity]?.attributes?.unit_of_measurement || ""; const hoursToShow = Number(cfg.hours_to_show ?? 24); const lineWidth = Number(cfg.line_width ?? 2.5); const refreshMinutes = Number(cfg.refresh_minutes ?? 0); const maxPoints = Number(cfg.max_points ?? 160); const lineOpacity = Number(cfg.line_opacity ?? 0.75); const fillOpacity = Number(cfg.fill_opacity ?? 0.10); const borderRadius = Number(cfg.border_radius ?? 28); const tooltipTop = Number(cfg.tooltip_top ?? 52); const paddingY = Number(cfg.padding_y ?? 12); const lineColor = cfg.line_color || "var(--primary-color)"; const fillColor = cfg.fill_color || lineColor; const host = this.shadowRoot?.querySelector("ha-card") || this.querySelector?.("ha-card") || card?.querySelector?.("ha-card") || card; if (!host || !entity) return ""; host.style.setProperty("--bubble-history-line-width", `${lineWidth}`); host.style.setProperty("--bubble-history-line-opacity", `${lineOpacity}`); host.style.setProperty("--bubble-history-fill-opacity", `${fillOpacity}`); host.style.setProperty("--bubble-history-border-radius", `${borderRadius}px`); host.style.setProperty("--bubble-history-line-color", lineColor); host.style.setProperty("--bubble-history-fill-color", fillColor); let bg = host.querySelector(".bubble-history-background"); let tooltip = host.querySelector(".bubble-history-tooltip"); let marker = host.querySelector(".bubble-history-marker"); 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); } const formatValue = (value) => { if (unit === "W" && Math.abs(value) >= 1000) { return `${(value / 1000).toFixed(2)} kW`; } 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 attachTooltip = (points) => { if (cfg.tooltip === false) return; host.onmousemove = (event) => { if (!points?.length) return; const rect = host.getBoundingClientRect(); const xRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); const minTime = points[0].t; const maxTime = points[points.length - 1].t; const targetTime = minTime + xRatio * (maxTime - minTime); 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; } } const x = ((nearest.t - minTime) / (maxTime - minTime || 1)) * rect.width; const safeX = Math.min(rect.width - 55, Math.max(55, x)); tooltip.innerHTML = ` ${entityLabel}: ${formatValue(nearest.v)}
${formatTime(nearest.t)} `; 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"; }; }; if (!hass?.states?.[entity]) { console.warn(`Bubble history graph: entity not found: ${entity}`); return ""; } if (!hass?.fetchWithAuth) { console.warn("Bubble history graph: hass.fetchWithAuth not available"); return ""; } if (bg.dataset.loading === "true") return ""; bg.dataset.loading = "true"; const end = new Date(); const start = new Date(end.getTime() - hoursToShow * 60 * 60 * 1000); const url = `/api/history/period/${encodeURIComponent(start.toISOString())}` + `?filter_entity_id=${encodeURIComponent(entity)}` + `&end_time=${encodeURIComponent(end.toISOString())}` + `&minimal_response`; hass.fetchWithAuth(url) .then((response) => response.json()) .then((history) => { bg.dataset.loading = "false"; 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 < 2) { console.warn(`Bubble history graph: not enough points: ${points.length}`); return; } if (points.length > maxPoints) { const step = Math.ceil(points.length / maxPoints); points = points.filter((_, index) => index % step === 0); } const width = 100; const height = 100; const minTime = Math.min(...points.map((point) => point.t)); const maxTime = Math.max(...points.map((point) => point.t)); const minValue = Math.min(...points.map((point) => point.v)); const maxValue = Math.max(...points.map((point) => point.v)); const timeRange = maxTime - minTime || 1; const valueRange = maxValue - minValue || 1; const xy = points.map((point) => { const x = ((point.t - minTime) / timeRange) * width; const y = height - paddingY - ((point.v - minValue) / valueRange) * (height - paddingY * 2); return [x, y]; }); const linePath = xy .map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}` ) .join(" "); const areaPath = `${linePath} L ${width} ${height} L 0 ${height} Z`; const svg = ` `; bg.innerHTML = svg; attachTooltip(points); }) .catch((error) => { bg.dataset.loading = "false"; console.warn("Bubble history graph error:", error); }); return ""; })()} editor: - name: entity label: Graph Sensor selector: entity: domain: sensor - name: label label: Tooltip Label selector: text: null - name: hours_to_show label: Stunden anzeigen selector: number: min: 1 max: 168 step: 1 mode: box - name: line_width label: Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: line_opacity label: Linien Sichtbarkeit selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: fill_opacity label: Flächen Sichtbarkeit selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: max_points label: Max. Datenpunkte selector: number: min: 20 max: 500 step: 10 mode: box - name: tooltip label: Tooltip anzeigen selector: boolean: null - name: tooltip_top label: Tooltip Position oben selector: number: min: 0 max: 200 step: 1 mode: box - name: padding_y label: Graph Abstand oben/unten selector: number: min: 0 max: 40 step: 1 mode: box - name: border_radius label: Eckenradius selector: number: min: 0 max: 60 step: 1 mode: box