Spaces:
Sleeping
Sleeping
| <!-- Final UI--> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>OptiChain β AI Supply Chain Optimizer</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Syne:wght@400;500;600;700;800&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0a0b0d; --bg2: #0f1114; --bg3: #14171c; --bg4: #1a1e25; | |
| --border: rgba(255,255,255,0.07); --border2: rgba(255,255,255,0.12); --border3: rgba(255,255,255,0.18); | |
| --text: #e8e6e0; --text2: #8a8880; --text3: #4a4845; | |
| --accent: #e8c84a; --accent2: #c4a832; --accent-bg: rgba(232,200,74,0.08); | |
| --green: #4aaa6e; --green-bg: rgba(74,170,110,0.10); | |
| --red: #d95f5f; --red-bg: rgba(217,95,95,0.10); | |
| --blue: #5b9bd5; --blue-bg: rgba(91,155,213,0.10); | |
| --orange: #e07a3a; --orange-bg: rgba(224,122,58,0.10); | |
| --font-display: 'Syne', sans-serif; --font-serif: 'Instrument Serif', serif; --font-mono: 'DM Mono', monospace; | |
| --r: 6px; --r2: 10px; --r3: 14px; | |
| } | |
| html { font-size: 14px; } | |
| body { background: var(--bg); color: var(--text); font-family: var(--font-mono); font-weight: 400; min-height: 100vh; overflow-x: hidden; } | |
| body::before { content: ''; position: fixed; inset: 0; background-image: linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px); background-size: 48px 48px; pointer-events: none; z-index: 0; } | |
| body::after { content: ''; position: fixed; inset: 0; background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px); pointer-events: none; z-index: 0; } | |
| .app { position: relative; z-index: 1; display: grid; grid-template-rows: auto 1fr; min-height: 100vh; } | |
| header { border-bottom: 1px solid var(--border); padding: 0 32px; display: flex; align-items: center; justify-content: space-between; height: 56px; background: rgba(10,11,13,0.95); backdrop-filter: blur(12px); position: sticky; top: 0; z-index: 100; } | |
| .header-left { display: flex; align-items: center; gap: 16px; } | |
| .logo { font-family: var(--font-display); font-weight: 800; font-size: 17px; letter-spacing: -0.02em; color: var(--text); } | |
| .logo span { color: var(--accent); } | |
| .header-tag { font-size: 10px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text3); border: 1px solid var(--border2); padding: 3px 8px; border-radius: 3px; } | |
| .header-right { display: flex; align-items: center; gap: 20px; } | |
| .status-dot { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--text2); font-family: var(--font-mono); } | |
| .status-dot::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse-dot 2s ease-in-out infinite; } | |
| .status-dot.idle::before { background: var(--text3); box-shadow: none; animation: none; } | |
| .status-dot.running::before { background: var(--accent); box-shadow: 0 0 8px var(--accent); } | |
| @keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.4} } | |
| .header-score { font-family: var(--font-display); font-size: 13px; font-weight: 600; color: var(--text2); } | |
| .header-score span { color: var(--accent); } | |
| main { display: grid; grid-template-columns: 280px 1fr; gap: 0; } | |
| .sidebar { border-right: 1px solid var(--border); background: var(--bg2); padding: 24px 20px; display: flex; flex-direction: column; gap: 28px; min-height: calc(100vh - 56px); } | |
| .sidebar-section-label { font-size: 9px; font-weight: 500; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text3); margin-bottom: 10px; } | |
| .task-card { border: 1px solid var(--border); border-radius: var(--r2); padding: 14px; cursor: pointer; transition: border-color 0.2s, background 0.2s; margin-bottom: 8px; position: relative; overflow: hidden; } | |
| .task-card::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; border-radius: 3px 0 0 3px; background: transparent; transition: background 0.2s; } | |
| .task-card:hover { border-color: var(--border2); background: var(--bg3); } | |
| .task-card.active { border-color: var(--accent); background: var(--accent-bg); } | |
| .task-card.active::before { background: var(--accent); } | |
| .task-card.active-green { border-color: var(--green); background: var(--green-bg); } | |
| .task-card.active-green::before { background: var(--green); } | |
| .task-card.active-red { border-color: var(--red); background: var(--red-bg); } | |
| .task-card.active-red::before { background: var(--red); } | |
| .task-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; } | |
| .task-name { font-family: var(--font-display); font-size: 13px; font-weight: 600; color: var(--text); } | |
| .diff-badge { font-size: 9px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; padding: 2px 7px; border-radius: 3px; } | |
| .diff-easy { background: var(--green-bg); color: var(--green); border: 1px solid rgba(74,170,110,0.2); } | |
| .diff-medium { background: var(--orange-bg); color: var(--orange); border: 1px solid rgba(224,122,58,0.2); } | |
| .diff-hard { background: var(--red-bg); color: var(--red); border: 1px solid rgba(217,95,95,0.2); } | |
| .task-meta { font-size: 11px; color: var(--text3); line-height: 1.6; } | |
| .task-meta strong { color: var(--text2); font-weight: 400; } | |
| .ctrl-group { display: flex; flex-direction: column; gap: 8px; } | |
| .btn { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border2); background: transparent; color: var(--text); font-family: var(--font-mono); font-size: 12px; padding: 9px 14px; border-radius: var(--r); cursor: pointer; transition: all 0.15s; letter-spacing: 0.02em; width: 100%; } | |
| .btn:hover { background: var(--bg3); border-color: var(--border3); } | |
| .btn:active { transform: scale(0.98); } | |
| .btn:disabled { opacity: 0.3; cursor: default; transform: none; } | |
| .btn-primary { background: var(--accent); border-color: var(--accent); color: #0a0b0d; font-weight: 500; } | |
| .btn-primary:hover { background: var(--accent2); border-color: var(--accent2); } | |
| .btn-danger { border-color: rgba(217,95,95,0.4); color: var(--red); } | |
| .btn-danger:hover { background: var(--red-bg); } | |
| .ico { width: 14px; height: 14px; flex-shrink: 0; } | |
| .speed-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; } | |
| .speed-label { font-size: 11px; color: var(--text3); white-space: nowrap; } | |
| input[type=range] { flex: 1; appearance: none; height: 3px; background: var(--border2); border-radius: 2px; outline: none; } | |
| input[type=range]::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); cursor: pointer; border: 2px solid var(--bg); } | |
| .speed-val { font-size: 11px; color: var(--accent); min-width: 20px; } | |
| .stat-strip { display: flex; gap: 8px; flex-direction: column; } | |
| .stat-item { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--border); } | |
| .stat-item:last-child { border-bottom: none; } | |
| .stat-key { font-size: 11px; color: var(--text3); } | |
| .stat-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); } | |
| .stat-val.up { color: var(--green); } | |
| .stat-val.down { color: var(--red); } | |
| .stat-val.warn { color: var(--orange); } | |
| .stat-val.accent { color: var(--accent); } | |
| .content { padding: 24px 28px; display: flex; flex-direction: column; gap: 20px; background: var(--bg); } | |
| .metrics-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; } | |
| .metric-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r2); padding: 16px; position: relative; overflow: hidden; transition: border-color 0.2s; } | |
| .metric-card:hover { border-color: var(--border2); } | |
| .metric-card::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: transparent; transition: background 0.3s; } | |
| .metric-card.flash::after { background: var(--accent); } | |
| .metric-label { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text3); margin-bottom: 10px; font-weight: 500; } | |
| .metric-val { font-family: var(--font-display); font-size: 26px; font-weight: 700; color: var(--text); line-height: 1; margin-bottom: 6px; transition: color 0.3s; } | |
| .metric-val.green { color: var(--green); } | |
| .metric-val.red { color: var(--red); } | |
| .metric-val.amber { color: var(--orange); } | |
| .metric-val.accent { color: var(--accent); } | |
| .metric-sub { font-size: 11px; color: var(--text3); } | |
| .progress-track { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 8px; } | |
| .progress-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.4s cubic-bezier(0.4,0,0.2,1); } | |
| .progress-fill.green { background: var(--green); } | |
| .progress-fill.red { background: var(--red); } | |
| .charts-row { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; } | |
| .chart-panel { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r2); padding: 18px 20px; } | |
| .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } | |
| .panel-title { font-family: var(--font-display); font-size: 13px; font-weight: 600; color: var(--text); letter-spacing: -0.01em; } | |
| .panel-meta { font-size: 11px; color: var(--text3); } | |
| .chart-legend { display: flex; gap: 16px; } | |
| .legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); } | |
| .legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; } | |
| .lower-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; } | |
| .pipeline-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 8px; } | |
| .pipe-cell { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--r); padding: 10px 8px; text-align: center; transition: all 0.2s; } | |
| .pipe-cell.has-stock { border-color: var(--blue); background: var(--blue-bg); } | |
| .pipe-cell.arriving { border-color: var(--green); background: var(--green-bg); animation: arrive-flash 0.6s ease; } | |
| @keyframes arrive-flash { 0%,100%{opacity:1} 50%{opacity:0.5} } | |
| .pipe-day-label { font-size: 9px; color: var(--text3); letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 4px; } | |
| .pipe-qty { font-family: var(--font-display); font-size: 18px; font-weight: 700; color: var(--text2); } | |
| .pipe-cell.has-stock .pipe-qty { color: var(--blue); } | |
| .pipe-cell.arriving .pipe-qty { color: var(--green); } | |
| .agent-panel { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--r); padding: 12px 14px; margin-top: 10px; min-height: 64px; position: relative; } | |
| .agent-label { display: inline-flex; align-items: center; gap: 6px; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 500; margin-bottom: 7px; } | |
| .agent-label.analyst { color: var(--blue); } | |
| .agent-label.exec { color: var(--green); } | |
| .agent-label::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: pulse-dot 1.8s infinite; } | |
| .agent-text { font-size: 11.5px; color: var(--text2); line-height: 1.7; font-family: var(--font-mono); } | |
| .agent-text.thinking { color: var(--text3); font-style: italic; } | |
| .json-output { font-family: var(--font-mono); font-size: 11px; color: var(--green); background: var(--bg); border: 1px solid rgba(74,170,110,0.2); border-radius: var(--r); padding: 8px 10px; margin-top: 6px; white-space: pre-wrap; word-break: break-all; } | |
| .log-panel { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r2); overflow: hidden; } | |
| .log-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); } | |
| .log-scroll { height: 220px; overflow-y: auto; padding: 8px 0; scrollbar-width: thin; scrollbar-color: var(--border2) transparent; } | |
| .log-scroll::-webkit-scrollbar { width: 4px; } | |
| .log-scroll::-webkit-scrollbar-track { background: transparent; } | |
| .log-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; } | |
| .log-row { display: grid; grid-template-columns: 52px 72px 60px 60px 60px 60px auto; align-items: center; padding: 5px 16px; border-bottom: 1px solid var(--border); font-size: 11px; font-family: var(--font-mono); transition: background 0.1s; animation: row-in 0.2s ease; } | |
| @keyframes row-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } | |
| .log-row:hover { background: var(--bg3); } | |
| .log-row:last-child { border-bottom: none; } | |
| .log-day { color: var(--accent); font-weight: 500; } | |
| .log-demand { color: var(--text2); } | |
| .log-sold { color: var(--green); } | |
| .log-lost { color: var(--red); } | |
| .log-order { color: var(--blue); } | |
| .log-pnl-pos { color: var(--green); } | |
| .log-pnl-neg { color: var(--red); } | |
| .log-cash { color: var(--text2); } | |
| .log-col-header { display: grid; grid-template-columns: 52px 72px 60px 60px 60px 60px auto; padding: 6px 16px; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text3); border-bottom: 1px solid var(--border); } | |
| .final-banner { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r3); padding: 32px; text-align: center; display: none; animation: banner-in 0.5s cubic-bezier(0.34,1.56,0.64,1); position: relative; overflow: hidden; } | |
| .final-banner.visible { display: block; } | |
| .final-banner::before { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at 50% 0%, rgba(232,200,74,0.07) 0%, transparent 60%); pointer-events: none; } | |
| @keyframes banner-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: none; } } | |
| .final-score { font-family: var(--font-display); font-size: 72px; font-weight: 800; letter-spacing: -0.04em; line-height: 1; margin-bottom: 4px; } | |
| .final-label { font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text3); margin-bottom: 20px; } | |
| .final-stars { font-size: 24px; letter-spacing: 4px; margin-bottom: 16px; color: var(--accent); } | |
| .final-detail { font-size: 12px; color: var(--text2); line-height: 1.8; font-family: var(--font-mono); } | |
| .report-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 20px 0; } | |
| .report-card { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; text-align: left; } | |
| .report-card-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text3); margin-bottom: 6px; } | |
| .report-card-val { font-family: var(--font-display); font-size: 20px; font-weight: 700; } | |
| .report-card-val.green { color: var(--green); } | |
| .report-card-val.red { color: var(--red); } | |
| .report-card-val.accent { color: var(--accent); } | |
| .report-card-sub { font-size: 10px; color: var(--text3); margin-top: 3px; } | |
| .signal-banner { background: var(--bg3); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--r); padding: 10px 14px; font-size: 11.5px; color: var(--text2); display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); } | |
| .signal-icon { font-size: 14px; flex-shrink: 0; } | |
| .signal-banner.warn { border-left-color: var(--orange); } | |
| .signal-banner.danger { border-left-color: var(--red); } | |
| .sparkline-wrap { height: 80px; position: relative; } | |
| .thinking-dots::after { content: '...'; animation: dots 1s steps(4,end) infinite; } | |
| @keyframes dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } } | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; } | |
| .s-label { font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text3); font-weight: 500; } | |
| .day-strip { display: flex; gap: 3px; flex-wrap: wrap; margin-top: 6px; } | |
| .day-pip { width: 7px; height: 7px; border-radius: 1px; background: var(--border); transition: background 0.3s; } | |
| .day-pip.done { background: var(--accent); } | |
| .day-pip.done.low { background: var(--red); } | |
| .day-pip.spike { background: var(--orange); } | |
| .day-pip.active { background: var(--accent); box-shadow: 0 0 6px var(--accent); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header> | |
| <div class="header-left"> | |
| <div class="logo">Opti<span>Chain</span></div> | |
| <div class="header-tag">Meta OpenEnv Β· v1.0.0</div> | |
| </div> | |
| <div class="header-right"> | |
| <div class="status-dot idle" id="status-dot">idle</div> | |
| <div class="header-score">Score <span id="header-score">β</span></div> | |
| </div> | |
| </header> | |
| <main> | |
| <aside class="sidebar"> | |
| <div> | |
| <div class="sidebar-section-label">Tasks</div> | |
| <div class="task-card active-green" data-task="task_01_easy" onclick="selectTask('task_01_easy', this)"> | |
| <div class="task-card-header"><div class="task-name">Stable Demand</div><div class="diff-badge diff-easy">Easy</div></div> | |
| <div class="task-meta"><strong>$30,000</strong> starting cash<br><strong>10</strong> units/day constant</div> | |
| </div> | |
| <div class="task-card" data-task="task_02_medium" onclick="selectTask('task_02_medium', this)"> | |
| <div class="task-card-header"><div class="task-name">Holiday Spike</div><div class="diff-badge diff-medium">Medium</div></div> | |
| <div class="task-meta"><strong>$50,000</strong> starting cash<br><strong>40</strong> units/day on days 10β17</div> | |
| </div> | |
| <div class="task-card" data-task="task_03_hard" onclick="selectTask('task_03_hard', this)"> | |
| <div class="task-card-header"><div class="task-name">Supply Crisis</div><div class="diff-badge diff-hard">Hard</div></div> | |
| <div class="task-meta"><strong>$30,000</strong> starting cash<br><strong>4-day</strong> standard shipping delay</div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="sidebar-section-label">Controls</div> | |
| <div class="ctrl-group"> | |
| <button class="btn btn-primary" id="btn-run" onclick="startSim()"> | |
| <svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M4 3l9 5-9 5V3z" fill="currentColor"/></svg> | |
| Run simulation | |
| </button> | |
| <button class="btn" id="btn-pause" onclick="pauseSim()" disabled> | |
| <svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor"/><rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor"/></svg> | |
| <span id="pause-label">Pause</span> | |
| </button> | |
| <button class="btn btn-danger" onclick="resetSim()"> | |
| <svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M2 12V8h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> | |
| Reset | |
| </button> | |
| </div> | |
| <div class="speed-row" style="margin-top:14px;"> | |
| <span class="speed-label">Speed</span> | |
| <input type="range" id="speed-range" min="1" max="5" value="3" step="1" oninput="onSpeedChange(this.value)"> | |
| <span class="speed-val" id="speed-lbl">3Γ</span> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="sidebar-section-label">Live stats</div> | |
| <div class="stat-strip" id="sidebar-stats"> | |
| <div class="stat-item"><span class="stat-key">Cash</span><span class="stat-val accent" id="ss-cash">$30,000</span></div> | |
| <div class="stat-item"><span class="stat-key">Stock</span><span class="stat-val" id="ss-stock">50</span></div> | |
| <div class="stat-item"><span class="stat-key">Sold yesterday</span><span class="stat-val up" id="ss-sold">β</span></div> | |
| <div class="stat-item"><span class="stat-key">Lost sales</span><span class="stat-val down" id="ss-lost">β</span></div> | |
| <div class="stat-item"><span class="stat-key">Order placed</span><span class="stat-val" id="ss-order">β</span></div> | |
| <div class="stat-item"><span class="stat-key">Avg reward</span><span class="stat-val accent" id="ss-profit">0.00</span></div> | |
| <div class="stat-item"><span class="stat-key">Grader score</span><span class="stat-val" id="ss-opt">0.00</span></div> | |
| </div> | |
| </div> | |
| <div style="margin-top:auto;"> | |
| <div class="sidebar-section-label">Environment</div> | |
| <div class="stat-strip"> | |
| <div class="stat-item"><span class="stat-key">Product</span><span class="stat-val">SKU-LAPTOP</span></div> | |
| <div class="stat-item"><span class="stat-key">Cost/unit</span><span class="stat-val">$800</span></div> | |
| <div class="stat-item"><span class="stat-key">Margin/unit</span><span class="stat-val up">$400</span></div> | |
| <div class="stat-item"><span class="stat-key">Holding cost</span><span class="stat-val warn">$2/unit/day</span></div> | |
| <div class="stat-item"><span class="stat-key">Stockout penalty</span><span class="stat-val down">$100/unit</span></div> | |
| <div class="stat-item"><span class="stat-key">Std. shipping</span><span class="stat-val" id="ss-shipping">2 days</span></div> | |
| </div> | |
| </div> | |
| </aside> | |
| <div class="content"> | |
| <div class="signal-banner" id="signal-banner"> | |
| <span class="signal-icon">π‘</span> | |
| <span id="signal-text">Market signal: Demand is stable at exactly 10 units per day.</span> | |
| </div> | |
| <div class="metrics-grid"> | |
| <div class="metric-card" id="mc-cash"> | |
| <div class="metric-label">Cash balance</div> | |
| <div class="metric-val accent" id="mc-cash-val">$30,000</div> | |
| <div class="metric-sub" id="mc-cash-sub">Starting capital</div> | |
| <div class="progress-track"><div class="progress-fill" id="mc-cash-prog" style="width:100%"></div></div> | |
| </div> | |
| <div class="metric-card" id="mc-stock"> | |
| <div class="metric-label">On-hand stock</div> | |
| <div class="metric-val green" id="mc-stock-val">50</div> | |
| <div class="metric-sub" id="mc-stock-sub">SKU-LAPTOP units</div> | |
| <div class="progress-track"><div class="progress-fill green" id="mc-stock-prog" style="width:100%"></div></div> | |
| </div> | |
| <div class="metric-card" id="mc-day"> | |
| <div class="metric-label">Day</div> | |
| <div class="metric-val" id="mc-day-val">0 <span style="font-size:16px;color:var(--text3)">/30</span></div> | |
| <div class="metric-sub">Progress</div> | |
| <div class="progress-track"><div class="progress-fill" id="mc-day-prog" style="width:0%"></div></div> | |
| </div> | |
| <div class="metric-card" id="mc-score"> | |
| <div class="metric-label">Step reward</div> | |
| <div class="metric-val" id="mc-score-val">β</div> | |
| <div class="metric-sub">Per-day newsvendor [0β1]</div> | |
| <div class="progress-track"><div class="progress-fill" id="mc-score-prog" style="width:0%"></div></div> | |
| </div> | |
| <div class="metric-card" id="mc-profit"> | |
| <div class="metric-label">Avg reward</div> | |
| <div class="metric-val" id="mc-profit-val">0.00</div> | |
| <div class="metric-sub" id="mc-profit-sub">reward / days elapsed [0β1]</div> | |
| <div class="progress-track"><div class="progress-fill green" id="mc-profit-prog" style="width:0%"></div></div> | |
| </div> | |
| <div class="metric-card" id="mc-cumprofit"> | |
| <div class="metric-label">Cumulative profit</div> | |
| <div class="metric-val" id="mc-cumprofit-val">$0</div> | |
| <div class="metric-sub" id="mc-cumprofit-sub">Cash delta from start</div> | |
| <div class="progress-track"><div class="progress-fill green" id="mc-cumprofit-prog" style="width:0%"></div></div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="s-label" style="margin-bottom:6px;">30-day timeline</div> | |
| <div class="day-strip" id="day-strip"></div> | |
| </div> | |
| <div class="charts-row"> | |
| <div class="chart-panel"> | |
| <div class="panel-header"> | |
| <div class="panel-title">Daily P&L + Cash Flow</div> | |
| <div class="chart-legend"> | |
| <div class="legend-item"><div class="legend-dot" style="background:#4aaa6e"></div>Profit day</div> | |
| <div class="legend-item"><div class="legend-dot" style="background:#d95f5f"></div>Loss day</div> | |
| <div class="legend-item"><div class="legend-dot" style="background:#5b9bd5"></div>Cash ($)</div> | |
| </div> | |
| </div> | |
| <div style="position:relative;height:180px;"><canvas id="main-chart"></canvas></div> | |
| </div> | |
| <div class="chart-panel"> | |
| <div class="panel-header"> | |
| <div class="panel-title">Inventory level</div> | |
| <div class="panel-meta" id="inv-meta">units</div> | |
| </div> | |
| <div style="position:relative;height:180px;"><canvas id="inv-chart"></canvas></div> | |
| </div> | |
| </div> | |
| <div class="lower-row"> | |
| <div class="chart-panel"> | |
| <div class="panel-header"> | |
| <div class="panel-title">Shipment pipeline</div> | |
| <div class="panel-meta">days to arrival</div> | |
| </div> | |
| <div class="pipeline-grid"> | |
| <div class="pipe-cell" id="pc-1"><div class="pipe-day-label">+1 day</div><div class="pipe-qty" id="pq-1">0</div></div> | |
| <div class="pipe-cell" id="pc-2"><div class="pipe-day-label">+2 days</div><div class="pipe-qty" id="pq-2">0</div></div> | |
| <div class="pipe-cell" id="pc-3"><div class="pipe-day-label">+3 days</div><div class="pipe-qty" id="pq-3">0</div></div> | |
| <div class="pipe-cell" id="pc-4"><div class="pipe-day-label">+4 days</div><div class="pipe-qty" id="pq-4">0</div></div> | |
| <div class="pipe-cell" id="pc-5p"><div class="pipe-day-label">+5+ days</div><div class="pipe-qty" id="pq-5p">0</div></div> | |
| </div> | |
| <div style="margin-top:14px;"> | |
| <div class="s-label" style="margin-bottom:8px;">Yesterday</div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;"> | |
| <div style="text-align:center;"> | |
| <div class="s-label" style="margin-bottom:4px;">Sold</div> | |
| <div style="font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--green)" id="yday-sold">β</div> | |
| </div> | |
| <div style="text-align:center;"> | |
| <div class="s-label" style="margin-bottom:4px;">Lost</div> | |
| <div style="font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--red)" id="yday-lost">β</div> | |
| </div> | |
| <div style="text-align:center;"> | |
| <div class="s-label" style="margin-bottom:4px;">Ordered</div> | |
| <div style="font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--blue)" id="yday-order">β</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="chart-panel"> | |
| <div class="panel-header"> | |
| <div class="panel-title">Analyst agent</div> | |
| <div class="panel-meta">Planner</div> | |
| </div> | |
| <div class="agent-panel"> | |
| <div class="agent-label analyst">Analyst Β· reasoning</div> | |
| <div class="agent-text thinking" id="analyst-text">Waiting for simulation to start...</div> | |
| </div> | |
| <div style="margin-top:10px;"> | |
| <div class="s-label" style="margin-bottom:6px;">Strategy constraints</div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px;"> | |
| <div style="background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);padding:8px 10px;"> | |
| <div class="s-label" style="margin-bottom:3px;">Min cash buffer</div> | |
| <div style="font-size:13px;color:var(--accent);font-family:var(--font-mono);">$8,000</div> | |
| </div> | |
| <div style="background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);padding:8px 10px;"> | |
| <div class="s-label" style="margin-bottom:3px;">Reorder trigger</div> | |
| <div style="font-size:13px;color:var(--text2);font-family:var(--font-mono);" id="reorder-trigger">stock < 20</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="chart-panel"> | |
| <div class="panel-header"> | |
| <div class="panel-title">Executor agent</div> | |
| <div class="panel-meta">JSON formatter</div> | |
| </div> | |
| <div class="agent-panel"> | |
| <div class="agent-label exec">Executor Β· output</div> | |
| <div class="agent-text thinking" id="exec-text">Will format Analyst decision into SupplyChainAction schema.</div> | |
| </div> | |
| <div id="json-output-wrap" style="display:none;"> | |
| <div class="json-output" id="json-output"></div> | |
| </div> | |
| <div style="margin-top:10px;"> | |
| <div class="s-label" style="margin-bottom:6px;">Schema</div> | |
| <div class="json-output" style="color:var(--text3);margin-top:0;">{"orders":[{"product_id":"SKU-LAPTOP","quantity":N,"expedite_shipping":bool}]}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="log-panel"> | |
| <div class="log-header"> | |
| <div class="panel-title">Event log</div> | |
| <div class="panel-meta" id="log-count">β entries</div> | |
| </div> | |
| <div class="log-col-header"> | |
| <span>Day</span><span>Demand</span><span>Sold</span><span>Lost</span><span>Order</span><span>P&L</span><span>Cash EOD</span> | |
| </div> | |
| <div class="log-scroll" id="log-scroll"> | |
| <div style="padding:16px;font-size:12px;color:var(--text3);font-family:var(--font-mono);">Select a task and click Run simulation to begin.</div> | |
| </div> | |
| </div> | |
| <div class="final-banner" id="final-banner"> | |
| <div class="final-label">Simulation complete</div> | |
| <div class="final-label">Grader score</div> | |
| <div class="final-score" id="final-score">β</div> | |
| <div class="final-label">/ 1.0</div> | |
| <div class="final-stars" id="final-stars"></div> | |
| <div class="report-grid" style="grid-template-columns: repeat(4, 1fr);"> | |
| <div class="report-card"> | |
| <div class="report-card-label">Avg reward</div> | |
| <div class="report-card-val accent" id="fr-profit">β</div> | |
| <div class="report-card-sub">total / 30 days [0β1]</div> | |
| </div> | |
| <div class="report-card"> | |
| <div class="report-card-label">Cumulative profit</div> | |
| <div class="report-card-val" id="fr-cumprofit">β</div> | |
| <div class="report-card-sub">Cash delta from start</div> | |
| </div> | |
| <div class="report-card"> | |
| <div class="report-card-label">Remaining stock</div> | |
| <div class="report-card-val" id="fr-lost">β</div> | |
| <div class="report-card-sub">Unsold units (overage waste)</div> | |
| </div> | |
| <div class="report-card"> | |
| <div class="report-card-label">Cash balance</div> | |
| <div class="report-card-val" id="fr-cash">β</div> | |
| <div class="report-card-sub">End of simulation</div> | |
| </div> | |
| </div> | |
| <div class="final-detail" id="final-detail"></div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| // βββ CONSTANTS βββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MAX_DAYS = 30; | |
| const TASKS = { | |
| task_01_easy: { | |
| name: 'Stable Demand', cash: 30000, stock: 50, | |
| signal: 'Market signal: Demand is stable at exactly 10 units per day.', signalClass: '', | |
| shipping: 2, triggerLabel: 'stock < 20' | |
| }, | |
| task_02_medium: { | |
| name: 'Holiday Spike', cash: 50000, stock: 20, | |
| signal: 'WARNING: Black Friday sale begins on Day 10. Demand will spike from 10 to 40 units per day.', signalClass: 'warn', | |
| shipping: 2, triggerLabel: 'pre-spike stock' | |
| }, | |
| task_03_hard: { | |
| name: 'Supply Crisis', cash: 30000, stock: 30, | |
| signal: 'WARNING: Global shipping crisis. Standard 2-day shipping is delayed to 4 days. Expedited 1-day shipping costs $100 extra per unit.', signalClass: 'danger', | |
| shipping: 4, triggerLabel: 'expedite always' | |
| } | |
| }; | |
| // βββ STATE βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Auto-detect backend: same origin on HF Spaces, localhost for local dev | |
| const API = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') | |
| ? `http://${window.location.hostname}:${window.location.port || 7860}` | |
| : window.location.origin; | |
| let S = {}; | |
| let paused = false; | |
| let running = false; | |
| let simLoopActive = false; // Prevents overlapping API calls | |
| let currentTask = 'task_01_easy'; | |
| let logCount = 0; | |
| let totalLost = 0; | |
| let mainChart = null, invChart = null; | |
| // βββ INIT ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initState(taskId) { | |
| const t = TASKS[taskId]; | |
| S = { | |
| taskId, day: 1, cash: t.cash, stock: t.stock, pipeline: {}, | |
| totalProfit: 0, stepReward: 0, totalReward: 0, graderScore: 0, | |
| salesYday: 0, lostYday: 0, lastOrder: 0, | |
| history: { days:[], pnl:[], cash:[], stock:[] } | |
| }; | |
| logCount = 0; totalLost = 0; | |
| } | |
| // βββ SMART SIMULATION LOOP βββββββββββββββββββββββββββββββββββ | |
| async function startSim() { | |
| if (running) return; | |
| initState(currentTask); | |
| clearLog(); | |
| setStatus('running'); | |
| document.getElementById('btn-run').disabled = true; | |
| document.getElementById('btn-pause').disabled = false; | |
| document.getElementById('final-banner').className = 'final-banner'; | |
| try { | |
| // 1. Reset Environment on the Backend | |
| const resetRes = await fetch(`${API}/reset`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ task_id: currentTask }) | |
| }); | |
| const resetData = await resetRes.json(); | |
| // openenv wraps as {observation, reward, done}; plain FastAPI returns directly | |
| const initObs = resetData.observation || resetData; | |
| applyObservation(initObs); | |
| } catch (e) { | |
| console.error(e); | |
| alert("Failed to connect to backend. Make sure main.py is running!"); | |
| setStatus('idle'); | |
| document.getElementById('btn-run').disabled = false; | |
| document.getElementById('btn-pause').disabled = true; | |
| return; | |
| } | |
| running = true; | |
| paused = false; | |
| runLoop(); // Start the smart loop | |
| } | |
| async function runLoop() { | |
| if (simLoopActive) return; | |
| simLoopActive = true; | |
| while (running && !paused) { | |
| try { | |
| // Show thinking state while waiting for LLM | |
| setThinking(true); | |
| // 1. Call the AI Bridge Endpoint (LLM decides action, then steps env) | |
| const res = await fetch(`${API}/demo/step_sim`, { method: 'POST' }); | |
| const data = await res.json(); | |
| const { observation, reward, done, action_taken, reasoning, accepted, rejected, reward_detail } = data; | |
| // Abort if user reset while LLM was thinking | |
| if (!running) break; | |
| // 2. Calculate real cash-delta P&L (not the normalized reward score) | |
| const prevCash = S.cash; | |
| applyObservation(observation, reward_detail); | |
| const cashDelta = S.cash - prevCash; | |
| // 3. Update agent panels with LLM output | |
| updateAgents(reasoning, JSON.stringify(action_taken, null, 2), accepted, rejected); | |
| setThinking(false); | |
| // 4. Track history for charts β P&L = real cash delta, not reward score | |
| S.history.days.push('Day ' + (observation.current_day - 1)); | |
| S.history.pnl.push(cashDelta); | |
| S.history.cash.push(observation.cash_balance); | |
| S.history.stock.push(observation.warehouse_status[0].current_stock); | |
| updateCharts(); | |
| // 5. Log entry β show accepted qty, real cash delta | |
| const orderQty = (accepted != null) ? accepted | |
| : (action_taken && action_taken.orders && action_taken.orders.length > 0 ? action_taken.orders[0].quantity : 0); | |
| appendLog( | |
| observation.current_day - 1, | |
| observation.warehouse_status[0].sales_yesterday + observation.warehouse_status[0].lost_sales_yesterday, | |
| observation.warehouse_status[0].sales_yesterday, | |
| observation.warehouse_status[0].lost_sales_yesterday, | |
| orderQty, | |
| cashDelta, | |
| observation.cash_balance | |
| ); | |
| updateDayStrip(observation.current_day - 1); | |
| // Stop exactly at day 30 | |
| if (done || observation.current_day > MAX_DAYS) { | |
| endSim(); | |
| break; | |
| } | |
| await new Promise(resolve => setTimeout(resolve, getInterval())); | |
| } catch(e) { | |
| console.error(e); | |
| setThinking(false); | |
| pauseSim(); | |
| alert("Connection Error. Check your backend for details."); | |
| break; | |
| } | |
| } | |
| simLoopActive = false; | |
| } | |
| function applyObservation(obs, rewardDetail) { | |
| const wh = obs.warehouse_status[0]; | |
| S.day = obs.current_day; | |
| S.cash = obs.cash_balance; | |
| S.stock = wh.current_stock; | |
| S.salesYday = wh.sales_yesterday; | |
| S.lostYday = wh.lost_sales_yesterday; | |
| S.pipeline = wh.incoming_shipments; | |
| S.totalProfit = S.cash - TASKS[currentTask].cash; | |
| totalLost += S.lostYday; | |
| // Track newsvendor rewards from backend | |
| if (rewardDetail) { | |
| S.stepReward = rewardDetail.step_reward || 0; | |
| S.totalReward = rewardDetail.total_reward || 0; | |
| S.graderScore = rewardDetail.grader_score || 0; | |
| } | |
| updateMetrics(S.stepReward, S.salesYday + S.lostYday, S.pipeline["1"] || 0); | |
| updatePipeline(); | |
| } | |
| async function endSim() { | |
| running = false; | |
| setStatus('idle'); | |
| document.getElementById('btn-run').disabled = false; | |
| document.getElementById('btn-pause').disabled = true; | |
| document.getElementById('pause-label').textContent = 'Pause'; | |
| let finalScore = S.graderScore; | |
| try { | |
| const res = await fetch(`${API}/grader`); | |
| const data = await res.json(); | |
| finalScore = data.score; | |
| } catch(e) { | |
| // fallback to last known grader score from reward_detail | |
| } | |
| const t = TASKS[S.taskId]; | |
| const stars = Math.round(finalScore * 5); | |
| const scoreEl = document.getElementById('final-score'); | |
| scoreEl.textContent = finalScore.toFixed(2); | |
| scoreEl.style.color = finalScore >= 0.1 ? 'var(--green)' : finalScore >= 0.05 ? 'var(--orange)' : 'var(--red)'; | |
| document.getElementById('final-stars').textContent = 'β '.repeat(stars) + 'β'.repeat(5-stars); | |
| const finalAvg = S.totalReward / 30; | |
| document.getElementById('fr-profit').textContent = finalAvg.toFixed(2); | |
| const frCp = document.getElementById('fr-cumprofit'); | |
| frCp.textContent = (S.totalProfit >= 0 ? '+$' : '-$') + Math.abs(Math.round(S.totalProfit)).toLocaleString(); | |
| frCp.className = 'report-card-val ' + (S.totalProfit >= 0 ? 'green' : 'red'); | |
| document.getElementById('fr-lost').textContent = S.stock + ' units'; | |
| document.getElementById('fr-cash').textContent = '$' + Math.round(S.cash).toLocaleString(); | |
| document.getElementById('final-detail').textContent = `Task: ${t.name} Β· Days: 30 Β· Grader: ${finalScore.toFixed(2)} Β· Avg Reward: ${finalAvg.toFixed(2)} Β· Profit: $${Math.round(S.totalProfit).toLocaleString()}`; | |
| document.getElementById('final-banner').className = 'final-banner visible'; | |
| } | |
| // βββ UI UPDATES ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function updateMetrics(score, demand, arriving) { | |
| const t = TASKS[S.taskId]; | |
| const cashPct = Math.min(100, (S.cash / (t.cash * 2)) * 100); | |
| const stockPct = Math.min(100, (S.stock / 100) * 100); | |
| const scorePct = score * 100; | |
| const cashEl = document.getElementById('mc-cash-val'); | |
| cashEl.textContent = '$' + Math.round(S.cash).toLocaleString(); | |
| cashEl.className = 'metric-val ' + (S.cash < 10000 ? 'red' : S.cash > 40000 ? 'green' : 'accent'); | |
| document.getElementById('mc-cash-prog').style.width = cashPct + '%'; | |
| document.getElementById('mc-cash-sub').textContent = S.cash < 8000 ? 'β Low cash β bankruptcy risk' : `Demand: ${demand}/day`; | |
| const stEl = document.getElementById('mc-stock-val'); | |
| stEl.textContent = S.stock; | |
| stEl.className = 'metric-val ' + (S.stock < 10 ? 'red' : S.stock > 40 ? 'green' : 'amber'); | |
| document.getElementById('mc-stock-prog').style.width = stockPct + '%'; | |
| document.getElementById('mc-stock-sub').textContent = arriving > 0 ? `+${arriving} arrived today` : 'No arrivals today'; | |
| document.getElementById('mc-day-val').innerHTML = `${S.day-1} <span style="font-size:16px;color:var(--text3)">/30</span>`; | |
| document.getElementById('mc-day-prog').style.width = ((S.day-1)/30*100) + '%'; | |
| const sEl = document.getElementById('mc-score-val'); | |
| sEl.textContent = score.toFixed(2); | |
| sEl.className = 'metric-val ' + (score >= 0.1 ? 'green' : score >= 0.05 ? 'amber' : 'red'); | |
| const sProg = document.getElementById('mc-score-prog'); | |
| sProg.style.width = scorePct + '%'; | |
| sProg.className = 'progress-fill ' + (score >= 0.1 ? 'green' : score >= 0.05 ? '' : 'red'); | |
| document.getElementById('header-score').textContent = S.graderScore.toFixed(2); | |
| const daysElapsed = Math.max(1, S.day - 1); | |
| const avgReward = S.totalReward / daysElapsed; | |
| const profEl = document.getElementById('mc-profit-val'); | |
| profEl.textContent = avgReward.toFixed(2); | |
| profEl.className = 'metric-val ' + (avgReward >= 0.1 ? 'green' : avgReward >= 0.05 ? 'amber' : 'red'); | |
| document.getElementById('mc-profit-prog').style.width = (avgReward * 100) + '%'; | |
| const cpEl = document.getElementById('mc-cumprofit-val'); | |
| cpEl.textContent = (S.totalProfit >= 0 ? '+$' : '-$') + Math.abs(Math.round(S.totalProfit)).toLocaleString(); | |
| cpEl.className = 'metric-val ' + (S.totalProfit >= 0 ? 'green' : 'red'); | |
| const cpProg = document.getElementById('mc-cumprofit-prog'); | |
| cpProg.style.width = Math.min(100, Math.abs(S.totalProfit) / (t.cash * 0.5) * 100) + '%'; | |
| cpProg.className = 'progress-fill ' + (S.totalProfit >= 0 ? 'green' : 'red'); | |
| document.getElementById('ss-cash').textContent = '$' + Math.round(S.cash).toLocaleString(); | |
| document.getElementById('ss-stock').textContent = S.stock; | |
| document.getElementById('ss-sold').textContent = S.salesYday; | |
| document.getElementById('ss-lost').textContent = S.lostYday; | |
| document.getElementById('ss-profit').textContent = avgReward.toFixed(2); | |
| document.getElementById('ss-opt').textContent = score.toFixed(2); | |
| document.getElementById('yday-sold').textContent = S.salesYday; | |
| document.getElementById('yday-lost').textContent = S.lostYday; | |
| } | |
| function updatePipeline() { | |
| for (let i = 1; i <= 4; i++) { | |
| const qty = S.pipeline[i] || 0; | |
| const el = document.getElementById('pq-' + i); | |
| const cell = document.getElementById('pc-' + i); | |
| el.textContent = qty; | |
| cell.className = 'pipe-cell' + (qty > 0 ? (i === 1 ? ' arriving' : ' has-stock') : ''); | |
| } | |
| let sum5p = 0; | |
| for (const [k,v] of Object.entries(S.pipeline)) { if (parseInt(k) >= 5) sum5p += v; } | |
| document.getElementById('pq-5p').textContent = sum5p; | |
| document.getElementById('pc-5p').className = 'pipe-cell' + (sum5p > 0 ? ' has-stock' : ''); | |
| } | |
| function updateAgents(reasoning, execJson, accepted, rejected) { | |
| const aEl = document.getElementById('analyst-text'); | |
| aEl.textContent = reasoning || 'No reasoning returned.'; | |
| aEl.className = 'agent-text'; | |
| const eEl = document.getElementById('exec-text'); | |
| const acceptedStr = (accepted != null) ? `${accepted} units accepted` : ''; | |
| const rejectedStr = (rejected != null && rejected > 0) ? `, ${rejected} rejected (insufficient funds)` : ''; | |
| eEl.textContent = `Day ${S.day-1} action dispatched.${acceptedStr ? ' ' + acceptedStr + rejectedStr : ''}`; | |
| eEl.className = 'agent-text'; | |
| const jsonWrap = document.getElementById('json-output-wrap'); | |
| const jsonEl = document.getElementById('json-output'); | |
| jsonEl.textContent = execJson; | |
| jsonWrap.style.display = 'block'; | |
| // Update order qty from accepted (authoritative) or parsed JSON | |
| const qty = (accepted != null) ? accepted : (() => { | |
| try { const p = JSON.parse(execJson); return p.orders && p.orders.length > 0 ? p.orders[0].quantity : 0; } catch(e) { return 0; } | |
| })(); | |
| document.getElementById('ss-order').textContent = qty > 0 ? qty : 'β'; | |
| document.getElementById('yday-order').textContent = qty > 0 ? qty : 'β'; | |
| } | |
| function setThinking(on) { | |
| const aEl = document.getElementById('analyst-text'); | |
| if (on) { | |
| aEl.innerHTML = '<span class="thinking-dots">Analyst reasoning</span>'; | |
| aEl.className = 'agent-text thinking'; | |
| const eEl = document.getElementById('exec-text'); | |
| eEl.textContent = 'Waiting for Analyst...'; | |
| eEl.className = 'agent-text thinking'; | |
| } | |
| } | |
| function updateCharts() { | |
| const days = S.history.days; | |
| const pnl = S.history.pnl; | |
| const cash = S.history.cash; | |
| const stock = S.history.stock; | |
| const barColors = pnl.map(v => v >= 0 ? 'rgba(74,170,110,0.75)' : 'rgba(217,95,95,0.75)'); | |
| const barBorder = pnl.map(v => v >= 0 ? '#4aaa6e' : '#d95f5f'); | |
| if (!mainChart) { | |
| mainChart = new Chart(document.getElementById('main-chart'), { | |
| data: { | |
| labels: days, | |
| datasets: [ | |
| { type: 'bar', label: 'Daily P&L', data: pnl, backgroundColor: barColors, borderColor: barBorder, borderWidth: 1, yAxisID: 'y1', order: 2 }, | |
| { type: 'line', label: 'Cash ($)', data: cash, borderColor: '#5b9bd5', backgroundColor: 'rgba(91,155,213,0.06)', fill: true, tension: 0.4, pointRadius: 0, borderWidth: 1.5, yAxisID: 'y2', order: 1 } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: '#1a1e25', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, titleColor: '#e8c84a', bodyColor: '#8a8880', | |
| titleFont: { family: "'DM Mono', monospace", size: 11 }, bodyFont: { family: "'DM Mono', monospace", size: 11 }, | |
| callbacks: { label: ctx => { const v = ctx.raw; return ctx.dataset.label === 'Cash ($)' ? ` Cash: $${v.toLocaleString()}` : ` P&L: $${v.toLocaleString()}`; } } | |
| } | |
| }, | |
| scales: { | |
| x: { ticks: { color: '#4a4845', font: { size: 10, family: "'DM Mono',monospace" }, maxRotation: 0, autoSkip: true }, grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'rgba(255,255,255,0.07)' } }, | |
| y1: { type: 'linear', position: 'left', ticks: { color: '#4a4845', font: { size: 10, family: "'DM Mono',monospace" }, callback: v => '$' + (v/1000).toFixed(0) + 'k' }, grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'rgba(255,255,255,0.07)' } }, | |
| y2: { type: 'linear', position: 'right', ticks: { color: '#5b9bd5', font: { size: 10, family: "'DM Mono',monospace" }, callback: v => '$' + (v/1000).toFixed(0) + 'k' }, grid: { drawOnChartArea: false }, border: { color: 'rgba(255,255,255,0.07)' } } | |
| } | |
| } | |
| }); | |
| } else { | |
| mainChart.data.labels = days; mainChart.data.datasets[0].data = pnl; mainChart.data.datasets[0].backgroundColor = barColors; mainChart.data.datasets[0].borderColor = barBorder; mainChart.data.datasets[1].data = cash; mainChart.update('none'); | |
| } | |
| if (!invChart) { | |
| invChart = new Chart(document.getElementById('inv-chart'), { | |
| type: 'line', | |
| data: { labels: days, datasets: [{ label: 'Stock', data: stock, borderColor: '#4aaa6e', backgroundColor: 'rgba(74,170,110,0.08)', fill: true, tension: 0.3, pointRadius: 0, borderWidth: 2 }] }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1a1e25', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, titleColor: '#e8c84a', bodyColor: '#8a8880', titleFont: { family: "'DM Mono', monospace", size: 11 }, bodyFont: { family: "'DM Mono', monospace", size: 11 } } }, | |
| scales: { | |
| x: { ticks: { color: '#4a4845', font: { size: 10, family: "'DM Mono',monospace" }, maxRotation: 0, autoSkip: true }, grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'rgba(255,255,255,0.07)' } }, | |
| y: { ticks: { color: '#4a4845', font: { size: 10, family: "'DM Mono',monospace" } }, grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'rgba(255,255,255,0.07)' }, min: 0 } | |
| } | |
| } | |
| }); | |
| } else { | |
| invChart.data.labels = days; invChart.data.datasets[0].data = stock; invChart.update('none'); | |
| } | |
| } | |
| function appendLog(day, demand, sold, lost, ordered, pnl, cash) { | |
| logCount++; | |
| document.getElementById('log-count').textContent = logCount + ' entries'; | |
| const scroll = document.getElementById('log-scroll'); | |
| const row = document.createElement('div'); | |
| row.className = 'log-row'; | |
| const pnlCls = pnl >= 0 ? 'log-pnl-pos' : 'log-pnl-neg'; | |
| row.innerHTML = ` | |
| <span class="log-day">Day ${day}</span> | |
| <span class="log-demand">${demand}/day</span> | |
| <span class="log-sold">+${sold}</span> | |
| <span class="log-lost">${lost > 0 ? '-'+lost : '0'}</span> | |
| <span class="log-order">${ordered > 0 ? ordered+'u' : 'β'}</span> | |
| <span class="${pnlCls}">${pnl >= 0 ? '+' : ''}$${pnl.toLocaleString()}</span> | |
| <span class="log-cash">$${Math.round(cash).toLocaleString()}</span> | |
| `; | |
| scroll.insertBefore(row, scroll.firstChild); | |
| } | |
| function updateDayStrip(day) { | |
| const pips = document.querySelectorAll('.day-pip'); | |
| pips.forEach((pip, i) => { | |
| const d = i + 1; | |
| if (d < day) { | |
| const isSpike = S.taskId === 'task_02_medium' && d >= 10 && d <= 17; | |
| const isLow = S.history.cash[d-1] < 10000; | |
| pip.className = 'day-pip done' + (isSpike ? ' spike' : '') + (isLow ? ' low' : ''); | |
| } else if (d === day) { pip.className = 'day-pip active'; } | |
| else { pip.className = 'day-pip'; } | |
| }); | |
| } | |
| function getInterval() { const v = parseInt(document.getElementById('speed-range').value); return [700, 400, 220, 100, 40][v-1]; } | |
| // βββ CONTROLS ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function pauseSim() { | |
| if (!running) return; | |
| paused = !paused; | |
| document.getElementById('pause-label').textContent = paused ? 'Resume' : 'Pause'; | |
| setStatus(paused ? 'idle' : 'running'); | |
| if (!paused) runLoop(); | |
| } | |
| function onSpeedChange(v) { | |
| document.getElementById('speed-lbl').textContent = v + 'Γ'; | |
| // The while loop automatically picks up the new speed on the next day! | |
| } | |
| function selectTask(taskId, el) { | |
| if (running) return; | |
| currentTask = taskId; | |
| document.querySelectorAll('.task-card').forEach(c => c.classList.remove('active','active-green','active-red')); | |
| const colorMap = { task_01_easy:'active-green', task_02_medium:'active', task_03_hard:'active-red' }; | |
| el.classList.add(colorMap[taskId]); | |
| const t = TASKS[taskId]; | |
| const sig = document.getElementById('signal-banner'); | |
| sig.className = 'signal-banner ' + t.signalClass; | |
| document.getElementById('signal-text').textContent = t.signal; | |
| document.getElementById('reorder-trigger').textContent = t.triggerLabel; | |
| document.getElementById('ss-shipping').textContent = taskId === 'task_03_hard' ? '4 days (crisis)' : '2 days'; | |
| resetSim(); | |
| } | |
| function setStatus(s) { const el = document.getElementById('status-dot'); el.className = 'status-dot ' + (s === 'idle' ? 'idle' : s === 'running' ? 'running' : ''); el.textContent = s; } | |
| function buildDayStrip() { const strip = document.getElementById('day-strip'); strip.innerHTML = ''; for (let i = 1; i <= MAX_DAYS; i++) { const pip = document.createElement('div'); pip.className = 'day-pip'; pip.title = 'Day ' + i; strip.appendChild(pip); } } | |
| function clearLog() { logCount = 0; document.getElementById('log-count').textContent = 'β entries'; document.getElementById('log-scroll').innerHTML = '<div style="padding:16px;font-size:12px;color:var(--text3);font-family:var(--font-mono);">Select a task and click Run simulation to begin.</div>'; } | |
| function resetSim() { | |
| running = false; | |
| paused = false; | |
| simLoopActive = false; | |
| setStatus('idle'); | |
| if (mainChart) { mainChart.destroy(); mainChart = null; } | |
| if (invChart) { invChart.destroy(); invChart = null; } | |
| initState(currentTask); | |
| document.getElementById('btn-run').disabled = false; | |
| document.getElementById('btn-pause').disabled = true; | |
| document.getElementById('pause-label').textContent = 'Pause'; | |
| document.getElementById('final-banner').className = 'final-banner'; | |
| document.getElementById('json-output-wrap').style.display = 'none'; | |
| document.getElementById('analyst-text').textContent = 'Waiting for simulation to start...'; document.getElementById('analyst-text').className = 'agent-text thinking'; | |
| document.getElementById('exec-text').textContent = 'Will format Analyst decision into SupplyChainAction schema.'; document.getElementById('exec-text').className = 'agent-text thinking'; | |
| const t = TASKS[currentTask]; | |
| document.getElementById('mc-cash-val').textContent = '$' + t.cash.toLocaleString(); document.getElementById('mc-cash-val').className = 'metric-val accent'; | |
| document.getElementById('mc-stock-val').textContent = t.stock; document.getElementById('mc-stock-val').className = 'metric-val green'; | |
| document.getElementById('mc-day-val').innerHTML = '0 <span style="font-size:16px;color:var(--text3)">/30</span>'; document.getElementById('mc-day-prog').style.width = '0%'; | |
| document.getElementById('mc-score-val').textContent = 'β'; document.getElementById('mc-score-prog').style.width = '0%'; | |
| document.getElementById('mc-profit-val').textContent = '0.00'; document.getElementById('mc-profit-val').className = 'metric-val'; document.getElementById('mc-profit-prog').style.width = '0%'; | |
| document.getElementById('mc-cumprofit-val').textContent = '$0'; document.getElementById('mc-cumprofit-val').className = 'metric-val'; document.getElementById('mc-cumprofit-prog').style.width = '0%'; | |
| document.getElementById('header-score').textContent = 'β'; document.getElementById('ss-cash').textContent = '$' + t.cash.toLocaleString(); | |
| document.getElementById('ss-stock').textContent = t.stock; document.getElementById('ss-sold').textContent = 'β'; document.getElementById('ss-lost').textContent = 'β'; | |
| document.getElementById('ss-order').textContent = 'β'; document.getElementById('ss-profit').textContent = '0.00'; document.getElementById('ss-opt').textContent = '0.00'; | |
| document.getElementById('yday-sold').textContent = 'β'; document.getElementById('yday-lost').textContent = 'β'; document.getElementById('yday-order').textContent = 'β'; | |
| for (let i=1;i<=4;i++) { document.getElementById('pq-'+i).textContent='0'; document.getElementById('pc-'+i).className='pipe-cell'; } | |
| document.getElementById('pq-5p').textContent='0'; document.getElementById('pc-5p').className='pipe-cell'; | |
| buildDayStrip(); | |
| clearLog(); | |
| } | |
| buildDayStrip(); | |
| initState('task_01_easy'); | |
| </script> | |
| </body> | |
| </html> |