const CATS = {
scheduling: { label: "Scheduling", color: "#F0A93A" },
"plant-control": { label: "Plant Control", color: "#4FD1C5" },
ventilation: { label: "Ventilation", color: "#5B9BD5" },
"variable-speed": { label: "Variable Speed", color: "#9C7BDB" },
operations: { label: "Operations", color: "#6FCF97" },
};
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const GROUPS = [
["scheduling", "Scheduling"],
["plant-control", "Plant Control"],
["ventilation", "Ventilation"],
["variable-speed", "Variable Speed"],
["operations", "Operations"],
];
let catalog = [];
let byId = {};
let tab = "ops";
let included = false;
let achieved = 70;
let annualCost = 250000;
let sliders = {};
let checks = {};
let openGroups = { scheduling: true, "plant-control": false, ventilation: false, "variable-speed": false, operations: false };
function route() {
const raw = (location.hash || "#/overview").replace(/^#\/?/, "");
const id = raw.split("?")[0].toUpperCase();
if (raw.includes("view=guide")) tab = "guide";
if (/^O\d+$/.test(id) && byId[id]) return { page: "opp", id };
return { page: "overview" };
}
function go(path) {
location.hash = path;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(//g, ">");
}
function navItem(href, label, active, oid) {
return `${
oid ? `${esc(oid)} ` : ""
}${esc(label)} `;
}
function renderSidebar(current) {
const r = route();
let html = `
Platform
${navItem("#/overview", "Fleet Overview", r.page === "overview")}`;
html += `Opportunities
`;
GROUPS.forEach(([sec, title]) => {
const color = CATS[sec].color;
const items = catalog.filter((o) => o.section === sec);
const expanded = openGroups[sec] || items.some((o) => r.id === o.opportunity_id);
html += `
▸ ${title}
`;
items.forEach((o) => {
html += navItem(`#/${o.opportunity_id}`, o.title, r.id === o.opportunity_id, o.opportunity_id);
});
html += `
`;
});
document.getElementById("sidebar").innerHTML = html;
document.querySelectorAll("[data-group]").forEach((btn) => {
btn.onclick = () => {
const g = btn.dataset.group;
openGroups[g] = !openGroups[g];
renderSidebar();
};
});
}
function drawChart(canvas, pts, color, xType) {
if (!canvas || !pts.length) return;
const ctx = canvas.getContext("2d");
const w = (canvas.width = canvas.clientWidth * 2);
const h = (canvas.height = canvas.clientHeight * 2);
ctx.clearRect(0, 0, w, h);
const ys = pts.flatMap((p) => [p.baseline, p.optimized]);
const ymin = Math.min(...ys);
const ymax = Math.max(...ys);
const span = ymax - ymin || 1;
const xOf = (i) => (i / Math.max(pts.length - 1, 1)) * w;
const yOf = (v) => h - ((v - ymin) / span) * (h - 24) - 12;
ctx.strokeStyle = "rgba(255,255,255,0.06)";
ctx.lineWidth = 2;
for (let i = 1; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(0, (h / 4) * i);
ctx.lineTo(w, (h / 4) * i);
ctx.stroke();
}
const line = (key, stroke, dash) => {
ctx.beginPath();
ctx.strokeStyle = stroke;
ctx.lineWidth = 4;
ctx.setLineDash(dash || []);
pts.forEach((p, i) => {
const x = xOf(i);
const y = yOf(p[key]);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
};
line("baseline", "#64748b", [10, 8]);
line("optimized", color);
}
function sliderDefaults(item) {
const v = {};
(item.sliders || []).forEach((s) => {
v[s.key] = sliders[s.key] ?? s.default;
});
return v;
}
function fmtSlider(v, unit, step) {
const d = step < 1 ? 2 : 0;
return `${Number(v).toFixed(d)}${unit ? ` ${unit}` : ""}`;
}
function renderOverview() {
tab = "ops";
const sections = GROUPS.map(([sec, title]) => {
const cards = catalog
.filter((o) => o.section === sec)
.map(
(o) => `
${esc(o.opportunity_id)}
AWAITING TELEMETRY
${esc(o.title)}
GUIDE_POTENTIAL ${esc(String(o.pct))}% · ${esc(o.control_kind || "control")}
`
)
.join("");
return ``;
}).join("");
document.getElementById("root").innerHTML = `
Fleet
Overview
Same Control Center as the product: O1–O20, Operations vs OEH guide. This Space has no live BMS — telemetry stays AWAITING / SIMULATED. Guide % is GUIDE_POTENTIAL, not measured kWh.
${sections}`;
}
function renderGuide(item) {
const cat = CATS[item.section] || CATS.scheduling;
const v = sliderDefaults(item);
const pts = window.OehPhysics.seriesFor(item.opportunity_id, v, item.x_type);
const metrics = window.OehPhysics.metricsFor(item.opportunity_id, pts, v);
const effectivePct = included ? (item.pct * achieved) / 100 : 0;
const dollars = annualCost * (effectivePct / 100);
const optWidth = included ? Math.max(100 - effectivePct, 4) : 100;
const circ = 2 * Math.PI * 32;
const dash = (item.pct / 100) * circ;
const equip = String(item.equipment || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const sliderHtml = (item.sliders || [])
.map(
(sl) => `
${esc(sl.label)}
${fmtSlider(v[sl.key], sl.unit, sl.step)}
`
)
.join("");
return `
OEH guide ${esc(item.opportunity_id)} · SIMULATED — not live BMS · agent read-only
${esc(item.summary)}
Include in savings estimate
${included ? "INCLUDED" : "NOT INCLUDED"}
${item.pct}%
OEH potential
Guide comparison (simulated) — ${esc(item.scope)}
Optimized ${optWidth.toFixed(0)}%
Simulation console — ${esc(item.sim_label)}
${sliderHtml}
${metrics.map((m) => `
${esc(m.value)}
${esc(m.label)}
`).join("")}
Savings Calculator
Not verified M&V. Not a BMS result. Assumed spend only.
${effectivePct.toFixed(1)}%
Effective saving
$${Math.round(dollars).toLocaleString()}
Estimated $ / year
$${Math.round(dollars * 5).toLocaleString()}
Projected / 5 years
${item.scenario ? `Field result · OEH example (not this building)
${esc(item.scenario)}
` : ""}
Control Principle
${esc(item.principle)}
Typical current practice
${esc(item.practice)}
Recommended action
${esc(item.recommendation)}
Commissioning checklist — minimum equipment
${equip
.map(
(eq, i) => `
${esc(eq)}
`
)
.join("")}
`;
}
function bindGuide(item) {
const cat = CATS[item.section] || CATS.scheduling;
const canvas = document.getElementById("guide-chart");
const v = sliderDefaults(item);
drawChart(canvas, window.OehPhysics.seriesFor(item.opportunity_id, v, item.x_type), cat.color, item.x_type);
document.getElementById("include-btn")?.addEventListener("click", () => {
included = !included;
render();
});
document.getElementById("annual")?.addEventListener("change", (e) => {
annualCost = Number(e.target.value) || 0;
render();
});
document.getElementById("achieved")?.addEventListener("input", (e) => {
achieved = Number(e.target.value);
render();
});
document.querySelectorAll("#guide-sliders input").forEach((input) => {
input.addEventListener("input", () => {
sliders[input.dataset.key] = Number(input.value);
render();
});
});
document.querySelectorAll(".check-eq").forEach((btn) => {
btn.addEventListener("click", () => {
const i = Number(btn.dataset.i);
checks[i] = !checks[i];
render();
});
});
}
function renderOps(item) {
const inputs = (item.required_inputs || []).map((x) => `${esc(x)} `).join("");
const risks = (item.risks || []).map((x) => `${esc(x)} `).join("");
return `
AWAITING TELEMETRY
No live BMS in this Space. Operations KPIs stay empty on purpose — same contract as the product (NO LIVE DATA / AWAITING TELEMETRY). Open OEH guide for the teaching simulation used in the app.
Recommended control logic
${esc(item.recommended_control_logic || item.principle)}
`;
}
function renderOpportunity(item) {
const cat = CATS[item.section] || CATS.scheduling;
const kind = item.control_kind === "advisory" ? "Advisory — no automatic plant write" : "Supervisory control (simulation)";
document.getElementById("root").innerHTML = `
${esc(CATS[item.section]?.label || item.section)} / ${esc(item.opportunity_id)}
${esc(item.opportunity_id)}
${esc(item.title)}
${esc(item.summary)}
Telemetry NO DATA
ML NO DATA
BMS OFFLINE
${esc(kind)}
Operations
OEH guide
${tab === "guide" ? renderGuide(item) : renderOps(item)}
`;
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.onclick = () => {
tab = btn.dataset.tab;
if (tab === "guide") {
sliders = {};
checks = {};
}
render();
};
});
if (tab === "guide") bindGuide(item);
}
function render() {
renderSidebar();
const r = route();
if (r.page === "opp") renderOpportunity(byId[r.id]);
else renderOverview();
}
fetch("catalog.json")
.then((r) => r.json())
.then((data) => {
catalog = data;
byId = Object.fromEntries(data.map((o) => [o.opportunity_id, o]));
let lastId = null;
window.addEventListener("hashchange", () => {
const r = route();
if (r.id !== lastId) {
sliders = {};
checks = {};
lastId = r.id || null;
}
render();
});
render();
})
.catch(() => {
document.getElementById("root").textContent = "Could not load catalog.json";
});