Spaces:
Sleeping
Sleeping
| <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 <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 (>100%)</div> | |
| <div style="color:var(--amber);">■ Warning (>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> | |