commit a195e189832690abdeafba34ff0530dca988aef7 Author: Torsten Date: Wed Jun 10 08:18:08 2026 +0000 unique_bubble_graph.yaml hinzugefügt diff --git a/unique_bubble_graph.yaml b/unique_bubble_graph.yaml new file mode 100644 index 0000000..62b2d31 --- /dev/null +++ b/unique_bubble_graph.yaml @@ -0,0 +1,413 @@ +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