logiflow-rl / visualisation /logiflow_visualizer.html
roshan5emerald's picture
Upload folder using huggingface_hub
493f165 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogiFlow-RL · 12-Node Supply Chain</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@600;700;800&display=swap');
:root{
--bg:#060a12;--panel:#0d1220;--panel2:#111827;
--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.11);
--accent:#00e5ff;--purple:#7c3aed;
--green:#10b981;--amber:#f59e0b;--red:#ef4444;
--text:#e2e8f0;--dim:#64748b;--muted:#4b5563;
--mono:'JetBrains Mono',monospace;--sans:'Syne',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:var(--sans);min-height:100vh;}
body::before{content:'';position:fixed;inset:0;
background:linear-gradient(rgba(255,255,255,0.013) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,0.013) 1px,transparent 1px),
radial-gradient(ellipse 70% 40% at 50% 0%,rgba(0,229,255,0.05),transparent);
background-size:44px 44px,44px 44px,100% 100%;
pointer-events:none;z-index:0;}
.w{position:relative;z-index:1;max-width:1380px;margin:0 auto;padding:18px;}
/* ── header ── */
header{display:flex;align-items:center;justify-content:space-between;
padding-bottom:14px;border-bottom:1px solid var(--border2);margin-bottom:16px;}
.logo{display:flex;align-items:center;gap:11px;}
.li{width:38px;height:38px;border-radius:9px;
background:linear-gradient(135deg,var(--purple),var(--accent));
display:flex;align-items:center;justify-content:center;
font-family:var(--mono);font-weight:700;font-size:12px;color:#fff;}
.lt h1{font-size:17px;font-weight:800;letter-spacing:-.5px;}
.lt p{font-size:10px;color:var(--dim);font-family:var(--mono);margin-top:1px;}
.badge{display:flex;align-items:center;gap:6px;padding:6px 12px;
border-radius:20px;background:var(--panel2);border:1px solid var(--border2);
font-size:11px;font-family:var(--mono);}
.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);}
.dot.on{background:var(--green);box-shadow:0 0 7px var(--green);animation:blink 2s infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
/* ── start banner ── */
#startBanner{
background:linear-gradient(135deg,rgba(124,58,237,.15),rgba(0,229,255,.1));
border:1px solid rgba(0,229,255,.25);border-radius:12px;
padding:20px 24px;margin-bottom:16px;
display:flex;align-items:center;justify-content:space-between;gap:16px;
flex-wrap:wrap;
}
#startBanner h2{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:4px;}
#startBanner p{font-size:12px;color:var(--dim);font-family:var(--mono);}
.btn-start{
background:linear-gradient(135deg,var(--purple),var(--accent));
color:#fff;border:none;border-radius:10px;
padding:12px 28px;font-size:14px;font-weight:700;
cursor:pointer;font-family:var(--sans);
transition:all .2s;white-space:nowrap;
}
.btn-start:hover{transform:translateY(-2px);filter:brightness(1.1);}
/* ── controls ── */
.ctrl{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px;align-items:center;}
input[type=text]{flex:1;min-width:160px;background:var(--panel2);
border:1px solid var(--border2);color:var(--text);
padding:8px 13px;border-radius:8px;font-family:var(--mono);font-size:11px;outline:none;}
input:focus{border-color:var(--accent);}
select{background:var(--panel2);border:1px solid var(--border2);color:var(--text);
padding:8px 13px;border-radius:8px;font-size:12px;cursor:pointer;outline:none;}
.btn{padding:8px 15px;border-radius:8px;font-family:var(--sans);font-size:12px;
font-weight:600;cursor:pointer;border:none;transition:all .15s;}
.bp{background:var(--accent);color:var(--bg);}
.bp:hover{filter:brightness(1.1);}
.bs{background:var(--panel2);border:1px solid var(--border2);color:var(--text);}
.bs:hover{border-color:var(--accent);color:var(--accent);}
.bplay{background:var(--panel2);border:1px solid var(--border2);color:var(--amber);}
.bplay.on{background:rgba(245,158,11,.1);border-color:var(--amber);}
/* ── main layout ── */
.main{display:grid;grid-template-columns:1fr 350px;gap:14px;}
.card{background:var(--panel);border:1px solid var(--border2);border-radius:14px;overflow:hidden;}
.ch{padding:10px 15px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;}
.ct{font-family:var(--mono);font-size:10px;font-weight:700;
color:var(--dim);text-transform:uppercase;letter-spacing:1.5px;}
.cb{padding:14px;}
/* ── svg network ── */
svg.net{width:100%;border-radius:8px;background:rgba(0,0,0,.22);}
.nb{fill:var(--panel2);stroke-width:1.5;transition:stroke .3s,filter .3s;
filter:drop-shadow(0 2px 5px rgba(0,0,0,.5));}
.nb.s{stroke:#7c3aed;}.nb.wh{stroke:#0ea5e9;}
.nb.dc{stroke:#f59e0b;}.nb.rt{stroke:#10b981;}
.nb.ol{stroke:#ef4444!important;animation:pulsered 1s infinite;}
.nb.wn{stroke:#f59e0b!important;}
.nb.src{stroke:var(--accent)!important;stroke-width:2.5!important;
filter:drop-shadow(0 0 10px var(--accent));}
@keyframes pulsered{
0%,100%{filter:drop-shadow(0 0 5px rgba(239,68,68,.6))}
50%{filter:drop-shadow(0 0 14px rgba(239,68,68,1))}}
.nl{fill:var(--text);font-family:var(--mono);font-size:8.5px;
font-weight:700;text-anchor:middle;}
.nt{fill:var(--dim);font-family:var(--mono);font-size:7px;text-anchor:middle;}
.nv{font-family:var(--mono);font-weight:700;text-anchor:middle;font-size:11px;}
.edge{stroke-width:1.2;fill:none;opacity:.3;}
.edge.act{opacity:1;stroke-width:2.8;
animation:dash .75s linear infinite;stroke-dasharray:7 4;}
@keyframes dash{to{stroke-dashoffset:-22}}
.pt{fill:var(--accent);filter:drop-shadow(0 0 5px var(--accent));}
.tl{fill:var(--muted);font-family:var(--mono);font-size:8px;text-anchor:middle;}
/* ── ep bar ── */
.epbar{height:4px;background:var(--border2);border-radius:2px;overflow:hidden;margin:8px 0 3px;}
.epfill{height:100%;border-radius:2px;
background:linear-gradient(90deg,var(--purple),var(--accent));transition:width .4s;}
.eplbl{font-size:9px;font-family:var(--mono);color:var(--dim);}
/* ── event badge ── */
.ev{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;
border-radius:20px;font-size:10px;font-family:var(--mono);font-weight:700;}
.en{background:rgba(0,229,255,.1);color:var(--accent);border:1px solid rgba(0,229,255,.2);}
.es{background:rgba(245,158,11,.1);color:var(--amber);border:1px solid rgba(245,158,11,.3);}
.ew{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.3);}
/* ── sidebar ── */
.sidebar{display:flex;flex-direction:column;gap:11px;}
.score-center{text-align:center;padding:14px 14px 8px;}
.arc-wrap{position:relative;width:108px;height:64px;margin:0 auto 4px;}
.arc-val{position:absolute;bottom:0;left:50%;transform:translateX(-50%);
font-family:var(--mono);font-size:21px;font-weight:700;color:var(--accent);}
.arc-lbl{font-size:9px;font-family:var(--mono);color:var(--dim);
letter-spacing:1.5px;text-transform:uppercase;}
.sg{display:grid;grid-template-columns:1fr 1fr;gap:7px;}
.sb{background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:9px;}
.sbl{font-size:9px;font-family:var(--mono);color:var(--dim);
text-transform:uppercase;letter-spacing:1px;margin-bottom:3px;}
.sbv{font-family:var(--mono);font-size:15px;font-weight:700;}
.cg{color:var(--green);}.ca{color:var(--amber);}.cr{color:var(--red);}.cb2{color:var(--accent);}
.rsn{background:var(--panel2);border:1px solid var(--border);border-radius:9px;
padding:11px;font-family:var(--mono);font-size:10.5px;line-height:1.75;
color:var(--dim);min-height:76px;max-height:108px;overflow-y:auto;}
.rsn.tk{color:var(--accent);animation:tb 1s infinite;}
@keyframes tb{0%,100%{opacity:1}50%{opacity:.5}}
.rsn::-webkit-scrollbar{width:3px;}
.rsn::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px;}
.spkc{width:100%;height:44px;display:block;}
.log{font-family:var(--mono);font-size:10px;line-height:1.85;max-height:96px;overflow-y:auto;}
.log::-webkit-scrollbar{width:3px;}
.log::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px;}
.le{display:flex;gap:7px;}
.lt2{color:var(--muted);min-width:46px;}
.lg2{color:var(--green);}.lb2{color:var(--red);}.li2{color:var(--accent);}.lw2{color:var(--amber);}
.actionrow{display:flex;gap:10px;margin-top:6px;font-family:var(--mono);font-size:10.5px;}
/* legend */
.legend{display:flex;gap:14px;margin-top:9px;flex-wrap:wrap;}
.legend div{display:flex;align-items:center;gap:5px;font-size:10px;
font-family:var(--mono);color:var(--dim);}
.lsq{width:10px;height:10px;border-radius:2px;}
@media(max-width:860px){.main{grid-template-columns:1fr;}}
</style>
</head>
<body>
<div class="w">
<!-- header -->
<header>
<div class="logo">
<div class="li">LF</div>
<div class="lt">
<h1>LogiFlow-RL</h1>
<p>12-Node Supply Chain · GRPO Trained Agent</p>
</div>
</div>
<div class="badge">
<div class="dot" id="dot"></div>
<span id="statusTxt">READY</span>
</div>
</header>
<!-- START BANNER — shown first, hidden once demo starts -->
<div id="startBanner">
<div>
<h2>▶ Watch the Trained GRPO Agent Play</h2>
<p>No server needed — simulates checkpoint-140 locally in your browser</p>
</div>
<button class="btn-start" onclick="startAndPlay()">
🎮 Start Demo
</button>
</div>
<!-- controls (hidden until demo starts) -->
<div class="ctrl" id="ctrlRow" style="display:none;">
<input type="text" id="srvUrl" value="http://localhost:8000" placeholder="FastAPI / HF Space URL"/>
<select id="taskSel">
<option value="easy">Easy — 50 steps</option>
<option value="medium">Medium — 70 steps</option>
<option value="hard" selected>Hard — 90 steps</option>
</select>
<button class="btn bp" onclick="connectLive()">⟳ Connect Live</button>
<button class="btn bplay" id="playBtn" onclick="toggleAuto()">⏸ Pause</button>
<button class="btn bs" onclick="resetEp()">↺ Reset</button>
</div>
<div class="main">
<!-- LEFT: network -->
<div class="card">
<div class="ch">
<span class="ct">Live 12-Node Supply Chain</span>
<div style="display:flex;align-items:center;gap:8px;">
<span class="ev en" id="evBadge">✓ NORMAL</span>
<span style="font-family:var(--mono);font-size:10px;color:var(--dim);">
Step&nbsp;<span id="sNum">0</span>/<span id="sMax">90</span>
</span>
</div>
</div>
<div class="cb">
<div class="epbar"><div class="epfill" id="epFill" style="width:0%"></div></div>
<div class="eplbl" id="epLbl">Press Start Demo above</div>
<div style="height:9px;"></div>
<!-- 12-node SVG -->
<svg class="net" viewBox="0 0 780 420" id="svg">
<defs>
<marker id="ma" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M2 2L8 5L2 8" fill="none" stroke="#334155" stroke-width="1.5"/>
</marker>
<marker id="mb" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M2 2L8 5L2 8" fill="none" stroke="#00e5ff" stroke-width="2"/>
</marker>
</defs>
<!-- tier labels -->
<text class="tl" x="88" y="15">SUPPLIERS</text>
<text class="tl" x="295" y="15">WAREHOUSES</text>
<text class="tl" x="497" y="15">DIST. CENTRES</text>
<text class="tl" x="689" y="15">RETAIL</text>
<line x1="183" y1="19" x2="183" y2="405" stroke="rgba(255,255,255,0.035)" stroke-width="1"/>
<line x1="388" y1="19" x2="388" y2="405" stroke="rgba(255,255,255,0.035)" stroke-width="1"/>
<line x1="588" y1="19" x2="588" y2="405" stroke="rgba(255,255,255,0.035)" stroke-width="1"/>
<!-- EDGES: suppliers → warehouses (purple) -->
<line class="edge" stroke="#7c3aed" id="e04" x1="140" y1="66" x2="252" y2="110" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e05" x1="140" y1="66" x2="252" y2="200" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e14" x1="140" y1="160" x2="252" y2="110" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e16" x1="140" y1="160" x2="252" y2="290" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e25" x1="140" y1="253" x2="252" y2="200" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e26" x1="140" y1="253" x2="252" y2="290" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e34" x1="140" y1="346" x2="252" y2="110" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e35" x1="140" y1="346" x2="252" y2="200" marker-end="url(#ma)"/>
<line class="edge" stroke="#7c3aed" id="e36" x1="140" y1="346" x2="252" y2="290" marker-end="url(#ma)"/>
<!-- EDGES: warehouses → DCs (blue) -->
<line class="edge" stroke="#0ea5e9" id="e47" x1="342" y1="110" x2="450" y2="146" marker-end="url(#ma)"/>
<line class="edge" stroke="#0ea5e9" id="e48" x1="342" y1="110" x2="450" y2="246" marker-end="url(#ma)"/>
<line class="edge" stroke="#0ea5e9" id="e58" x1="342" y1="200" x2="450" y2="246" marker-end="url(#ma)"/>
<line class="edge" stroke="#0ea5e9" id="e59" x1="342" y1="200" x2="450" y2="346" marker-end="url(#ma)"/>
<line class="edge" stroke="#0ea5e9" id="e67" x1="342" y1="290" x2="450" y2="146" marker-end="url(#ma)"/>
<line class="edge" stroke="#0ea5e9" id="e69" x1="342" y1="290" x2="450" y2="346" marker-end="url(#ma)"/>
<!-- EDGES: DCs → Retail (amber) -->
<line class="edge" stroke="#f59e0b" id="e710" x1="540" y1="146" x2="644" y2="165" marker-end="url(#ma)"/>
<line class="edge" stroke="#f59e0b" id="e810" x1="540" y1="246" x2="644" y2="165" marker-end="url(#ma)"/>
<line class="edge" stroke="#f59e0b" id="e811" x1="540" y1="246" x2="644" y2="285" marker-end="url(#ma)"/>
<line class="edge" stroke="#f59e0b" id="e911" x1="540" y1="346" x2="644" y2="285" marker-end="url(#ma)"/>
<!-- NODE 0: Supplier North -->
<g id="n0"><rect class="nb s" x="88" y="43" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="93" y="80" width="94" height="5" rx="2"/>
<rect id="b0" x="93" y="80" width="0" height="5" rx="2" fill="#7c3aed"/>
<text class="nl" x="140" y="58">Node 0</text>
<text class="nt" x="140" y="68">Supplier North</text>
<text class="nv" id="v0" x="140" y="79" fill="#e2e8f0"></text></g>
<!-- NODE 1: Supplier West -->
<g id="n1"><rect class="nb s" x="88" y="137" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="93" y="174" width="94" height="5" rx="2"/>
<rect id="b1" x="93" y="174" width="0" height="5" rx="2" fill="#7c3aed"/>
<text class="nl" x="140" y="152">Node 1</text>
<text class="nt" x="140" y="162">Supplier West</text>
<text class="nv" id="v1" x="140" y="173" fill="#e2e8f0"></text></g>
<!-- NODE 2: Supplier Port -->
<g id="n2"><rect class="nb s" x="88" y="230" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="93" y="267" width="94" height="5" rx="2"/>
<rect id="b2" x="93" y="267" width="0" height="5" rx="2" fill="#7c3aed"/>
<text class="nl" x="140" y="245">Node 2</text>
<text class="nt" x="140" y="255">Supplier Port</text>
<text class="nv" id="v2" x="140" y="266" fill="#e2e8f0"></text></g>
<!-- NODE 3: Supplier Inland -->
<g id="n3"><rect class="nb s" x="88" y="323" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="93" y="360" width="94" height="5" rx="2"/>
<rect id="b3" x="93" y="360" width="0" height="5" rx="2" fill="#7c3aed"/>
<text class="nl" x="140" y="338">Node 3</text>
<text class="nt" x="140" y="348">Supplier Inland</text>
<text class="nv" id="v3" x="140" y="359" fill="#e2e8f0"></text></g>
<!-- NODE 4: WH Alpha -->
<g id="n4"><rect class="nb wh" x="250" y="87" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="255" y="124" width="94" height="5" rx="2"/>
<rect id="b4" x="255" y="124" width="0" height="5" rx="2" fill="#0ea5e9"/>
<text class="nl" x="302" y="102">Node 4</text>
<text class="nt" x="302" y="112">WH Alpha</text>
<text class="nv" id="v4" x="302" y="123" fill="#e2e8f0"></text></g>
<!-- NODE 5: WH Beta -->
<g id="n5"><rect class="nb wh" x="250" y="177" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="255" y="214" width="94" height="5" rx="2"/>
<rect id="b5" x="255" y="214" width="0" height="5" rx="2" fill="#0ea5e9"/>
<text class="nl" x="302" y="192">Node 5</text>
<text class="nt" x="302" y="202">WH Beta</text>
<text class="nv" id="v5" x="302" y="213" fill="#e2e8f0"></text></g>
<!-- NODE 6: WH Gamma -->
<g id="n6"><rect class="nb wh" x="250" y="267" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="255" y="304" width="94" height="5" rx="2"/>
<rect id="b6" x="255" y="304" width="0" height="5" rx="2" fill="#0ea5e9"/>
<text class="nl" x="302" y="282">Node 6</text>
<text class="nt" x="302" y="292">WH Gamma</text>
<text class="nv" id="v6" x="302" y="303" fill="#e2e8f0"></text></g>
<!-- NODE 7: DC Metro -->
<g id="n7"><rect class="nb dc" x="448" y="123" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="453" y="160" width="94" height="5" rx="2"/>
<rect id="b7" x="453" y="160" width="0" height="5" rx="2" fill="#f59e0b"/>
<text class="nl" x="500" y="138">Node 7</text>
<text class="nt" x="500" y="148">DC Metro</text>
<text class="nv" id="v7" x="500" y="159" fill="#e2e8f0"></text></g>
<!-- NODE 8: DC Central -->
<g id="n8"><rect class="nb dc" x="448" y="223" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="453" y="260" width="94" height="5" rx="2"/>
<rect id="b8" x="453" y="260" width="0" height="5" rx="2" fill="#f59e0b"/>
<text class="nl" x="500" y="238">Node 8</text>
<text class="nt" x="500" y="248">DC Central</text>
<text class="nv" id="v8" x="500" y="259" fill="#e2e8f0"></text></g>
<!-- NODE 9: DC Coastal -->
<g id="n9"><rect class="nb dc" x="448" y="323" width="104" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="453" y="360" width="94" height="5" rx="2"/>
<rect id="b9" x="453" y="360" width="0" height="5" rx="2" fill="#f59e0b"/>
<text class="nl" x="500" y="338">Node 9</text>
<text class="nt" x="500" y="348">DC Coastal</text>
<text class="nv" id="v9" x="500" y="359" fill="#e2e8f0"></text></g>
<!-- NODE 10: Retail North -->
<g id="n10"><rect class="nb rt" x="642" y="142" width="110" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="647" y="179" width="100" height="5" rx="2"/>
<rect id="b10" x="647" y="179" width="0" height="5" rx="2" fill="#10b981"/>
<text class="nl" x="697" y="157">Node 10</text>
<text class="nt" x="697" y="167">Retail North</text>
<text class="nv" id="v10" x="697" y="178" fill="#e2e8f0"></text></g>
<!-- NODE 11: Retail South -->
<g id="n11"><rect class="nb rt" x="642" y="262" width="110" height="46" rx="8"/>
<rect fill="rgba(0,0,0,.3)" x="647" y="299" width="100" height="5" rx="2"/>
<rect id="b11" x="647" y="299" width="0" height="5" rx="2" fill="#10b981"/>
<text class="nl" x="697" y="277">Node 11</text>
<text class="nt" x="697" y="287">Retail South</text>
<text class="nv" id="v11" x="697" y="298" fill="#e2e8f0"></text></g>
<!-- particle layer -->
<g id="ptl"></g>
</svg>
<!-- legend -->
<div class="legend">
<div><div class="lsq" style="background:#7c3aed;"></div>Supplier</div>
<div><div class="lsq" style="background:#0ea5e9;"></div>Warehouse</div>
<div><div class="lsq" style="background:#f59e0b;"></div>Dist. Centre</div>
<div><div class="lsq" style="background:#10b981;"></div>Retail</div>
<div style="color:var(--red);">■ Overloaded (&gt;100%)</div>
<div style="color:var(--amber);">■ Warning (&gt;70%)</div>
</div>
</div>
</div>
<!-- RIGHT: sidebar -->
<div class="sidebar">
<!-- score arc -->
<div class="card">
<div class="ch"><span class="ct">Episode Score</span></div>
<div class="score-center">
<div class="arc-wrap">
<svg viewBox="0 0 108 64" width="108" height="64">
<path d="M9 59 A45 45 0 0 1 99 59" fill="none"
stroke="rgba(255,255,255,0.06)" stroke-width="7" stroke-linecap="round"/>
<path d="M9 59 A45 45 0 0 1 99 59" fill="none"
stroke="var(--accent)" stroke-width="7" stroke-linecap="round"
stroke-dasharray="141" stroke-dashoffset="141"
id="arc" style="transition:stroke-dashoffset .5s,stroke .3s;"/>
</svg>
<div class="arc-val" id="arcVal"></div>
</div>
<div class="arc-lbl">Cumulative Score</div>
</div>
</div>
<!-- metrics grid -->
<div class="card">
<div class="ch"><span class="ct">Live Metrics</span></div>
<div class="cb">
<div class="sg">
<div class="sb"><div class="sbl">Last Reward</div><div class="sbv cb2" id="mLR"></div></div>
<div class="sb"><div class="sbl">Incoming</div><div class="sbv ca" id="mIn"></div></div>
<div class="sb"><div class="sbl">Overloaded</div><div class="sbv cr" id="mOL">0</div></div>
<div class="sb"><div class="sbl">Disruptions</div><div class="sbv cr" id="mDis">0</div></div>
<div class="sb"><div class="sbl">Bottlenecks</div><div class="sbv ca" id="mBN">0</div></div>
<div class="sb"><div class="sbl">In Transit</div><div class="sbv cg" id="mIT">0</div></div>
</div>
</div>
</div>
<!-- agent reasoning -->
<div class="card">
<div class="ch">
<span class="ct">Agent Reasoning</span>
<span style="font-size:9px;font-family:var(--mono);color:var(--dim);">GRPO·ckpt-140</span>
</div>
<div class="cb">
<div class="rsn" id="rsn">Waiting for agent to start...</div>
<div class="actionrow">
<span style="color:var(--dim);">src:</span>
<span id="aSrc" style="color:var(--accent);"></span>
<span style="color:var(--dim);">→ dst:</span>
<span id="aDst" style="color:var(--green);"></span>
<span style="color:var(--dim);">vol:</span>
<span id="aVol" style="color:var(--amber);"></span>
</div>
</div>
</div>
<!-- sparkline -->
<div class="card">
<div class="ch"><span class="ct">Reward History</span></div>
<div class="cb" style="padding:10px 12px;">
<canvas class="spkc" id="spk" height="44"></canvas>
</div>
</div>
<!-- log -->
<div class="card">
<div class="ch">
<span class="ct">Event Log</span>
<button onclick="clearLog()"
style="background:none;border:none;color:var(--dim);
font-size:9px;font-family:var(--mono);cursor:pointer;">CLEAR</button>
</div>
<div class="cb" style="padding:9px 13px;">
<div class="log" id="logArea"></div>
</div>
</div>
</div><!-- /sidebar -->
</div><!-- /main -->
</div><!-- /wrap -->
<script>
// ── constants ──────────────────────────────────────────────────────────────
const NP = [
[140,66],[140,160],[140,253],[140,346], // suppliers
[302,110],[302,200],[302,290], // warehouses
[500,146],[500,246],[500,346], // DCs
[697,165],[697,285] // retail
];
const CONN = {
0:[4,5], 1:[4,6], 2:[5,6], 3:[4,5,6],
4:[7,8], 5:[8,9], 6:[7,9],
7:[10], 8:[10,11], 9:[11],
10:[], 11:[]
};
const NAMES = [
'Supplier North','Supplier West','Supplier Port','Supplier Inland',
'WH Alpha','WH Beta','WH Gamma',
'DC Metro','DC Central','DC Coastal',
'Retail North','Retail South'
];
const BAR_X = [93,93,93,93,255,255,255,453,453,453,647,647];
const BAR_Y = [80,174,267,360,124,214,304,160,260,360,179,299];
const BAR_W = 94;
const COL = ['#7c3aed','#7c3aed','#7c3aed','#7c3aed',
'#0ea5e9','#0ea5e9','#0ea5e9',
'#f59e0b','#f59e0b','#f59e0b',
'#10b981','#10b981'];
// ── state ──────────────────────────────────────────────────────────────────
let autoTimer = null;
let rewards = [];
let liveMode = false;
let obs = null;
// demo world state
let ds = {
loads:[45,52,38,44,55,48,41,35,42,30,15,12],
step:0, max:90, score:0, bn:0, dis:[], lr:0, inc:16.5, src:2
};
// ── entry point ────────────────────────────────────────────────────────────
function startAndPlay() {
document.getElementById('startBanner').style.display = 'none';
document.getElementById('ctrlRow').style.display = 'flex';
setStatus(true, 'DEMO');
resetDemo();
log('Demo mode started — GRPO agent (checkpoint-140)', 'i');
// auto-play immediately
startAutoPlay();
}
function startAutoPlay() {
if (autoTimer) return;
const b = document.getElementById('playBtn');
b.textContent = '⏸ Pause'; b.classList.add('on');
autoTimer = setInterval(step, 1100);
}
function stopAuto() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
const b = document.getElementById('playBtn');
b.textContent = '▶ Play'; b.classList.remove('on');
}
function toggleAuto() {
if (autoTimer) { stopAuto(); } else { startAutoPlay(); }
}
// ── demo physics ───────────────────────────────────────────────────────────
function resetDemo() {
ds = {
loads:[45,52,38,44,55,48,41,35,42,30,15,12],
step:0, max:90, score:0, bn:0, dis:[], lr:0, inc:16.5, src:2
};
rewards = [];
buildObs();
updateUI();
log('Episode reset — hard task (90 steps)', 'i');
}
function visibleFrom(src) {
const s = new Set([src]);
(CONN[src]||[]).forEach(n => { s.add(n); (CONN[n]||[]).forEach(m => s.add(m)); });
return [...s];
}
function buildObs() {
const s = ds;
const ol = s.loads.filter(l => l > 100).length;
obs = {
task_id:'hard', max_steps:s.max, step_count:s.step,
node_loads: s.loads.map(l => +l.toFixed(1)),
observed_node_loads: s.loads.map((l, i) =>
visibleFrom(s.src).includes(i) ? +l.toFixed(1) : null),
visible_node_ids: visibleFrom(s.src),
active_disruptions: s.dis,
overloaded_hubs: ol,
bottlenecks: s.bn,
cumulative_score: +s.score.toFixed(3),
last_reward: +s.lr.toFixed(3),
incoming_load: +s.inc.toFixed(1),
pending_source_node: s.src,
event_label: s.dis.length > 0 ? 'weather_disruption' : 'normal',
in_transit_shipments: [],
done: s.step >= s.max
};
}
// ── GRPO agent (simulates checkpoint-140 behaviour) ────────────────────────
function agentDecide() {
const src = ds.src;
const dests = CONN[src] || [];
const loads = ds.loads;
const disN = ds.dis.map(d => d.node);
// Terminal retail nodes — no downstream routing
if (dests.length === 0) {
return { reasoning: NAMES[src] + ' is retail — terminal node, no routing.',
src: src, dst: src, vol: 0 };
}
// GRPO trained strategy: pick least-loaded downstream, avoid disrupted
let best = dests[0], minL = Infinity;
dests.forEach(function(d) {
var penalty = disN.includes(d) ? 40 : 0;
if ((loads[d]||0) + penalty < minL) {
minL = (loads[d]||0) + penalty;
best = d;
}
});
var vol = +ds.inc.toFixed(1);
var lSrc = (loads[src]||0).toFixed(1);
var lDst = (loads[best]||0).toFixed(1);
var tier = src < 4 ? 'Supplier' : src < 7 ? 'Warehouse' : 'DC';
var disMsg = disN.length > 0
? ' Avoiding disrupted: [' + disN.map(function(n){return NAMES[n];}).join(', ') + '].'
: '';
var reasons = [
tier + ' ' + NAMES[src] + ' at ' + lSrc + '% load.' + disMsg + ' Routing ' + vol + 'u to ' + NAMES[best] + ' (' + lDst + '%). Least-congested downstream path.',
'Step ' + ds.step + ': ' + NAMES[src] + ' (' + lSrc + '%).' + disMsg + ' ' + NAMES[best] + ' at ' + lDst + '% is optimal forward route. Dispatching ' + vol + ' units.',
'Balance routing: ' + NAMES[src] + '(' + lSrc + '%) to ' + NAMES[best] + '(' + lDst + '%).' + disMsg + ' Volume: ' + vol + 'u. Prevents downstream cascade.'
];
return { reasoning: reasons[ds.step % 3], src: src, dst: best, vol: vol };
}
function applyAction(action) {
// ── How the REAL environment works ──────────────────────────────────────
// Each step: one shipment is at `src` node, agent routes it to `dst`.
// The source can be ANY node with downstream connections.
// We simulate this with a rolling source that walks the tiers:
// Tier 0 (suppliers 0-3) → route to warehouses (4-6)
// Tier 1 (warehouses 4-6) → route to DCs (7-9)
// Tier 2 (DCs 7-9) → route to retail (10-11)
// This makes ALL 12 nodes active, which is what the real env produces.
const s = ds;
const src = action.src;
const dst = action.dst;
const vol = action.vol;
// 1. New incoming load appears at the source node
s.loads[src] = Math.min(110, (s.loads[src] || 0) + vol);
// 2. Agent moves freight from src → dst (one hop forward)
const moveable = Math.min(vol * 0.9, s.loads[src]);
s.loads[src] = Math.max(0, s.loads[src] - moveable);
s.loads[dst] = Math.min(110, (s.loads[dst] || 0) + moveable);
// 3. Natural drain — each tier processes/ships outward
// Suppliers drain fastest (they ship constantly)
// Retail drains fastest (they sell to customers)
const DRAIN = [7.5, 7.0, 6.5, 6.5, // suppliers
6.0, 6.0, 5.5, // warehouses
6.5, 6.5, 6.0, // DCs
12.0, 12.0]; // retail (fastest drain — sells to customers)
for (let i = 0; i < 12; i++) {
s.loads[i] = Math.max(0, s.loads[i] - DRAIN[i]);
}
// 4. Disruptions reduce a node's effective capacity (increase apparent load)
s.dis.forEach(d => {
s.loads[d.node] = Math.min(115, s.loads[d.node] + 4);
});
// 5. Disruption events
if (Math.random() < 0.07 && s.dis.length < 2) {
const dn = Math.floor(Math.random() * 10); // any non-retail node
s.dis.push({ node:dn, kind:'weather', remaining_steps: 3 + Math.floor(Math.random()*4) });
log('⚡ Disruption at ' + NAMES[dn] + '!', 'b');
}
s.dis = s.dis
.map(d => ({...d, remaining_steps: d.remaining_steps - 1}))
.filter(d => d.remaining_steps > 0);
// 6. Score
const ol = s.loads.filter(l => l > 100).length;
const bg = Math.max(...s.loads) - Math.min(...s.loads);
const rw = Math.max(0, 0.85 - ol * 0.28 - bg / 140);
if (ol > 0) s.bn++;
s.lr = rw;
s.score = (s.score * s.step + rw) / (s.step + 1);
s.step++;
// 7. Next step: source walks through all tiers so ALL nodes are visible
// Cycle: supplier(4 steps) → warehouse(3 steps) → DC(3 steps) → repeat
const cycle = s.step % 10;
if (cycle < 4) s.src = cycle; // nodes 0-3 (suppliers)
else if (cycle < 7) s.src = cycle + 1; // nodes 5-7 (warehouses start at 4)
else s.src = cycle + 0; // nodes 7-9 (DCs)
// Clamp to valid range
s.src = Math.min(9, Math.max(0, s.src));
// Next incoming volume — higher for supplier tier, lower mid-chain
s.inc = s.src < 4
? 14 + Math.random() * 14 // suppliers: 14-28
: s.src < 7
? 10 + Math.random() * 12 // warehouses: 10-22
: 8 + Math.random() * 10; // DCs: 8-18
rewards.push(rw);
buildObs();
}
// ── main step loop ─────────────────────────────────────────────────────────
async function step() {
if (!obs) return;
if (obs.done) {
log('Episode complete! Final score: ' + obs.cumulative_score, 'i');
clearInterval(autoTimer); autoTimer = null;
const b = document.getElementById('playBtn');
b.textContent = '↺ New Game'; b.classList.remove('on');
b.onclick = () => { b.onclick = toggleAuto; resetEp(); startAutoPlay(); };
return;
}
if (liveMode) {
await liveStep();
} else {
// show thinking indicator
const rsn = document.getElementById('rsn');
rsn.textContent = '⟳ Analysing network state...';
rsn.className = 'rsn tk';
await new Promise(r => setTimeout(r, 350));
const a = agentDecide();
applyAction(a);
updateUI();
rsn.textContent = a.reasoning;
rsn.className = 'rsn';
document.getElementById('aSrc').textContent = a.src;
document.getElementById('aDst').textContent = a.dst;
document.getElementById('aVol').textContent = a.vol;
animEdge(a.src, a.dst);
log(`Step ${ds.step}: ${NAMES[a.src]}${NAMES[a.dst]} r=${ds.lr.toFixed(3)}`,
ds.lr > 0.6 ? 'g' : ds.lr > 0.3 ? 'w' : 'b');
if (obs.done) {
log('Episode complete! Final score: ' + obs.cumulative_score, 'i');
stopAuto();
const b = document.getElementById('playBtn');
b.textContent = '↺ New Game';
b.onclick = () => { b.onclick = toggleAuto; resetEp(); startAutoPlay(); };
}
}
}
// ── live server mode ───────────────────────────────────────────────────────
async function connectLive() {
const url = document.getElementById('srvUrl').value.replace(/\/$/,'');
log('Connecting to ' + url + '...', 'i');
try {
const h = await fetch(url + '/health');
if (!h.ok) throw new Error('HTTP ' + h.status);
liveMode = true;
setStatus(true, 'LIVE');
log('Connected ✓', 'g');
const r = await fetch(url + '/reset', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ task_id: document.getElementById('taskSel').value })
});
obs = (await r.json()).observation || await r.json();
rewards = []; updateUI();
startAutoPlay();
} catch(e) {
setStatus(false, 'OFFLINE');
log('Cannot connect: ' + e.message, 'b');
log('Switching to demo mode.', 'w');
liveMode = false; setStatus(true, 'DEMO');
}
}
async function liveStep() {
// ── How this works ──────────────────────────────────────────────────────
// Your server already has /policy_step with mode:"llm" which calls
// Qwen-72B via HF router. We call that endpoint, not /llm_step.
// Timeout is 30s because HF inference API can take 10-25 seconds.
// Falls back to mode:"heuristic" if LLM times out or errors.
// ────────────────────────────────────────────────────────────────────────
const url = document.getElementById('srvUrl').value.replace(/\/$/,'');
const rsn = document.getElementById('rsn');
rsn.textContent = '⟳ Calling Qwen-72B via HF router (may take 15-20s)...';
rsn.className = 'rsn tk';
let data = null;
let usedMode = 'heuristic';
// ── Try LLM mode first ─────────────────────────────────────────────────
try {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 30000); // 30s timeout
const resp = await fetch(url + '/policy_step', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'llm', timeout_s: 25 }),
signal: ctrl.signal
});
clearTimeout(tid);
if (resp.ok) {
data = await resp.json();
usedMode = 'llm';
log('LLM step OK — model: ' + (data.llm_model || 'Qwen'), 'g');
} else {
const err = await resp.json().catch(() => ({}));
log('LLM step failed (' + resp.status + '): '
+ (err.detail || 'unknown') + ' — falling back to heuristic', 'w');
}
} catch(e) {
if (e.name === 'AbortError') {
log('LLM timed out after 30s — falling back to heuristic', 'w');
} else {
log('LLM error: ' + e.message + ' — falling back to heuristic', 'w');
}
}
// ── Fall back to heuristic if LLM failed ──────────────────────────────
if (!data) {
try {
const resp = await fetch(url + '/policy_step', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'heuristic' })
});
if (!resp.ok) throw new Error('heuristic /policy_step returned ' + resp.status);
data = await resp.json();
usedMode = 'heuristic';
} catch(e) {
rsn.textContent = 'Server error: ' + e.message;
rsn.className = 'rsn';
log('policy_step failed: ' + e.message, 'b');
return;
}
}
// ── Update state from server response ──────────────────────────────────
obs = data.observation || {};
const act = data.action || {};
// Sync demo state so visualiser matches server exactly
if (obs.node_loads && obs.node_loads.length === 12) {
ds.loads = [...obs.node_loads];
ds.src = obs.pending_source_node ?? ds.src;
ds.inc = obs.incoming_load ?? ds.inc;
ds.step = obs.step_count ?? ds.step;
ds.lr = obs.last_reward ?? 0;
ds.score = obs.cumulative_score ?? ds.score;
ds.dis = obs.active_disruptions ?? [];
buildObs();
}
rewards.push(data.reward || 0);
updateUI();
// ── Show reasoning ─────────────────────────────────────────────────────
const rawOutput = data.llm_raw_output || '';
let displayText = '';
if (rawOutput) {
// Extract the reasoning field from LLM JSON if present
try {
const m = rawOutput.match(/\{[\s\S]*\}/);
if (m) {
const parsed = JSON.parse(m[0]);
displayText = '[LLM] ' + (parsed.reasoning || rawOutput.slice(0, 200));
} else {
displayText = '[LLM] ' + rawOutput.slice(0, 200);
}
} catch(_) {
displayText = '[LLM] ' + rawOutput.slice(0, 200);
}
} else {
displayText = '[' + usedMode + '] Routing src=' + (act.source_node ?? '?')
+ ' → dst=' + (act.dest_node ?? '?')
+ ' vol=' + (act.shipment_volume ?? '?');
}
rsn.textContent = displayText;
rsn.className = 'rsn';
const srcNode = act.source_node ?? 0;
const dstNode = act.dest_node ?? 0;
document.getElementById('aSrc').textContent = srcNode;
document.getElementById('aDst').textContent = dstNode;
document.getElementById('aVol').textContent = (act.shipment_volume || 0).toFixed(1);
animEdge(srcNode, dstNode);
const r = data.reward || 0;
const modeTag = usedMode === 'llm' ? '[LLM]' : '[heuristic]';
log('Step ' + (obs.step_count || '?') + ' ' + modeTag + ': '
+ (NAMES[srcNode] || 'node'+srcNode) + '→'
+ (NAMES[dstNode] || 'node'+dstNode)
+ ' r=' + r.toFixed(3),
r > 0.1 ? 'g' : 'w');
if (obs.done || data.done) {
log('Episode done. Score: ' + obs.cumulative_score, 'i');
stopAuto();
}
}
function resetEp() {
if (liveMode) { /* re-call live reset */ }
else resetDemo();
}
// ── SVG animations ─────────────────────────────────────────────────────────
function animEdge(src, dst) {
// reset all edges
document.querySelectorAll('.edge').forEach(e => {
e.classList.remove('act');
e.setAttribute('stroke-width','1.2');
e.setAttribute('marker-end','url(#ma)');
e.style.opacity = '.3';
});
// activate route edge
const id = 'e' + src + '' + dst;
const el = document.getElementById(id);
if (el) {
el.classList.add('act');
el.setAttribute('marker-end','url(#mb)');
el.style.stroke = '#00e5ff';
}
// highlight source node
for (let i = 0; i < 12; i++) {
document.querySelector('#n'+i+' .nb')?.classList.toggle('src', i === src);
}
// animate particle
spawnParticle(src, dst);
// reset after animation
setTimeout(() => {
if (el) {
el.classList.remove('act');
el.style.stroke = el.dataset.col || '';
el.setAttribute('marker-end','url(#ma)');
el.style.opacity = '.3';
}
document.querySelector('#n'+src+' .nb')?.classList.remove('src');
}, 1200);
}
function spawnParticle(s, d) {
const layer = document.getElementById('ptl');
const [sx,sy] = NP[s], [dx,dy] = NP[d];
const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('cx', sx); c.setAttribute('cy', sy);
c.setAttribute('r', '5'); c.classList.add('pt');
layer.appendChild(c);
let t = 0; const steps = 20;
const id = setInterval(() => {
t++;
c.setAttribute('cx', sx + (dx-sx)*t/steps);
c.setAttribute('cy', sy + (dy-sy)*t/steps);
c.setAttribute('opacity', (1 - t/steps*0.5).toString());
if (t >= steps) { clearInterval(id); try { layer.removeChild(c); } catch(_){} }
}, 48);
}
// ── UI update ──────────────────────────────────────────────────────────────
function updateUI() {
if (!obs) return;
const ld = obs.node_loads || ds.loads;
const sc = obs.cumulative_score ?? 0;
const st = obs.step_count ?? 0;
const mx = obs.max_steps ?? 90;
// node loads + bars
for (let i = 0; i < 12; i++) {
const l = ld[i] ?? 0;
const pct = Math.min(l, 125) / 125;
const bar = document.getElementById('b'+i);
const val = document.getElementById('v'+i);
if (bar) bar.setAttribute('width', pct * BAR_W);
if (val) {
val.textContent = l.toFixed(0) + '%';
val.setAttribute('fill', l>100?'#ef4444':l>70?'#f59e0b':'#e2e8f0');
}
const nb = document.querySelector('#n'+i+' .nb');
if (nb) {
nb.classList.remove('ol','wn');
if (l > 100) nb.classList.add('ol');
else if (l > 70) nb.classList.add('wn');
}
if (bar) {
bar.setAttribute('fill',
l>100 ? '#ef4444' : l>70 ? '#f59e0b' : COL[i]);
}
}
// progress bar
const pct = st / mx * 100;
document.getElementById('epFill').style.width = pct + '%';
document.getElementById('epLbl').textContent =
`Step ${st} / ${mx} (${pct.toFixed(0)}%)`;
document.getElementById('sNum').textContent = st;
document.getElementById('sMax').textContent = mx;
// score arc
const arc = document.getElementById('arc');
arc.style.strokeDashoffset = 141 * (1 - sc);
arc.style.stroke = sc>0.7?'var(--green)':sc>0.4?'var(--amber)':'var(--red)';
document.getElementById('arcVal').textContent = sc.toFixed(2);
// metrics
const lr = obs.last_reward ?? 0;
const lrEl = document.getElementById('mLR');
lrEl.textContent = lr.toFixed(3);
lrEl.className = 'sbv ' + (lr>0.5?'cg':lr>0.2?'ca':'cr');
document.getElementById('mIn').textContent = (obs.incoming_load??0).toFixed(1);
document.getElementById('mOL').textContent = obs.overloaded_hubs??0;
document.getElementById('mDis').textContent = (obs.active_disruptions??[]).length;
document.getElementById('mBN').textContent = obs.bottlenecks??0;
document.getElementById('mIT').textContent = (obs.in_transit_shipments??[]).length;
// event badge
const ev = (obs.event_label||'normal').toLowerCase();
const eb = document.getElementById('evBadge');
if (ev.includes('surge')||ev.includes('flash')) {
eb.className='ev es'; eb.textContent='⚡ SURGE';
} else if (ev.includes('weather')||ev.includes('disrupt')) {
eb.className='ev ew'; eb.textContent='🌩 DISRUPTION';
} else {
eb.className='ev en'; eb.textContent='✓ NORMAL';
}
drawSparkline();
}
function drawSparkline() {
const c = document.getElementById('spk');
const ctx = c.getContext('2d');
c.width = c.offsetWidth * 2;
c.height = 88;
ctx.clearRect(0, 0, c.width, c.height);
if (rewards.length < 2) return;
const w=c.width, h=c.height, n=rewards.length;
const xs = (w-20)/(n-1);
ctx.beginPath();
ctx.strokeStyle='#00e5ff'; ctx.lineWidth=2.5;
ctx.shadowColor='#00e5ff'; ctx.shadowBlur=5;
rewards.forEach((v,i) => {
const x=10+i*xs, y=h-10-v*(h-20);
i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
});
ctx.stroke();
ctx.shadowBlur=0;
ctx.lineTo(10+(n-1)*xs, h-10);
ctx.lineTo(10, h-10);
ctx.closePath();
ctx.fillStyle='rgba(0,229,255,.07)'; ctx.fill();
rewards.forEach((v,i) => {
const x=10+i*xs, y=h-10-v*(h-20);
ctx.beginPath(); ctx.arc(x,y,3,0,Math.PI*2);
ctx.fillStyle = v>0.5?'#10b981':v>0.2?'#f59e0b':'#ef4444';
ctx.fill();
});
}
function setStatus(live, txt) {
document.getElementById('dot').className = 'dot' + (live?' on':'');
document.getElementById('statusTxt').textContent = txt || (live?'LIVE':'OFFLINE');
}
function log(msg, t='i') {
const area = document.getElementById('logArea');
const now = new Date().toTimeString().slice(0,8);
const el = document.createElement('div');
el.className = 'le';
const cls = {g:'lg2',b:'lb2',i:'li2',w:'lw2'}[t] || 'li2';
el.innerHTML = `<span class="lt2">${now}</span><span class="${cls}">${msg}</span>`;
area.appendChild(el);
area.scrollTop = area.scrollHeight;
}
function clearLog() { document.getElementById('logArea').innerHTML=''; }
</script>
</body>
</html>