diff --git a/unique_bubble_graph.yaml b/unique_bubble_graph.yaml index 62b2d31..47dd434 100644 --- a/unique_bubble_graph.yaml +++ b/unique_bubble_graph.yaml @@ -1,10 +1,10 @@ unique_bubble_graph: - name: Unique Bubble History Background Graph - version: 1.3.1 + name: Unique Bubble Multi History Background Graph + version: 2.6.0 creator: Torsten supported: - button - description: Shows a Home Assistant sensor history graph as Bubble Card background. + 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; @@ -43,22 +43,40 @@ unique_bubble_graph: } .bubble-history-background path.area { - fill: var(--bubble-history-fill-color, var(--primary-color)) !important; - opacity: var(--bubble-history-fill-opacity, 0.10) !important; + stroke: none !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-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 { @@ -70,13 +88,27 @@ unique_bubble_graph: background: rgba(20, 20, 20, 0.82); color: white; font-size: 11px; - line-height: 1.25; + 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; @@ -107,57 +139,119 @@ 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 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 lineColor = cfg.line_color || "var(--primary-color)"; - const fillColor = cfg.fill_color || lineColor; + 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 || !entity) return ""; + if (!host || !graphs.length) 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"); + let loader = host.querySelector(".bubble-history-loader"); if (!bg) { bg = document.createElement("div"); bg.className = "bubble-history-background"; host.prepend(bg); - - bg.innerHTML = ` - - `; + bg.innerHTML = ``; } if (!tooltip) { @@ -172,9 +266,30 @@ unique_bubble_graph: host.appendChild(marker); } - const formatValue = (value) => { - if (unit === "W" && Math.abs(value) >= 1000) { - return `${(value / 1000).toFixed(2)} kW`; + 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)) { @@ -193,36 +308,86 @@ unique_bubble_graph: }); }; - const attachTooltip = (points) => { + 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) => { - if (!points?.length) return; + 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 minTime = points[0].t; - const maxTime = points[points.length - 1].t; - const targetTime = minTime + xRatio * (maxTime - minTime); + 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); - let nearest = points[0]; - let nearestDiff = Math.abs(points[0].t - targetTime); + const rows = usableSeries.map((series) => { + const nearest = findNearestPoint(series.points, targetTime); + if (!nearest) return ""; - for (const point of points) { - const diff = Math.abs(point.t - targetTime); - if (diff < nearestDiff) { - nearest = point; - nearestDiff = diff; - } - } + return ` +
+ + ${series.label}: ${formatValue(nearest.v, series.unit)} +
+ `; + }).join(""); - const x = ((nearest.t - minTime) / (maxTime - minTime || 1)) * rect.width; - const safeX = Math.min(rect.width - 55, Math.max(55, x)); + 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 = ` - ${entityLabel}: ${formatValue(nearest.v)}
- ${formatTime(nearest.t)} + ${rows} +
${formatTime(tooltipTime)}
`; tooltip.style.display = "block"; @@ -239,66 +404,34 @@ unique_bubble_graph: }; }; - if (!hass?.states?.[entity]) { - console.warn(`Bubble history graph: entity not found: ${entity}`); - return ""; - } + const renderGraph = (seriesList) => { + const usableSeries = extendSeriesToNow(seriesList) + .filter((series) => series.points?.length >= 2); - if (!hass?.fetchWithAuth) { - console.warn("Bubble history graph: hass.fetchWithAuth not available"); - return ""; - } + if (!usableSeries.length) return false; - if (bg.dataset.loading === "true") return ""; - bg.dataset.loading = "true"; + const width = 100; + const height = 100; - const end = new Date(); - const start = new Date(end.getTime() - hoursToShow * 60 * 60 * 1000); + 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 url = - `/api/history/period/${encodeURIComponent(start.toISOString())}` + - `?filter_entity_id=${encodeURIComponent(entity)}` + - `&end_time=${encodeURIComponent(end.toISOString())}` + - `&minimal_response`; + const buildPath = (series) => { + const rawMinValue = Math.min(...series.points.map((point) => point.v)); + const rawMaxValue = Math.max(...series.points.map((point) => point.v)); - hass.fetchWithAuth(url) - .then((response) => response.json()) - .then((history) => { - bg.dataset.loading = "false"; + const rawRange = rawMaxValue - rawMinValue; + const safeRange = rawRange || Math.max(Math.abs(rawMaxValue), 1); + const valuePadding = safeRange * (valuePaddingPercent / 100); - const states = Array.isArray(history) ? history.flat() : []; + const minValue = rawMinValue - valuePadding; + const maxValue = rawMaxValue + valuePadding; - 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 timeRange = globalMaxTime - globalMinTime || 1; const valueRange = maxValue - minValue || 1; - const xy = points.map((point) => { - const x = ((point.t - minTime) / timeRange) * width; + const xy = series.points.map((point) => { + const x = ((point.t - globalMinTime) / timeRange) * width; const y = height - paddingY - @@ -308,51 +441,362 @@ unique_bubble_graph: return [x, y]; }); - const linePath = xy + 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 svg = ` - + const area = series.fillOpacity > 0 + ? `` + : ""; + + const line = ` + `; - bg.innerHTML = svg; - attachTooltip(points); + 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) => { - bg.dataset.loading = "false"; + hideLoader(); + + window.__uniqueBubbleGraphDataCache[cacheKey] = { + ...(window.__uniqueBubbleGraphDataCache[cacheKey] || {}), + loading: false, + timestamp: Date.now() + }; + 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 + 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: Linien Dicke + label: Anzeige · Standard Linien Dicke selector: number: min: 0.5 @@ -360,7 +804,7 @@ unique_bubble_graph: step: 0.1 mode: box - name: line_opacity - label: Linien Sichtbarkeit + label: Anzeige · Linien Sichtbarkeit selector: number: min: 0 @@ -368,35 +812,23 @@ unique_bubble_graph: step: 0.05 mode: slider - name: fill_opacity - label: Flächen Sichtbarkeit + label: Anzeige · Standard Fläche 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 + - name: value_padding_percent + label: Anzeige · Abstand oben/unten Prozent selector: number: min: 0 - max: 200 + max: 30 step: 1 mode: box - name: padding_y - label: Graph Abstand oben/unten + label: Anzeige · Graph Abstand oben/unten selector: number: min: 0 @@ -404,10 +836,43 @@ unique_bubble_graph: step: 1 mode: box - name: border_radius - label: Eckenradius + 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