Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>EV Camper β Energy Dashboard</title> | |
| <script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&display=swap" rel="stylesheet"> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| :root{ | |
| --bg-0:#060a10;--bg-1:#0c1118;--bg-2:#121a24;--bg-3:#182230; | |
| --border:#1c2838;--border-hi:#283848; | |
| --t1:#e8edf4;--t2:#8a9bb0;--t3:#506070; | |
| --accent:#2ee8a8;--accent-d:#1cba80;--accent-g:rgba(46,232,168,.1); | |
| --solar:#ffb020;--solar-g:rgba(255,176,32,.08); | |
| --batt:#4890ff;--batt-g:rgba(72,144,255,.08); | |
| --shore:#b48afa;--hvac:#ff5050;--cook:#ff8830; | |
| --fresh:#18d4e8;--grey-t:#7888a0;--black-t:#585858; | |
| --danger:#ff4040;--warn:#ffa020;--ok:#30e090; | |
| --mono:'JetBrains Mono',monospace;--sans:'DM Sans',sans-serif; | |
| } | |
| html,body{height:100%;background:var(--bg-0);color:var(--t1);font-family:var(--sans)} | |
| ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-0)} | |
| ::-webkit-scrollbar-thumb{background:var(--border-hi);border-radius:3px} | |
| #app{display:flex;height:100vh;overflow:hidden} | |
| .sb{width:208px;background:var(--bg-1);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0} | |
| .sb-logo{padding:18px 16px;border-bottom:1px solid var(--border)} | |
| .sb-logo h1{font:600 13px/1 var(--mono);letter-spacing:1.8px;color:var(--accent);text-transform:uppercase} | |
| .sb-logo p{font:11px var(--mono);color:var(--t3);margin-top:5px} | |
| .sb-nav{flex:1;padding:10px 0;overflow-y:auto} | |
| .sb-sec{padding:10px 16px 4px;font:500 9px/1 var(--mono);letter-spacing:1.8px;text-transform:uppercase;color:var(--t3)} | |
| .ni{display:flex;align-items:center;gap:9px;padding:8px 16px;cursor:pointer;transition:.12s;color:var(--t2);font:500 12.5px var(--sans);border-left:2px solid transparent;user-select:none} | |
| .ni:hover{background:var(--bg-3);color:var(--t1)} | |
| .ni.on{color:var(--accent);border-left-color:var(--accent);background:var(--accent-g)} | |
| .ni svg{width:15px;height:15px;flex-shrink:0;opacity:.65}.ni.on svg{opacity:1} | |
| .sb-ft{padding:12px 16px;border-top:1px solid var(--border);font:11px var(--mono);color:var(--t3)} | |
| .mn{flex:1;overflow-y:auto;overflow-x:hidden} | |
| .ph{padding:22px 28px 0;display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap} | |
| .ph h2{font:600 20px/1.2 var(--sans);letter-spacing:-.3px} | |
| .ph .sub{font:13px var(--sans);color:var(--t2);margin-top:3px} | |
| .badges{display:flex;gap:6px;flex-wrap:wrap} | |
| .bdg{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:5px;font:500 10px var(--mono);border:1px solid var(--border)} | |
| .bdg-g{color:var(--accent);border-color:var(--accent-d);background:var(--accent-g)} | |
| .bdg-s{color:var(--solar);border-color:#b07810;background:var(--solar-g)} | |
| .pc{padding:18px 28px 48px} | |
| .g{display:grid;gap:14px} | |
| .g4{grid-template-columns:repeat(4,1fr)}.g3{grid-template-columns:repeat(3,1fr)} | |
| .g2{grid-template-columns:repeat(2,1fr)}.g21{grid-template-columns:2fr 1fr} | |
| .g12{grid-template-columns:1fr 2fr}.g1{grid-template-columns:1fr} | |
| .s2{grid-column:span 2}.s3{grid-column:span 3}.s4{grid-column:span 4} | |
| .cd{background:var(--bg-2);border:1px solid var(--border);border-radius:10px;overflow:hidden} | |
| .cd-h{padding:13px 16px 0;display:flex;justify-content:space-between;align-items:center} | |
| .cd-t{font:500 10.5px/1 var(--mono);letter-spacing:1.2px;text-transform:uppercase;color:var(--t3)} | |
| .cd-b{padding:12px 16px 16px;position:relative}.cd-b canvas{width:100%!important} | |
| .kpi{padding:16px} | |
| .kpi-l{font:10px var(--mono);letter-spacing:1.4px;text-transform:uppercase;color:var(--t3);margin-bottom:6px} | |
| .kpi-v{font:700 26px/1 var(--mono);letter-spacing:-1px} | |
| .kpi-s{font:11px var(--mono);color:var(--t2);margin-top:5px} | |
| .kpi-v.grn{color:var(--accent)}.kpi-v.sol{color:var(--solar)}.kpi-v.blu{color:var(--batt)} | |
| .kpi-v.cyn{color:var(--fresh)}.kpi-v.red{color:var(--hvac)}.kpi-v.prp{color:var(--shore)} | |
| .kpi-bar{height:3px;border-radius:2px;background:var(--bg-0);margin-top:8px;overflow:hidden} | |
| .kpi-bar-f{height:100%;border-radius:2px;transition:width .6s ease} | |
| .gauge-w{display:flex;align-items:center;justify-content:center;padding:16px} | |
| .gauge-r{position:relative;width:140px;height:140px} | |
| .gauge-r svg{transform:rotate(-90deg)}.gauge-r circle{fill:none;stroke-linecap:round} | |
| .gauge-r .trk{stroke:var(--border)}.gauge-r .fil{transition:stroke-dashoffset .7s ease} | |
| .gauge-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center} | |
| .gauge-p{font:700 28px/1 var(--mono)} | |
| .gauge-lb{font:9px var(--mono);letter-spacing:1.2px;text-transform:uppercase;color:var(--t3);margin-top:4px} | |
| .tk{display:flex;align-items:center;gap:10px;padding:6px 0} | |
| .tk-l{width:50px;font:10px var(--mono);color:var(--t2);text-transform:uppercase} | |
| .tk-bg{flex:1;height:18px;background:var(--bg-0);border-radius:3px;overflow:hidden;position:relative} | |
| .tk-f{height:100%;border-radius:3px;transition:width .5s ease;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;font:600 9px var(--mono);color:rgba(255,255,255,.85);min-width:30px} | |
| .tk-v{width:55px;text-align:right;font:11px var(--mono);color:var(--t2)} | |
| .dtabs{display:flex;gap:3px;margin-bottom:14px;flex-wrap:wrap} | |
| .dtab{padding:5px 12px;border-radius:5px;font:11px var(--mono);cursor:pointer;border:1px solid var(--border);background:transparent;color:var(--t2);transition:.12s} | |
| .dtab:hover{border-color:var(--border-hi);color:var(--t1)} | |
| .dtab.on{border-color:var(--accent-d);color:var(--accent);background:var(--accent-g)} | |
| .tb{width:100%;border-collapse:collapse;font-size:11px} | |
| .tb th{text-align:left;padding:7px 10px;font:500 9px var(--mono);text-transform:uppercase;letter-spacing:1px;color:var(--t3);border-bottom:1px solid var(--border)} | |
| .tb td{padding:6px 10px;border-bottom:1px solid var(--border);color:var(--t2);font:11px var(--mono)} | |
| .tb tr:hover td{background:var(--bg-3);color:var(--t1)} | |
| .tb-cat{padding:8px 10px;font:600 10px var(--mono);letter-spacing:1.5px;text-transform:uppercase;color:var(--accent);background:var(--accent-g);border-bottom:1px solid var(--border)} | |
| .donut-w{max-width:220px;margin:0 auto} | |
| .insight{display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border-radius:6px;background:var(--bg-3);border-left:3px solid var(--accent);margin-bottom:12px;font:12px/1.5 var(--sans);color:var(--t2)} | |
| .insight.warn{border-left-color:var(--warn)}.insight.danger{border-left-color:var(--danger)} | |
| .insight b{color:var(--t1);font-weight:600} | |
| .insight-icon{font-size:14px;flex-shrink:0;margin-top:1px} | |
| .form-group{margin-bottom:14px} | |
| .form-label{display:block;font:500 10px var(--mono);letter-spacing:1px;text-transform:uppercase;color:var(--t3);margin-bottom:5px} | |
| .form-select,.form-input{width:100%;padding:8px 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg-1);color:var(--t1);font:13px var(--sans);outline:none;transition:.15s} | |
| .form-select:focus,.form-input:focus{border-color:var(--accent)} | |
| .form-select option{background:var(--bg-1)} | |
| .form-row{display:flex;gap:12px}.form-row>*{flex:1} | |
| .btn{padding:10px 20px;border-radius:7px;border:none;cursor:pointer;font:600 12px var(--sans);transition:.15s} | |
| .btn-primary{background:var(--accent);color:var(--bg-0)}.btn-primary:hover{background:var(--accent-d)} | |
| .btn-primary:disabled{opacity:.5;cursor:wait} | |
| .gen-status{margin-top:10px;font:12px var(--mono);color:var(--t2);min-height:20px} | |
| .gen-status.ok{color:var(--accent)}.gen-status.err{color:var(--danger)} | |
| .ld{display:flex;align-items:center;justify-content:center;height:200px;color:var(--t3);font:12px var(--mono);letter-spacing:1px} | |
| .pulse{animation:pulse 1.2s ease infinite} | |
| .mermaid-wrap{display:flex;justify-content:center;align-items:center;padding:20px;background:var(--bg-0);border-radius:6px}.mermaid-wrap svg{max-width:100%} | |
| @keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}} | |
| @media(max-width:900px){ | |
| .sb{width:54px}.sb-logo p,.ni span,.sb-ft,.sb-sec{display:none} | |
| .sb-logo h1{font-size:10px;text-align:center}.ni{justify-content:center;padding:10px} | |
| .ph,.pc{padding-left:14px;padding-right:14px} | |
| .g4,.g3{grid-template-columns:repeat(2,1fr)} | |
| .g2,.g12,.g21{grid-template-columns:1fr} | |
| .s2,.s3,.s4{grid-column:span 1} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <aside class="sb"> | |
| <div class="sb-logo"><h1>β‘ EV Camp</h1><p>Resource Monitor</p></div> | |
| <nav class="sb-nav"> | |
| <div class="sb-sec">Dashboard</div> | |
| <div class="ni" :class="{on:pg==='overview'}" @click="go('overview')"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><span>Overview</span> | |
| </div> | |
| <div class="ni" :class="{on:pg==='power'}" @click="go('power')"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg><span>Power</span> | |
| </div> | |
| <div class="ni" :class="{on:pg==='water'}" @click="go('water')"> | |
| <svg 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><span>Water</span> | |
| </div> | |
| <div class="ni" :class="{on:pg==='budget'}" @click="go('budget')"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 010-4h14v4"/><path d="M3 5v14a2 2 0 002 2h16v-5"/><path d="M18 12a2 2 0 100 4 2 2 0 000-4z"/></svg><span>Budget</span> | |
| </div> | |
| <div class="ni" :class="{on:pg==='components'}" @click="go('components')"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg><span>Components</span> | |
| </div> | |
| <div class="sb-sec" style="margin-top:8px">Data</div> | |
| <div class="ni" :class="{on:pg==='data'}" @click="go('data')"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg><span>Schema & Tables</span> | |
| </div> | |
| <div class="sb-sec" style="margin-top:8px">Settings</div> | |
| <div class="ni" :class="{on:pg==='generate'}" @click="go('generate')"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg><span>Generate Data</span> | |
| </div> | |
| </nav> | |
| <div class="sb-ft">v1.0 Β· {{cfg?.inputs?.params?.user_type?.value||'...'}}</div> | |
| </aside> | |
| <main class="mn"> | |
| <!-- OVERVIEW --> | |
| <template v-if="pg==='overview'"> | |
| <div class="ph"> | |
| <div><h2>Trip Overview</h2><div class="sub">{{S.trip_days}}-day trip Β· {{cfg?.inputs?.params?.num_people?.value}} occupants Β· {{cfg?.inputs?.params?.temperature?.value}}</div></div> | |
| <div class="badges"> | |
| <span class="bdg bdg-s">β {{cfg?.inputs?.params?.sunlight?.value}}</span> | |
| <span class="bdg bdg-g">{{S.self_sufficiency_pct}}% Solar Coverage</span> | |
| </div> | |
| </div> | |
| <div class="pc"> | |
| <div v-if="S.self_sufficiency_pct>=100" class="insight"><span class="insight-icon">π</span><div>Solar generation <b>exceeds</b> total consumption by <b>{{Math.round(S.total_solar_kwh-S.total_consumption_kwh)}} kWh</b>. Fully self-sufficient β no shore power needed.</div></div> | |
| <div v-else class="insight warn"><span class="insight-icon">β οΈ</span><div>Solar covers only <b>{{S.self_sufficiency_pct}}%</b> of demand. Consider reducing HVAC usage or connecting shore power.</div></div> | |
| <div v-if="(S.water?.fresh_remaining_pct||100)<30" class="insight danger"><span class="insight-icon">π§</span><div>Fresh water at <b>{{Math.round(S.water.fresh_remaining_pct)}}%</b>. At ~{{Math.round(S.water.total_fresh_used_L/S.trip_days)}} L/day, roughly <b>{{(S.water.fresh_remaining_L/(S.water.total_fresh_used_L/S.trip_days)).toFixed(1)}}</b> days remain.</div></div> | |
| <div class="g g4" style="margin-bottom:14px"> | |
| <div class="cd kpi"><div class="kpi-l">Solar Generated</div><div class="kpi-v sol">{{S.total_solar_kwh}}</div><div class="kpi-s">kWh total Β· {{rd(S.total_solar_kwh/S.trip_days)}}/day</div><div class="kpi-bar"><div class="kpi-bar-f" style="width:100%;background:var(--solar)"></div></div></div> | |
| <div class="cd kpi"><div class="kpi-l">Total Consumed</div><div class="kpi-v red">{{S.total_consumption_kwh}}</div><div class="kpi-s">kWh total Β· {{rd(S.total_consumption_kwh/S.trip_days)}}/day</div><div class="kpi-bar"><div class="kpi-bar-f" :style="{width:Math.min(100,S.total_consumption_kwh/Math.max(S.total_solar_kwh,.1)*100)+'%',background:'var(--hvac)'}"></div></div></div> | |
| <div class="cd kpi"><div class="kpi-l">Battery Now</div><div class="kpi-v blu">{{S.battery?.current_pct}}%</div><div class="kpi-s">min {{S.battery?.min_pct}}% Β· avg {{S.battery?.avg_pct}}%</div><div class="kpi-bar"><div class="kpi-bar-f" :style="{width:(S.battery?.current_pct||0)+'%',background:battCol}"></div></div></div> | |
| <div class="cd kpi"><div class="kpi-l">Fresh Water Left</div><div class="kpi-v cyn">{{Math.round(S.water?.fresh_remaining_pct||0)}}%</div><div class="kpi-s">{{fL(S.water?.fresh_remaining_L)}} L remaining</div><div class="kpi-bar"><div class="kpi-bar-f" :style="{width:(S.water?.fresh_remaining_pct||0)+'%',background:'var(--fresh)'}"></div></div></div> | |
| </div> | |
| <div class="g g21" style="margin-bottom:14px"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Daily Energy Balance</span><span class="cd-t" style="color:var(--t2)">kWh/day</span></div><div class="cd-b"><canvas id="ov-energy" height="220"></canvas></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">System Status</span></div><div class="cd-b"> | |
| <div class="gauge-w"><div class="gauge-r"><svg viewBox="0 0 140 140" width="140" height="140"><circle cx="70" cy="70" r="58" class="trk" stroke-width="9"/><circle cx="70" cy="70" r="58" class="fil" stroke-width="9" :stroke="battCol" stroke-dasharray="364.4" :stroke-dashoffset="364.4*(1-(S.battery?.current_pct||0)/100)"/></svg><div class="gauge-c"><div class="gauge-p" :style="{color:battCol}">{{S.battery?.current_pct||0}}%</div><div class="gauge-lb">Battery SOC</div></div></div></div> | |
| <div style="padding:0 2px"> | |
| <div class="tk"><span class="tk-l" style="color:var(--fresh)">Fresh</span><div class="tk-bg"><div class="tk-f" :style="{width:Math.max(8,S.water?.fresh_remaining_pct||0)+'%',background:'var(--fresh)'}">{{Math.round(S.water?.fresh_remaining_pct||0)}}%</div></div><span class="tk-v">{{fL(S.water?.fresh_remaining_L)}}L</span></div> | |
| <div class="tk"><span class="tk-l" style="color:var(--grey-t)">Grey</span><div class="tk-bg"><div class="tk-f" :style="{width:Math.max(8,S.water?.grey_level_pct||0)+'%',background:'var(--grey-t)'}">{{Math.round(S.water?.grey_level_pct||0)}}%</div></div><span class="tk-v">{{fL(S.water?.grey_level_L)}}L</span></div> | |
| <div class="tk"><span class="tk-l" style="color:var(--black-t)">Black</span><div class="tk-bg"><div class="tk-f" :style="{width:Math.max(8,S.water?.black_level_pct||0)+'%',background:'var(--black-t)'}">{{Math.round(S.water?.black_level_pct||0)}}%</div></div><span class="tk-v">{{fL(S.water?.black_level_L)}}L</span></div> | |
| </div> | |
| </div></div> | |
| </div> | |
| <div class="g g2"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Consumption by Circuit</span><span class="cd-t" style="color:var(--t2)">kWh</span></div><div class="cd-b"><div class="donut-w"><canvas id="ov-donut" height="220"></canvas></div></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Daily Breakdown</span></div><div class="cd-b"><table class="tb"><thead><tr><th>Day</th><th>Solar</th><th>Load</th><th>Net</th><th>Batt</th><th>Water</th></tr></thead><tbody><tr v-for="d in S.daily_breakdown||[]" :key="d.day"><td>Day {{d.day}}</td><td style="color:var(--solar)">{{d.solar_kwh}}</td><td style="color:var(--hvac)">{{d.consumption_kwh}}</td><td :style="{color:d.solar_kwh>=d.consumption_kwh?'var(--accent)':'var(--danger)'}">{{rd(d.solar_kwh-d.consumption_kwh)>0?'+':''}}{{rd(d.solar_kwh-d.consumption_kwh)}}</td><td :style="{color:d.battery_end_pct>40?'var(--accent)':'var(--danger)'}">{{d.battery_end_pct}}%</td><td style="color:var(--fresh)">{{d.fresh_used_L}}L</td></tr></tbody></table></div></div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- POWER --> | |
| <template v-if="pg==='power'"> | |
| <div class="ph"><div><h2>Power Analytics</h2><div class="sub">Generation, consumption & battery analysis</div></div><div class="badges"><span class="bdg bdg-s">Avg Solar: {{rd(S.total_solar_kwh/S.trip_days)}} kWh/day</span></div></div> | |
| <div class="pc"> | |
| <div class="dtabs"><div class="dtab" :class="{on:pDay===0}" @click="setPDay(0)">All Days</div><div class="dtab" v-for="d in S.trip_days" :key="d" :class="{on:pDay===d}" @click="setPDay(d)">Day {{d}}</div></div> | |
| <div v-if="pDay>0&&pDayInsight" class="insight"><span class="insight-icon">π</span><div v-html="pDayInsight"></div></div> | |
| <div class="g g1" style="margin-bottom:14px"><div class="cd"><div class="cd-h"><span class="cd-t">Solar Generation vs Total Load</span><span class="cd-t" style="color:var(--t2)">{{pDay?'Day '+pDay:'All'}} Β· 15-min kW</span></div><div class="cd-b"><canvas id="pw-solar" height="200" data-height="200"></canvas></div></div></div> | |
| <div class="g g2" style="margin-bottom:14px"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Battery State of Charge</span><span class="cd-t" style="color:var(--t2)">%</span></div><div class="cd-b"><canvas id="pw-batt" height="200" data-height="200"></canvas></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Avg Hourly Load Profile</span><span class="cd-t" style="color:var(--t2)">kWh by circuit</span></div><div class="cd-b"><canvas id="pw-hourly" height="200" data-height="200"></canvas></div></div> | |
| </div> | |
| <div class="g g2"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Circuit Breakdown (Hourly)</span><span class="cd-t" style="color:var(--t2)">Stacked kWh</span></div><div class="cd-b"><canvas id="pw-stack" height="220" data-height="220"></canvas></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Energy Source Mix</span><span class="cd-t" style="color:var(--t2)">Solar / Battery / Shore</span></div><div class="cd-b"><canvas id="pw-source" height="220" data-height="220"></canvas></div></div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- WATER --> | |
| <template v-if="pg==='water'"> | |
| <div class="ph"><div><h2>Water Management</h2><div class="sub">Tank levels, flow rates & usage patterns</div></div></div> | |
| <div class="pc"> | |
| <div v-if="(S.water?.grey_level_pct||0)>80" class="insight warn"><span class="insight-icon">πΏ</span><div>Grey tank at <b>{{Math.round(S.water.grey_level_pct)}}%</b>. Consider dumping soon.</div></div> | |
| <div class="g g4" style="margin-bottom:14px"> | |
| <div class="cd kpi"><div class="kpi-l">Total Fresh Used</div><div class="kpi-v cyn">{{fL(S.water?.total_fresh_used_L)}}</div><div class="kpi-s">litres Β· {{fL((S.water?.total_fresh_used_L||0)/S.trip_days)}}/day</div></div> | |
| <div class="cd kpi"><div class="kpi-l">Fresh Remaining</div><div class="kpi-v cyn">{{Math.round(S.water?.fresh_remaining_pct||0)}}%</div><div class="kpi-s">{{fL(S.water?.fresh_remaining_L)}} L</div><div class="kpi-bar"><div class="kpi-bar-f" :style="{width:(S.water?.fresh_remaining_pct||0)+'%',background:'var(--fresh)'}"></div></div></div> | |
| <div class="cd kpi"><div class="kpi-l">Grey Tank</div><div class="kpi-v" style="color:var(--grey-t)">{{Math.round(S.water?.grey_level_pct||0)}}%</div><div class="kpi-s">{{fL(S.water?.grey_level_L)}} L</div><div class="kpi-bar"><div class="kpi-bar-f" :style="{width:(S.water?.grey_level_pct||0)+'%',background:'var(--grey-t)'}"></div></div></div> | |
| <div class="cd kpi"><div class="kpi-l">Black Tank</div><div class="kpi-v" style="color:var(--black-t)">{{Math.round(S.water?.black_level_pct||0)}}%</div><div class="kpi-s">{{fL(S.water?.black_level_L)}} L</div><div class="kpi-bar"><div class="kpi-bar-f" :style="{width:(S.water?.black_level_pct||0)+'%',background:'var(--black-t)'}"></div></div></div> | |
| </div> | |
| <div class="g g1" style="margin-bottom:14px"><div class="cd"><div class="cd-h"><span class="cd-t">Tank Levels Over Time</span><span class="cd-t" style="color:var(--t2)">Hourly Β· Litres</span></div><div class="cd-b"><canvas id="wt-tanks" height="200"></canvas></div></div></div> | |
| <div class="g g2" style="margin-bottom:14px"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Daily Usage by Source</span><span class="cd-t" style="color:var(--t2)">Litres</span></div><div class="cd-b"><canvas id="wt-daily" height="220"></canvas></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Avg Hourly Profile</span><span class="cd-t" style="color:var(--t2)">Litres/hour</span></div><div class="cd-b"><canvas id="wt-hourly" height="220"></canvas></div></div> | |
| </div> | |
| <div class="g g2"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Total Water Split</span></div><div class="cd-b"><div class="donut-w"><canvas id="wt-donut" height="220"></canvas></div></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Pump Activity</span><span class="cd-t" style="color:var(--t2)">15-min Lpm</span></div><div class="cd-b"><canvas id="wt-pump" height="220"></canvas></div></div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- BUDGET --> | |
| <template v-if="pg==='budget'"> | |
| <div class="ph"><div><h2>Budget Analysis</h2><div class="sub">Planned vs actual resource consumption</div></div></div> | |
| <div class="pc"> | |
| <div class="g g2" style="margin-bottom:14px"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Power: Budget vs Actual</span><span class="cd-t" style="color:var(--t2)">Trip kWh</span></div><div class="cd-b"><canvas id="bg-power" height="260"></canvas></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Water: Budget vs Actual</span><span class="cd-t" style="color:var(--t2)">Trip Litres</span></div><div class="cd-b"><canvas id="bg-water" height="260"></canvas></div></div> | |
| </div> | |
| <div class="g g1" style="margin-bottom:14px"><div class="cd"><div class="cd-h"><span class="cd-t">Daily Efficiency Trend</span><span class="cd-t" style="color:var(--t2)">Solar coverage & battery %</span></div><div class="cd-b"><canvas id="bg-trend" height="180"></canvas></div></div></div> | |
| <div class="g g2"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Power Budget Detail</span><span class="cd-t" style="color:var(--t2)">kWh/day</span></div><div class="cd-b"><div v-for="(v,k) in pBudget" :key="k" style="display:flex;align-items:center;gap:10px;padding:5px 0"><span style="width:90px;font:10px var(--mono);color:var(--t2);text-transform:uppercase">{{fmtK(k)}}</span><div style="flex:1;height:14px;background:var(--bg-0);border-radius:3px;overflow:hidden"><div :style="{height:'100%',width:bPct(v,pBMax)+'%',background:bCol(k),borderRadius:'3px'}"></div></div><span style="width:60px;text-align:right;font:11px var(--mono);color:var(--t1)">{{v}}</span></div></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Water Budget Detail</span><span class="cd-t" style="color:var(--t2)">L/day</span></div><div class="cd-b"><div v-for="(v,k) in wBudget" :key="k" style="display:flex;align-items:center;gap:10px;padding:5px 0"><span style="width:90px;font:10px var(--mono);color:var(--t2);text-transform:uppercase">{{fmtK(k)}}</span><div style="flex:1;height:14px;background:var(--bg-0);border-radius:3px;overflow:hidden"><div :style="{height:'100%',width:bPct(v,wBMax)+'%',background:k.includes('shower')?'var(--fresh)':k.includes('toilet')?'var(--black-t)':'var(--accent)',borderRadius:'3px'}"></div></div><span style="width:60px;text-align:right;font:11px var(--mono);color:var(--t1)">{{v}}</span></div></div></div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- COMPONENTS --> | |
| <template v-if="pg==='components'"> | |
| <div class="ph"><div><h2>Component Catalog</h2><div class="sub">All trailer electrical & water components</div></div></div> | |
| <div class="pc"><div class="cd"><div class="cd-b" style="padding:0;max-height:calc(100vh - 130px);overflow-y:auto"><table class="tb"><thead><tr><th>Component</th><th>Voltage</th><th>Amps</th><th>Power</th><th>Water</th><th>Idle</th></tr></thead><tbody><template v-for="(items,cat) in comps" :key="cat"><tr><td colspan="6" class="tb-cat">{{cat}}</td></tr><tr v-for="(c,i) in items" :key="i"><td style="color:var(--t1)">{{c.name}}</td><td>{{c.voltage_v}}V</td><td>{{c.avg_amps}}A</td><td style="color:var(--solar)">{{(c.voltage_v*c.avg_amps).toFixed(1)}}W</td><td>{{c.water_gal_per_cycle||'β'}}</td><td>{{c.idle_amps||'β'}}</td></tr></template></tbody></table></div></div></div> | |
| </template> | |
| <!-- DATA: Schema & Data Tables --> | |
| <template v-if="pg==='data'"> | |
| <div class="ph"><div><h2>Data Schema & Tables</h2><div class="sub">CSV structure and raw data tables</div></div></div> | |
| <div class="pc"> | |
| <div class="dtabs"> | |
| <div class="dtab" :class="{on:dataSubTab==='schema'}" @click="dataSubTab='schema'">Schema (Mermaid)</div> | |
| <div class="dtab" :class="{on:dataSubTab==='tables'}" @click="dataSubTab='tables'">Data Tables</div> | |
| </div> | |
| <template v-if="dataSubTab==='schema'"> | |
| <div class="cd" style="margin-bottom:14px"><div class="cd-h"><span class="cd-t">Entity relationship (CSV schema)</span></div><div class="cd-b" style="min-height:320px"><div id="mermaid-container" class="mermaid-wrap"></div><div v-if="schemaMermaidErr" class="insight danger"><span class="insight-icon">β οΈ</span><div>{{schemaMermaidErr}}</div></div></div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Table definitions</span></div><div class="cd-b" style="max-height:400px;overflow-y:auto"><table class="tb"><thead><tr><th>Table</th><th>Column</th><th>Type</th></tr></thead><tbody><template v-for="(meta,key) in schemaTables" :key="key"><tr v-for="(col,i) in (meta.columns||[])" :key="i"><td v-if="i===0" :rowspan="(meta.columns||[]).length" class="tb-cat">{{key}}</td><td>{{col.name}}</td><td style="color:var(--t3)">{{col.type}}</td></tr></template></tbody></table></div></div></div> | |
| </template> | |
| <template v-if="dataSubTab==='tables'"> | |
| <div class="cd" style="margin-bottom:14px"><div class="cd-h"><span class="cd-t">Select table</span></div><div class="cd-b"><div class="form-row"><select class="form-select" v-model="selectedTableId" style="max-width:280px" @change="loadTableData"><option value="">β Choose β</option><option v-for="t in dataTablesList" :key="t.id" :value="t.id">{{t.stream}} / {{t.resolution}} ({{t.row_count}} rows)</option></select><span class="cd-t" style="color:var(--t2)" v-if="selectedTableId">Limit</span><input type="number" class="form-input" v-model.number="tableLimit" min="10" max="2000" step="50" style="width:90px" @change="loadTableData"/></div></div></div> | |
| <div class="cd"><div class="cd-b" style="padding:0;max-height:calc(100vh - 280px);overflow:auto"><table class="tb" v-if="tableData.length"><thead><tr><th v-for="col in tableColumns" :key="col">{{col}}</th></tr></thead><tbody><tr v-for="(row,i) in tableData" :key="i"><td v-for="col in tableColumns" :key="col">{{fmtCell(row[col])}}</td></tr></tbody></table><div v-else class="ld" style="min-height:120px">{{selectedTableId?'Loading...':'Select a table above.'}}</div></div></div></div> | |
| </template> | |
| </div> | |
| </template> | |
| <!-- GENERATE --> | |
| <template v-if="pg==='generate'"> | |
| <div class="ph"><div><h2>Generate Data</h2><div class="sub">Configure trip parameters and regenerate simulation</div></div></div> | |
| <div class="pc"><div class="g g2"> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Trip Configuration</span></div><div class="cd-b"> | |
| <div class="form-row"><div class="form-group"><label class="form-label">User Profile</label><select class="form-select" v-model="gen.user_type"><option>Glamper</option><option>Typical</option><option>Expert</option></select></div><div class="form-group"><label class="form-label">People</label><select class="form-select" v-model.number="gen.num_people"><option :value="1">1</option><option :value="2">2</option><option :value="3">3</option><option :value="4">4</option></select></div></div> | |
| <div class="form-row"><div class="form-group"><label class="form-label">Trip Duration (days)</label><input class="form-input" type="number" v-model.number="gen.trip_duration_days" min="1" max="30"></div><div class="form-group"><label class="form-label">Random Seed</label><input class="form-input" type="number" v-model.number="gen.seed" min="1"></div></div> | |
| <div class="form-row"><div class="form-group"><label class="form-label">Temperature</label><select class="form-select" v-model="gen.temperature"><option>Hot</option><option>Temperate</option><option>Cold</option></select></div><div class="form-group"><label class="form-label">Humidity</label><select class="form-select" v-model="gen.humidity"><option>Humid</option><option>Comfortable</option><option>Dry</option></select></div></div> | |
| <div class="form-group"><label class="form-label">Sunlight</label><select class="form-select" v-model="gen.sunlight"><option>Hi- Sunny</option><option>Mid- Cloudy</option><option>Lo- Shady</option></select></div> | |
| <button class="btn btn-primary" :disabled="genBusy" @click="doGen" style="width:100%;margin-top:6px">{{genBusy?'Generating...':'Generate & Reload Dashboard'}}</button> | |
| <div class="gen-status" :class="{ok:genOk,err:!genOk}" v-if="genMsg">{{genMsg}}</div> | |
| </div></div> | |
| <div class="cd"><div class="cd-h"><span class="cd-t">Profile Info</span></div><div class="cd-b" style="font:13px/1.7 var(--sans);color:var(--t2)"> | |
| <div style="margin-bottom:16px"><div style="font:600 14px var(--sans);color:var(--t1);margin-bottom:4px">{{gen.user_type==='Glamper'?'ποΈ Glamper':gen.user_type==='Expert'?'π§βπ§ Expert':'π€ Typical'}}</div><template v-if="gen.user_type==='Glamper'">High comfort β 3 meals/day, long showers, party lighting, dishwasher every meal. Highest resource usage.</template><template v-else-if="gen.user_type==='Expert'">Minimal usage β 1 meal/day, short showers, LED-only. Most efficient, longest off-grid.</template><template v-else>Balanced β 2 meals/day, standard showers, typical electronics. Good comfort/efficiency balance.</template></div> | |
| <div style="margin-bottom:16px"><div style="font:600 14px var(--sans);color:var(--t1);margin-bottom:4px">{{gen.temperature==='Hot'?'π‘οΈ Hot':gen.temperature==='Cold'?'βοΈ Cold':'π€οΈ Temperate'}}</div><template v-if="gen.temperature==='Hot'">HVAC cooling ~6.9 kWh/day. Best solar. AC compressor is dominant load.</template><template v-else-if="gen.temperature==='Cold'">HVAC heating ~15.6 kWh/day. Reduced solar. Significant overnight drain.</template><template v-else>Mild HVAC ~0.7 kWh/day. Good solar. Most efficient climate.</template></div> | |
| <div><div style="font:600 14px var(--sans);color:var(--t1);margin-bottom:4px">{{gen.sunlight==='Hi- Sunny'?'βοΈ Full Sun':gen.sunlight==='Lo- Shady'?'π² Shady':'βοΈ Cloudy'}}</div><template v-if="gen.sunlight==='Hi- Sunny'">100% efficiency. Max generation from 35-panel 5.25kW array.</template><template v-else-if="gen.sunlight==='Lo- Shady'">25% efficiency. Will likely need shore power.</template><template v-else>50% efficiency. May be self-sufficient with conservative use.</template></div> | |
| </div></div> | |
| </div></div> | |
| </template> | |
| <div v-if="loading" class="ld"><span class="pulse">LOADING DATA...</span></div> | |
| </main> | |
| </div> | |
| <script> | |
| const{createApp,ref,reactive,computed,onMounted,watch,nextTick}=Vue; | |
| const CC={HVAC:'#ff5050',Lighting:'#ffb020',Devices:'#a78bfa',Fridge:'#18d4e8',WaterPump:'#4890ff',Cooking:'#ff8830',Inverter:'#e879f9',Unmetered:'#506070'}; | |
| const CL=['HVAC','Lighting','Devices','Fridge','WaterPump','Cooking','Inverter','Unmetered']; | |
| Chart.defaults.font.family="'JetBrains Mono','monospace'";Chart.defaults.color='#506070';Chart.defaults.borderColor='#1c2838'; | |
| // ββ Stable chart registry keyed by canvas ID ββ | |
| const _charts={}; | |
| function mk(id,type,data,opts){ | |
| const el=document.getElementById(id);if(!el)return null; | |
| if(_charts[id]){_charts[id].destroy();delete _charts[id]; | |
| const parent=el.parentElement; | |
| if(parent){ | |
| const fixedH=parseInt(el.getAttribute('data-height'),10); | |
| el.width=parent.clientWidth; | |
| el.height=Number.isFinite(fixedH)?fixedH:200; | |
| } | |
| } | |
| const c=new Chart(el,{type,data,options:opts});_charts[id]=c;return c; | |
| } | |
| const B={responsive:true,maintainAspectRatio:false,animation:{duration:350}, | |
| plugins:{legend:{position:'bottom',labels:{color:'#8a9bb0',font:{size:10},boxWidth:10,padding:10}},tooltip:{backgroundColor:'#182230ee',borderColor:'#283848',borderWidth:1,titleFont:{size:11},bodyFont:{size:11},padding:10,cornerRadius:6}}, | |
| scales:{x:{ticks:{color:'#506070',font:{size:9},maxRotation:0},grid:{color:'#1c283880'}},y:{ticks:{color:'#506070',font:{size:9}},grid:{color:'#1c283880'}}} | |
| }; | |
| function mO(ov){const o=JSON.parse(JSON.stringify(B));(function m(t,s){for(const k in s)if(s[k]&&typeof s[k]==='object'&&!Array.isArray(s[k])){t[k]=t[k]||{};m(t[k],s[k]);}else t[k]=s[k];})(o,ov);return o;} | |
| function fT(ts){if(!ts)return'';const d=new Date(ts);return d.toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',hour12:false});} | |
| function fH(h){return`${String(h).padStart(2,'0')}:00`} | |
| createApp({ | |
| setup(){ | |
| const pg=ref('overview'),loading=ref(true); | |
| const cfg=ref({}),S=ref({trip_days:0,total_solar_kwh:0,total_consumption_kwh:0,self_sufficiency_pct:0,circuit_totals_kwh:{},battery:{},water:{},daily_breakdown:[]}); | |
| const pBudget=ref({}),wBudget=ref({}),comps=ref({}),hProfile=ref([]),whProfile=ref([]); | |
| const pDay=ref(0),pDayInsight=ref(''); | |
| const gen=reactive({user_type:'Typical',num_people:2,trip_duration_days:5,temperature:'Hot',sunlight:'Hi- Sunny',humidity:'Comfortable',seed:42}); | |
| const genBusy=ref(false),genMsg=ref(''),genOk=ref(true); | |
| const dataSubTab=ref('schema'),schemaTables=ref({}),schemaMermaidText=ref(''),schemaMermaidErr=ref(''),dataTablesList=ref([]); | |
| const selectedTableId=ref(''),tableLimit=ref(500),tableData=ref([]),tableColumns=ref([]); | |
| let pw15=[],pwH=[],wtH=[],wtD=[],wt15=[]; | |
| const pBMax=computed(()=>Math.max(...Object.values(pBudget.value||{}),1)); | |
| const wBMax=computed(()=>Math.max(...Object.values(wBudget.value||{}),1)); | |
| const battCol=computed(()=>{const p=S.value.battery?.current_pct||0;return p>60?'#2ee8a8':p>30?'#ffb020':'#ff4040';}); | |
| function rd(v){return Math.round((v||0)*100)/100} | |
| function fL(v){return v!=null?Math.round(v):'β'} | |
| function fmtK(k){return k.replace(/_/g,' ').replace(/kwh|L/gi,'').trim()} | |
| function bPct(v,mx){return Math.min(100,(v||0)/mx*100)} | |
| function bCol(k){if(k.includes('solar'))return'var(--solar)';if(k.includes('hvac'))return'var(--hvac)';if(k.includes('cook'))return'var(--cook)';if(k.includes('light'))return'var(--solar)';if(k.includes('device'))return'#a78bfa';if(k.includes('fridge'))return'var(--fresh)';if(k.includes('pump'))return'var(--batt)';return'var(--accent)';} | |
| async function api(u){return(await fetch(u)).json();} | |
| async function loadAll(){ | |
| loading.value=true; | |
| try{ | |
| const[c,s,pb,wb,co,hp,whp]=await Promise.all([api('/api/config'),api('/api/summary'),api('/api/power/budget'),api('/api/water/budget'),api('/api/components'),api('/api/power/hourly-profile'),api('/api/water/hourly-profile')]); | |
| cfg.value=c;S.value=s;pBudget.value=pb;wBudget.value=wb;comps.value=co;hProfile.value=hp;whProfile.value=whp; | |
| const p=c.inputs?.params||{};gen.user_type=p.user_type?.value||'Typical';gen.num_people=p.num_people?.value||2;gen.trip_duration_days=p.trip_duration_days?.value||5;gen.temperature=p.temperature?.value||'Hot';gen.sunlight=p.sunlight?.value||'Hi- Sunny';gen.humidity=p.humidity?.value||'Comfortable'; | |
| }catch(e){console.error(e);} | |
| loading.value=false; | |
| } | |
| // ββ Overview ββ | |
| function renderOV(){ | |
| const s=S.value;if(!s.daily_breakdown?.length)return;const db=s.daily_breakdown; | |
| mk('ov-energy','bar',{labels:db.map(d=>`Day ${d.day}`),datasets:[ | |
| {label:'Solar',data:db.map(d=>d.solar_kwh),backgroundColor:'#ffb020cc',borderRadius:5,barPercentage:.55}, | |
| {label:'Load',data:db.map(d=>-d.consumption_kwh),backgroundColor:'#ff5050cc',borderRadius:5,barPercentage:.55}, | |
| {label:'Shore',data:db.map(d=>d.shore_kwh),backgroundColor:'#b48afacc',borderRadius:5,barPercentage:.55}, | |
| ]},mO({plugins:{legend:{display:true},tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${Math.abs(c.raw).toFixed(2)} kWh`}},annotation:{annotations:{zero:{type:'line',yMin:0,yMax:0,borderColor:'#28384888',borderWidth:1,borderDash:[4,4]}}}},scales:{y:{title:{display:true,text:'kWh',color:'#506070',font:{size:10}}}}})); | |
| const ct=s.circuit_totals_kwh||{};const ll=Object.keys(ct).filter(k=>ct[k]>0);const vv=ll.map(l=>Math.round(ct[l]*100)/100);const tot=vv.reduce((a,b)=>a+b,0); | |
| mk('ov-donut','doughnut',{labels:ll,datasets:[{data:vv,backgroundColor:ll.map(l=>CC[l]||'#506070'),borderWidth:0,hoverOffset:6}]}, | |
| {responsive:true,maintainAspectRatio:true,cutout:'62%',plugins:{legend:{position:'right',labels:{color:'#8a9bb0',font:{size:10},boxWidth:10,padding:8}},tooltip:{callbacks:{label:c=>`${c.label}: ${c.raw} kWh (${Math.round(c.raw/tot*100)}%)`}}}}); | |
| } | |
| // ββ Power ββ | |
| async function loadPW(){ | |
| const dq=pDay.value>0?`&day=${pDay.value}`:''; | |
| const[r1,r2]=await Promise.all([api(`/api/power/15MIN?limit=10000${dq}`),api(`/api/power/1H?limit=5000${dq}`)]); | |
| pw15=r1.data||[];pwH=r2.data||[]; | |
| if(pDay.value>0){const db=(S.value.daily_breakdown||[]).find(d=>d.day===pDay.value); | |
| if(db){const n=rd(db.solar_kwh-db.consumption_kwh);pDayInsight.value=`Day ${db.day}: <b>${db.solar_kwh} kWh</b> solar, <b>${db.consumption_kwh} kWh</b> consumed. Net: <b style="color:${n>=0?'var(--accent)':'var(--danger)'}">${n>0?'+':''}${n} kWh</b>. Battery ended at <b>${db.battery_end_pct}%</b>.`;} | |
| }else pDayInsight.value=''; | |
| await nextTick();renderPW(); | |
| } | |
| function renderPW(){ | |
| if(!pw15.length)return;const lb=pw15.map(r=>fT(r.Time));const tl=pDay.value?12:20; | |
| mk('pw-solar','line',{labels:lb,datasets:[ | |
| {label:'Solar',data:pw15.map(r=>r.Solar_Flow_kW||0),borderColor:'#ffb020',backgroundColor:'rgba(255,176,32,.06)',fill:true,pointRadius:0,borderWidth:1.5,tension:.35}, | |
| {label:'Total Load',data:pw15.map(r=>CL.reduce((s,c)=>s+(r[`${c}_Flow_kW`]||0),0)),borderColor:'#ff5050',backgroundColor:'rgba(255,80,80,.04)',fill:true,pointRadius:0,borderWidth:1.5,tension:.35}, | |
| ]},mO({interaction:{intersect:false,mode:'index'},scales:{x:{ticks:{maxTicksLimit:tl}},y:{title:{display:true,text:'kW',color:'#506070',font:{size:10}}}},plugins:{tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${c.raw.toFixed(3)} kW`}}}})); | |
| mk('pw-batt','line',{labels:lb,datasets:[{label:'Battery %',data:pw15.map(r=>r.Battery_Level_Pct||0),borderColor:'#4890ff',backgroundColor:'rgba(72,144,255,.08)',fill:true,pointRadius:0,borderWidth:1.5,tension:.3}]}, | |
| mO({scales:{x:{ticks:{maxTicksLimit:tl}},y:{min:0,max:100,title:{display:true,text:'%',color:'#506070'}}},plugins:{annotation:{annotations:{ | |
| dzone:{type:'box',yMin:0,yMax:20,backgroundColor:'rgba(255,64,64,.05)',borderWidth:0}, | |
| wzone:{type:'box',yMin:20,yMax:40,backgroundColor:'rgba(255,160,32,.03)',borderWidth:0}, | |
| crit:{type:'line',yMin:20,yMax:20,borderColor:'#ff404055',borderWidth:1,borderDash:[4,4],label:{display:true,content:'Critical 20%',position:'end',color:'#ff4040',font:{size:8},backgroundColor:'transparent'}}, | |
| }}}})); | |
| if(hProfile.value?.length){const hp=hProfile.value; | |
| mk('pw-hourly','bar',{labels:hp.map(h=>fH(h.hour)),datasets:CL.filter(c=>c!=='Unmetered').map(c=>({label:c,data:hp.map(h=>h[`${c}_kwh`]||0),backgroundColor:CC[c]+'cc',borderRadius:2,barPercentage:.9,categoryPercentage:.85}))},mO({scales:{x:{stacked:true,ticks:{maxTicksLimit:12}},y:{stacked:true,title:{display:true,text:'kWh',color:'#506070'}}}}));} | |
| if(pwH.length){ | |
| mk('pw-stack','bar',{labels:pwH.map(r=>fT(r.Time)),datasets:CL.filter(c=>c!=='Unmetered').map(c=>({label:c,data:pwH.map(r=>r[`${c}_Total_kWh`]||0),backgroundColor:CC[c]+'aa',borderRadius:1,barPercentage:.95}))},mO({scales:{x:{stacked:true,ticks:{maxTicksLimit:tl}},y:{stacked:true,title:{display:true,text:'kWh',color:'#506070'}}}})); | |
| mk('pw-source','line',{labels:pwH.map(r=>fT(r.Time)),datasets:[ | |
| {label:'Solar',data:pwH.map(r=>r.Solar_Total_kWh||0),borderColor:'#ffb020',backgroundColor:'rgba(255,176,32,.12)',fill:true,pointRadius:0,borderWidth:1.5,tension:.3}, | |
| {label:'Battery Out',data:pwH.map(r=>r.Battery_Discharged_kWh||0),borderColor:'#4890ff',backgroundColor:'rgba(72,144,255,.1)',fill:true,pointRadius:0,borderWidth:1.5,tension:.3}, | |
| {label:'Shore',data:pwH.map(r=>r.Shore_Total_kWh||0),borderColor:'#b48afa',backgroundColor:'rgba(180,138,250,.1)',fill:true,pointRadius:0,borderWidth:1.5,tension:.3}, | |
| ]},mO({interaction:{intersect:false,mode:'index'},scales:{x:{ticks:{maxTicksLimit:tl}},y:{stacked:true,title:{display:true,text:'kWh',color:'#506070'}}}})); | |
| } | |
| } | |
| function setPDay(d){pDay.value=d;loadPW();} | |
| // ββ Water ββ | |
| async function loadWT(){ | |
| const[a,b,c]=await Promise.all([api('/api/water/1H?limit=5000'),api('/api/water/1DAY?limit=30'),api('/api/water/15MIN?limit=5000')]); | |
| wtH=a.data||[];wtD=b.data||[];wt15=c.data||[];await nextTick();renderWT(); | |
| } | |
| function renderWT(){ | |
| if(!wtH.length)return; | |
| const fc=cfg.value?.trailer_specs?.specs?.freshwater_capacity_gal?.value*3.785||378; | |
| const gc=cfg.value?.trailer_specs?.specs?.greywater_capacity_gal?.value*3.785||189; | |
| mk('wt-tanks','line',{labels:wtH.map(r=>fT(r.Time)),datasets:[ | |
| {label:'Fresh',data:wtH.map(r=>r.FreshTank_Level_L||0),borderColor:'#18d4e8',backgroundColor:'rgba(24,212,232,.06)',fill:true,pointRadius:0,borderWidth:1.5,tension:.3}, | |
| {label:'Grey',data:wtH.map(r=>r.GreyTank_Level_L||0),borderColor:'#7888a0',backgroundColor:'rgba(120,136,160,.04)',fill:true,pointRadius:0,borderWidth:1.5,tension:.3}, | |
| {label:'Black',data:wtH.map(r=>r.BlackTank_Level_L||0),borderColor:'#585858',backgroundColor:'rgba(88,88,88,.04)',fill:true,pointRadius:0,borderWidth:1.5,tension:.3}, | |
| ]},mO({interaction:{intersect:false,mode:'index'},scales:{x:{ticks:{maxTicksLimit:14}},y:{title:{display:true,text:'Litres',color:'#506070'}}},plugins:{tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${c.raw.toFixed(1)} L`}},annotation:{annotations:{fcap:{type:'line',yMin:fc,yMax:fc,borderColor:'#18d4e833',borderWidth:1,borderDash:[3,3],label:{display:true,content:`Fresh cap (${Math.round(fc)}L)`,position:'start',color:'#18d4e8',font:{size:8},backgroundColor:'transparent'}},gcap:{type:'line',yMin:gc,yMax:gc,borderColor:'#7888a033',borderWidth:1,borderDash:[3,3]}}}}})); | |
| if(wtD.length){mk('wt-daily','bar',{labels:wtD.map((_,i)=>`Day ${i+1}`),datasets:[ | |
| {label:'Shower',data:wtD.map(r=>Math.round(r.Shower_Total_L||0)),backgroundColor:'#18d4e8cc',borderRadius:4}, | |
| {label:'Kitchen',data:wtD.map(r=>Math.round(r.Kitchen_Total_L||0)),backgroundColor:'#2ee8a8cc',borderRadius:4}, | |
| {label:'Toilet',data:wtD.map(r=>Math.round(r.Toilet_Total_L||0)),backgroundColor:'#585858cc',borderRadius:4}, | |
| ]},mO({scales:{x:{stacked:true},y:{stacked:true,title:{display:true,text:'Litres',color:'#506070'}}}}));} | |
| if(whProfile.value?.length){const wp=whProfile.value;mk('wt-hourly','bar',{labels:wp.map(h=>fH(h.hour)),datasets:[ | |
| {label:'Shower',data:wp.map(h=>h.Shower_L||0),backgroundColor:'#18d4e8cc',borderRadius:2}, | |
| {label:'Kitchen',data:wp.map(h=>h.Kitchen_L||0),backgroundColor:'#2ee8a8cc',borderRadius:2}, | |
| {label:'Toilet',data:wp.map(h=>h.Toilet_L||0),backgroundColor:'#585858cc',borderRadius:2}, | |
| ]},mO({scales:{x:{stacked:true,ticks:{maxTicksLimit:12}},y:{stacked:true,title:{display:true,text:'Litres',color:'#506070'}}}}));} | |
| if(wtD.length){const t={S:0,K:0,T:0};wtD.forEach(r=>{t.S+=r.Shower_Total_L||0;t.K+=r.Kitchen_Total_L||0;t.T+=r.Toilet_Total_L||0;});const tt=t.S+t.K+t.T; | |
| mk('wt-donut','doughnut',{labels:['Shower','Kitchen','Toilet'],datasets:[{data:[Math.round(t.S),Math.round(t.K),Math.round(t.T)],backgroundColor:['#18d4e8','#2ee8a8','#585858'],borderWidth:0,hoverOffset:6}]}, | |
| {responsive:true,maintainAspectRatio:true,cutout:'62%',plugins:{legend:{position:'right',labels:{color:'#8a9bb0',font:{size:10},boxWidth:10,padding:8}},tooltip:{callbacks:{label:c=>`${c.label}: ${c.raw} L (${Math.round(c.raw/tt*100)}%)`}}}});} | |
| if(wt15.length){const pm=wt15.map(r=>r.Pump_Flow_Lpm||0); | |
| mk('wt-pump','bar',{labels:wt15.map(r=>fT(r.Time)),datasets:[{label:'Pump',data:pm,backgroundColor:pm.map(v=>`rgba(24,212,232,${Math.min(.9,v/.4+.08)})`),borderRadius:1,barPercentage:1,categoryPercentage:1}]},mO({plugins:{legend:{display:false}},scales:{x:{ticks:{maxTicksLimit:14}},y:{title:{display:true,text:'Lpm',color:'#506070'}}}}));} | |
| } | |
| // ββ Budget ββ | |
| async function loadBG(){if(!wtD.length){const r=await api('/api/water/1DAY?limit=30');wtD=r.data||[];}await nextTick();renderBG();} | |
| function renderBG(){ | |
| const s=S.value;if(!s.trip_days)return;const pb=pBudget.value,wb=wBudget.value,days=s.trip_days,ct=s.circuit_totals_kwh||{}; | |
| const cM={hvac_kwh:'HVAC',lighting_kwh:'Lighting',devices_kwh:'Devices',fridge_kwh:'Fridge',water_pump_kwh:'WaterPump',cooking_kwh:'Cooking',inverter_kwh:'Inverter'}; | |
| const bL=Object.keys(cM).map(k=>cM[k]),bB=Object.keys(cM).map(k=>rd((pb[k]||0)*days)),bA=Object.keys(cM).map(k=>rd(ct[cM[k]]||0)); | |
| mk('bg-power','bar',{labels:bL,datasets:[ | |
| {label:'Budget',data:bB,backgroundColor:'rgba(46,232,168,.3)',borderColor:'#2ee8a8',borderWidth:1,borderRadius:5,barPercentage:.55}, | |
| {label:'Actual',data:bA,backgroundColor:'rgba(255,80,80,.3)',borderColor:'#ff5050',borderWidth:1,borderRadius:5,barPercentage:.55}, | |
| ]},mO({scales:{y:{title:{display:true,text:'kWh (trip)',color:'#506070'}}},plugins:{tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${c.raw} kWh`}}}})); | |
| const wL=['Shower','Kitchen','Toilet'],wB=[rd(wb.shower_L*days),rd(wb.kitchen_L*days),rd(wb.toilet_L*days)];let wA=[0,0,0];wtD.forEach(r=>{wA[0]+=r.Shower_Total_L||0;wA[1]+=r.Kitchen_Total_L||0;wA[2]+=r.Toilet_Total_L||0;});wA=wA.map(v=>Math.round(v)); | |
| mk('bg-water','bar',{labels:wL,datasets:[ | |
| {label:'Budget',data:wB,backgroundColor:'rgba(24,212,232,.3)',borderColor:'#18d4e8',borderWidth:1,borderRadius:5,barPercentage:.55}, | |
| {label:'Actual',data:wA,backgroundColor:'rgba(88,88,88,.35)',borderColor:'#585858',borderWidth:1,borderRadius:5,barPercentage:.55}, | |
| ]},mO({scales:{y:{title:{display:true,text:'Litres (trip)',color:'#506070'}}}})); | |
| const db=s.daily_breakdown||[]; | |
| mk('bg-trend','line',{labels:db.map(d=>`Day ${d.day}`),datasets:[ | |
| {label:'Solar Coverage %',data:db.map(d=>Math.min(200,Math.round(d.solar_kwh/Math.max(d.consumption_kwh,.01)*100))),borderColor:'#2ee8a8',backgroundColor:'rgba(46,232,168,.06)',fill:true,pointRadius:5,pointBackgroundColor:'#2ee8a8',borderWidth:2,tension:.3}, | |
| {label:'Battery End %',data:db.map(d=>d.battery_end_pct),borderColor:'#4890ff',backgroundColor:'rgba(72,144,255,.04)',fill:false,pointRadius:5,pointBackgroundColor:'#4890ff',borderWidth:2,tension:.3}, | |
| ]},mO({scales:{y:{min:0,title:{display:true,text:'%',color:'#506070'}}},plugins:{annotation:{annotations:{target:{type:'line',yMin:100,yMax:100,borderColor:'#2ee8a855',borderWidth:1,borderDash:[4,4],label:{display:true,content:'100% self-sufficient',position:'end',color:'#2ee8a8',font:{size:8},backgroundColor:'transparent'}}}}}})); | |
| } | |
| // ββ Generate ββ | |
| async function doGen(){ | |
| genBusy.value=true;genMsg.value='Generating simulation data...';genOk.value=true; | |
| try{ | |
| const r=await fetch('/api/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(gen)}); | |
| const d=await r.json(); | |
| if(r.ok){genMsg.value='β '+d.message+'. Reloading...';genOk.value=true;pw15=[];pwH=[];wtH=[];wtD=[];wt15=[];await loadAll();genMsg.value='β Dashboard refreshed!';} | |
| else{genMsg.value='β '+(d.error||'Error');genOk.value=false;} | |
| }catch(e){genMsg.value='β '+e.message;genOk.value=false;} | |
| genBusy.value=false; | |
| } | |
| async function renderMermaid(){ | |
| const container=document.getElementById('mermaid-container');if(!container||!schemaMermaidText.value||typeof mermaid==='undefined')return; | |
| container.innerHTML=''; | |
| try{ | |
| mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{primaryColor:'#0c1118',primaryTextColor:'#e8edf4',primaryBorderColor:'#283848',lineColor:'#506070',secondaryColor:'#121a24',tertiaryColor:'#182230'}}); | |
| const id='mermaid-'+Date.now();const {svg}=await mermaid.render(id,schemaMermaidText.value);container.innerHTML=svg;schemaMermaidErr.value=''; | |
| }catch(e){schemaMermaidErr.value=e.message||'Mermaid render failed';} | |
| } | |
| async function loadSchema(){ | |
| try{ | |
| const r=await api('/api/schema');schemaTables.value=r.tables||{};schemaMermaidText.value=r.mermaid||'';schemaMermaidErr.value=''; | |
| await nextTick();await renderMermaid(); | |
| }catch(e){schemaMermaidErr.value=e.message||'Failed to load schema';} | |
| } | |
| async function loadDataTablesList(){try{const r=await api('/api/data/tables');dataTablesList.value=r.tables||[];}catch(e){dataTablesList.value=[];}} | |
| async function loadTableData(){ | |
| if(!selectedTableId.value){tableData.value=[];tableColumns.value=[];return;} | |
| const [stream,res]=selectedTableId.value.split('_');const limit=Math.min(2000,Math.max(10,tableLimit.value||500)); | |
| try{ | |
| const r=await api(`/api/${stream}/${res}?limit=${limit}`);const rows=r.data||[];tableData.value=rows; | |
| tableColumns.value=rows.length?Object.keys(rows[0]):(r.columns||[]); | |
| }catch(e){tableData.value=[];tableColumns.value=[];} | |
| } | |
| function fmtCell(v){if(v==null||v==='')return 'β';if(typeof v==='number')return Number.isInteger(v)?v:v.toFixed(4);return v;} | |
| function go(p){pg.value=p;} | |
| watch(pg,async p=>{await nextTick();if(p==='overview')renderOV();if(p==='power')loadPW();if(p==='water')loadWT();if(p==='budget')loadBG();if(p==='data'){await loadSchema();loadDataTablesList();}}); | |
| watch(dataSubTab,async (t)=>{if(t==='schema'&&schemaMermaidText.value)await nextTick().then(()=>renderMermaid());}); | |
| onMounted(async()=>{await loadAll();await nextTick();renderOV();}); | |
| return{pg,loading,cfg,S,pBudget,wBudget,comps,hProfile,whProfile,pDay,pDayInsight,gen,genBusy,genMsg,genOk,battCol,pBMax,wBMax,rd,fL,fmtK,bPct,bCol,go,setPDay,doGen,dataSubTab,schemaTables,schemaMermaidErr,dataTablesList,selectedTableId,tableLimit,tableData,tableColumns,loadTableData,fmtCell}; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> |