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 = `${navItem("#/overview", "Fleet Overview", r.page === "overview")}`; html += ``; 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 += `
`; 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 `

${esc(title)}

${cards}
`; }).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) => `` ) .join(""); return `
OEH guide ${esc(item.opportunity_id)} · SIMULATED — not live BMS · agent read-only

${esc(item.summary)}

${item.pct}% OEH potential
Guide comparison (simulated) — ${esc(item.scope)}
Baseline
100%
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
${item.prev_id ? `← ${esc(item.prev_id)}` : `
`} ${item.next_id ? `${esc(item.next_id)} →` : ""}
`; } 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.

    Telemetry
    NO DATA
    ML
    NO DATA
    BMS
    OFFLINE
    Dispatch
    WRITE OFF
    Recommended control logic

    ${esc(item.recommended_control_logic || item.principle)}

    Required inputs
    Risks
    `; } 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)}
    ${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"; });