Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/renderers/sparkline.ts
T
2026-07-08 10:43:39 -04:00

362 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Trigger section renderer (task detail).
*
* Owns the task semantics — which values the chart shows and what the
* reference lines mean — and delegates the actual plotting to the
* responsive <maintenance-trigger-chart> component:
*
* - threshold tasks plot the raw sensor with the danger zone shaded;
* - counter tasks plot **progress since the last service** (cumulative,
* clamped at 0 — an odometer can never be negative) against the target,
* headed by a "8,507 / 15,000 km · 57 %" progress bar;
* - everything else plots the raw series.
*/
import { html, nothing } from "lit";
import { t, fireMoreInfo } from "../styles";
import { fmtNum, fmtVal } from "./chart-utils";
import "../components/trigger-chart";
import type { ChartPoint, ChartEvent } from "../components/trigger-chart";
import type { MaintenanceTask, TriggerConfig, StatisticsPoint } from "../types";
export interface SparklineContext {
lang: string;
detailStatsData: Map<string, StatisticsPoint[]>;
hasStatsService: boolean;
isCounterEntity: (tc: TriggerConfig) => boolean;
rangeDays: number;
setRangeDays: (days: number) => void;
/** When true, drop statistical outliers (sensor glitches) from the chart. */
hideOutliers: boolean;
setHideOutliers: (hide: boolean) => void;
}
/** Drop outliers via the IQR fence (Tukey): keep points within
* [Q1 1.5·IQR, Q3 + 1.5·IQR]. Robust to a few wild glitch readings (a
* pressure sensor spiking to 100 while it normally sits at 1.53). No-ops on
* short series (< 4 points) where quartiles aren't meaningful. */
export function filterOutliers(points: ChartPoint[]): ChartPoint[] {
if (points.length < 4) return points;
const vals = points.map((p) => p.val).sort((a, b) => a - b);
const q = (frac: number) => {
const idx = (vals.length - 1) * frac;
const lo = Math.floor(idx), hi = Math.ceil(idx);
return vals[lo] + (vals[hi] - vals[lo]) * (idx - lo);
};
const q1 = q(0.25), q3 = q(0.75), iqr = q3 - q1;
if (iqr === 0) return points; // flat/degenerate — nothing to trim
const lower = q1 - 1.5 * iqr, upper = q3 + 1.5 * iqr;
const kept = points.filter((p) => p.val >= lower && p.val <= upper);
return kept.length >= 2 ? kept : points; // never strip below a drawable series
}
export function renderTriggerSection(task: MaintenanceTask, ctx: SparklineContext) {
const tc = task.trigger_config;
if (!tc) return nothing;
const L = ctx.lang;
const info = task.trigger_entity_info;
const infos = task.trigger_entity_infos;
const friendlyName = info?.friendly_name || tc.entity_id || "—";
const entityId = tc.entity_id || "";
const entityIds = tc.entity_ids || (entityId ? [entityId] : []);
const unit = info?.unit_of_measurement || "";
const currentVal = task.trigger_current_value;
const triggerType = tc.type || "threshold";
const isMultiEntity = entityIds.length > 1;
const spec = progressSpec(task, unit, ctx);
return html`
<h3>${t("trigger", L)}</h3>
<div class="trigger-card">
<div class="trigger-header">
<ha-icon icon="mdi:pulse" style="color: var(--primary-color); --mdc-icon-size: 20px;"></ha-icon>
<div>
${isMultiEntity ? html`
<div class="trigger-entity-name">${entityIds.length} ${t("entities", L)} (${tc.entity_logic || "any"})</div>
<div class="trigger-entity-id">${entityIds.map((eid, i) => html`${i > 0 ? ", " : ""}<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, eid)}>${eid}</span>`)}${tc.attribute ? ` → ${tc.attribute}` : ""}</div>
` : html`
<div class="trigger-entity-name">${friendlyName}</div>
<div class="trigger-entity-id">${entityId ? html`<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, entityId)}>${entityId}</span>` : ""}${tc.attribute ? ` → ${tc.attribute}` : ""}</div>
`}
</div>
<span class="status-badge ${task.trigger_active ? "triggered" : "ok"}" style="margin-left: auto;">
${task.trigger_active ? t("triggered", L) : t("ok", L)}
</span>
</div>
${spec
? renderProgress(spec, L)
: currentVal !== null && currentVal !== undefined
? html`
<div class="trigger-value-row">
<span class="trigger-current ${task.trigger_active ? "active" : ""}">${typeof currentVal === "number" ? fmtVal(currentVal, "", L) : currentVal}</span>
${unit ? html`<span class="trigger-unit">${unit}</span>` : nothing}
</div>
`
: nothing}
<div class="trigger-limits">
${triggerType === "threshold" ? html`
${tc.trigger_above != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("threshold_above", L)}: ${tc.trigger_above} ${unit}</span>` : nothing}
${tc.trigger_below != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("threshold_below", L)}: ${tc.trigger_below} ${unit}</span>` : nothing}
${tc.trigger_for_minutes ? html`<span class="trigger-limit-item"><span class="dot range" aria-hidden="true"></span> ${t("for_minutes", L)}: ${tc.trigger_for_minutes}</span>` : nothing}
` : nothing}
${triggerType === "state_change" ? html`
${tc.trigger_target_changes != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("target_changes", L)}: ${tc.trigger_target_changes}</span>` : nothing}
` : nothing}
${triggerType === "runtime" ? html`
${tc.trigger_runtime_hours != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("runtime_hours", L)}: ${tc.trigger_runtime_hours}h</span>` : nothing}
` : nothing}
${triggerType === "compound" ? html`
<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("compound_logic", L)}: ${tc.compound_logic || (tc as any).operator || "AND"}</span>
${(tc.conditions || []).map((cond: any, i: number) => html`
<span class="trigger-limit-item"><span class="dot range" aria-hidden="true"></span> ${i + 1}. ${t(cond.type || "unknown", L)}: ${cond.entity_id ? html`<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, cond.entity_id)}>${cond.entity_id}</span>` : ""}</span>
`)}
` : nothing}
</div>
${infos && infos.length > 1 ? html`
<div class="trigger-entity-list">
${infos.map(info => html`
<span class="trigger-entity-id">${info.friendly_name} (<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, info.entity_id)}>${info.entity_id}</span>)</span>
`)}
</div>
` : nothing}
${renderChart(task, unit, ctx)}
</div>
`;
}
/** Progress toward a trigger target — the "8,507 / 15,000 km · 57 %" story.
*
* All three accumulating trigger types map onto it: counters measure the
* raw meter against a baseline; state_change counts and runtime hours
* already accumulate from zero since the last reset.
*/
interface ProgressSpec {
progress: number;
target: number;
unit: string;
/** Raw meter reading, when it differs from the progress (counters). */
meter: number | null;
}
function progressSpec(task: MaintenanceTask, unit: string, ctx: SparklineContext): ProgressSpec | null {
const tc = task.trigger_config;
const cur = task.trigger_current_value;
if (!tc || cur == null) return null;
switch (tc.type || "threshold") {
case "counter": {
const target = tc.trigger_target_value;
if (target == null || target <= 0) return null;
const base = counterBaseline(task, rawStatsPoints(task, ctx));
return { progress: Math.max(0, cur - (base?.value ?? cur)), target, unit, meter: cur };
}
case "state_change": {
const target = tc.trigger_target_changes;
if (target == null || target <= 0) return null;
return { progress: Math.max(0, cur), target, unit: "", meter: null };
}
case "runtime": {
const target = tc.trigger_runtime_hours;
if (target == null || target <= 0) return null;
return { progress: Math.max(0, cur), target, unit: "h", meter: null };
}
}
return null;
}
/** Absolute baseline a counter's progress is measured from: the stored
* delta baseline if present, else the reading nearest the last service. */
function counterBaseline(task: MaintenanceTask, rawPoints: ChartPoint[]): { value: number; ts: number | null } | null {
if (task.trigger_baseline_value != null) {
return { value: task.trigger_baseline_value, ts: lastServiceTs(task) };
}
if (!rawPoints.length) return null;
const ts = lastServiceTs(task);
if (ts == null) return { value: rawPoints[0].val, ts: null };
let best = rawPoints[0];
let bestD = Math.abs(rawPoints[0].ts - ts);
for (const p of rawPoints) {
const d = Math.abs(p.ts - ts);
if (d < bestD) { best = p; bestD = d; }
}
return { value: best.val, ts };
}
function lastServiceTs(task: MaintenanceTask): number | null {
const e = [...task.history]
.filter((h) => h.type === "completed" || h.type === "reset")
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
return e ? new Date(e.timestamp).getTime() : null;
}
/** "8,507 / 15,000 km · 57 %" header + progress bar (counter / state_change / runtime). */
function renderProgress(spec: ProgressSpec, L: string) {
const pct = Math.min(999, Math.round((spec.progress / spec.target) * 100));
const level = pct >= 100 ? "over" : pct >= 75 ? "near" : "ok";
return html`
<div class="counter-progress">
<div class="counter-progress-nums">
<span class="counter-progress-main">${fmtVal(spec.progress, "", L)}<span class="counter-progress-target"> / ${fmtVal(spec.target, spec.unit, L)}</span></span>
<span class="counter-progress-pct ${level}">${pct} %</span>
</div>
<div class="counter-progress-bar" role="progressbar" aria-valuenow=${pct} aria-valuemin="0" aria-valuemax="100">
<div class="counter-progress-fill ${level}" style="width:${Math.min(100, pct)}%"></div>
</div>
<div class="counter-progress-caption">
${t("chart_since_service", L)}${spec.meter != null ? html` · ${t("current", L)}: ${fmtVal(spec.meter, spec.unit, L)}` : nothing}
</div>
</div>
`;
}
/** Raw stats/history series for the task's entity (no transforms). */
function rawStatsPoints(task: MaintenanceTask, ctx: SparklineContext): ChartPoint[] {
const tc = task.trigger_config;
if (!tc) return [];
const triggerType = tc.type || "threshold";
const entityId = tc.entity_id || "";
// Runtime accumulates hours DERIVED from the entity's on/off time — the
// entity's own long-term statistics (an on/off ratio, or an unrelated raw
// sensor value) are NOT that accumulation. Plot only the recorded per-cycle
// trigger_value snapshots + the live current value instead.
const statsPoints =
triggerType === "runtime" ? [] : (ctx.detailStatsData.get(entityId) || []);
const isCounter = ctx.isCounterEntity(tc);
const points: ChartPoint[] = [];
if (statsPoints.length >= 2) {
for (const sp of statsPoints) {
const pt: ChartPoint = { ts: sp.ts, val: sp.val };
if (!isCounter && sp.min != null && sp.max != null) {
pt.min = sp.min;
pt.max = sp.max;
}
points.push(pt);
}
} else {
for (const h of task.history) {
if (h.trigger_value != null) {
points.push({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value });
}
}
}
if (task.trigger_current_value != null) {
points.push({ ts: Date.now(), val: task.trigger_current_value });
}
points.sort((a, b) => a.ts - b.ts);
return points;
}
// Module-private chart assembly — maps task semantics onto chart props.
function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext) {
const tc = task.trigger_config;
if (!tc) return nothing;
const triggerType = tc.type || "threshold";
const entityId = tc.entity_id || "";
let points = rawStatsPoints(task, ctx);
// Runtime accumulates hours with no stored intermediate snapshots: synthesize
// the current cycle (0 at the last service → live value now) so the chart
// resets each completion and always has a drawable 2-point line.
if (
triggerType === "runtime" &&
tc.trigger_runtime_hours &&
task.trigger_current_value != null
) {
const cycleStart = lastServiceTs(task) ?? points[0]?.ts ?? (Date.now() - 86400000);
points = [
{ ts: cycleStart, val: 0 },
{ ts: Date.now(), val: Math.max(0, task.trigger_current_value) },
];
}
if (ctx.hideOutliers) points = filterOutliers(points);
// Still waiting for the first stats fetch → placeholder with the range chips.
const loading = points.length < 2 && !!entityId && ctx.hasStatsService && !ctx.detailStatsData.has(entityId);
if (points.length < 2 && !loading) return nothing;
// Entities without long-term statistics (input_booleans, sensors without a
// state_class) silently fall back to the sparse maintenance-event values —
// say so instead of letting the thin chart look broken.
const statsFetchedEmpty =
!!entityId && ctx.detailStatsData.has(entityId) && (ctx.detailStatsData.get(entityId)?.length ?? 0) < 2;
// The history fallback can span years; honor the selected window when enough
// points remain (never crop below a drawable series).
const cutoff = Date.now() - ctx.rangeDays * 86400000;
const inRange = points.filter((p) => p.ts >= cutoff);
if (inRange.length >= 2) points = inRange;
let targetValue: number | null = null;
let forceZero = false;
if (triggerType === "counter" && tc.trigger_target_value != null && points.length) {
// Progress domain: cumulative since the last service, never negative.
const base = counterBaseline(task, points);
if (base) {
if (base.ts != null) {
const kept = points.filter((p) => p.ts >= base.ts!);
if (kept.length >= 2) points = kept;
}
points = points.map((p) => ({ ...p, val: Math.max(0, p.val - base.value) }));
}
targetValue = tc.trigger_target_value;
forceZero = true;
} else if (triggerType === "state_change" && tc.trigger_target_changes) {
// Change counts / runtime hours already accumulate from zero.
targetValue = tc.trigger_target_changes;
forceZero = true;
} else if (triggerType === "runtime" && tc.trigger_runtime_hours) {
// Points are the synthesized current-cycle line (built above).
targetValue = tc.trigger_runtime_hours;
forceZero = true;
}
// Dashed degradation projection (30 days ahead of the last reading). Also
// shown for a "stable"-classified slope when a real threshold prediction
// exists — a slow 0.25 %/day decline classifies stable yet still crosses
// the threshold in a foreseeable number of days.
let projection: ChartPoint[] | null = null;
if (
targetValue == null &&
task.degradation_rate != null &&
(task.degradation_trend !== "stable" || task.days_until_threshold != null) &&
task.degradation_trend !== "insufficient_data" &&
points.length >= 2
) {
const lp = points[points.length - 1];
projection = [lp, { ts: lp.ts + 30 * 86400000, val: lp.val + task.degradation_rate * 30 }];
}
const events: ChartEvent[] = task.history
.filter((h) => ["completed", "skipped", "reset"].includes(h.type))
.map((h) => ({ ts: new Date(h.timestamp).getTime(), type: h.type }));
return html`
<maintenance-trigger-chart
.points=${loading ? [] : points}
.events=${events}
.unit=${unit}
.lang=${ctx.lang}
.thresholdAbove=${triggerType === "threshold" ? tc.trigger_above ?? null : null}
.thresholdBelow=${triggerType === "threshold" ? tc.trigger_below ?? null : null}
.targetValue=${targetValue}
.forceZero=${forceZero}
.projection=${projection}
.rangeDays=${ctx.rangeDays}
.hideOutliers=${ctx.hideOutliers}
.busy=${loading}
@range-change=${(e: CustomEvent<{ days: number }>) => ctx.setRangeDays(e.detail.days)}
@outlier-toggle=${(e: CustomEvent<{ hide: boolean }>) => ctx.setHideOutliers(e.detail.hide)}
></maintenance-trigger-chart>
${statsFetchedEmpty && !loading
? html`<div class="chart-note">
<ha-icon icon="mdi:information-outline"></ha-icon>
${t("chart_no_stats", ctx.lang)}
</div>`
: nothing}
`;
}