/** Responsive sensor-history chart for the task detail view. * * Replaces the fixed 300x140 sparkline: renders at the card's real pixel * width (ResizeObserver), draws round y-ticks with gridlines, shades the * danger zone beyond a threshold (with the in-zone line segments in the * error color via clip paths), pins a target line for counters, moves * completion markers into a calm bottom lane, and offers a crosshair that * follows the pointer (works for touch) plus 7d/30d/90d/1y range chips. * * The component is deliberately dumb: it plots the points and reference * lines it is given. All task semantics (delta baselines, cumulative * clamping, projections) live in renderers/sparkline.ts. */ import { LitElement, html, css, svg, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { t } from "../styles"; import { niceTicks, fmtNum, fmtVal, fmtDateTick, fmtDateTime, timeTicks, needsYear } from "../renderers/chart-utils"; export interface ChartPoint { ts: number; val: number; min?: number; max?: number; } export interface ChartEvent { ts: number; type: string; // completed | skipped | reset } const H = 210; const PAD_L = 46; const PAD_R = 14; const PAD_T = 12; const LANE_H = 14; // completion-marker lane between plot and x labels const PAD_B = 20 + LANE_H; const RANGES = [ { days: 7, key: "chart_range_7d" }, { days: 30, key: "chart_range_30d" }, { days: 90, key: "chart_range_90d" }, { days: 365, key: "chart_range_1y" }, ] as const; export class MaintenanceTriggerChart extends LitElement { @property({ attribute: false }) public points: ChartPoint[] = []; @property({ attribute: false }) public events: ChartEvent[] = []; @property() public unit = ""; @property() public lang = "en"; @property({ attribute: false }) public thresholdAbove: number | null = null; @property({ attribute: false }) public thresholdBelow: number | null = null; /** Absolute y of a counter target line (points are pre-transformed). */ @property({ attribute: false }) public targetValue: number | null = null; @property({ type: Boolean }) public forceZero = false; /** Optional dashed projection segment (e.g. degradation trend). */ @property({ attribute: false }) public projection: ChartPoint[] | null = null; @property({ attribute: false }) public rangeDays = 30; @property({ type: Boolean }) public showRange = true; @property({ type: Boolean }) public busy = false; /** Reflects the parent's "hide outliers" state (filtering happens upstream). */ @property({ type: Boolean }) public hideOutliers = false; /** Show the outlier toggle chip (only where a raw oscillating series exists). */ @property({ type: Boolean }) public showOutlierToggle = true; @state() private _width = 0; @state() private _hover: { x: number; y: number; p: ChartPoint } | null = null; private _ro: ResizeObserver | null = null; connectedCallback(): void { super.connectedCallback(); this._ro = new ResizeObserver((entries) => { const w = Math.floor(entries[0]?.contentRect?.width || 0); if (w && Math.abs(w - this._width) > 2) this._width = w; }); this._ro.observe(this); } disconnectedCallback(): void { super.disconnectedCallback(); this._ro?.disconnect(); this._ro = null; } private _emitRange(days: number): void { if (days === this.rangeDays) return; this.dispatchEvent(new CustomEvent("range-change", { detail: { days }, bubbles: true, composed: true })); } private _toggleOutliers(): void { this.dispatchEvent(new CustomEvent("outlier-toggle", { detail: { hide: !this.hideOutliers }, bubbles: true, composed: true, })); } render() { const W = this._width || 320; const pts = [...this.points].sort((a, b) => a.ts - b.ts); const L = this.lang; return html`
${this.showRange ? html`
${this.showOutlierToggle ? html`` : nothing} ${RANGES.map( (r) => html``, )}
` : nothing} ${pts.length < 2 ? html`
${t("loading_chart", L)}
` : this._renderSvg(W, pts)}
`; } private _renderSvg(W: number, pts: ChartPoint[]) { const L = this.lang; const plotW = W - PAD_L - PAD_R; const plotB = H - PAD_B; const plotH = plotB - PAD_T; // Domain: data ∪ min/max band ∪ reference lines ∪ optional zero floor. let lo = Infinity; let hi = -Infinity; for (const p of pts) { lo = Math.min(lo, p.min ?? p.val); hi = Math.max(hi, p.max ?? p.val); } if (this.thresholdAbove != null) { lo = Math.min(lo, this.thresholdAbove); hi = Math.max(hi, this.thresholdAbove); } if (this.thresholdBelow != null) { lo = Math.min(lo, this.thresholdBelow); hi = Math.max(hi, this.thresholdBelow); } if (this.targetValue != null) { lo = Math.min(lo, this.targetValue); hi = Math.max(hi, this.targetValue); } if (this.forceZero) lo = Math.min(lo, 0); const pad = (hi - lo || 1) * 0.06; // A progress axis never dips below zero — padding must not create a -50 tick. const loPadded = this.forceZero && lo >= 0 ? 0 : lo - pad; let { ticks, niceMin, niceMax } = niceTicks(loPadded, hi + pad, 4); if (this.forceZero && lo >= 0 && niceMin < 0) { niceMin = 0; ticks = ticks.filter((v) => v >= 0); } const tsMin = pts[0].ts; const tsMax = pts[pts.length - 1].ts; const tsSpan = tsMax - tsMin || 1; const withYear = needsYear(tsMin, tsMax); const toX = (ts: number) => PAD_L + ((ts - tsMin) / tsSpan) * plotW; const toY = (v: number) => PAD_T + (1 - (v - niceMin) / (niceMax - niceMin || 1)) * plotH; const linePts = pts.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" "); const areaPath = `M${toX(pts[0].ts).toFixed(1)},${plotB} ` + pts.map((p) => `L${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ") + ` L${toX(pts[pts.length - 1].ts).toFixed(1)},${plotB} Z`; // Optional min/max band behind the line. let bandPath = ""; const band = pts.filter((p) => p.min != null && p.max != null); if (band.length >= 2) { const up = band.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.max!).toFixed(1)}`); const dn = [...band].reverse().map((p) => `${toX(p.ts).toFixed(1)},${toY(p.min!).toFixed(1)}`); bandPath = `M${up[0]} ` + up.slice(1).map((x) => `L${x}`).join(" ") + ` L${dn.join(" L")} Z`; } // Danger zones: rect (shading + line-color clip), the threshold line y, // and where its label sits (just inside the zone). const zones: { y: number; h: number; lineY: number; label: string; labelY: number }[] = []; if (this.thresholdBelow != null) { const zy = toY(this.thresholdBelow); zones.push({ y: zy, h: Math.max(0, plotB - zy), lineY: zy, label: `▼ ${fmtNum(this.thresholdBelow)}`, labelY: Math.min(plotB - 4, zy + 13) }); } if (this.thresholdAbove != null) { const zy = toY(this.thresholdAbove); zones.push({ y: PAD_T, h: Math.max(0, zy - PAD_T), lineY: zy, label: `▲ ${fmtNum(this.thresholdAbove)}`, labelY: Math.max(PAD_T + 11, zy - 5) }); } const lastP = pts[pts.length - 1]; const events = (this.events || []).filter((e) => e.ts >= tsMin && e.ts <= tsMax); const xTicks = timeTicks(tsMin, tsMax, Math.max(2, Math.min(5, Math.floor(plotW / 110) + 1))); const hover = this._hover; return html`
this._onPointer(e, pts, toX, toY, W)} @pointerdown=${(e: PointerEvent) => this._onPointer(e, pts, toX, toY, W)} @pointerleave=${() => (this._hover = null)} > ${zones.length ? svg`${zones.map((z) => svg``)}` : nothing} ${ticks.map((v) => { const y = toY(v); if (y < PAD_T - 1 || y > plotB + 1) return nothing; return svg` ${fmtNum(v)}`; })} ${zones.map( (z) => svg``, )} ${bandPath ? svg`` : nothing} ${zones.length ? svg`` : nothing} ${zones.map( (z) => svg` ${z.label}`, )} ${this.targetValue != null ? svg` ◆ ${fmtNum(this.targetValue)} ${this.unit}` : nothing} ${this.projection && this.projection.length === 2 ? svg`` : nothing} ${xTicks.map((ts, i) => { const x = toX(ts); const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle"; return svg`${fmtDateTick(ts, L, withYear)}`; })} ${events.map((e) => { const x = toX(e.ts); const color = e.type === "completed" ? "var(--success-color, #4caf50)" : e.type === "skipped" ? "var(--warning-color, #ff9800)" : "var(--info-color, #2196f3)"; return svg` ${fmtDateTime(e.ts, L)} `; })} ${hover ? svg` ` : svg``} ${hover ? html`
${fmtDateTime(hover.p.ts, L)}
${fmtVal(hover.p.val, this.unit, L)} ${hover.p.min != null && hover.p.max != null ? html`(${fmtNum(hover.p.min)}–${fmtNum(hover.p.max)})` : nothing}
` : nothing}
`; } private _onPointer( e: PointerEvent, pts: ChartPoint[], toX: (ts: number) => number, toY: (v: number) => number, W: number, ): void { const svgEl = e.currentTarget as SVGSVGElement; const rect = svgEl.getBoundingClientRect(); const px = ((e.clientX - rect.left) / rect.width) * W; if (px < PAD_L - 8 || px > W - PAD_R + 8) { this._hover = null; return; } let best = pts[0]; let bestD = Infinity; for (const p of pts) { const d = Math.abs(toX(p.ts) - px); if (d < bestD) { bestD = d; best = p; } } this._hover = { x: toX(best.ts), y: toY(best.val), p: best }; } static styles = css` :host { display: block; width: 100%; } .chart-wrap { position: relative; } .range-chips { display: flex; gap: 4px; justify-content: flex-end; margin-bottom: 2px; } .range-chip { font: inherit; font-size: 11.5px; padding: 2px 9px; border-radius: 12px; cursor: pointer; border: 1px solid var(--divider-color); background: transparent; color: var(--secondary-text-color); } /* Outlier toggle sits left of the range chips as an icon button. */ .outlier-chip { margin-right: auto; padding: 2px 7px; display: inline-flex; align-items: center; } .outlier-chip ha-icon { --mdc-icon-size: 15px; } .range-chip.active { background: var(--primary-color); border-color: var(--primary-color); color: var(--text-primary-color, #fff); } .range-chip[disabled] { opacity: 0.5; pointer-events: none; } .svg-holder { position: relative; } .chart-svg { display: block; touch-action: pan-y; } .tick-label { fill: var(--secondary-text-color); font-size: 10.5px; } .zone-label { fill: var(--error-color, #f44336); font-size: 11px; font-weight: 600; } .chart-empty { display: flex; align-items: center; justify-content: center; gap: 8px; height: 120px; color: var(--secondary-text-color); font-size: 12.5px; } .chart-empty ha-icon { --mdc-icon-size: 17px; } .hover-chip { position: absolute; top: 0; transform: translateX(-50%); background: var(--card-background-color, #fff); border: 1px solid var(--divider-color); border-radius: 8px; padding: 4px 9px; pointer-events: none; white-space: nowrap; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); z-index: 3; } .hover-date { font-size: 10.5px; color: var(--secondary-text-color); } .hover-val { font-size: 12.5px; font-weight: 600; color: var(--primary-text-color); } .hover-range { font-weight: 400; color: var(--secondary-text-color); font-size: 11px; } `; } if (!customElements.get("maintenance-trigger-chart")) { customElements.define("maintenance-trigger-chart", MaintenanceTriggerChart); }