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 `
${series.label}: ${formatTooltipValue(nearest, series)}
`; }).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)); tooltipElement.innerHTML = ` ${rows}
${formatTime(tooltipTime)}
`; tooltipElement.style.display = "block"; markerElement.style.display = "block"; tooltipElement.style.left = `${safeX}px`; tooltipElement.style.top = `${host.__bubbleHistoryTooltipTop}px`; markerElement.style.left = `${x}px`; }; host.onmouseleave = () => { const tooltipElement = host.__bubbleHistoryTooltip; const markerElement = host.__bubbleHistoryMarker; if (tooltipElement) tooltipElement.style.display = "none"; if (markerElement) markerElement.style.display = "none"; }; }; const getSameScaleRange = (seriesList) => { const allValues = seriesList.flatMap((series) => (series.points || []).map((point) => point.v) ); if (!allValues.length) return null; const rawMinValue = Math.min(...allValues); const rawMaxValue = Math.max(...allValues); const rawRange = rawMaxValue - rawMinValue; const safeRange = rawRange || Math.max(Math.abs(rawMaxValue), 1); const valuePadding = safeRange * (valuePaddingPercent / 100); const calculatedMinValue = rawMinValue - valuePadding; const calculatedMaxValue = rawMaxValue + valuePadding; return { minValue: Number.isFinite(sameScaleMin) ? sameScaleMin : calculatedMinValue, maxValue: Number.isFinite(sameScaleMax) ? sameScaleMax : calculatedMaxValue }; }; 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 sameScaleRange = sameScale ? getSameScaleRange(usableSeries) : null; const buildPath = (series) => { let minValue; let maxValue; if (sameScaleRange) { minValue = sameScaleRange.minValue; maxValue = sameScaleRange.maxValue; } else { 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); minValue = rawMinValue - valuePadding; 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 = ` `; host.__bubbleHistorySeries = usableSeries; host.__bubbleHistoryMinTime = globalMinTime; host.__bubbleHistoryMaxTime = globalMaxTime; attachTooltipOnce(); 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 applyCurrentGraphSettingsToSeries = (seriesList) => { if (!Array.isArray(seriesList)) return []; return seriesList.map((cachedSeries) => { const currentGraph = validGraphs.find((graph) => graph.entity === cachedSeries.entity); if (!currentGraph) return cachedSeries; return { ...cachedSeries, label: currentGraph.label, color: currentGraph.color, lineWidth: currentGraph.lineWidth, lineOpacity: currentGraph.lineOpacity, fillOpacity: currentGraph.fillOpacity, rawUnit: currentGraph.rawUnit, unit: currentGraph.unit, valueMode: currentGraph.valueMode, maxValue: currentGraph.maxValue }; }); }; const dataCacheParts = validGraphs .map((graph) => `${graph.entity}:${graph.valueMode}:${graph.maxValue ?? ""}`) .join("|"); const cacheKey = [ "unique-bubble-graph-data-v285", dataCacheParts, hoursToShow, maxPoints, sameScale ? "same-scale" : "individual-scale", sameScaleMin ?? "auto-min", sameScaleMax ?? "auto-max" ].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) { cache = { ...cache, seriesList: applyCurrentGraphSettingsToSeries(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) => { const rawValue = Number(state.s ?? state.state); return { t: new Date(state.lu || state.lc || state.last_updated || state.last_changed).getTime(), v: transformValueForGraph(rawValue, graph), rawV: rawValue }; }) .filter((point) => Number.isFinite(point.t) && Number.isFinite(point.v) && Number.isFinite(point.rawV) ) .sort((a, b) => a.t - b.t); points = downsampleMinMax(points, maxPoints); 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: - type: expandable name: general_settings title: Allgemein icon: mdi:cog-outline expanded: true schema: - name: hours_to_show label: Stunden anzeigen selector: number: min: 1 max: 168 step: 1 mode: box - name: refresh_minutes label: Aktualisierung Minuten selector: number: min: 1 max: 120 step: 1 mode: box - name: persistent_cache label: Cache nach F5 behalten selector: boolean: null - name: max_cache_age_hours label: Max. Cache-Alter Stunden selector: number: min: 1 max: 168 step: 1 mode: box - name: show_loading label: Lade-Kringel anzeigen selector: boolean: null - type: expandable name: scale_settings title: Skalierung & Prozent-Umrechnung icon: mdi:chart-line-variant expanded: true schema: - name: same_scale label: Gemeinsame Skalierung für alle Graphen selector: boolean: null - name: same_scale_min label: Gemeinsame Skala Minimum selector: number: min: -100000 max: 100000 step: 1 mode: box - name: same_scale_max label: Gemeinsame Skala Maximum selector: number: min: -100000 max: 100000 step: 1 mode: box - type: expandable name: graph_1_settings title: ① Graph icon: mdi:numeric-1-circle-outline expanded: true schema: - name: graph_1_entity label: Sensor selector: entity: domain: sensor - name: graph_1_label label: Label selector: text: null - name: graph_1_color label: Farbe selector: color_rgb: null - name: graph_1_line_width label: Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: graph_1_fill_opacity label: Fläche selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: graph_1_value_mode label: Wert-Modus selector: select: options: - label: Originalwert value: default - label: Prozent von Maximalwert value: percent_of_max - name: graph_1_max_value label: Maximalwert für Prozent selector: number: min: 0 max: 100000 step: 1 mode: box - type: expandable name: graph_2_settings title: ② Graph icon: mdi:numeric-2-circle-outline expanded: false schema: - name: graph_2_entity label: Sensor selector: entity: domain: sensor - name: graph_2_label label: Label selector: text: null - name: graph_2_color label: Farbe selector: color_rgb: null - name: graph_2_line_width label: Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: graph_2_fill_opacity label: Fläche selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: graph_2_value_mode label: Wert-Modus selector: select: options: - label: Originalwert value: default - label: Prozent von Maximalwert value: percent_of_max - name: graph_2_max_value label: Maximalwert für Prozent selector: number: min: 0 max: 100000 step: 1 mode: box - type: expandable name: graph_3_settings title: ③ Graph icon: mdi:numeric-3-circle-outline expanded: false schema: - name: graph_3_entity label: Sensor selector: entity: domain: sensor - name: graph_3_label label: Label selector: text: null - name: graph_3_color label: Farbe selector: color_rgb: null - name: graph_3_line_width label: Linien Dicke selector: number: min: 0.5 max: 10 step: 0.1 mode: box - name: graph_3_fill_opacity label: Fläche selector: number: min: 0 max: 1 step: 0.05 mode: slider - name: graph_3_value_mode label: Wert-Modus selector: select: options: - label: Originalwert value: default - label: Prozent von Maximalwert value: percent_of_max - name: graph_3_max_value label: Maximalwert für Prozent selector: number: min: 0 max: 100000 step: 1 mode: box - type: expandable name: advanced_settings title: Erweitert icon: mdi:tune-variant expanded: false schema: - name: extend_to_now label: Linie bis jetzt verlängern selector: boolean: null - name: extend_threshold_minutes label: Verlängern nach Minuten selector: number: min: 0 max: 60 step: 1 mode: box - 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