unique_bubble_graph.yaml aktualisiert
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
unique_bubble_graph:
|
unique_bubble_graph:
|
||||||
name: Unique Bubble History Background Graph
|
name: Unique Bubble Multi History Background Graph
|
||||||
version: 1.3.1
|
version: 2.6.0
|
||||||
creator: Torsten
|
creator: Torsten
|
||||||
supported:
|
supported:
|
||||||
- button
|
- 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: |
|
code: |
|
||||||
ha-card {
|
ha-card {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
@@ -43,22 +43,40 @@ unique_bubble_graph:
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bubble-history-background path.area {
|
.bubble-history-background path.area {
|
||||||
fill: var(--bubble-history-fill-color, var(--primary-color)) !important;
|
stroke: none !important;
|
||||||
opacity: var(--bubble-history-fill-opacity, 0.10) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-history-background path.line {
|
.bubble-history-background path.line {
|
||||||
fill: none !important;
|
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-linecap: round !important;
|
||||||
stroke-linejoin: round !important;
|
stroke-linejoin: round !important;
|
||||||
opacity: var(--bubble-history-line-opacity, 0.75) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-history-background path.loading-line {
|
.bubble-history-loader {
|
||||||
opacity: 0.35 !important;
|
position: absolute !important;
|
||||||
stroke-dasharray: 6 6 !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 {
|
.bubble-history-tooltip {
|
||||||
@@ -70,13 +88,27 @@ unique_bubble_graph:
|
|||||||
background: rgba(20, 20, 20, 0.82);
|
background: rgba(20, 20, 20, 0.82);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.25;
|
line-height: 1.35;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
backdrop-filter: blur(8px);
|
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 {
|
.bubble-history-marker {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
z-index: 8 !important;
|
z-index: 8 !important;
|
||||||
@@ -107,57 +139,119 @@ unique_bubble_graph:
|
|||||||
this.config.history_background_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 hoursToShow = Number(cfg.hours_to_show ?? 24);
|
||||||
const lineWidth = Number(cfg.line_width ?? 2.5);
|
const refreshMinutes = Number(cfg.refresh_minutes ?? 10);
|
||||||
const refreshMinutes = Number(cfg.refresh_minutes ?? 0);
|
const maxPoints = Number(cfg.max_points ?? 120);
|
||||||
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 borderRadius = Number(cfg.border_radius ?? 28);
|
||||||
const tooltipTop = Number(cfg.tooltip_top ?? 52);
|
const tooltipTop = Number(cfg.tooltip_top ?? 52);
|
||||||
const paddingY = Number(cfg.padding_y ?? 12);
|
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 extendToNow = cfg.extend_to_now !== false;
|
||||||
const fillColor = cfg.fill_color || lineColor;
|
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")
|
const host = this.shadowRoot?.querySelector("ha-card")
|
||||||
|| this.querySelector?.("ha-card")
|
|| this.querySelector?.("ha-card")
|
||||||
|| card?.querySelector?.("ha-card")
|
|| card?.querySelector?.("ha-card")
|
||||||
|| 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-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 bg = host.querySelector(".bubble-history-background");
|
||||||
let tooltip = host.querySelector(".bubble-history-tooltip");
|
let tooltip = host.querySelector(".bubble-history-tooltip");
|
||||||
let marker = host.querySelector(".bubble-history-marker");
|
let marker = host.querySelector(".bubble-history-marker");
|
||||||
|
let loader = host.querySelector(".bubble-history-loader");
|
||||||
|
|
||||||
if (!bg) {
|
if (!bg) {
|
||||||
bg = document.createElement("div");
|
bg = document.createElement("div");
|
||||||
bg.className = "bubble-history-background";
|
bg.className = "bubble-history-background";
|
||||||
host.prepend(bg);
|
host.prepend(bg);
|
||||||
|
bg.innerHTML = `<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>`;
|
||||||
bg.innerHTML = `
|
|
||||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
||||||
<path class="line loading-line" d="M 0 70 L 20 55 L 40 65 L 60 45 L 80 58 L 100 38"></path>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tooltip) {
|
if (!tooltip) {
|
||||||
@@ -172,9 +266,30 @@ unique_bubble_graph:
|
|||||||
host.appendChild(marker);
|
host.appendChild(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatValue = (value) => {
|
if (!loader) {
|
||||||
if (unit === "W" && Math.abs(value) >= 1000) {
|
loader = document.createElement("div");
|
||||||
return `${(value / 1000).toFixed(2)} kW`;
|
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)) {
|
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;
|
if (cfg.tooltip === false) return;
|
||||||
|
|
||||||
host.onmousemove = (event) => {
|
host.onmousemove = (event) => {
|
||||||
if (!points?.length) return;
|
const usableSeries = seriesList.filter((series) => series.points?.length);
|
||||||
|
if (!usableSeries.length) return;
|
||||||
|
|
||||||
const rect = host.getBoundingClientRect();
|
const rect = host.getBoundingClientRect();
|
||||||
const xRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
const xRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||||
|
|
||||||
const minTime = points[0].t;
|
const globalMinTime = Math.min(...usableSeries.map((series) => series.points[0].t));
|
||||||
const maxTime = points[points.length - 1].t;
|
const globalMaxTime = Math.max(...usableSeries.map((series) => series.points[series.points.length - 1].t));
|
||||||
const targetTime = minTime + xRatio * (maxTime - minTime);
|
const targetTime = globalMinTime + xRatio * (globalMaxTime - globalMinTime);
|
||||||
|
|
||||||
let nearest = points[0];
|
const rows = usableSeries.map((series) => {
|
||||||
let nearestDiff = Math.abs(points[0].t - targetTime);
|
const nearest = findNearestPoint(series.points, targetTime);
|
||||||
|
if (!nearest) return "";
|
||||||
|
|
||||||
for (const point of points) {
|
return `
|
||||||
const diff = Math.abs(point.t - targetTime);
|
<div class="bubble-history-tooltip-row">
|
||||||
if (diff < nearestDiff) {
|
<span class="bubble-history-tooltip-dot" style="background:${series.color};"></span>
|
||||||
nearest = point;
|
<span><strong>${series.label}:</strong> ${formatValue(nearest.v, series.unit)}</span>
|
||||||
nearestDiff = diff;
|
</div>
|
||||||
}
|
`;
|
||||||
}
|
}).join("");
|
||||||
|
|
||||||
const x = ((nearest.t - minTime) / (maxTime - minTime || 1)) * rect.width;
|
const firstNearest = findNearestPoint(usableSeries[0].points, targetTime);
|
||||||
const safeX = Math.min(rect.width - 55, Math.max(55, x));
|
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 = `
|
tooltip.innerHTML = `
|
||||||
<strong>${entityLabel}: ${formatValue(nearest.v)}</strong><br>
|
${rows}
|
||||||
${formatTime(nearest.t)}
|
<div style="opacity:0.75;margin-top:3px;">${formatTime(tooltipTime)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tooltip.style.display = "block";
|
tooltip.style.display = "block";
|
||||||
@@ -239,66 +404,34 @@ unique_bubble_graph:
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!hass?.states?.[entity]) {
|
const renderGraph = (seriesList) => {
|
||||||
console.warn(`Bubble history graph: entity not found: ${entity}`);
|
const usableSeries = extendSeriesToNow(seriesList)
|
||||||
return "";
|
.filter((series) => series.points?.length >= 2);
|
||||||
}
|
|
||||||
|
|
||||||
if (!hass?.fetchWithAuth) {
|
if (!usableSeries.length) return false;
|
||||||
console.warn("Bubble history graph: hass.fetchWithAuth not available");
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bg.dataset.loading === "true") return "";
|
const width = 100;
|
||||||
bg.dataset.loading = "true";
|
const height = 100;
|
||||||
|
|
||||||
const end = new Date();
|
const globalMinTime = Math.min(...usableSeries.map((series) => series.points[0].t));
|
||||||
const start = new Date(end.getTime() - hoursToShow * 60 * 60 * 1000);
|
const globalMaxTime = Math.max(...usableSeries.map((series) => series.points[series.points.length - 1].t));
|
||||||
|
|
||||||
const url =
|
const buildPath = (series) => {
|
||||||
`/api/history/period/${encodeURIComponent(start.toISOString())}` +
|
const rawMinValue = Math.min(...series.points.map((point) => point.v));
|
||||||
`?filter_entity_id=${encodeURIComponent(entity)}` +
|
const rawMaxValue = Math.max(...series.points.map((point) => point.v));
|
||||||
`&end_time=${encodeURIComponent(end.toISOString())}` +
|
|
||||||
`&minimal_response`;
|
|
||||||
|
|
||||||
hass.fetchWithAuth(url)
|
const rawRange = rawMaxValue - rawMinValue;
|
||||||
.then((response) => response.json())
|
const safeRange = rawRange || Math.max(Math.abs(rawMaxValue), 1);
|
||||||
.then((history) => {
|
const valuePadding = safeRange * (valuePaddingPercent / 100);
|
||||||
bg.dataset.loading = "false";
|
|
||||||
|
|
||||||
const states = Array.isArray(history) ? history.flat() : [];
|
const minValue = rawMinValue - valuePadding;
|
||||||
|
const maxValue = rawMaxValue + valuePadding;
|
||||||
|
|
||||||
let points = states
|
const timeRange = globalMaxTime - globalMinTime || 1;
|
||||||
.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 valueRange = maxValue - minValue || 1;
|
||||||
|
|
||||||
const xy = points.map((point) => {
|
const xy = series.points.map((point) => {
|
||||||
const x = ((point.t - minTime) / timeRange) * width;
|
const x = ((point.t - globalMinTime) / timeRange) * width;
|
||||||
const y =
|
const y =
|
||||||
height -
|
height -
|
||||||
paddingY -
|
paddingY -
|
||||||
@@ -308,51 +441,362 @@ unique_bubble_graph:
|
|||||||
return [x, y];
|
return [x, y];
|
||||||
});
|
});
|
||||||
|
|
||||||
const linePath = xy
|
return xy
|
||||||
.map(([x, y], index) =>
|
.map(([x, y], index) =>
|
||||||
`${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`
|
`${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`
|
||||||
)
|
)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const paths = usableSeries.map((series, index) => {
|
||||||
|
const linePath = buildPath(series);
|
||||||
const areaPath = `${linePath} L ${width} ${height} L 0 ${height} Z`;
|
const areaPath = `${linePath} L ${width} ${height} L 0 ${height} Z`;
|
||||||
|
|
||||||
const svg = `
|
const area = series.fillOpacity > 0
|
||||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
? `<path class="area area-${index + 1}" d="${areaPath}" style="fill:${series.color};opacity:${series.fillOpacity};"></path>`
|
||||||
<path class="area" d="${areaPath}"></path>
|
: "";
|
||||||
<path class="line" d="${linePath}"></path>
|
|
||||||
</svg>
|
const line = `
|
||||||
|
<path
|
||||||
|
class="line line-${index + 1}"
|
||||||
|
d="${linePath}"
|
||||||
|
style="
|
||||||
|
stroke:${series.color};
|
||||||
|
stroke-width:${series.lineWidth};
|
||||||
|
opacity:${series.lineOpacity};
|
||||||
|
"
|
||||||
|
></path>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
bg.innerHTML = svg;
|
return `${area}${line}`;
|
||||||
attachTooltip(points);
|
}).join("");
|
||||||
|
|
||||||
|
bg.innerHTML = `
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
${paths}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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) => {
|
.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);
|
console.warn("Bubble history graph error:", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
})()}
|
})()}
|
||||||
editor:
|
editor:
|
||||||
- name: entity
|
|
||||||
label: Graph Sensor
|
|
||||||
selector:
|
|
||||||
entity:
|
|
||||||
domain: sensor
|
|
||||||
- name: label
|
|
||||||
label: Tooltip Label
|
|
||||||
selector:
|
|
||||||
text: null
|
|
||||||
- name: hours_to_show
|
- name: hours_to_show
|
||||||
label: Stunden anzeigen
|
label: Allgemein · Stunden anzeigen
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 1
|
min: 1
|
||||||
max: 168
|
max: 168
|
||||||
step: 1
|
step: 1
|
||||||
mode: box
|
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
|
- name: line_width
|
||||||
label: Linien Dicke
|
label: Anzeige · Standard Linien Dicke
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 0.5
|
min: 0.5
|
||||||
@@ -360,7 +804,7 @@ unique_bubble_graph:
|
|||||||
step: 0.1
|
step: 0.1
|
||||||
mode: box
|
mode: box
|
||||||
- name: line_opacity
|
- name: line_opacity
|
||||||
label: Linien Sichtbarkeit
|
label: Anzeige · Linien Sichtbarkeit
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 0
|
min: 0
|
||||||
@@ -368,35 +812,23 @@ unique_bubble_graph:
|
|||||||
step: 0.05
|
step: 0.05
|
||||||
mode: slider
|
mode: slider
|
||||||
- name: fill_opacity
|
- name: fill_opacity
|
||||||
label: Flächen Sichtbarkeit
|
label: Anzeige · Standard Fläche
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 0
|
min: 0
|
||||||
max: 1
|
max: 1
|
||||||
step: 0.05
|
step: 0.05
|
||||||
mode: slider
|
mode: slider
|
||||||
- name: max_points
|
- name: value_padding_percent
|
||||||
label: Max. Datenpunkte
|
label: Anzeige · Abstand oben/unten Prozent
|
||||||
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:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 0
|
min: 0
|
||||||
max: 200
|
max: 30
|
||||||
step: 1
|
step: 1
|
||||||
mode: box
|
mode: box
|
||||||
- name: padding_y
|
- name: padding_y
|
||||||
label: Graph Abstand oben/unten
|
label: Anzeige · Graph Abstand oben/unten
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 0
|
min: 0
|
||||||
@@ -404,10 +836,43 @@ unique_bubble_graph:
|
|||||||
step: 1
|
step: 1
|
||||||
mode: box
|
mode: box
|
||||||
- name: border_radius
|
- name: border_radius
|
||||||
label: Eckenradius
|
label: Anzeige · Eckenradius
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 0
|
min: 0
|
||||||
max: 60
|
max: 60
|
||||||
step: 1
|
step: 1
|
||||||
mode: box
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user