| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>AI Load Consolidation | FairRelay Brain</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/> |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> |
| <style> |
| :root { |
| --bg: #060a13; |
| --bg2: #0b1120; |
| --bg3: #101827; |
| --card: #111a2e; |
| --card-alt: #0f1628; |
| --glass: rgba(17,26,46,.7); |
| --glass2: rgba(17,26,46,.5); |
| --border: rgba(56,97,150,.25); |
| --border-hi: rgba(56,97,150,.5); |
| --accent: #f97316; |
| --accent2: #fb923c; |
| --glow: rgba(249,115,22,.3); |
| --glow2: rgba(249,115,22,.12); |
| --green: #10b981; |
| --green-d: #059669; |
| --green-g: rgba(16,185,129,.3); |
| --blue: #3b82f6; |
| --blue-g: rgba(59,130,246,.25); |
| --purple: #a855f7; |
| --purple-g: rgba(168,85,247,.25); |
| --cyan: #06b6d4; |
| --cyan-g: rgba(6,182,212,.25); |
| --pink: #ec4899; |
| --yellow: #fbbf24; |
| --red: #ef4444; |
| --txt: #f1f5f9; |
| --txt2: #94a3b8; |
| --txt3: #64748b; |
| --txt4: #475569; |
| --mono: 'JetBrains Mono', monospace; |
| --sans: 'Plus Jakarta Sans', system-ui, sans-serif; |
| } |
| *{margin:0;padding:0;box-sizing:border-box} |
| html{scroll-behavior:smooth} |
| body{font-family:var(--sans);background:var(--bg);color:var(--txt);min-height:100vh;overflow-x:hidden} |
| body::before{content:'';position:fixed;top:0;left:0;right:0;bottom:0;background:radial-gradient(ellipse 80% 50% at 50% -20%,rgba(249,115,22,.06),transparent 70%),radial-gradient(ellipse 60% 40% at 80% 100%,rgba(59,130,246,.04),transparent);pointer-events:none;z-index:0} |
|
|
| /* βββ HEADER βββ */ |
| .header{height:52px;background:var(--glass);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:fixed;top:0;left:0;right:0;z-index:200} |
| .logo{display:flex;align-items:center;gap:10px} |
| .logo-mark{width:32px;height:32px;background:linear-gradient(135deg,var(--accent),#ea580c);border-radius:8px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 24px var(--glow);position:relative;overflow:hidden} |
| .logo-mark::after{content:'';position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:linear-gradient(45deg,transparent 40%,rgba(255,255,255,.15) 50%,transparent 60%);animation:shimmer 3s infinite} |
| @keyframes shimmer{0%{transform:translateX(-100%) rotate(45deg)}100%{transform:translateX(100%) rotate(45deg)}} |
| .logo-mark svg{width:18px;height:18px;color:#fff;position:relative;z-index:1} |
| .logo-text h1{font-size:14px;font-weight:800;letter-spacing:-.02em}.logo-text h1 span{color:var(--accent)} |
| .logo-text .sub{font-size:10px;color:var(--txt3);font-weight:500;letter-spacing:.03em} |
| .nav{display:flex;gap:4px} |
| .nav a{color:var(--txt3);text-decoration:none;padding:5px 12px;border-radius:6px;font-size:11px;font-weight:600;transition:all .2s;border:1px solid transparent;letter-spacing:.02em} |
| .nav a:hover{color:var(--accent);background:var(--glow2)} |
| .nav a.active{color:var(--accent);background:var(--glow2);border-color:rgba(249,115,22,.3)} |
| .header-right{display:flex;align-items:center;gap:12px} |
| .sys-badge{font-size:10px;font-weight:600;color:var(--green);background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.2);padding:3px 10px;border-radius:20px;display:flex;align-items:center;gap:5px} |
| .sys-badge .dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:blink 2s infinite} |
| .sys-badge.processing{color:var(--yellow);background:rgba(251,191,36,.1);border-color:rgba(251,191,36,.2)} |
| .sys-badge.processing .dot{background:var(--yellow)} |
| @keyframes blink{0%,100%{opacity:1}50%{opacity:.3}} |
|
|
| /* βββ LAYOUT βββ */ |
| .page{margin-top:52px;padding:16px;position:relative;z-index:1;max-width:1480px;margin-left:auto;margin-right:auto} |
| .section{margin-bottom:16px} |
| .section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px} |
| .section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--txt3);display:flex;align-items:center;gap:8px} |
| .section-label svg{width:14px;height:14px;color:var(--accent)} |
| .card{background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden} |
| .card-glass{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:12px;overflow:hidden} |
|
|
| /* βββ CONTROLS βββ */ |
| .controls-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap} |
| .btn{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border:1px solid var(--border);border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;transition:all .25s;font-family:var(--sans);background:var(--card);color:var(--txt2)} |
| .btn:hover{border-color:var(--border-hi);color:var(--txt);transform:translateY(-1px)} |
| .btn svg{width:14px;height:14px} |
| .btn-primary{background:linear-gradient(135deg,var(--accent),#ea580c);border-color:transparent;color:#fff;box-shadow:0 4px 24px var(--glow)} |
| .btn-primary:hover{box-shadow:0 8px 32px var(--glow);transform:translateY(-2px)} |
| .btn-primary:disabled{opacity:.5;cursor:not-allowed;transform:none} |
| .btn-primary:active{transform:translateY(0)} |
| .param-group{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:6px 14px} |
| .param-label{font-size:10px;font-weight:700;color:var(--txt3);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap} |
| .param-val{font-size:12px;font-weight:700;color:var(--accent);font-family:var(--mono);min-width:36px;text-align:right} |
| input[type=range]{-webkit-appearance:none;appearance:none;width:100px;height:4px;background:var(--border);border-radius:2px;outline:none;cursor:pointer} |
| input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;background:var(--accent);border-radius:50%;box-shadow:0 0 8px var(--glow);cursor:pointer} |
|
|
| /* βββ PIPELINE βββ */ |
| .pipeline-track{display:flex;align-items:center;gap:0;padding:20px 0} |
| .pipe-node{flex:1;position:relative} |
| .pipe-card{background:var(--bg3);border:2px solid var(--border);border-radius:14px;padding:14px 16px;transition:all .5s cubic-bezier(.4,0,.2,1);position:relative;overflow:hidden} |
| .pipe-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,transparent,transparent);transition:background .5s} |
| .pipe-card:hover{border-color:var(--border-hi);transform:translateY(-3px);box-shadow:0 8px 32px rgba(0,0,0,.3)} |
| .pipe-card.running{border-color:var(--yellow);box-shadow:0 0 30px rgba(251,191,36,.2)} |
| .pipe-card.running::before{background:linear-gradient(90deg,var(--yellow),var(--accent));animation:scanline 1.5s ease-in-out infinite} |
| @keyframes scanline{0%{opacity:.4}50%{opacity:1}100%{opacity:.4}} |
| .pipe-card.done{border-color:var(--green)} |
| .pipe-card.done::before{background:linear-gradient(90deg,var(--green),var(--cyan))} |
| .pipe-card.error{border-color:var(--red)} |
| .pipe-head{display:flex;align-items:center;gap:10px;margin-bottom:8px} |
| .pipe-icon{width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0} |
| .pipe-icon svg{width:16px;height:16px;color:#fff} |
| .pipe-icon.c1{background:linear-gradient(135deg,var(--blue),#2563eb)} |
| .pipe-icon.c2{background:linear-gradient(135deg,var(--purple),#7c3aed)} |
| .pipe-icon.c3{background:linear-gradient(135deg,var(--accent),#dc2626)} |
| .pipe-icon.c4{background:linear-gradient(135deg,var(--green),var(--green-d))} |
| .pipe-icon.c5{background:linear-gradient(135deg,var(--cyan),#0891b2)} |
| .pipe-title{font-size:12px;font-weight:700;line-height:1.2} |
| .pipe-num{font-size:9px;font-weight:700;color:var(--txt4);font-family:var(--mono)} |
| .pipe-status{margin-left:auto;width:8px;height:8px;border-radius:50%;background:var(--txt4);flex-shrink:0;transition:all .3s} |
| .pipe-card.running .pipe-status{background:var(--yellow);box-shadow:0 0 10px rgba(251,191,36,.5);animation:blink .6s infinite} |
| .pipe-card.done .pipe-status{background:var(--green);box-shadow:0 0 8px var(--green-g)} |
| .pipe-desc{font-size:10px;color:var(--txt3);line-height:1.5;margin-bottom:10px;min-height:28px} |
| .pipe-foot{display:flex;justify-content:space-between;align-items:center;padding-top:8px;border-top:1px solid var(--border);font-size:10px;color:var(--txt4)} |
| .pipe-foot .val{color:var(--accent);font-weight:700;font-family:var(--mono)} |
| .pipe-foot .ms{color:var(--green);font-family:var(--mono)} |
| .pipe-arrow{display:flex;align-items:center;padding:0 3px;z-index:2} |
| .pipe-arrow svg{width:22px;height:22px;color:var(--txt4);transition:color .4s} |
| .pipe-arrow.active svg{color:var(--accent);filter:drop-shadow(0 0 6px var(--glow))} |
|
|
| /* βββ AI SCORE HERO βββ */ |
| .hero-row{display:grid;grid-template-columns:200px 1fr;gap:16px;align-items:stretch} |
| .score-hero{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden} |
| .score-hero::before{content:'';position:absolute;top:-40%;left:-40%;width:180%;height:180%;background:conic-gradient(from 0deg,transparent,var(--glow2),transparent,var(--glow2),transparent);animation:rotateBg 8s linear infinite;opacity:.5} |
| @keyframes rotateBg{to{transform:rotate(360deg)}} |
| .score-ring{position:relative;width:110px;height:110px;z-index:1} |
| .score-ring canvas{width:110px!important;height:110px!important} |
| .score-center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center} |
| .score-val{font-size:28px;font-weight:800;color:var(--accent);font-family:var(--mono);line-height:1} |
| .score-max{font-size:10px;color:var(--txt3);font-weight:600} |
| .score-grade{font-size:20px;font-weight:800;margin-top:8px;z-index:1} |
| .score-grade.a-plus{color:var(--green)}.score-grade.a{color:#22c55e}.score-grade.b{color:var(--yellow)}.score-grade.c{color:var(--accent)}.score-grade.d{color:var(--red)} |
| .score-label{font-size:9px;font-weight:700;color:var(--txt3);text-transform:uppercase;letter-spacing:.1em;margin-top:4px;z-index:1} |
|
|
| /* βββ KPI GRID βββ */ |
| .kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px} |
| .kpi{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;position:relative;overflow:hidden;transition:all .3s} |
| .kpi:hover{border-color:var(--border-hi);transform:translateY(-2px)} |
| .kpi-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px} |
| .kpi-icon{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center} |
| .kpi-icon svg{width:14px;height:14px;color:#fff} |
| .kpi-delta{font-size:10px;font-weight:700;font-family:var(--mono);padding:2px 6px;border-radius:4px} |
| .kpi-delta.up{color:var(--green);background:rgba(16,185,129,.1)} |
| .kpi-delta.down{color:var(--red);background:rgba(239,68,68,.1)} |
| .kpi-val{font-size:22px;font-weight:800;color:var(--txt);font-family:var(--mono);line-height:1;margin-bottom:2px} |
| .kpi-label{font-size:9px;font-weight:700;color:var(--txt4);text-transform:uppercase;letter-spacing:.06em} |
| .kpi-bar{position:absolute;bottom:0;left:0;right:0;height:3px;background:var(--bg)} |
| .kpi-bar-fill{height:100%;border-radius:0 2px 0 0;transition:width 1s ease-out} |
|
|
| /* βββ MAP βββ */ |
| .map-wrap{border-radius:12px;overflow:hidden;border:1px solid var(--border);position:relative;height:380px} |
| .map-controls{position:absolute;top:10px;right:10px;z-index:1000;display:flex;gap:6px} |
| .map-btn{background:var(--glass);backdrop-filter:blur(12px);border:1px solid var(--border);color:var(--txt2);font-size:10px;font-weight:700;padding:5px 12px;border-radius:6px;cursor:pointer;transition:all .2s} |
| .map-btn:hover,.map-btn.active{color:var(--accent);border-color:var(--accent);background:rgba(249,115,22,.1)} |
| .map-legend{position:absolute;bottom:10px;left:10px;z-index:1000;background:var(--glass);backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:10px;color:var(--txt2)} |
| .map-legend .lg-item{display:flex;align-items:center;gap:6px;margin-bottom:3px} |
| .map-legend .lg-item:last-child{margin-bottom:0} |
| .map-legend .lg-dot{width:8px;height:8px;border-radius:50%} |
|
|
| /* βββ GROUPS TABLE βββ */ |
| .groups-wrap{max-height:360px;overflow-y:auto} |
| .group-row{display:grid;grid-template-columns:40px 1fr 80px 80px 100px 60px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--border);transition:background .2s;gap:10px} |
| .group-row:hover{background:rgba(249,115,22,.03)} |
| .group-row.header{position:sticky;top:0;background:var(--bg3);z-index:2;border-bottom:1px solid var(--border-hi)} |
| .group-row.header span{font-size:10px;font-weight:700;color:var(--txt4);text-transform:uppercase;letter-spacing:.06em} |
| .group-id{font-size:12px;font-weight:800;color:var(--accent);font-family:var(--mono)} |
| .group-info{display:flex;flex-direction:column;gap:1px} |
| .group-truck{font-size:12px;font-weight:700} |
| .group-ship{font-size:10px;color:var(--txt3)} |
| .group-bar-wrap{display:flex;flex-direction:column;gap:2px} |
| .group-bar{height:6px;background:var(--bg);border-radius:3px;overflow:hidden} |
| .group-bar-fill{height:100%;border-radius:3px;transition:width 1s ease-out} |
| .group-bar-label{font-size:9px;color:var(--txt4);font-family:var(--mono)} |
| .conf-badge{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:12px;font-size:10px;font-weight:700;font-family:var(--mono)} |
| .conf-hi{color:var(--green);background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.2)} |
| .conf-md{color:var(--yellow);background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.2)} |
| .conf-lo{color:var(--red);background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.2)} |
|
|
| /* βββ CHARTS βββ */ |
| .charts-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px} |
| .chart-box{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;position:relative} |
| .chart-title{font-size:11px;font-weight:700;color:var(--txt3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;display:flex;align-items:center;gap:6px} |
| .chart-title svg{width:12px;height:12px;color:var(--accent)} |
| .chart-canvas{position:relative;width:100%;height:200px} |
| .chart-canvas canvas{width:100%!important;height:200px!important} |
|
|
| /* βββ INSIGHTS βββ */ |
| .insights-list{display:flex;flex-direction:column;gap:8px;padding:14px} |
| .insight{display:flex;align-items:flex-start;gap:10px;padding:10px 14px;background:var(--bg3);border:1px solid var(--border);border-radius:10px;transition:all .3s} |
| .insight:hover{border-color:var(--border-hi);transform:translateX(3px)} |
| .insight-icon{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:13px} |
| .insight-icon.pattern{background:rgba(168,85,247,.15);border:1px solid rgba(168,85,247,.2)} |
| .insight-icon.recommendation{background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.2)} |
| .insight-icon.learning{background:rgba(6,182,212,.15);border:1px solid rgba(6,182,212,.2)} |
| .insight-body{flex:1} |
| .insight-text{font-size:12px;color:var(--txt2);line-height:1.5} |
| .insight-meta{display:flex;align-items:center;gap:6px;margin-top:4px} |
| .insight-type{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:2px 6px;border-radius:4px} |
| .insight-type.pattern{color:var(--purple);background:rgba(168,85,247,.1)} |
| .insight-type.recommendation{color:var(--blue);background:rgba(59,130,246,.1)} |
| .insight-type.learning{color:var(--cyan);background:rgba(6,182,212,.1)} |
| .impact-dot{font-size:9px;font-weight:700;font-family:var(--mono)} |
| .impact-dot.high{color:var(--red)}.impact-dot.medium{color:var(--yellow)}.impact-dot.low{color:var(--green)} |
|
|
| /* βββ AGENT LOGS TERMINAL βββ */ |
| .term-wrap{background:#0a0a0a;border:1px solid var(--border);border-radius:12px;overflow:hidden} |
| .term-bar{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;background:#111;border-bottom:1px solid #222} |
| .term-dots{display:flex;gap:6px} |
| .term-dots span{width:10px;height:10px;border-radius:50%} |
| .term-dots .r{background:#ff5f56}.term-dots .y{background:#ffbd2e}.term-dots .g{background:#27c93f} |
| .term-title{font-size:11px;font-weight:600;color:var(--txt3);font-family:var(--mono)} |
| .term-body{padding:14px;max-height:280px;overflow-y:auto;font-family:var(--mono);font-size:11px;line-height:1.9;color:var(--green)} |
| .term-line{margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #1a1a1a} |
| .term-agent{color:var(--accent);font-weight:700} |
| .term-action{color:var(--yellow)} |
| .term-ms{color:var(--txt4)} |
| .term-detail{color:var(--txt3);margin-left:16px;display:block} |
|
|
| /* βββ HEATMAP βββ */ |
| .heatmap-grid{display:grid;gap:2px;padding:14px} |
| .hm-cell{width:100%;aspect-ratio:1;border-radius:3px;transition:all .2s;cursor:pointer;position:relative} |
| .hm-cell:hover{transform:scale(1.3);z-index:10;box-shadow:0 0 12px rgba(0,0,0,.5)} |
| .hm-label{font-size:8px;font-weight:700;color:var(--txt4);font-family:var(--mono);text-align:center;padding:2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
|
|
| /* βββ SCENARIO PANEL βββ */ |
| .scenario-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:14px} |
| .scenario-card{background:var(--bg3);border:2px solid var(--border);border-radius:12px;padding:14px;text-align:center;transition:all .3s;cursor:pointer;position:relative} |
| .scenario-card:hover{border-color:var(--border-hi);transform:translateY(-2px)} |
| .scenario-card.best{border-color:var(--green);box-shadow:0 0 20px var(--green-g)} |
| .scenario-badge{position:absolute;top:-8px;right:10px;background:var(--green);color:#fff;font-size:8px;font-weight:800;padding:2px 8px;border-radius:10px;letter-spacing:.05em} |
| .scenario-name{font-size:13px;font-weight:800;margin-bottom:8px} |
| .scenario-stats{display:flex;flex-direction:column;gap:4px} |
| .scenario-stat{display:flex;justify-content:space-between;font-size:10px} |
| .scenario-stat .k{color:var(--txt4)}.scenario-stat .v{font-weight:700;font-family:var(--mono);color:var(--txt2)} |
|
|
| /* βββ TWO-COL LAYOUT βββ */ |
| .two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px} |
| .three-col{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px} |
| .full{grid-column:1/-1} |
|
|
| /* βββ SCROLLBAR βββ */ |
| ::-webkit-scrollbar{width:5px;height:5px} |
| ::-webkit-scrollbar-track{background:transparent} |
| ::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px} |
| ::-webkit-scrollbar-thumb:hover{background:var(--txt4)} |
|
|
| /* βββ TABS βββ */ |
| .tab-bar{display:flex;gap:2px;background:var(--bg);border-radius:8px;padding:3px;border:1px solid var(--border)} |
| .tab{padding:5px 14px;font-size:10px;font-weight:700;color:var(--txt4);border-radius:6px;cursor:pointer;transition:all .2s;border:none;background:none;font-family:var(--sans)} |
| .tab:hover{color:var(--txt2)} |
| .tab.active{background:var(--card);color:var(--accent);box-shadow:0 1px 4px rgba(0,0,0,.2)} |
|
|
| /* βββ RESPONSIVE βββ */ |
| @media(max-width:1200px){.hero-row{grid-template-columns:1fr}.charts-grid,.three-col{grid-template-columns:1fr 1fr}.kpi-grid{grid-template-columns:repeat(2,1fr)}} |
| @media(max-width:800px){.two-col,.charts-grid,.three-col,.scenario-grid{grid-template-columns:1fr}.kpi-grid{grid-template-columns:1fr 1fr}.pipeline-track{flex-direction:column}.pipe-arrow{transform:rotate(90deg)}} |
|
|
| /* βββ LOADING βββ */ |
| .page-loader{display:none;position:fixed;top:52px;left:0;right:0;bottom:0;background:rgba(6,10,19,.92);backdrop-filter:blur(8px);z-index:150;flex-direction:column;align-items:center;justify-content:center;gap:20px} |
| .page-loader.active{display:flex} |
| .loader-orb{width:80px;height:80px;position:relative} |
| .loader-orb span{position:absolute;border-radius:50%;border:2px solid transparent;animation:orbSpin 1.2s linear infinite} |
| .loader-orb span:nth-child(1){width:100%;height:100%;border-top-color:var(--accent)} |
| .loader-orb span:nth-child(2){width:75%;height:75%;top:12.5%;left:12.5%;border-right-color:var(--blue);animation-duration:1.8s;animation-direction:reverse} |
| .loader-orb span:nth-child(3){width:50%;height:50%;top:25%;left:25%;border-bottom-color:var(--green);animation-duration:2.4s} |
| @keyframes orbSpin{to{transform:rotate(360deg)}} |
| .loader-text{font-size:14px;color:var(--txt2);font-weight:600} |
| .loader-agent{font-size:12px;color:var(--accent);font-weight:700;font-family:var(--mono)} |
|
|
| /* βββ EMPTY βββ */ |
| .empty{text-align:center;padding:40px 20px;color:var(--txt4)} |
| .empty svg{width:48px;height:48px;margin-bottom:10px;opacity:.3} |
| .empty h3{font-size:14px;color:var(--txt3);margin-bottom:4px} |
| .empty p{font-size:11px} |
|
|
| /* βββ JSON I/O βββ */ |
| .io-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px} |
| .io-head{padding:8px 14px;background:var(--bg3);border-bottom:1px solid var(--border);font-size:10px;font-weight:700;color:var(--txt3);display:flex;align-items:center;gap:6px;letter-spacing:.04em} |
| .io-head svg{width:12px;height:12px;color:var(--accent)} |
| textarea.io-area{width:100%;height:220px;background:var(--bg);border:none;color:var(--green);font-family:var(--mono);font-size:10px;padding:12px;resize:none;line-height:1.7} |
| textarea.io-area:focus{outline:none} |
| pre.io-out{margin:0;padding:12px;font-family:var(--mono);font-size:10px;color:var(--green);white-space:pre-wrap;max-height:220px;overflow-y:auto;background:var(--bg)} |
| </style> |
| </head> |
| <body> |
|
|
| |
| <header class="header"> |
| <div class="logo"> |
| <div class="logo-mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></div> |
| <div class="logo-text"><h1>Fair<span>Relay</span> Brain</h1><div class="sub">AI Load Consolidation Engine</div></div> |
| </div> |
| <nav class="nav"> |
| <a href="/demo/allocate">Allocate</a> |
| <a href="/demo/visualization">Dispatch Agents</a> |
| <a href="/demo/consolidation" class="active">Consolidation</a> |
| <a href="/docs">API Docs</a> |
| </nav> |
| <div class="header-right"> |
| <div class="sys-badge" id="sysBadge"><span class="dot"></span><span>System Ready</span></div> |
| </div> |
| </header> |
|
|
| |
| <div class="page-loader" id="loader"> |
| <div class="loader-orb"><span></span><span></span><span></span></div> |
| <div class="loader-text">Running 5-Agent Pipeline...</div> |
| <div class="loader-agent" id="loaderAgent">Initializing agents</div> |
| </div> |
|
|
| |
| <main class="page"> |
|
|
| |
| <div class="section"> |
| <div class="controls-bar"> |
| <button class="btn btn-primary" id="btnRun" onclick="runPipeline()"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> |
| Run 5-Agent Consolidation |
| </button> |
| <button class="btn" onclick="runSimulation()"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> |
| Simulate Scenarios |
| </button> |
| <div class="param-group"> |
| <span class="param-label">Radius</span> |
| <input type="range" id="sliderRadius" min="5" max="100" value="30" oninput="document.getElementById('valRadius').textContent=this.value+'km'"> |
| <span class="param-val" id="valRadius">30km</span> |
| </div> |
| <div class="param-group"> |
| <span class="param-label">Time Window</span> |
| <input type="range" id="sliderTime" min="30" max="480" value="120" step="30" oninput="document.getElementById('valTime').textContent=this.value+'m'"> |
| <span class="param-val" id="valTime">120m</span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="section"> |
| <div class="section-head"> |
| <div class="section-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4"/></svg> LangGraph Multi-Agent Pipeline</div> |
| </div> |
| <div class="pipeline-track"> |
| <div class="pipe-node"><div class="pipe-card" id="p-geo"> |
| <div class="pipe-head"><div class="pipe-icon c1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div><div><div class="pipe-title">Geographic Clustering</div><div class="pipe-num">AGENT 1</div></div><div class="pipe-status"></div></div> |
| <div class="pipe-desc">K-Means spatial clustering using Haversine distance on pickup + drop coordinates</div> |
| <div class="pipe-foot"><span>Clusters: <span class="val" id="m-geo-v">--</span></span><span class="ms" id="m-geo-ms"></span></div> |
| </div></div> |
| <div class="pipe-arrow" id="arr1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div> |
| <div class="pipe-node"><div class="pipe-card" id="p-time"> |
| <div class="pipe-head"><div class="pipe-icon c2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div><div><div class="pipe-title">Time Window Filter</div><div class="pipe-num">AGENT 2</div></div><div class="pipe-status"></div></div> |
| <div class="pipe-desc">Filters clusters by delivery time overlap with configurable tolerance</div> |
| <div class="pipe-foot"><span>Groups: <span class="val" id="m-time-v">--</span></span><span class="ms" id="m-time-ms"></span></div> |
| </div></div> |
| <div class="pipe-arrow" id="arr2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div> |
| <div class="pipe-node"><div class="pipe-card" id="p-cap"> |
| <div class="pipe-head"><div class="pipe-icon c3"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="3" width="15" height="13" rx="2"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg></div><div><div class="pipe-title">Capacity Optimization</div><div class="pipe-num">AGENT 3</div></div><div class="pipe-status"></div></div> |
| <div class="pipe-desc">First-Fit-Decreasing bin-packing into available truck fleet by weight + volume</div> |
| <div class="pipe-foot"><span>Bins: <span class="val" id="m-cap-v">--</span></span><span class="ms" id="m-cap-ms"></span></div> |
| </div></div> |
| <div class="pipe-arrow" id="arr3"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div> |
| <div class="pipe-node"><div class="pipe-card" id="p-score"> |
| <div class="pipe-head"><div class="pipe-icon c4"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></div><div><div class="pipe-title">Scoring & Confidence</div><div class="pipe-num">AGENT 4</div></div><div class="pipe-status"></div></div> |
| <div class="pipe-desc">Computes per-group AI confidence and global optimization score (A+ grading)</div> |
| <div class="pipe-foot"><span>Score: <span class="val" id="m-score-v">--</span></span><span class="ms" id="m-score-ms"></span></div> |
| </div></div> |
| <div class="pipe-arrow" id="arr4"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div> |
| <div class="pipe-node"><div class="pipe-card" id="p-learn"> |
| <div class="pipe-head"><div class="pipe-icon c5"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg></div><div><div class="pipe-title">Continuous Learning</div><div class="pipe-num">AGENT 5</div></div><div class="pipe-status"></div></div> |
| <div class="pipe-desc">Pattern detection, corridor analysis, actionable optimization insights</div> |
| <div class="pipe-foot"><span>Insights: <span class="val" id="m-learn-v">--</span></span><span class="ms" id="m-learn-ms"></span></div> |
| </div></div> |
| </div> |
| </div> |
|
|
| |
| <div class="section" id="secKpi" style="display:none"> |
| <div class="hero-row"> |
| <div class="score-hero"> |
| <div class="score-ring"><canvas id="scoreRingChart"></canvas><div class="score-center"><div class="score-val" id="heroScore">--</div><div class="score-max">/100</div></div></div> |
| <div class="score-grade" id="heroGrade">--</div> |
| <div class="score-label">Optimization Grade</div> |
| </div> |
| <div class="kpi-grid" id="kpiGrid"></div> |
| </div> |
| </div> |
|
|
| |
| <div class="section" id="secMap" style="display:none"> |
| <div class="two-col"> |
| <div> |
| <div class="section-head"> |
| <div class="section-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> Route Map</div> |
| <div class="tab-bar"> |
| <button class="tab active" onclick="setMapMode('optimized',this)">Optimized</button> |
| <button class="tab" onclick="setMapMode('naive',this)">Before (Naive)</button> |
| <button class="tab" onclick="setMapMode('both',this)">Compare</button> |
| </div> |
| </div> |
| <div class="map-wrap" id="mapContainer"> |
| <div class="map-legend" id="mapLegend"></div> |
| </div> |
| </div> |
| <div> |
| <div class="section-head"> |
| <div class="section-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="3" width="15" height="13" rx="2"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg> Consolidated Groups</div> |
| </div> |
| <div class="card"> |
| <div class="groups-wrap" id="groupsWrap"> |
| <div class="group-row header"><span>#</span><span>Truck / Shipments</span><span>Weight</span><span>Volume</span><span>Utilization</span><span>AI Conf</span></div> |
| <div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="3" width="15" height="13" rx="2"/><path d="M16 8h4l3 3v5h-7V8z"/></svg><h3>No groups yet</h3><p>Run the pipeline to see consolidated groups</p></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="section" id="secCharts" style="display:none"> |
| <div class="charts-grid"> |
| <div class="chart-box"><div class="chart-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> Utilization Before vs After</div><div class="chart-canvas"><canvas id="chartUtil"></canvas></div></div> |
| <div class="chart-box"><div class="chart-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Group Confidence Radar</div><div class="chart-canvas"><canvas id="chartRadar"></canvas></div></div> |
| <div class="chart-box"><div class="chart-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 10 10"/><path d="M12 2a10 10 0 0 0-10 10"/></svg> Weight Distribution</div><div class="chart-canvas"><canvas id="chartDist"></canvas></div></div> |
| </div> |
| </div> |
|
|
| |
| <div class="section" id="secBottom" style="display:none"> |
| <div class="three-col"> |
| <div class="card"> |
| <div class="io-head"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> AI Learning Insights</div> |
| <div class="insights-list" id="insightsList"><div class="empty"><h3>No insights</h3><p>Run pipeline first</p></div></div> |
| </div> |
| <div class="card"> |
| <div class="io-head"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> Shipment Compatibility Heatmap</div> |
| <div id="heatmapArea" style="padding:14px"><div class="empty"><h3>No data</h3></div></div> |
| </div> |
| <div class="card"> |
| <div class="io-head"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> Scenario Comparison</div> |
| <div class="scenario-grid" id="scenarioArea"><div class="empty" style="grid-column:1/-1"><h3>Click "Simulate Scenarios"</h3><p>Compare Tight / Balanced / Aggressive</p></div></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="section" id="secTerm" style="display:none"> |
| <div class="section-head"> |
| <div class="section-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> Agent Decision Logs</div> |
| </div> |
| <div class="term-wrap"> |
| <div class="term-bar"><div class="term-dots"><span class="r"></span><span class="y"></span><span class="g"></span></div><div class="term-title">consolidation-pipeline.log</div></div> |
| <div class="term-body" id="termBody"></div> |
| </div> |
| </div> |
|
|
| |
| <div class="section"> |
| <div class="section-head"><div class="section-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Request / Response</div></div> |
| <div class="io-grid"> |
| <div class="card"><div class="io-head"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> REQUEST JSON</div><textarea class="io-area" id="reqJson" spellcheck="false"></textarea></div> |
| <div class="card"><div class="io-head"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> RESPONSE</div><pre class="io-out" id="resJson">Run pipeline to see response...</pre></div> |
| </div> |
| </div> |
|
|
| </main> |
|
|
| <script> |
| |
| |
| |
| let pipelineData = null; |
| let map = null; |
| let mapLayers = { optimized: [], naive: [] }; |
| let mapMode = 'optimized'; |
| let scoreRingChart = null; |
| let chartUtil = null, chartRadar = null, chartDist = null; |
| |
| const GROUP_COLORS = ['#3b82f6','#f97316','#a855f7','#10b981','#ec4899','#06b6d4','#fbbf24','#ef4444','#84cc16','#f43f5e']; |
| |
| const DEFAULT_REQ = { |
| shipments: [ |
| {id:"SH-001",pickupLat:19.076,pickupLng:72.877,dropLat:18.52,dropLng:73.856,pickupLocation:"Mumbai Port",dropLocation:"Pune Warehouse",weight:800,volume:3.2,timeWindowStart:"2026-02-20T06:00:00Z",timeWindowEnd:"2026-02-20T12:00:00Z"}, |
| {id:"SH-002",pickupLat:19.098,pickupLng:72.89,dropLat:18.532,dropLng:73.87,pickupLocation:"Mumbai JNPT",dropLocation:"Pune Industrial",weight:600,volume:2.5,timeWindowStart:"2026-02-20T07:00:00Z",timeWindowEnd:"2026-02-20T13:00:00Z"}, |
| {id:"SH-003",pickupLat:19.06,pickupLng:72.868,dropLat:18.54,dropLng:73.88,pickupLocation:"Mumbai Dock",dropLocation:"Pune Hub",weight:450,volume:1.8,timeWindowStart:"2026-02-20T06:30:00Z",timeWindowEnd:"2026-02-20T11:30:00Z"}, |
| {id:"SH-004",pickupLat:12.971,pickupLng:77.594,dropLat:13.083,dropLng:80.27,pickupLocation:"Bangalore Warehouse",dropLocation:"Chennai Central",weight:1200,volume:5.0,timeWindowStart:"2026-02-20T08:00:00Z",timeWindowEnd:"2026-02-20T16:00:00Z"}, |
| {id:"SH-005",pickupLat:12.985,pickupLng:77.61,dropLat:13.06,dropLng:80.25,pickupLocation:"Bangalore Tech Park",dropLocation:"Chennai Port",weight:900,volume:3.8,timeWindowStart:"2026-02-20T09:00:00Z",timeWindowEnd:"2026-02-20T15:00:00Z"}, |
| {id:"SH-006",pickupLat:28.704,pickupLng:77.102,dropLat:26.912,dropLng:75.787,pickupLocation:"Delhi NCR Hub",dropLocation:"Jaipur Depot",weight:1500,volume:6.0,timeWindowStart:"2026-02-20T05:00:00Z",timeWindowEnd:"2026-02-20T14:00:00Z"}, |
| {id:"SH-007",pickupLat:28.72,pickupLng:77.12,dropLat:26.93,dropLng:75.8,pickupLocation:"Delhi Warehouse",dropLocation:"Jaipur Industrial",weight:1100,volume:4.5,timeWindowStart:"2026-02-20T05:30:00Z",timeWindowEnd:"2026-02-20T13:30:00Z"}, |
| {id:"SH-008",pickupLat:19.08,pickupLng:72.88,dropLat:18.55,dropLng:73.84,pickupLocation:"Mumbai Central",dropLocation:"Pune East",weight:700,volume:2.8,timeWindowStart:"2026-02-20T07:30:00Z",timeWindowEnd:"2026-02-20T12:30:00Z"}, |
| {id:"SH-009",pickupLat:12.96,pickupLng:77.58,dropLat:13.09,dropLng:80.28,pickupLocation:"Bangalore South",dropLocation:"Chennai North",weight:550,volume:2.2,timeWindowStart:"2026-02-20T08:30:00Z",timeWindowEnd:"2026-02-20T14:30:00Z"}, |
| {id:"SH-010",pickupLat:28.69,pickupLng:77.09,dropLat:26.9,dropLng:75.77,pickupLocation:"Delhi South",dropLocation:"Jaipur West",weight:950,volume:3.5,timeWindowStart:"2026-02-20T06:00:00Z",timeWindowEnd:"2026-02-20T14:00:00Z"} |
| ], |
| trucks: [ |
| {id:"TRK-001",name:"Tata Ace Gold",maxWeight:2000,maxVolume:8.0}, |
| {id:"TRK-002",name:"Ashok Leyland Dost",maxWeight:2500,maxVolume:10.0}, |
| {id:"TRK-003",name:"Eicher Pro 2049",maxWeight:5000,maxVolume:20.0}, |
| {id:"TRK-004",name:"BharatBenz 1015R",maxWeight:3000,maxVolume:12.0}, |
| {id:"TRK-005",name:"Mahindra Bolero Pickup",maxWeight:1500,maxVolume:6.0} |
| ], |
| options: {maxGroupRadiusKm:30,timeWindowToleranceMinutes:120} |
| }; |
| |
| document.getElementById('reqJson').value = JSON.stringify(DEFAULT_REQ, null, 2); |
| |
| |
| |
| |
| const PIPES = ['geo','time','cap','score','learn']; |
| const PIPE_LABELS = ['GeoClusteringAgent','TimeWindowAgent','CapacityOptimizationAgent','ScoringConfidenceAgent','ContinuousLearningAgent']; |
| const PIPE_DELAYS = [500,400,600,500,350]; |
| |
| function setPipe(id, state) { |
| document.getElementById('p-'+id).className = 'pipe-card ' + state; |
| } |
| function setArrow(n, active) { |
| const el = document.getElementById('arr'+n); |
| if (el) el.className = 'pipe-arrow' + (active?' active':''); |
| } |
| function setBadge(state, text) { |
| const b = document.getElementById('sysBadge'); |
| b.className = 'sys-badge' + (state==='processing'?' processing':''); |
| b.querySelector('span:last-child').textContent = text; |
| } |
| |
| async function runPipeline() { |
| const btn = document.getElementById('btnRun'); |
| let body; |
| try { body = JSON.parse(document.getElementById('reqJson').value); } catch(e) { alert('Invalid JSON: '+e.message); return; } |
| |
| body.options = body.options || {}; |
| body.options.maxGroupRadiusKm = parseInt(document.getElementById('sliderRadius').value); |
| body.options.timeWindowToleranceMinutes = parseInt(document.getElementById('sliderTime').value); |
| document.getElementById('reqJson').value = JSON.stringify(body, null, 2); |
| |
| |
| PIPES.forEach(p => { setPipe(p,''); document.getElementById('m-'+p+'-v').textContent='--'; document.getElementById('m-'+p+'-ms').textContent=''; }); |
| [1,2,3,4].forEach(n => setArrow(n, false)); |
| document.getElementById('secKpi').style.display='none'; |
| document.getElementById('secMap').style.display='none'; |
| document.getElementById('secCharts').style.display='none'; |
| document.getElementById('secBottom').style.display='none'; |
| document.getElementById('secTerm').style.display='none'; |
| document.getElementById('resJson').textContent='Processing...'; |
| document.getElementById('loader').classList.add('active'); |
| btn.disabled = true; |
| setBadge('processing','Running pipeline...'); |
| |
| for (let i = 0; i < PIPES.length; i++) { |
| setPipe(PIPES[i], 'running'); |
| if (i > 0) setArrow(i, true); |
| document.getElementById('loaderAgent').textContent = `Agent ${i+1}/5: ${PIPE_LABELS[i]}`; |
| setBadge('processing', `Agent ${i+1}/5`); |
| await sleep(PIPE_DELAYS[i]); |
| } |
| |
| try { |
| const res = await fetch('/api/v1/consolidate', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) }); |
| if (!res.ok) { const e = await res.json(); throw new Error(e.detail || JSON.stringify(e)); } |
| const data = await res.json(); |
| pipelineData = data; |
| |
| PIPES.forEach(p => setPipe(p,'done')); |
| [1,2,3,4].forEach(n => setArrow(n, true)); |
| setBadge('','Completed'); |
| |
| fillAgentMetas(data); |
| fillKPIs(data); |
| fillGroups(data); |
| fillInsights(data); |
| fillTerminal(data); |
| |
| |
| document.getElementById('secKpi').style.display=''; |
| document.getElementById('secMap').style.display=''; |
| document.getElementById('secCharts').style.display=''; |
| document.getElementById('secBottom').style.display=''; |
| document.getElementById('secTerm').style.display=''; |
| |
| |
| await sleep(50); |
| fillMap(data, body.shipments); |
| fillCharts(data); |
| fillHeatmap(body.shipments); |
| document.getElementById('resJson').textContent = JSON.stringify(data, null, 2); |
| |
| } catch(err) { |
| PIPES.forEach(p => { if(document.getElementById('p-'+p).classList.contains('running')) setPipe(p,'error'); }); |
| setBadge('','Error'); |
| document.getElementById('resJson').textContent='Error: '+err.message; |
| } finally { |
| document.getElementById('loader').classList.remove('active'); |
| btn.disabled = false; |
| } |
| } |
| |
| |
| |
| |
| function fillAgentMetas(data) { |
| if (!data.agentSteps) return; |
| data.agentSteps.forEach(s => { |
| if (s.agent==='GeoClusteringAgent') { document.getElementById('m-geo-v').textContent=s.clusters_formed; document.getElementById('m-geo-ms').textContent=ms(s.duration_ms); } |
| if (s.agent==='TimeWindowAgent') { document.getElementById('m-time-v').textContent=s.output_groups; document.getElementById('m-time-ms').textContent=ms(s.duration_ms); } |
| if (s.agent==='CapacityOptimizationAgent') { document.getElementById('m-cap-v').textContent=s.bins_packed; document.getElementById('m-cap-ms').textContent=ms(s.duration_ms); } |
| if (s.agent==='ScoringConfidenceAgent') { document.getElementById('m-score-v').textContent=s.optimization_score+'/100'; document.getElementById('m-score-ms').textContent=ms(s.duration_ms); } |
| if (s.agent==='ContinuousLearningAgent') { document.getElementById('m-learn-v').textContent=s.insights_generated; document.getElementById('m-learn-ms').textContent=ms(s.duration_ms); } |
| }); |
| } |
| |
| |
| |
| |
| function fillKPIs(data) { |
| const m = data.metrics; |
| const score = m.optimizationScore; |
| const grade = score>=90?'A+':score>=80?'A':score>=65?'B':score>=50?'C':'D'; |
| const gradeClass = score>=90?'a-plus':score>=80?'a':score>=65?'b':score>=50?'c':'d'; |
| |
| document.getElementById('heroScore').textContent = score; |
| const ge = document.getElementById('heroGrade'); |
| ge.textContent = grade; |
| ge.className = 'score-grade ' + gradeClass; |
| |
| drawScoreRing(score); |
| |
| const kpis = [ |
| { icon:'truck', color:'var(--green)', label:'Utilization After', val:m.utilizationAfter+'%', delta:'+'+m.utilizationImprovement+'%', bar:m.utilizationAfter, barColor:'var(--green)' }, |
| { icon:'route', color:'var(--blue)', label:'Trips Reduced', val:m.tripsReduced+' trips', delta:'-'+m.tripReductionPercent+'%', bar:m.tripReductionPercent, barColor:'var(--blue)' }, |
| { icon:'dist', color:'var(--purple)', label:'Distance Saved', val:m.distanceSavedKm+' km', delta:'', bar:Math.min(m.distanceSavedKm/10,100), barColor:'var(--purple)' }, |
| { icon:'co2', color:'var(--cyan)', label:'CO2 Saved', val:m.carbonSavedKg+' kg', delta:'$'+m.carbonCreditUSD, bar:Math.min(m.carbonSavedKg,100), barColor:'var(--cyan)' }, |
| { icon:'fuel', color:'var(--accent)', label:'Fuel Saved', val:'Rs.'+m.fuelSavedINR.toLocaleString(), delta:'', bar:Math.min(m.fuelSavedINR/500,100), barColor:'var(--accent)' }, |
| { icon:'conf', color:'var(--pink)', label:'Avg Confidence', val:m.avgConfidence+'%', delta:'', bar:m.avgConfidence, barColor:'var(--pink)' }, |
| { icon:'grp', color:'var(--yellow)', label:'Groups Formed', val:m.totalGroups+' / '+m.totalTrucks+' trucks', delta:m.totalShipments+' shipments', bar:(m.totalGroups/m.totalTrucks)*100, barColor:'var(--yellow)' }, |
| { icon:'cost', color:'var(--green-d)', label:'Cost Reduction', val:m.costSavedPercent+'%', delta:'Projected', bar:m.costSavedPercent, barColor:'var(--green-d)' } |
| ]; |
| |
| const grid = document.getElementById('kpiGrid'); |
| grid.innerHTML = kpis.map(k => ` |
| <div class="kpi"> |
| <div class="kpi-top"> |
| <div class="kpi-icon" style="background:${k.color}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:#fff">${kpiSvg(k.icon)}</svg></div> |
| ${k.delta ? `<span class="kpi-delta up">${k.delta}</span>` : ''} |
| </div> |
| <div class="kpi-val">${k.val}</div> |
| <div class="kpi-label">${k.label}</div> |
| <div class="kpi-bar"><div class="kpi-bar-fill" style="width:${k.bar}%;background:${k.barColor}"></div></div> |
| </div> |
| `).join(''); |
| } |
| |
| function kpiSvg(type) { |
| const svgs = { |
| truck:'<rect x="1" y="3" width="15" height="13" rx="2"/><path d="M16 8h4l3 3v5h-7V8z"/>', |
| route:'<circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/>', |
| dist:'<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18"/>', |
| co2:'<path d="M2 22l1-1h18l1 1"/><path d="M6 18v-7a6 6 0 1 1 12 0v7"/>', |
| fuel:'<path d="M14 2c0 0 2 2 2 5s-2 5-2 5"/><rect x="4" y="6" width="8" height="14" rx="1"/><path d="M4 10h8"/>', |
| conf:'<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>', |
| grp:'<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/>', |
| cost:'<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' |
| }; |
| return svgs[type] || ''; |
| } |
| |
| function drawScoreRing(score) { |
| const ctx = document.getElementById('scoreRingChart').getContext('2d'); |
| if (scoreRingChart) scoreRingChart.destroy(); |
| const color = score>=80?'#10b981':score>=60?'#fbbf24':'#ef4444'; |
| scoreRingChart = new Chart(ctx, { |
| type: 'doughnut', |
| data: { datasets: [{ data: [score, 100-score], backgroundColor: [color, 'rgba(30,58,95,.3)'], borderWidth: 0 }] }, |
| options: { cutout: '78%', responsive: false, plugins: { legend:{display:false}, tooltip:{enabled:false} }, animation:{duration:1200,easing:'easeOutQuart'} } |
| }); |
| } |
| |
| |
| |
| |
| function fillGroups(data) { |
| const wrap = document.getElementById('groupsWrap'); |
| const header = '<div class="group-row header"><span>#</span><span>Truck / Shipments</span><span>Weight</span><span>Volume</span><span>Utilization</span><span>AI Conf</span></div>'; |
| if (!data.groups || !data.groups.length) { wrap.innerHTML = header + '<div class="empty"><h3>No groups</h3></div>'; return; } |
| |
| let html = header; |
| data.groups.forEach((g, i) => { |
| const confClass = g.confidence>=75?'conf-hi':g.confidence>=50?'conf-md':'conf-lo'; |
| const color = GROUP_COLORS[i % GROUP_COLORS.length]; |
| const shipNames = g.shipments.map(s => s.id).join(', '); |
| html += `<div class="group-row"> |
| <div class="group-id" style="color:${color}">#${g.groupId}</div> |
| <div class="group-info"><div class="group-truck">${g.truckName}</div><div class="group-ship">${g.shipmentCount} shipments: ${shipNames}</div></div> |
| <div class="group-bar-wrap"><div class="group-bar"><div class="group-bar-fill" style="width:${g.utilizationWeight}%;background:${color}"></div></div><div class="group-bar-label">${g.totalWeight}kg / ${g.truckCapacity?.maxWeight || '?'}kg</div></div> |
| <div class="group-bar-wrap"><div class="group-bar"><div class="group-bar-fill" style="width:${g.utilizationVolume}%;background:${color}"></div></div><div class="group-bar-label">${g.totalVolume}m3 / ${g.truckCapacity?.maxVolume || '?'}m3</div></div> |
| <div class="group-bar-wrap"><div class="group-bar"><div class="group-bar-fill" style="width:${g.utilizationWeight}%;background:${g.utilizationWeight>80?'var(--green)':g.utilizationWeight>50?'var(--yellow)':'var(--red)'}"></div></div><div class="group-bar-label">${g.utilizationWeight}% wt / ${g.utilizationVolume}% vol</div></div> |
| <div><span class="${confClass} conf-badge">${g.confidence}%</span></div> |
| </div>`; |
| }); |
| wrap.innerHTML = html; |
| } |
| |
| |
| |
| |
| function fillMap(data, shipments) { |
| if (!map) { |
| map = L.map('mapContainer', { zoomControl: false }).setView([20, 78], 5); |
| L.control.zoom({ position: 'topright' }).addTo(map); |
| L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { |
| attribution: '© OSM © CARTO', maxZoom: 18 |
| }).addTo(map); |
| } |
| |
| mapLayers.optimized.forEach(l => map.removeLayer(l)); |
| mapLayers.naive.forEach(l => map.removeLayer(l)); |
| mapLayers = { optimized: [], naive: [] }; |
| |
| const bounds = []; |
| |
| |
| shipments.forEach(s => { |
| const line = L.polyline([[s.pickupLat, s.pickupLng],[s.dropLat, s.dropLng]], { color:'#64748b', weight:2, opacity:.5, dashArray:'6,4' }); |
| const pickM = L.circleMarker([s.pickupLat, s.pickupLng], { radius:4, fillColor:'#64748b', fillOpacity:.7, color:'#334155', weight:1 }).bindTooltip(s.id+' (pickup)', {className:'dark-tip'}); |
| const dropM = L.circleMarker([s.dropLat, s.dropLng], { radius:4, fillColor:'#94a3b8', fillOpacity:.7, color:'#334155', weight:1 }).bindTooltip(s.id+' (drop)'); |
| mapLayers.naive.push(line, pickM, dropM); |
| bounds.push([s.pickupLat, s.pickupLng], [s.dropLat, s.dropLng]); |
| }); |
| |
| |
| if (data.groups) { |
| data.groups.forEach((g, gi) => { |
| const color = GROUP_COLORS[gi % GROUP_COLORS.length]; |
| g.shipments.forEach(s => { |
| const orig = shipments.find(o => o.id === s.id); |
| if (!orig) return; |
| const line = L.polyline([[orig.pickupLat, orig.pickupLng],[orig.dropLat, orig.dropLng]], { color, weight:3, opacity:.8 }); |
| const pickM = L.circleMarker([orig.pickupLat, orig.pickupLng], { radius:6, fillColor:color, fillOpacity:.9, color:'#fff', weight:2 }).bindTooltip(`<b>Group #${g.groupId}</b><br>${s.id}<br>${orig.pickupLocation || 'Pickup'}`, {className:'dark-tip'}); |
| const dropM = L.circleMarker([orig.dropLat, orig.dropLng], { radius:6, fillColor:color, fillOpacity:.6, color, weight:2 }).bindTooltip(`<b>Group #${g.groupId}</b><br>${s.id}<br>${orig.dropLocation || 'Drop'}`); |
| mapLayers.optimized.push(line, pickM, dropM); |
| }); |
| }); |
| } |
| |
| setMapMode('optimized'); |
| if (bounds.length) map.fitBounds(bounds, { padding: [30, 30] }); |
| |
| |
| let legendHtml = ''; |
| if (data.groups) { |
| data.groups.forEach((g, i) => { |
| legendHtml += `<div class="lg-item"><div class="lg-dot" style="background:${GROUP_COLORS[i%GROUP_COLORS.length]}"></div> Group #${g.groupId} β ${g.truckName}</div>`; |
| }); |
| legendHtml += `<div class="lg-item"><div class="lg-dot" style="background:#64748b"></div> Naive (unoptimized)</div>`; |
| } |
| document.getElementById('mapLegend').innerHTML = legendHtml; |
| } |
| |
| function setMapMode(mode, btn) { |
| mapMode = mode; |
| document.querySelectorAll('.tab-bar .tab').forEach(t => t.classList.remove('active')); |
| if (btn) btn.classList.add('active'); |
| else document.querySelector('.tab-bar .tab')?.classList.add('active'); |
| |
| mapLayers.optimized.forEach(l => map.removeLayer(l)); |
| mapLayers.naive.forEach(l => map.removeLayer(l)); |
| |
| if (mode === 'optimized' || mode === 'both') mapLayers.optimized.forEach(l => l.addTo(map)); |
| if (mode === 'naive' || mode === 'both') mapLayers.naive.forEach(l => l.addTo(map)); |
| } |
| |
| |
| |
| |
| const CHART_DEFAULTS = { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color:'#94a3b8', font:{family:'Plus Jakarta Sans',size:10} } } }, scales: { x: { ticks:{color:'#475569',font:{size:9}}, grid:{color:'rgba(56,97,150,.15)'} }, y: { ticks:{color:'#475569',font:{size:9}}, grid:{color:'rgba(56,97,150,.15)'} } } }; |
| |
| function fillCharts(data) { |
| const m = data.metrics; |
| |
| |
| if (chartUtil) chartUtil.destroy(); |
| chartUtil = new Chart(document.getElementById('chartUtil'), { |
| type: 'bar', |
| data: { |
| labels: ['Before','After'], |
| datasets: [{ |
| label: 'Utilization %', |
| data: [m.utilizationBefore, m.utilizationAfter], |
| backgroundColor: ['rgba(100,116,139,.5)','rgba(16,185,129,.6)'], |
| borderColor: ['#64748b','#10b981'], |
| borderWidth: 2, borderRadius: 6, barThickness: 40 |
| }] |
| }, |
| options: { ...CHART_DEFAULTS, indexAxis:'y', plugins:{...CHART_DEFAULTS.plugins, legend:{display:false}}, scales:{x:{max:100,ticks:{color:'#475569',font:{size:9}},grid:{color:'rgba(56,97,150,.15)'}},y:{ticks:{color:'#94a3b8',font:{size:11,weight:600}},grid:{display:false}}} } |
| }); |
| |
| |
| if (chartRadar) chartRadar.destroy(); |
| if (data.groups && data.groups.length) { |
| const labels = data.groups.map(g => 'Group #'+g.groupId); |
| chartRadar = new Chart(document.getElementById('chartRadar'), { |
| type: 'radar', |
| data: { |
| labels: ['Capacity Fit','Geo Score','Time Score','Confidence'], |
| datasets: data.groups.map((g, i) => ({ |
| label: 'G#'+g.groupId, |
| data: [g.capFit, g.geoScore, g.timeScore, g.confidence], |
| borderColor: GROUP_COLORS[i%GROUP_COLORS.length], |
| backgroundColor: GROUP_COLORS[i%GROUP_COLORS.length]+'22', |
| borderWidth: 2, pointRadius: 3 |
| })) |
| }, |
| options: { responsive:true, maintainAspectRatio:false, scales:{ r:{ beginAtZero:true, max:100, ticks:{color:'#475569',backdropColor:'transparent',font:{size:8}}, grid:{color:'rgba(56,97,150,.2)'}, pointLabels:{color:'#94a3b8',font:{size:9}} } }, plugins:{legend:{position:'bottom',labels:{color:'#94a3b8',font:{size:9},boxWidth:8}}} } |
| }); |
| } |
| |
| |
| if (chartDist) chartDist.destroy(); |
| if (data.groups && data.groups.length) { |
| chartDist = new Chart(document.getElementById('chartDist'), { |
| type: 'doughnut', |
| data: { |
| labels: data.groups.map(g => g.truckName), |
| datasets: [{ data: data.groups.map(g => g.totalWeight), backgroundColor: data.groups.map((_, i) => GROUP_COLORS[i%GROUP_COLORS.length]+'cc'), borderColor: data.groups.map((_, i) => GROUP_COLORS[i%GROUP_COLORS.length]), borderWidth: 2 }] |
| }, |
| options: { responsive:true, maintainAspectRatio:false, cutout:'55%', plugins:{ legend:{position:'bottom',labels:{color:'#94a3b8',font:{size:9},boxWidth:10,padding:8}}, tooltip:{callbacks:{label:ctx=>ctx.label+': '+ctx.raw+'kg'}} } } |
| }); |
| } |
| } |
| |
| |
| |
| |
| function fillInsights(data) { |
| const el = document.getElementById('insightsList'); |
| if (!data.insights || !data.insights.length) { el.innerHTML='<div class="empty"><h3>No insights</h3></div>'; return; } |
| |
| const icons = { pattern:'🔍', recommendation:'💡', learning:'🧠' }; |
| |
| el.innerHTML = data.insights.map(ins => ` |
| <div class="insight"> |
| <div class="insight-icon ${ins.type}">${icons[ins.type] || '?'}</div> |
| <div class="insight-body"> |
| <div class="insight-text">${ins.text}</div> |
| <div class="insight-meta"> |
| <span class="insight-type ${ins.type}">${ins.type}</span> |
| <span class="impact-dot ${ins.impact}">${ins.impact.toUpperCase()} IMPACT</span> |
| </div> |
| </div> |
| </div> |
| `).join(''); |
| } |
| |
| |
| |
| |
| function fillHeatmap(shipments) { |
| const el = document.getElementById('heatmapArea'); |
| if (!shipments || shipments.length < 2) { el.innerHTML='<div class="empty"><h3>Not enough shipments</h3></div>'; return; } |
| |
| const n = shipments.length; |
| const matrix = []; |
| for (let i = 0; i < n; i++) { |
| matrix[i] = []; |
| for (let j = 0; j < n; j++) { |
| if (i === j) { matrix[i][j] = 100; continue; } |
| const a = shipments[i], b = shipments[j]; |
| const pd = haversine(a.pickupLat, a.pickupLng, b.pickupLat, b.pickupLng); |
| const dd = haversine(a.dropLat, a.dropLng, b.dropLat, b.dropLng); |
| const geoCompat = Math.max(0, 100 - (pd + dd) * 0.15); |
| |
| let timeCompat = 80; |
| if (a.timeWindowStart && b.timeWindowStart) { |
| const diff = Math.abs(new Date(a.timeWindowStart) - new Date(b.timeWindowStart)) / 3600000; |
| timeCompat = Math.max(0, 100 - diff * 20); |
| } |
| matrix[i][j] = Math.round(geoCompat * 0.6 + timeCompat * 0.4); |
| } |
| } |
| |
| const cols = n + 1; |
| let html = `<div class="heatmap-grid" style="grid-template-columns:50px repeat(${n},1fr)">`; |
| html += '<div></div>'; |
| for (let j = 0; j < n; j++) html += `<div class="hm-label">${shipments[j].id.replace('SH-','S')}</div>`; |
| for (let i = 0; i < n; i++) { |
| html += `<div class="hm-label">${shipments[i].id.replace('SH-','S')}</div>`; |
| for (let j = 0; j < n; j++) { |
| const v = matrix[i][j]; |
| const bg = v > 70 ? `rgba(16,185,129,${v/120})` : v > 40 ? `rgba(251,191,36,${v/140})` : `rgba(239,68,68,${v/150})`; |
| html += `<div class="hm-cell" style="background:${bg}" title="${shipments[i].id} vs ${shipments[j].id}: ${v}%"></div>`; |
| } |
| } |
| html += '</div>'; |
| el.innerHTML = html; |
| } |
| |
| function haversine(lat1,lng1,lat2,lng2) { |
| const R=6371, dLat=(lat2-lat1)*Math.PI/180, dLng=(lng2-lng1)*Math.PI/180; |
| const a=Math.sin(dLat/2)**2+Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2; |
| return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)); |
| } |
| |
| |
| |
| |
| function fillTerminal(data) { |
| const el = document.getElementById('termBody'); |
| if (!data.agentSteps || !data.agentSteps.length) { el.innerHTML='<span style="color:var(--txt4)">No logs</span>'; return; } |
| |
| el.innerHTML = data.agentSteps.map((s, i) => { |
| const details = Object.entries(s).filter(([k]) => !['agent','action','duration_ms'].includes(k)).map(([k,v]) => `<span class="term-detail">${k}: ${JSON.stringify(v)}</span>`).join(''); |
| return `<div class="term-line"><span class="term-agent">[${s.agent}]</span> <span class="term-action">${s.action}</span> <span class="term-ms">${ms(s.duration_ms)}</span>${details}</div>`; |
| }).join(''); |
| } |
| |
| |
| |
| |
| async function runSimulation() { |
| let body; |
| try { body = JSON.parse(document.getElementById('reqJson').value); } catch(e) { alert('Invalid JSON'); return; } |
| |
| const scenarios = [ |
| { name:'Tight Clustering', maxGroupRadiusKm:15, timeWindowToleranceMinutes:60 }, |
| { name:'Balanced', maxGroupRadiusKm:30, timeWindowToleranceMinutes:120 }, |
| { name:'Aggressive Merge', maxGroupRadiusKm:60, timeWindowToleranceMinutes:240 } |
| ]; |
| |
| const area = document.getElementById('scenarioArea'); |
| area.innerHTML = '<div class="empty" style="grid-column:1/-1"><div class="loader-orb" style="width:40px;height:40px"><span></span><span></span><span></span></div><p style="margin-top:12px">Running 3 scenarios...</p></div>'; |
| document.getElementById('secBottom').style.display=''; |
| |
| try { |
| const res = await fetch('/api/v1/consolidate/simulate', { |
| method:'POST', headers:{'Content-Type':'application/json'}, |
| body: JSON.stringify({ shipments: body.shipments, trucks: body.trucks, scenarios }) |
| }); |
| if (!res.ok) throw new Error('Simulation failed'); |
| const result = await res.json(); |
| |
| area.innerHTML = result.scenarios.map(sc => { |
| const isBest = sc.name === result.recommendation; |
| const m = sc.metrics; |
| return `<div class="scenario-card${isBest?' best':''}"> |
| ${isBest?'<div class="scenario-badge">RECOMMENDED</div>':''} |
| <div class="scenario-name">${sc.name}</div> |
| <div class="scenario-stats"> |
| <div class="scenario-stat"><span class="k">Score</span><span class="v" style="color:${m.optimizationScore>=70?'var(--green)':'var(--yellow)'}">${m.optimizationScore}/100</span></div> |
| <div class="scenario-stat"><span class="k">Utilization</span><span class="v">${m.utilizationAfter}%</span></div> |
| <div class="scenario-stat"><span class="k">Trips Cut</span><span class="v">${m.tripsReduced}</span></div> |
| <div class="scenario-stat"><span class="k">Groups</span><span class="v">${m.totalGroups}</span></div> |
| <div class="scenario-stat"><span class="k">Distance Saved</span><span class="v">${m.distanceSavedKm} km</span></div> |
| <div class="scenario-stat"><span class="k">CO2 Saved</span><span class="v">${m.carbonSavedKg} kg</span></div> |
| <div class="scenario-stat"><span class="k">Fuel Saved</span><span class="v">Rs.${m.fuelSavedINR}</span></div> |
| <div class="scenario-stat"><span class="k">Confidence</span><span class="v">${m.avgConfidence}%</span></div> |
| </div> |
| </div>`; |
| }).join(''); |
| |
| } catch(err) { |
| area.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>Error</h3><p>${err.message}</p></div>`; |
| } |
| } |
| |
| |
| |
| |
| function ms(v) { return v != null ? v.toFixed(1)+'ms' : ''; } |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } |
| </script> |
| </body> |
| </html> |
|
|