From f7f77f45b4852858b55e82c0d9a0a0ec3eef95fd Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 9 Feb 2026 15:01:09 +0100 Subject: [PATCH] feat: replace SVG timeline with heatmap grid Replace the 720x200 SVG line chart and separate cooling strip with a compact two-row heatmap: 24 colored temp cells (48px tall) with a thin cooling mode row below. Hour labels every 3h, tooltip on hover/click, responsive mobile layout (temp values hidden <640px, color-only). --- web/js/dashboard.js | 504 ++++++++++++++++++++++++----------- web/templates/dashboard.html | 113 ++++++-- 2 files changed, 437 insertions(+), 180 deletions(-) diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 5b4ba84..86b116a 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -3,6 +3,7 @@ "use strict"; const $ = (id) => document.getElementById(id); + const t = () => window.HG.t || {}; function show(id) { $(id).classList.remove("hidden"); } function hide(id) { $(id).classList.add("hidden"); } @@ -14,16 +15,46 @@ extreme: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300", border: "border-red-500" }, }; - const budgetColors = { - comfortable: "bg-green-500", - marginal: "bg-yellow-500", - overloaded: "bg-red-500", + const riskGradients = { + low: { from: "#f0fdf4", to: "#dcfce7", darkFrom: "#052e16", darkTo: "#064e3b" }, + moderate: { from: "#fefce8", to: "#fef9c3", darkFrom: "#422006", darkTo: "#3f3f00" }, + high: { from: "#fff7ed", to: "#ffedd5", darkFrom: "#431407", darkTo: "#7c2d12" }, + extreme: { from: "#fef2f2", to: "#fecaca", darkFrom: "#450a0a", darkTo: "#7f1d1d" }, }; - const budgetHexColors = { - comfortable: "#22c55e", - marginal: "#eab308", - overloaded: "#ef4444", + const warningSeverityColors = { + Minor: { bg: "bg-yellow-50 dark:bg-yellow-900/30", border: "border-yellow-300 dark:border-yellow-700", text: "text-yellow-700 dark:text-yellow-300", pill: "bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200" }, + Moderate: { bg: "bg-orange-50 dark:bg-orange-900/30", border: "border-orange-300 dark:border-orange-700", text: "text-orange-700 dark:text-orange-300", pill: "bg-orange-200 text-orange-800 dark:bg-orange-800 dark:text-orange-200" }, + Severe: { bg: "bg-red-50 dark:bg-red-900/30", border: "border-red-300 dark:border-red-800", text: "text-red-700 dark:text-red-300", pill: "bg-red-200 text-red-800 dark:bg-red-800 dark:text-red-200" }, + Extreme: { bg: "bg-red-100 dark:bg-red-900/50", border: "border-red-500 dark:border-red-600", text: "text-red-800 dark:text-red-200", pill: "bg-red-300 text-red-900 dark:bg-red-700 dark:text-red-100" }, + }; + + const budgetBorderColors = { + comfortable: "border-green-400", + marginal: "border-yellow-400", + overloaded: "border-red-400", + }; + + const effortColors = { + none: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300", + low: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", + medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300", + high: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300", + }; + + const impactColors = { + low: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300", + medium: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + high: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300", + }; + + const categoryIcons = { + shading: '', + ventilation: '', + internal_gains: '', + ac_strategy: '', + hydration: '', + care: '', }; function tempColorHex(temp) { @@ -35,6 +66,11 @@ return "#bfdbfe"; } + function isDark() { + return document.documentElement.classList.contains("dark") || + window.matchMedia("(prefers-color-scheme: dark)").matches; + } + window.loadDashboard = async function() { hide("no-data"); hide("no-forecast"); @@ -81,6 +117,12 @@ show("data-display"); renderDashboard(data); + // LLM credentials (shared between summary and actions) + const llmProvider = await getSetting("llmProvider"); + const llmApiKey = await getSetting("llmApiKey"); + const llmModel = await getSetting("llmModel"); + const language = window.HG && window.HG.lang === "de" ? "German" : "English"; + // LLM summary (async) try { const llmBody = { @@ -90,13 +132,8 @@ riskLevel: data.riskLevel, acHeadroomBTUH: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].headroomBtuh : 0, budgetStatus: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].status : "comfortable", - language: window.HG && window.HG.lang === "de" ? "German" : "English", + language: language, }; - - // Include client-side LLM settings if available - const llmProvider = await getSetting("llmProvider"); - const llmApiKey = await getSetting("llmApiKey"); - const llmModel = await getSetting("llmModel"); if (llmProvider && llmApiKey) { llmBody.provider = llmProvider; llmBody.apiKey = llmApiKey; @@ -111,8 +148,7 @@ if (llmResp.ok) { const llmData = await llmResp.json(); if (llmData.summary) { - $("llm-summary").innerHTML = `

${esc(llmData.summary)}

-

AI-generated summary. Not a substitute for professional advice.

`; + renderLlmSummary(llmData.summary); } else { $("llm-section").classList.add("hidden"); } @@ -121,6 +157,61 @@ $("llm-section").classList.add("hidden"); } + // AI actions (async) + if (llmProvider && llmApiKey) { + show("actions-loading"); + try { + const rooms = (payload.rooms || []).map(r => ({ + name: r.name, + orientation: r.orientation || "", + shadingType: r.shadingType || "none", + hasAC: (payload.acAssignments || []).some(a => a.roomId === r.id), + })); + const actionsBody = { + date: data.date, + indoorTempC: data.indoorTempC, + peakTempC: data.peakTempC, + minNightTempC: data.minNightTempC, + poorNightCool: data.poorNightCool, + riskLevel: data.riskLevel, + riskWindows: data.riskWindows || [], + timeline: (data.timeline || []).map(s => ({ + hour: s.hour, + tempC: s.tempC, + humidityPct: s.humidityPct, + budgetStatus: s.budgetStatus, + coolMode: s.coolMode, + })), + roomBudgets: (data.roomBudgets || []).map(rb => ({ + totalGainW: rb.totalGainW, + })), + rooms: rooms, + provider: llmProvider, + apiKey: llmApiKey, + language: language, + }; + if (llmModel) actionsBody.model = llmModel; + + const actResp = await fetch("/api/llm/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(actionsBody), + }); + if (actResp.ok) { + const actData = await actResp.json(); + if (actData.actions && actData.actions.length > 0) { + renderAIActions(actData.actions); + } else { + hide("actions-loading"); + } + } else { + hide("actions-loading"); + } + } catch (e) { + hide("actions-loading"); + } + } + } catch (err) { hide("loading"); show("error-state"); @@ -128,12 +219,49 @@ } }; + // ========== Mini Markdown ========== + function miniMarkdown(text) { + return text + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/^- (.+)$/gm, "
  • $1
  • "); + } + + // ========== LLM Summary ========== + function renderLlmSummary(summary) { + const paragraphs = summary.split(/\n\n+/).filter(Boolean); + const html = paragraphs.map(p => { + const rendered = miniMarkdown(esc(p)); + if (rendered.includes("${rendered}`; + } + return `

    ${rendered}

    `; + }).join(""); + + const el = $("llm-summary"); + el.innerHTML = ` +
    ${html}
    +

    ${esc(t().aiDisclaimer)}

    + `; + // Fade in + requestAnimationFrame(() => el.classList.remove("opacity-0")); + } + + // ========== Main Render ========== function renderDashboard(data) { $("profile-name").textContent = data.profileName + " \u2014 " + data.date; - // Risk card + // Risk card with gradient const rc = riskColors[data.riskLevel] || riskColors.low; - $("risk-card").className = `rounded-xl p-4 text-center shadow-sm ${rc.bg}`; + const rg = riskGradients[data.riskLevel] || riskGradients.low; + const dark = isDark(); + const gradFrom = dark ? rg.darkFrom : rg.from; + const gradTo = dark ? rg.darkTo : rg.to; + const riskCard = $("risk-card"); + riskCard.className = "rounded-xl p-4 text-center shadow-sm transition-all duration-200 hover:shadow-md"; + riskCard.style.background = `linear-gradient(135deg, ${gradFrom}, ${gradTo})`; + + const shieldSvg = ''; + $("risk-icon").innerHTML = `${shieldSvg}`; $("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`; $("risk-level").textContent = data.riskLevel; @@ -146,18 +274,24 @@ $("poor-night-cool").classList.remove("hidden"); } - // Warnings + // Warnings — severity-based coloring if (data.warnings && data.warnings.length > 0) { show("warnings-section"); - $("warnings-section").innerHTML = '

    Warnings

    ' + - data.warnings.map(w => ` -
    -
    ${esc(w.headline)}
    -
    ${esc(w.description)}
    - ${w.instruction ? `
    ${esc(w.instruction)}
    ` : ''} -
    ${esc(w.onset)} \u2014 ${esc(w.expires)}
    -
    - `).join(""); + $("warnings-section").innerHTML = '

    ' + esc(t().warnings) + '

    ' + + data.warnings.map(w => { + const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate; + return ` +
    +
    + ${esc(w.headline)} + ${esc(w.severity)} +
    +
    ${esc(w.description)}
    + ${w.instruction ? `
    ${esc(w.instruction)}
    ` : ''} +
    ${esc(w.onset)} \u2014 ${esc(w.expires)}
    +
    + `; + }).join(""); } // Risk windows @@ -166,7 +300,7 @@ $("risk-windows").innerHTML = data.riskWindows.map(w => { const wc = riskColors[w.level] || riskColors.low; return ` -
    +
    ${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00 ${w.level} ${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)} @@ -175,10 +309,9 @@ }).join(""); } - // Timeline SVG chart + budget strip + // Timeline heatmap if (data.timeline && data.timeline.length > 0) { - renderTimelineChart(data.timeline); - renderBudgetStrip(data.timeline); + renderTimelineHeatmap(data.timeline); } // Room budgets @@ -186,30 +319,36 @@ show("budgets-section"); $("room-budgets").innerHTML = data.roomBudgets.map(rb => { const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1); - const gainPct = Math.min((rb.totalGainBtuh / maxVal) * 100, 100); + const internalPct = (rb.internalGainsW / maxVal) * 100; + const solarPct = (rb.solarGainW / maxVal) * 100; + const ventPct = (rb.ventGainW / maxVal) * 100; const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100); + const bc = budgetBorderColors[rb.status] || budgetBorderColors.comfortable; return ` -
    -
    - ${esc(rb.roomName)} -
    -
    -
    Internal${rb.internalGainsW.toFixed(0)} W
    -
    Solar${rb.solarGainW.toFixed(0)} W
    -
    Ventilation${rb.ventGainW.toFixed(0)} W
    -
    Total${rb.totalGainBtuh.toFixed(0)} BTU/h
    -
    AC Capacity${rb.acCapacityBtuh.toFixed(0)} BTU/h
    -
    Headroom${rb.headroomBtuh.toFixed(0)} BTU/h
    +
    +
    ${esc(rb.roomName)}
    +
    +
    ${esc(t().internalGains)}${rb.internalGainsW.toFixed(0)} W
    +
    ${esc(t().solarGain)}${rb.solarGainW.toFixed(0)} W
    +
    ${esc(t().ventGain)}${rb.ventGainW.toFixed(0)} W
    +
    ${esc(t().totalGain)}${rb.totalGainBtuh.toFixed(0)} BTU/h
    +
    ${esc(t().acCapacity)}${rb.acCapacityBtuh.toFixed(0)} BTU/h
    +
    ${esc(t().headroom)}${rb.headroomBtuh.toFixed(0)} BTU/h
    -
    -
    +
    +
    +
    +
    -
    -
    +
    +
    -
    - GainAC +
    + ${esc(t().internalGains)} + ${esc(t().solarGain)} + ${esc(t().ventGain)} + ${esc(t().acCapacity)}
    @@ -227,119 +366,176 @@ `).join(""); } + + // Fade in LLM skeleton + requestAnimationFrame(() => $("llm-summary").classList.remove("opacity-0")); } - // ========== SVG Timeline Chart ========== - function renderTimelineChart(timeline) { - const container = $("timeline-chart"); - const width = 720; - const height = 200; - const padLeft = 40; - const padRight = 10; - const padTop = 15; - const padBottom = 25; - const chartW = width - padLeft - padRight; - const chartH = height - padTop - padBottom; + function formatHourRange(hours) { + if (hours.length === 0) return ""; + if (hours.length === 1) return String(hours[0]).padStart(2, '0') + ":00"; - const temps = timeline.map(s => s.tempC); - const minTemp = Math.floor(Math.min(...temps) / 5) * 5; - const maxTemp = Math.ceil(Math.max(...temps) / 5) * 5; - const tempRange = maxTemp - minTemp || 10; + const ranges = []; + let start = hours[0], end = hours[0]; + for (let i = 1; i < hours.length; i++) { + if (hours[i] === end + 1) { + end = hours[i]; + } else { + ranges.push(start === end + ? String(start).padStart(2, '0') + ":00" + : String(start).padStart(2, '0') + ":00\u2013" + String(end).padStart(2, '0') + ":00"); + start = end = hours[i]; + } + } + ranges.push(start === end + ? String(start).padStart(2, '0') + ":00" + : String(start).padStart(2, '0') + ":00\u2013" + String(end).padStart(2, '0') + ":00"); + return ranges.join(", "); + } - function x(i) { return padLeft + (i / (timeline.length - 1)) * chartW; } - function y(t) { return padTop + (1 - (t - minTemp) / tempRange) * chartH; } - - // Build area path (temperature curve filled to bottom) - const points = timeline.map((s, i) => `${x(i).toFixed(1)},${y(s.tempC).toFixed(1)}`); - const linePath = `M${points.join(" L")}`; - const areaPath = `${linePath} L${x(timeline.length - 1).toFixed(1)},${(padTop + chartH).toFixed(1)} L${padLeft},${(padTop + chartH).toFixed(1)} Z`; - - // Build gradient stops based on temperature - const gradientStops = timeline.map((s, i) => { - const pct = ((i / (timeline.length - 1)) * 100).toFixed(1); - return ``; - }).join(""); - - const gradientStopsLine = timeline.map((s, i) => { - const pct = ((i / (timeline.length - 1)) * 100).toFixed(1); - return ``; - }).join(""); - - // Threshold lines - const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp); - const thresholdLines = thresholds.map(t => - ` - ${t}\u00b0` - ).join(""); - - // Y-axis labels (min, max) - const yLabels = ` - ${maxTemp}\u00b0 - ${minTemp}\u00b0 - `; - - // X-axis labels (every 3 hours) - const xLabels = timeline - .filter((s, i) => s.hour % 3 === 0) - .map((s, i) => { - const idx = timeline.findIndex(t => t.hour === s.hour); - return `${String(s.hour).padStart(2, '0')}`; - }).join(""); - - // Data points (circles) - const circles = timeline.map((s, i) => - `` - ).join(""); - - const svg = ` - - ${gradientStops} - ${gradientStopsLine} - - - - ${thresholdLines} - ${yLabels} - ${xLabels} - ${circles} - `; - - container.innerHTML = svg; - - // Tooltip on hover/click - const tooltip = $("timeline-tooltip"); - container.querySelectorAll(".timeline-dot").forEach(dot => { - const handler = (e) => { - const idx = parseInt(dot.dataset.idx); - const slot = timeline[idx]; - const actions = slot.actions && slot.actions.length > 0 - ? slot.actions.map(a => `\u2022 ${esc(a.name)}`).join("
    ") - : "No actions"; - tooltip.innerHTML = ` -
    ${slot.hourStr}
    -
    ${slot.tempC.toFixed(1)}\u00b0C · ${(slot.humidityPct || 0).toFixed(0)}% RH
    -
    ${slot.budgetStatus}
    -
    ${actions}
    - `; - const rect = dot.getBoundingClientRect(); - tooltip.style.left = (rect.left + window.scrollX - 60) + "px"; - tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 8) + "px"; - tooltip.classList.remove("hidden"); - }; - dot.addEventListener("mouseenter", handler); - dot.addEventListener("click", handler); + // ========== AI Actions ========== + function renderAIActions(actions) { + const groups = {}; + actions.forEach(a => { + const cat = a.category || "other"; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(a); }); - container.addEventListener("mouseleave", () => tooltip.classList.add("hidden")); + const categoryOrder = ["shading", "ventilation", "internal_gains", "ac_strategy", "hydration", "care"]; + const sortedCats = Object.keys(groups).sort((a, b) => { + const ia = categoryOrder.indexOf(a); + const ib = categoryOrder.indexOf(b); + return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); + }); + + const html = sortedCats.map(cat => { + const icon = categoryIcons[cat] || ''; + const label = (t().category && t().category[cat]) || cat; + const cards = groups[cat].map(a => { + const hours = (a.hours || []).sort((x, y) => x - y); + const hourRange = formatHourRange(hours); + const ec = effortColors[a.effort] || effortColors.none; + const ic = impactColors[a.impact] || impactColors.low; + return ` +
    +
    +
    ${esc(a.name)}
    +
    ${hourRange}
    +
    + ${a.description ? `
    ${esc(a.description)}
    ` : ''} +
    + ${esc(t().effort)}: ${esc(a.effort || 'none')} + ${esc(t().impact)}: ${esc(a.impact || 'low')} +
    +
    + `; + }).join(""); + + return ` +
    +
    + ${icon} + ${esc(label)} +
    +
    ${cards}
    +
    + `; + }).join(""); + + hide("actions-loading"); + show("actions-section"); + const badge = $("actions-badge"); + if (badge) { + badge.textContent = t().aiActions || "AI"; + badge.classList.remove("hidden"); + } + $("actions-list").innerHTML = html; } - // ========== Budget Status Strip ========== - function renderBudgetStrip(timeline) { - const strip = $("timeline-strip"); - strip.innerHTML = timeline.map(slot => { - const color = budgetHexColors[slot.budgetStatus] || "#d1d5db"; - return `
    `; + // ========== Heatmap Timeline ========== + const coolModeColors = { + ventilate: "#38bdf8", + ac: "#4ade80", + overloaded: "#f87171", + }; + + const coolModeLabels = () => ({ + ventilate: t().coolVentilate || "Open windows", + ac: t().coolAC || "AC cooling", + overloaded: t().coolOverloaded || "AC overloaded", + }); + + function renderTimelineHeatmap(timeline) { + const container = $("timeline-chart"); + const tooltip = $("timeline-tooltip"); + const labels = coolModeLabels(); + + // Hour labels (every 3h) + const hourLabelsHtml = timeline.map(s => { + if (s.hour % 3 !== 0) return `
    `; + return `
    ${String(s.hour).padStart(2, "0")}
    `; }).join(""); + + // Temp cells + const tempCellsHtml = timeline.map((s, i) => { + const color = tempColorHex(s.tempC); + const textColor = s.tempC >= 35 ? "white" : "#1f2937"; + return `
    ` + + `
    `; + }).join(""); + + // Cooling mode cells + const coolCellsHtml = timeline.map(s => { + const mode = s.coolMode || "ac"; + const color = coolModeColors[mode] || "#d1d5db"; + return `
    `; + }).join(""); + + container.innerHTML = ` +
    + ${hourLabelsHtml} +
    +
    + ${tempCellsHtml} +
    +
    + ${coolCellsHtml} +
    + `; + + // Tooltip handlers + container.querySelectorAll(".hm-temp-cell").forEach(cell => { + const handler = () => { + const idx = parseInt(cell.dataset.idx); + const slot = timeline[idx]; + const modeLabel = labels[slot.coolMode] || slot.coolMode || ""; + tooltip.innerHTML = ` +
    ${slot.hourStr}
    +
    ${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH
    +
    ${slot.budgetStatus} \u00b7 ${esc(modeLabel)}
    + `; + const rect = cell.getBoundingClientRect(); + const parentRect = tooltip.parentElement.getBoundingClientRect(); + tooltip.style.left = (rect.left - parentRect.left + rect.width / 2 - 60) + "px"; + tooltip.style.top = (rect.top - parentRect.top - tooltip.offsetHeight - 8) + "px"; + tooltip.classList.remove("hidden"); + }; + cell.addEventListener("mouseenter", handler); + cell.addEventListener("click", handler); + }); + container.addEventListener("mouseleave", () => tooltip.classList.add("hidden")); + + // Legend + const usedModes = [...new Set(timeline.map(s => s.coolMode || "ac"))]; + const legend = $("cooling-legend"); + if (legend) { + legend.innerHTML = usedModes.map(mode => { + const color = coolModeColors[mode] || "#d1d5db"; + const lbl = labels[mode] || mode; + return `${esc(lbl)}`; + }).join(""); + } } function esc(s) { diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 0993449..2a52744 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -39,7 +39,8 @@
    -