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).
This commit is contained in:
@@ -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: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18"/></svg>',
|
||||
ventilation: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2"/></svg>',
|
||||
internal_gains: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
|
||||
ac_strategy: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
hydration: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/></svg>',
|
||||
care: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>',
|
||||
};
|
||||
|
||||
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 = `<p class="text-sm whitespace-pre-line">${esc(llmData.summary)}</p>
|
||||
<p class="text-xs text-gray-400 mt-2">AI-generated summary. Not a substitute for professional advice.</p>`;
|
||||
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, "<strong>$1</strong>")
|
||||
.replace(/^- (.+)$/gm, "<li class=\"ml-4 list-disc\">$1</li>");
|
||||
}
|
||||
|
||||
// ========== 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("<li")) {
|
||||
return `<ul class="text-sm space-y-1">${rendered}</ul>`;
|
||||
}
|
||||
return `<p class="text-sm">${rendered}</p>`;
|
||||
}).join("");
|
||||
|
||||
const el = $("llm-summary");
|
||||
el.innerHTML = `
|
||||
<div class="space-y-2">${html}</div>
|
||||
<p class="text-xs text-gray-400 mt-3">${esc(t().aiDisclaimer)}</p>
|
||||
`;
|
||||
// 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 = '<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
||||
$("risk-icon").innerHTML = `<span class="${rc.text}">${shieldSvg}</span>`;
|
||||
$("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 = '<h2 class="text-lg font-semibold mb-2">Warnings</h2>' +
|
||||
data.warnings.map(w => `
|
||||
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<div class="font-medium text-red-700 dark:text-red-300">${esc(w.headline)}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
|
||||
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
|
||||
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
$("warnings-section").innerHTML = '<h2 class="text-lg font-semibold mb-2">' + esc(t().warnings) + '</h2>' +
|
||||
data.warnings.map(w => {
|
||||
const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate;
|
||||
return `
|
||||
<div class="${sc.bg} border ${sc.border} rounded-lg p-3 transition-all duration-200 hover:shadow-md">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium ${sc.text}">${esc(w.headline)}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${sc.pill}">${esc(w.severity)}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
|
||||
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
|
||||
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Risk windows
|
||||
@@ -166,7 +300,7 @@
|
||||
$("risk-windows").innerHTML = data.riskWindows.map(w => {
|
||||
const wc = riskColors[w.level] || riskColors.low;
|
||||
return `
|
||||
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2">
|
||||
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2 transition-all duration-200 hover:shadow-md">
|
||||
<span class="font-mono text-sm">${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00</span>
|
||||
<span class="capitalize font-medium ${wc.text}">${w.level}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}</span>
|
||||
@@ -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 `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium">${esc(rb.roomName)}</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between"><span>Internal</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>Solar</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>Ventilation</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between font-medium"><span>Total</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between"><span>AC Capacity</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between font-medium"><span>Headroom</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-3 shadow-sm border-l-4 ${bc}">
|
||||
<div class="font-medium text-sm mb-2">${esc(rb.roomName)}</div>
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div class="flex justify-between"><span>${esc(t().internalGains)}</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>${esc(t().solarGain)}</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>${esc(t().ventGain)}</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between font-medium"><span>${esc(t().totalGain)}</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between"><span>${esc(t().acCapacity)}</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between font-medium"><span>${esc(t().headroom)}</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-red-400" style="width: ${gainPct}%"></div>
|
||||
<div class="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex">
|
||||
<div class="h-full bg-amber-400" style="width: ${internalPct.toFixed(1)}%" title="${esc(t().internalGains)}"></div>
|
||||
<div class="h-full bg-orange-400" style="width: ${solarPct.toFixed(1)}%" title="${esc(t().solarGain)}"></div>
|
||||
<div class="h-full bg-rose-400" style="width: ${ventPct.toFixed(1)}%" title="${esc(t().ventGain)}"></div>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct}%"></div>
|
||||
<div class="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct.toFixed(1)}%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-400">
|
||||
<span>Gain</span><span>AC</span>
|
||||
<div class="flex gap-2 text-xs text-gray-400 flex-wrap">
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400"></span>${esc(t().internalGains)}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-orange-400"></span>${esc(t().solarGain)}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400"></span>${esc(t().ventGain)}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>${esc(t().acCapacity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,119 +366,176 @@
|
||||
</li>
|
||||
`).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 `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}" stop-opacity="0.4"/>`;
|
||||
}).join("");
|
||||
|
||||
const gradientStopsLine = timeline.map((s, i) => {
|
||||
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
|
||||
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}"/>`;
|
||||
}).join("");
|
||||
|
||||
// Threshold lines
|
||||
const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp);
|
||||
const thresholdLines = thresholds.map(t =>
|
||||
`<line x1="${padLeft}" y1="${y(t).toFixed(1)}" x2="${padLeft + chartW}" y2="${y(t).toFixed(1)}" stroke="#9ca3af" stroke-width="0.5" stroke-dasharray="4,3"/>
|
||||
<text x="${padLeft - 3}" y="${(y(t) + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${t}\u00b0</text>`
|
||||
).join("");
|
||||
|
||||
// Y-axis labels (min, max)
|
||||
const yLabels = `
|
||||
<text x="${padLeft - 3}" y="${(padTop + 4).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${maxTemp}\u00b0</text>
|
||||
<text x="${padLeft - 3}" y="${(padTop + chartH + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${minTemp}\u00b0</text>
|
||||
`;
|
||||
|
||||
// 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 `<text x="${x(idx).toFixed(1)}" y="${(height - 3).toFixed(1)}" text-anchor="middle" fill="#9ca3af" font-size="9">${String(s.hour).padStart(2, '0')}</text>`;
|
||||
}).join("");
|
||||
|
||||
// Data points (circles)
|
||||
const circles = timeline.map((s, i) =>
|
||||
`<circle cx="${x(i).toFixed(1)}" cy="${y(s.tempC).toFixed(1)}" r="4" fill="${tempColorHex(s.tempC)}" stroke="white" stroke-width="1.5" class="timeline-dot" data-idx="${i}" style="cursor:pointer"/>`
|
||||
).join("");
|
||||
|
||||
const svg = `<svg viewBox="0 0 ${width} ${height}" class="w-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="1" y2="0">${gradientStops}</linearGradient>
|
||||
<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">${gradientStopsLine}</linearGradient>
|
||||
</defs>
|
||||
<path d="${areaPath}" fill="url(#areaGrad)"/>
|
||||
<path d="${linePath}" fill="none" stroke="url(#lineGrad)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
${thresholdLines}
|
||||
${yLabels}
|
||||
${xLabels}
|
||||
${circles}
|
||||
</svg>`;
|
||||
|
||||
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("<br>")
|
||||
: "No actions";
|
||||
tooltip.innerHTML = `
|
||||
<div class="font-medium mb-1">${slot.hourStr}</div>
|
||||
<div>${slot.tempC.toFixed(1)}\u00b0C · ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
|
||||
<div class="capitalize">${slot.budgetStatus}</div>
|
||||
<div class="mt-1 text-gray-300">${actions}</div>
|
||||
`;
|
||||
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 `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="font-medium text-sm">${esc(a.name)}</div>
|
||||
<div class="text-xs text-gray-400 whitespace-nowrap">${hourRange}</div>
|
||||
</div>
|
||||
${a.description ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${esc(a.description)}</div>` : ''}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${ec}">${esc(t().effort)}: ${esc(a.effort || 'none')}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${ic}">${esc(t().impact)}: ${esc(a.impact || 'low')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2 text-gray-600 dark:text-gray-300">
|
||||
${icon}
|
||||
<span class="font-medium text-sm">${esc(label)}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2">${cards}</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 `<div class="flex-1 h-3 rounded-sm" style="background:${color}" title="${slot.hourStr}: ${slot.budgetStatus}"></div>`;
|
||||
// ========== 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 `<div></div>`;
|
||||
return `<div class="text-center text-xs text-gray-400">${String(s.hour).padStart(2, "0")}</div>`;
|
||||
}).join("");
|
||||
|
||||
// Temp cells
|
||||
const tempCellsHtml = timeline.map((s, i) => {
|
||||
const color = tempColorHex(s.tempC);
|
||||
const textColor = s.tempC >= 35 ? "white" : "#1f2937";
|
||||
return `<div class="hm-temp-cell flex items-center justify-center rounded-sm cursor-pointer" data-idx="${i}" style="background:${color};color:${textColor};height:48px">`
|
||||
+ `<span class="hm-temp-label hidden sm:inline text-xs font-medium">${Math.round(s.tempC)}</span></div>`;
|
||||
}).join("");
|
||||
|
||||
// Cooling mode cells
|
||||
const coolCellsHtml = timeline.map(s => {
|
||||
const mode = s.coolMode || "ac";
|
||||
const color = coolModeColors[mode] || "#d1d5db";
|
||||
return `<div class="rounded-sm" style="background:${color};height:14px"></div>`;
|
||||
}).join("");
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="grid gap-px" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
|
||||
${hourLabelsHtml}
|
||||
</div>
|
||||
<div class="grid gap-px mt-1" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
|
||||
${tempCellsHtml}
|
||||
</div>
|
||||
<div class="grid gap-px mt-px" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
|
||||
${coolCellsHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div class="font-medium mb-1">${slot.hourStr}</div>
|
||||
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
|
||||
<div class="capitalize">${slot.budgetStatus} \u00b7 ${esc(modeLabel)}</div>
|
||||
`;
|
||||
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 `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Data display -->
|
||||
<div id="data-display" class="hidden space-y-6">
|
||||
<div id="data-display" class="hidden space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">{{t "dashboard.title"}}</h1>
|
||||
<span id="profile-name" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
||||
@@ -49,16 +50,19 @@
|
||||
<div id="warnings-section" class="hidden space-y-2"></div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div id="risk-card" class="rounded-xl p-4 text-center">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div id="risk-card" class="rounded-xl p-4 text-center transition-all duration-200 hover:shadow-md">
|
||||
<div id="risk-icon" class="mb-1"></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.riskLevel"}}</div>
|
||||
<div id="risk-level" class="text-2xl font-bold"></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-orange-400 transition-all duration-200 hover:shadow-md">
|
||||
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6m0 0a6 6 0 106 6V8a6 6 0 10-6 0z"/></svg></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.peakTemp"}}</div>
|
||||
<div id="peak-temp" class="text-2xl font-bold"></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-blue-400 transition-all duration-200 hover:shadow-md">
|
||||
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.minNightTemp"}}</div>
|
||||
<div id="min-night-temp" class="text-2xl font-bold"></div>
|
||||
<div id="poor-night-cool" class="hidden text-xs text-orange-600 dark:text-orange-400 mt-1">{{t "dashboard.poorNightCool"}}</div>
|
||||
@@ -67,36 +71,65 @@
|
||||
|
||||
<!-- Risk windows -->
|
||||
<div id="risk-windows-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.riskWindows"}}</h2>
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.riskWindows"}}</h2>
|
||||
<div id="risk-windows" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.timeline"}}</h2>
|
||||
<!-- Timeline heatmap -->
|
||||
<div class="relative">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.timeline"}}</h2>
|
||||
<div id="timeline-chart" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"></div>
|
||||
<div id="timeline-strip" class="flex gap-0.5 mt-2"></div>
|
||||
<div id="cooling-legend" class="flex gap-4 text-xs text-gray-400 mt-1"></div>
|
||||
<div id="timeline-tooltip" class="hidden absolute z-50 bg-gray-800 text-white text-xs rounded-lg p-3 shadow-lg max-w-xs pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Room budgets -->
|
||||
<div id="budgets-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.roomBudgets"}}</h2>
|
||||
<div id="room-budgets" class="grid grid-cols-1 md:grid-cols-2 gap-4"></div>
|
||||
</div>
|
||||
<!-- Two-column: Actions + Sidebar (budgets, summary, care) -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<!-- Left column: AI Actions -->
|
||||
<div class="lg:col-span-2">
|
||||
<div id="actions-section" class="hidden">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h2 class="text-lg font-semibold">{{t "dashboard.actions"}}</h2>
|
||||
<span id="actions-badge" class="hidden text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">AI</span>
|
||||
</div>
|
||||
<div id="actions-list" class="space-y-4"></div>
|
||||
</div>
|
||||
<!-- Actions loading skeleton (shown while AI is loading) -->
|
||||
<div id="actions-loading" class="hidden">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h2 class="text-lg font-semibold">{{t "dashboard.actions"}}</h2>
|
||||
<div class="inline-block animate-spin rounded-full h-4 w-4 border-2 border-purple-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm animate-pulse"><div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3 mb-2"></div><div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div></div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm animate-pulse"><div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div><div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div></div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm animate-pulse"><div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mb-2"></div><div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/5"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Care checklist -->
|
||||
<div id="care-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.careChecklist"}}</h2>
|
||||
<ul id="care-checklist" class="space-y-1"></ul>
|
||||
</div>
|
||||
<!-- Right column: Budgets + Summary + Care -->
|
||||
<div class="space-y-5">
|
||||
<!-- Room budgets -->
|
||||
<div id="budgets-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.roomBudgets"}}</h2>
|
||||
<div id="room-budgets" class="space-y-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- LLM Summary -->
|
||||
<div id="llm-section">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.llmSummary"}}</h2>
|
||||
<div id="llm-summary" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
<!-- LLM Summary -->
|
||||
<div id="llm-section">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.llmSummary"}}</h2>
|
||||
<div id="llm-summary" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border-l-4 border-purple-400 opacity-0 transition-opacity duration-500">
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Care checklist -->
|
||||
<div id="care-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.careChecklist"}}</h2>
|
||||
<ul id="care-checklist" class="space-y-1"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,6 +137,34 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
window.HG.t = {
|
||||
warnings: "{{t "dashboard.warnings"}}",
|
||||
noActions: "{{t "dashboard.noActions"}}",
|
||||
effort: "{{t "dashboard.effort"}}",
|
||||
impact: "{{t "dashboard.impact"}}",
|
||||
aiDisclaimer: "{{t "dashboard.aiDisclaimer"}}",
|
||||
actions: "{{t "dashboard.actions"}}",
|
||||
internalGains: "{{t "dashboard.internalGains"}}",
|
||||
solarGain: "{{t "dashboard.solarGain"}}",
|
||||
ventGain: "{{t "dashboard.ventGain"}}",
|
||||
totalGain: "{{t "dashboard.totalGain"}}",
|
||||
acCapacity: "{{t "dashboard.acCapacity"}}",
|
||||
headroom: "{{t "dashboard.headroom"}}",
|
||||
coolVentilate: "{{t "dashboard.coolVentilate"}}",
|
||||
coolAC: "{{t "dashboard.coolAC"}}",
|
||||
coolOverloaded: "{{t "dashboard.coolOverloaded"}}",
|
||||
aiActions: "{{t "dashboard.aiActions"}}",
|
||||
category: {
|
||||
shading: "{{t "dashboard.category.shading"}}",
|
||||
ventilation: "{{t "dashboard.category.ventilation"}}",
|
||||
internal_gains: "{{t "dashboard.category.internal_gains"}}",
|
||||
ac_strategy: "{{t "dashboard.category.ac_strategy"}}",
|
||||
hydration: "{{t "dashboard.category.hydration"}}",
|
||||
care: "{{t "dashboard.category.care"}}",
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="/assets/js/db.js"></script>
|
||||
<script src="/assets/js/dashboard.js"></script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user