1310 lines
41 KiB
YAML
1310 lines
41 KiB
YAML
unique_bubble_graph:
|
|
name: Unique Bubble Multi History Background Graph
|
|
version: 2.8.7
|
|
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-history-point-markers {
|
|
position: absolute !important;
|
|
inset: 0 !important;
|
|
z-index: 8 !important;
|
|
display: none;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.bubble-history-point-marker {
|
|
position: absolute !important;
|
|
width: 9px;
|
|
height: 9px;
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
box-shadow:
|
|
0 0 0 2px rgba(20, 20, 20, 0.55),
|
|
0 0 8px rgba(255, 255, 255, 0.65);
|
|
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 tooltipIndicator = ["points", "line", "both", "none"].includes(cfg.tooltip_indicator)
|
|
? cfg.tooltip_indicator
|
|
: "points";
|
|
|
|
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 pointLayer = host.querySelector(".bubble-history-point-markers");
|
|
let loader = host.querySelector(".bubble-history-loader");
|
|
|
|
if (!bg) {
|
|
bg = document.createElement("div");
|
|
bg.className = "bubble-history-background";
|
|
host.prepend(bg);
|
|
bg.innerHTML = `<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>`;
|
|
}
|
|
|
|
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 (!pointLayer) {
|
|
pointLayer = document.createElement("div");
|
|
pointLayer.className = "bubble-history-point-markers";
|
|
host.appendChild(pointLayer);
|
|
}
|
|
|
|
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.__bubbleHistoryPointLayer = pointLayer;
|
|
host.__bubbleHistoryTooltipTop = tooltipTop;
|
|
host.__bubbleHistoryTooltipIndicator = tooltipIndicator;
|
|
host.__bubbleHistoryPaddingY = paddingY;
|
|
|
|
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;
|
|
const pointLayerElement = host.__bubbleHistoryPointLayer;
|
|
|
|
if (!tooltipElement || !markerElement || !pointLayerElement) 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 timeRange = globalMaxTime - globalMinTime || 1;
|
|
const indicatorMode = host.__bubbleHistoryTooltipIndicator || "points";
|
|
|
|
const nearestBySeries = usableSeries.map((series) => ({
|
|
series,
|
|
nearest: findNearestPoint(series.points, targetTime)
|
|
})).filter((entry) => entry.nearest);
|
|
|
|
const rows = nearestBySeries.map(({ series, nearest }) => {
|
|
return `
|
|
<div class="bubble-history-tooltip-row">
|
|
<span class="bubble-history-tooltip-dot" style="background:${series.color};"></span>
|
|
<span><strong>${series.label}:</strong> ${formatTooltipValue(nearest, series)}</span>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
|
|
const firstNearest = nearestBySeries[0]?.nearest;
|
|
const tooltipTime = firstNearest ? firstNearest.t : targetTime;
|
|
|
|
const x = ((targetTime - globalMinTime) / timeRange) * rect.width;
|
|
const safeX = Math.min(rect.width - 60, Math.max(60, x));
|
|
|
|
const pointMarkers = nearestBySeries.map(({ series, nearest }) => {
|
|
const minValue = Number.isFinite(series.__minValue) ? series.__minValue : 0;
|
|
const maxValue = Number.isFinite(series.__maxValue) ? series.__maxValue : 1;
|
|
const valueRange = maxValue - minValue || 1;
|
|
const pointX = ((nearest.t - globalMinTime) / timeRange) * rect.width;
|
|
const pointYView =
|
|
100 -
|
|
host.__bubbleHistoryPaddingY -
|
|
((nearest.v - minValue) / valueRange) *
|
|
(100 - host.__bubbleHistoryPaddingY * 2);
|
|
const pointY = (pointYView / 100) * rect.height;
|
|
|
|
return `
|
|
<span
|
|
class="bubble-history-point-marker"
|
|
style="left:${pointX}px;top:${pointY}px;background:${series.color};"
|
|
></span>
|
|
`;
|
|
}).join("");
|
|
|
|
tooltipElement.innerHTML = `
|
|
${rows}
|
|
<div style="opacity:0.75;margin-top:3px;">${formatTime(tooltipTime)}</div>
|
|
`;
|
|
|
|
tooltipElement.style.display = "block";
|
|
tooltipElement.style.left = `${safeX}px`;
|
|
tooltipElement.style.top = `${host.__bubbleHistoryTooltipTop}px`;
|
|
|
|
if (indicatorMode === "line" || indicatorMode === "both") {
|
|
markerElement.style.display = "block";
|
|
markerElement.style.left = `${x}px`;
|
|
} else {
|
|
markerElement.style.display = "none";
|
|
}
|
|
|
|
if (indicatorMode === "points" || indicatorMode === "both") {
|
|
pointLayerElement.innerHTML = pointMarkers;
|
|
pointLayerElement.style.display = "block";
|
|
} else {
|
|
pointLayerElement.innerHTML = "";
|
|
pointLayerElement.style.display = "none";
|
|
}
|
|
};
|
|
|
|
host.onmouseleave = () => {
|
|
const tooltipElement = host.__bubbleHistoryTooltip;
|
|
const markerElement = host.__bubbleHistoryMarker;
|
|
const pointLayerElement = host.__bubbleHistoryPointLayer;
|
|
|
|
if (tooltipElement) tooltipElement.style.display = "none";
|
|
if (markerElement) markerElement.style.display = "none";
|
|
if (pointLayerElement) {
|
|
pointLayerElement.style.display = "none";
|
|
pointLayerElement.innerHTML = "";
|
|
}
|
|
};
|
|
};
|
|
|
|
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 getValueRange = (series) => {
|
|
if (sameScaleRange) {
|
|
return {
|
|
minValue: sameScaleRange.minValue,
|
|
maxValue: sameScaleRange.maxValue
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
return {
|
|
minValue: rawMinValue - valuePadding,
|
|
maxValue: rawMaxValue + valuePadding
|
|
};
|
|
};
|
|
|
|
const renderSeries = usableSeries.map((series) => {
|
|
const range = getValueRange(series);
|
|
|
|
return {
|
|
...series,
|
|
__minValue: range.minValue,
|
|
__maxValue: range.maxValue
|
|
};
|
|
});
|
|
|
|
const buildPath = (series) => {
|
|
const minValue = series.__minValue;
|
|
const maxValue = series.__maxValue;
|
|
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 = renderSeries.map((series, index) => {
|
|
const linePath = buildPath(series);
|
|
const areaPath = `${linePath} L ${width} ${height} L 0 ${height} Z`;
|
|
|
|
const area = series.fillOpacity > 0
|
|
? `<path class="area area-${index + 1}" d="${areaPath}" style="fill:${series.color};opacity:${series.fillOpacity};"></path>`
|
|
: "";
|
|
|
|
const line = `
|
|
<path
|
|
class="line line-${index + 1}"
|
|
d="${linePath}"
|
|
style="
|
|
stroke:${series.color};
|
|
stroke-width:${series.lineWidth};
|
|
opacity:${series.lineOpacity};
|
|
"
|
|
></path>
|
|
`;
|
|
|
|
return `${area}${line}`;
|
|
}).join("");
|
|
|
|
bg.innerHTML = `
|
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
${paths}
|
|
</svg>
|
|
`;
|
|
|
|
host.__bubbleHistorySeries = renderSeries;
|
|
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_indicator
|
|
label: Tooltip Markierung
|
|
selector:
|
|
select:
|
|
options:
|
|
- label: Punkte auf dem Graphen
|
|
value: points
|
|
- label: Vertikale Linie
|
|
value: line
|
|
- label: Punkte und Linie
|
|
value: both
|
|
- label: Keine Markierung
|
|
value: none
|
|
- name: tooltip_top
|
|
label: Tooltip Position oben
|
|
selector:
|
|
number:
|
|
min: 0
|
|
max: 200
|
|
step: 1
|
|
mode: box
|