soci2 / web /index.html
RayMelius's picture
Add StockEx trading integration for player agents
f25ca9f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soci City — Population Simulator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
overflow: hidden;
height: 100vh;
}
#header {
background: #16213e;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid #0f3460;
height: 50px;
}
#header h1 { font-size: 18px; color: #e94560; letter-spacing: 2px; }
#header .info { display: flex; gap: 20px; font-size: 13px; color: #a0a0c0; align-items: center; }
#header .info span { display: flex; align-items: center; gap: 6px; }
#weekend-badge {
display: none; align-items: center; gap: 5px;
background: linear-gradient(135deg, #f0c040, #e67e22);
color: #1a1a2e; font-weight: 800; font-size: 11px;
padding: 3px 10px; border-radius: 12px;
letter-spacing: 1px; text-transform: uppercase;
box-shadow: 0 0 10px rgba(240,192,64,0.5);
animation: weekend-pulse 2s ease-in-out infinite;
}
@keyframes weekend-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(240,192,64,0.4); }
50% { box-shadow: 0 0 18px rgba(240,192,64,0.8); }
}
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.green { background: #4ecca3; } .dot.yellow { background: #f0c040; } .dot.red { background: #e94560; }
#main { display: flex; height: calc(100vh - 50px); }
#canvas-container { flex: 1; position: relative; min-width: 0; }
#cityCanvas { width: 100%; height: 100%; display: block; }
/* SIDEBAR */
#sidebar {
width: 260px; background: #16213e; border-left: 2px solid #0f3460;
display: flex; flex-direction: column; overflow: hidden;
}
.sidebar-tabs {
display: flex; border-bottom: 2px solid #0f3460; flex-shrink: 0;
}
.sidebar-tab {
flex: 1; padding: 8px 4px; text-align: center; font-size: 11px; font-weight: bold;
color: #666; cursor: pointer; border-bottom: 2px solid transparent;
transition: all 0.2s; text-transform: uppercase; letter-spacing: 0.5px;
}
.sidebar-tab:hover { color: #a0a0c0; background: rgba(255,255,255,0.02); }
.sidebar-tab.active { color: #4ecca3; border-bottom-color: #4ecca3; }
.tab-content { display: none; flex: 1; overflow-y: auto; }
.tab-content.active { display: flex; flex-direction: column; }
#agent-detail { padding: 12px; flex: 1; overflow-y: auto; }
#agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; }
#agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 6px; }
.bar-container { margin: 3px 0; }
.bar-label { font-size: 11px; color: #a0a0c0; display: flex; justify-content: space-between; }
.bar-bg { height: 6px; background: #0f3460; border-radius: 3px; overflow: hidden; margin-top: 1px; }
.bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
.bar-fill.green { background: #4ecca3; } .bar-fill.yellow { background: #f0c040; }
.bar-fill.red { background: #e94560; } .bar-fill.blue { background: #4e9eca; }
.bar-fill.purple { background: #9b59b6; } .bar-fill.orange { background: #e67e22; }
.bar-fill.pink { background: #e91e90; }
.memory-item { font-size: 11px; color: #c0c0d0; padding: 3px 0; border-bottom: 1px solid #0f346030; }
.memory-time { color: #666; font-size: 10px; }
.agent-list-item {
font-size: 11px; padding: 5px 6px; cursor: pointer; display: flex; align-items: center; gap: 6px;
border-bottom: 1px solid #0f346020; transition: background 0.15s;
}
.agent-list-item:hover { background: rgba(78,204,163,0.08); }
.agent-list-item .agent-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.agent-list-item .agent-info { flex: 1; min-width: 0; }
.agent-list-item .agent-name { font-weight: 600; }
.agent-list-item .agent-action { color: #666; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#conversations-panel { padding: 0; flex: 1; overflow-y: auto; }
.conv-card {
margin: 8px; padding: 10px; background: #1a1a2e; border-radius: 6px;
border: 1px solid #0f3460;
}
.conv-card.active-conv { border-color: #4ecca3; }
.conv-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.conv-topic { font-size: 12px; color: #4ecca3; font-weight: 600; }
.conv-badge { font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: bold; text-transform: uppercase; }
.conv-badge.live { background: #4ecca3; color: #1a1a2e; }
.conv-badge.ended { background: #333; color: #888; }
.conv-participants { font-size: 10px; color: #888; margin-bottom: 6px; }
.conv-turn { padding: 4px 0; font-size: 11px; line-height: 1.4; }
.conv-speaker { font-weight: 600; }
.conv-message { color: #d0d0e0; }
.conv-empty { padding: 20px; text-align: center; color: #555; font-size: 12px; }
#event-log-panel { padding: 8px 12px; flex: 1; overflow-y: auto; font-size: 11px; line-height: 1.5; }
.event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; }
.event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; }
.event-line.event { color: #e94560; } .event-line.time { color: #666; font-weight: bold; margin-top: 6px; }
.event-line.romance { color: #e91e90; }
.event-line.move { color: #4e9eca; }
.event-line.reflect { color: #9b59b6; }
#tooltip {
position: absolute; background: #16213eee; border: 1px solid #4ecca3;
border-radius: 6px; padding: 8px 12px; font-size: 12px;
pointer-events: none; display: none; z-index: 100; max-width: 280px;
}
#toast-container {
position: absolute; bottom: 12px; left: 12px; z-index: 200;
display: flex; flex-direction: column-reverse; gap: 6px;
pointer-events: none; max-width: 360px;
}
.toast {
background: #16213eee; border-radius: 6px; padding: 8px 14px;
font-size: 12px; color: #e0e0e0; border-left: 3px solid #4ecca3;
animation: toastIn 0.3s ease, toastOut 0.5s ease 4.5s forwards;
backdrop-filter: blur(4px); line-height: 1.4;
}
.toast.romance { border-left-color: #e91e90; }
.toast.event { border-left-color: #e94560; }
.toast.conv { border-left-color: #f0c040; }
.toast.gossip { border-left-color: #9b59b6; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
#llm-model { cursor: pointer; user-select: none; }
#llm-model:hover { color: #fff; }
#llm-popup {
display: none; position: fixed; z-index: 9999;
background: #0d1b2e; border: 1px solid #1a3a6e;
border-radius: 8px; padding: 6px 0; min-width: 200px;
box-shadow: 0 6px 24px rgba(0,0,0,0.6);
font-size: 13px;
}
#llm-popup .llm-pop-title {
padding: 6px 14px 8px; color: #4ecca3; font-size: 11px;
font-weight: 700; letter-spacing: 1px; border-bottom: 1px solid #0f3460;
margin-bottom: 4px;
}
#llm-popup .llm-opt {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 7px 14px; cursor: pointer; color: #c8c8d8;
transition: background 0.15s;
}
#llm-popup .llm-opt:hover { background: #0f3460; color: #fff; }
#llm-popup .llm-opt.active { color: #4ecca3; }
#llm-popup .llm-opt .llm-check { width: 12px; text-align: center; font-size: 10px; }
.section-header {
font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
display: flex; align-items: center; gap: 6px;
}
.section-header::after { content: ''; flex: 1; height: 1px; background: #0f3460; }
.rel-item { font-size: 11px; padding: 4px 0; border-bottom: 1px solid #0f346020; cursor: pointer; }
.rel-item:hover { background: rgba(78,204,163,0.05); }
.rel-name { font-weight: 600; color: #d0d0e0; }
.rel-bars { display: flex; gap: 4px; margin-top: 2px; }
.rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; }
.rel-mini-fill { height: 100%; border-radius: 2px; }
.controls { display: flex; align-items: center; gap: 4px; }
.ctrl-btn {
background: #0f3460; border: 1px solid #1a3a6e; color: #a0a0c0;
padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px;
transition: all 0.15s;
}
.ctrl-btn:hover { background: #1a4a80; color: #fff; }
.ctrl-btn.active { background: #4ecca3; color: #1a1a2e; border-color: #4ecca3; }
.ctrl-btn.paused { background: #e94560; color: #fff; border-color: #e94560; }
.speed-label { font-size: 10px; color: #666; margin-left: 2px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #1a1a2e; }
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
/* ── Player / Auth UI ────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-box {
background: #0f1b35; border: 1px solid #1a3a6e; border-radius: 8px;
padding: 24px 28px; min-width: 300px; max-width: 420px; width: 90%;
color: #e0e0e0;
}
.modal-box h2 { color: #4ecca3; margin: 0 0 16px; font-size: 18px; }
.modal-box label { display: block; font-size: 11px; color: #888; margin: 10px 0 3px; }
.modal-box input, .modal-box textarea, .modal-box select {
width: 100%; box-sizing: border-box; background: #0a1628; border: 1px solid #1a3a6e;
color: #e0e0e0; padding: 7px 10px; border-radius: 4px; font-size: 13px;
}
.modal-box textarea { resize: vertical; min-height: 60px; font-family: inherit; }
.modal-box input[type=range] { padding: 4px 0; border: none; background: none; }
.modal-row { display: flex; gap: 10px; }
.modal-row > * { flex: 1; }
.modal-actions { display: flex; gap: 10px; margin-top: 18px; justify-content: flex-end; }
.btn-primary {
background: #4ecca3; color: #0a1628; border: none; padding: 8px 18px;
border-radius: 4px; cursor: pointer; font-weight: 700; font-size: 13px;
}
.btn-primary:hover { background: #3ab88e; }
.btn-secondary {
background: transparent; color: #a0a0c0; border: 1px solid #1a3a6e;
padding: 8px 18px; border-radius: 4px; cursor: pointer; font-size: 13px;
}
.btn-secondary:hover { color: #e0e0e0; border-color: #4ecca3; }
.btn-danger {
background: transparent; color: #e94560; border: 1px solid #e94560;
padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;
}
.auth-switch { text-align: center; margin-top: 12px; font-size: 12px; color: #666; }
.auth-switch a { color: #4ecca3; cursor: pointer; text-decoration: underline; }
.auth-error { color: #e94560; font-size: 12px; margin-top: 8px; }
#player-panel {
background: rgba(78,204,163,0.06); border: 1px solid rgba(78,204,163,0.2);
border-radius: 6px; margin: 8px; padding: 10px 12px; display: none;
}
#player-panel .pp-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;
}
#player-panel .pp-title { color: #4ecca3; font-size: 11px; font-weight: 700; letter-spacing: 1px; }
#player-panel .pp-name { color: #e0e0e0; font-size: 13px; font-weight: 600; }
#player-panel .pp-loc { color: #888; font-size: 10px; }
.pp-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.pp-btn {
background: #0f3460; color: #a0a0c0; border: 1px solid #1a3a6e;
padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px;
}
.pp-btn:hover { background: #1a4a80; color: #e0e0e0; }
.pp-move { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
.pp-move select { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 4px 6px; border-radius: 3px; font-size: 11px; }
.pp-move button { background: #4ecca3; color: #0a1628; border: none; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px; font-weight: 700; }
/* Chat panel */
#chat-panel {
border-top: 1px solid #1a3a6e; margin-top: 10px; padding-top: 10px; display: none;
}
#chat-panel .chat-header { color: #4ecca3; font-size: 11px; font-weight: 700; margin-bottom: 6px; display: flex; justify-content: space-between; }
#chat-messages { max-height: 180px; overflow-y: auto; font-size: 12px; margin-bottom: 8px; }
.chat-msg-you { color: #4ecca3; margin: 3px 0; }
.chat-msg-npc { color: #e0e0e0; margin: 3px 0; }
.chat-msg-thinking { color: #666; font-style: italic; margin: 3px 0; }
#chat-input-row { display: flex; gap: 6px; }
#chat-input { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 6px 8px; border-radius: 3px; font-size: 12px; font-family: inherit; }
#chat-send { background: #4ecca3; color: #0a1628; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-weight: 700; font-size: 12px; }
.slider-val { font-size: 12px; color: #4ecca3; min-width: 20px; text-align: right; }
</style>
</head>
<body>
<div id="header">
<h1>SOCI CITY</h1>
<div class="info">
<span id="clock">Day 1, 06:00</span>
<span id="weekend-badge">🏖️ Weekend</span>
<span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
<span id="agent-count"><span class="dot green"></span> 0 agents</span>
<span id="conv-count">0 convos</span>
<span id="llm-model" title="Active LLM model">⚡ —</span>
<span class="controls">
<button class="ctrl-btn" id="btn-pause" onclick="togglePause()" title="Pause/Resume">&#x23EF;</button>
<button class="ctrl-btn" id="btn-slow" onclick="setSpeed(3.0)" title="Slow">&#x1F422;</button>
<button class="ctrl-btn active" id="btn-1x" onclick="setSpeed(1.0)" title="Normal">1x</button>
<button class="ctrl-btn" id="btn-2x" onclick="setSpeed(0.5)" title="2x speed">2x</button>
<button class="ctrl-btn" id="btn-5x" onclick="setSpeed(0.2)" title="5x speed">5x</button>
<button class="ctrl-btn" id="btn-10x" onclick="setSpeed(0.1)" title="10x speed">10x</button>
<button class="ctrl-btn" id="btn-50x" onclick="setSpeed(0.02)" title="50x speed">50x</button>
<span class="speed-label" id="speed-label">1x</span>
<span style="color:#1a3a6e;margin:0 4px"></span>
<span style="font-size:10px;color:#666;white-space:nowrap" title="LLM call probability: controls how often agents use AI reasoning vs. routine behaviour. At 45% with Gemini free tier ≈ 10h daily runtime.">🧠</span>
<input type="range" id="llm-prob-slider" min="0" max="100" value="10" step="5"
style="width:64px;height:6px;accent-color:#4ecca3;cursor:pointer;vertical-align:middle;"
oninput="onLlmProbSlider(this.value)" title="LLM usage probability">
<span id="llm-prob-label" style="font-size:10px;color:#4ecca3;min-width:28px;text-align:right;">10%</span>
<span style="color:#1a3a6e;margin:0 4px"></span>
<button class="ctrl-btn" id="btn-rect-zoom" onclick="toggleRectZoom()" title="Draw a rectangle to zoom into that area (Shift+drag)"></button>
<button class="ctrl-btn" onclick="zoomBy(1.3)" title="Zoom In (scroll up)"></button>
<button class="ctrl-btn" onclick="zoomBy(1/1.3)" title="Zoom Out (scroll down)"></button>
<button class="ctrl-btn" onclick="zoomFit()" title="Fit entire city on screen">Fit</button>
</span>
<span id="api-calls">API: 0</span>
<span id="cost">$0.00</span>
<span id="status"><span class="dot yellow"></span> Connecting...</span>
</div>
</div>
<div id="main">
<div id="canvas-container">
<canvas id="cityCanvas"></canvas>
<div id="tooltip"></div>
<div id="toast-container"></div>
<div id="llm-popup"><div class="llm-pop-title">SWITCH LLM</div></div>
<input type="range" id="pan-x" min="0" max="100" value="0"
style="position:absolute;bottom:4px;left:10px;right:10px;width:calc(100% - 20px);height:14px;opacity:0.5;z-index:50;"
oninput="onPanSlider()">
<input type="range" id="pan-y" min="0" max="100" value="0"
orient="vertical"
style="position:absolute;right:4px;top:10px;bottom:24px;width:14px;height:calc(100% - 34px);opacity:0.5;z-index:50;writing-mode:vertical-lr;direction:ltr;-webkit-appearance:slider-vertical;"
oninput="onPanSlider()">
</div>
<div id="sidebar">
<div class="sidebar-tabs">
<div class="sidebar-tab active" data-tab="agents" onclick="switchTab('agents')">Agents</div>
<div class="sidebar-tab" data-tab="conversations" onclick="switchTab('conversations')">Chat</div>
<div class="sidebar-tab" data-tab="events" onclick="switchTab('events')">Events</div>
</div>
<div id="tab-agents" class="tab-content active">
<!-- Player panel (shown when logged in) -->
<div id="player-panel">
<div class="pp-header">
<span class="pp-title">YOU</span>
<button class="btn-danger" onclick="authLogout()" style="padding:2px 8px;font-size:10px">Logout</button>
</div>
<div id="pp-name" class="pp-name">Loading...</div>
<div id="pp-loc" class="pp-loc"></div>
<div class="pp-move">
<select id="pp-move-select"></select>
<button onclick="playerMove()">Go</button>
</div>
<div class="pp-actions">
<button class="pp-btn" onclick="openProfileEditor()">Edit Profile</button>
<button class="pp-btn" onclick="openPlansModal()">My Plans</button>
<button class="pp-btn" onclick="openStockExModal()" style="background:#1a3a2e;border-color:#2a6a4e">StockEx</button>
</div>
</div>
<div id="agent-detail"></div>
</div>
<div id="tab-conversations" class="tab-content">
<div id="conversations-panel"></div>
</div>
<div id="tab-events" class="tab-content">
<div id="event-log-panel"></div>
</div>
</div>
</div>
<!-- Login / Register modal -->
<div id="login-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h2 id="auth-title">Welcome to Soci City</h2>
<div id="auth-error" class="auth-error" style="display:none"></div>
<label>Username</label>
<input type="text" id="auth-username" placeholder="Your name" autocomplete="username">
<label>Password</label>
<input type="password" id="auth-password" placeholder="Password" autocomplete="current-password">
<div class="modal-actions">
<button class="btn-secondary" onclick="closeLoginModal()">Skip</button>
<button class="btn-primary" id="auth-btn" onclick="authSubmit()">Login</button>
</div>
<div class="auth-switch" id="auth-switch-text">
No account? <a onclick="toggleAuthMode()">Register</a>
</div>
</div>
</div>
<!-- Agent Profile Editor modal -->
<div id="profile-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h2>Your Agent Profile</h2>
<div class="modal-row">
<div><label>Name</label><input type="text" id="pe-name" placeholder="Your name"></div>
<div><label>Age</label><input type="number" id="pe-age" min="16" max="90" value="30"></div>
</div>
<div class="modal-row">
<div style="flex:1">
<label>Gender</label>
<select id="pe-gender" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px">
<option value="unknown">Prefer not to say</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="nonbinary">Non-binary</option>
</select>
</div>
<div style="flex:1">
<label>Occupation</label>
<input type="text" id="pe-occupation" placeholder="e.g. Artist, Engineer, Chef" style="width:100%">
</div>
</div>
<label>Background (how you describe yourself)</label>
<textarea id="pe-background" rows="3" placeholder="A few sentences about yourself..."></textarea>
<label>Extraversion: <span id="pe-extra-val" class="slider-val">5</span></label>
<input type="range" id="pe-extraversion" min="1" max="10" value="5" oninput="document.getElementById('pe-extra-val').textContent=this.value">
<label>Agreeableness: <span id="pe-agree-val" class="slider-val">7</span></label>
<input type="range" id="pe-agreeableness" min="1" max="10" value="7" oninput="document.getElementById('pe-agree-val').textContent=this.value">
<label>Openness: <span id="pe-open-val" class="slider-val">6</span></label>
<input type="range" id="pe-openness" min="1" max="10" value="6" oninput="document.getElementById('pe-open-val').textContent=this.value">
<div class="modal-actions">
<button class="btn-secondary" onclick="document.getElementById('profile-modal').style.display='none'">Cancel</button>
<button class="btn-primary" onclick="saveProfile()">Save</button>
</div>
</div>
</div>
<!-- My Plans modal -->
<div id="plans-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h2>My Plans</h2>
<div id="plans-list" style="font-size:12px;color:#a0a0c0;margin-bottom:12px;min-height:40px"></div>
<label>Add a plan</label>
<input type="text" id="plans-input" placeholder="e.g. Go to the cafe and meet someone" onkeydown="if(event.key==='Enter')addPlan()">
<div class="modal-actions">
<button class="btn-secondary" onclick="document.getElementById('plans-modal').style.display='none'">Close</button>
<button class="btn-primary" onclick="addPlan()">Add</button>
</div>
</div>
</div>
<!-- StockEx Trading modal -->
<div id="stockex-modal" class="modal-overlay" style="display:none">
<div class="modal-box" style="max-width:480px">
<h2 style="color:#4ecca3">StockEx Trading</h2>
<div id="stockex-status" style="font-size:11px;color:#888;margin-bottom:8px"></div>
<div id="stockex-portfolio" style="font-size:11px;color:#a0a0c0;margin-bottom:10px;max-height:120px;overflow-y:auto"></div>
<div class="modal-row">
<div style="flex:1">
<label>Member ID</label>
<input type="text" id="sx-member" placeholder="USR02" value="USR02" style="text-transform:uppercase">
</div>
<div style="flex:1">
<label>Symbol</label>
<select id="sx-symbol" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px">
<option>Loading...</option>
</select>
</div>
</div>
<div class="modal-row">
<div style="flex:1">
<label>Side</label>
<select id="sx-side" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px">
<option value="BUY">BUY</option>
<option value="SELL">SELL</option>
</select>
</div>
<div style="flex:1">
<label>Quantity</label>
<input type="number" id="sx-qty" min="1" value="100">
</div>
<div style="flex:1">
<label>Price</label>
<input type="number" id="sx-price" step="0.01" min="0.01" value="0">
</div>
</div>
<div id="sx-market-info" style="font-size:10px;color:#555;margin:4px 0"></div>
<div id="sx-result" style="font-size:11px;margin:6px 0;min-height:18px"></div>
<div class="modal-actions">
<button class="btn-secondary" onclick="document.getElementById('stockex-modal').style.display='none'">Close</button>
<button class="btn-secondary" onclick="loadStockExPortfolio()">Refresh</button>
<button class="btn-primary" onclick="submitStockExOrder()" style="background:#1a6a3e">Place Order</button>
</div>
</div>
</div>
<script>
// ============================================================
// CONFIG
// ============================================================
const API_BASE = window.location.origin + '/api';
const POLL_INTERVAL = 2000;
const HORIZON = 0.14;
// --- CITY LAYOUT ---
// World dimensions — 1:1 with canvas (zoom handled separately)
const WORLD_W = 1.0;
const WORLD_H = 1.0;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 10.0;
// Road network — full grid connecting every building
const ROADS = [
// === Horizontal roads (full width) ===
{ x1: 0.03, y1: 0.22, x2: 0.97, y2: 0.22, width: 10, name: 'North Access' },
{ x1: 0.03, y1: 0.28, x2: 0.97, y2: 0.28, width: 14, name: 'North Road' },
{ x1: 0.03, y1: 0.43, x2: 0.97, y2: 0.43, width: 10, name: 'Mid Road' },
{ x1: 0.03, y1: 0.58, x2: 0.97, y2: 0.58, width: 14, name: 'South Road' },
{ x1: 0.03, y1: 0.72, x2: 0.97, y2: 0.72, width: 10, name: 'South Connector' },
{ x1: 0.03, y1: 0.80, x2: 0.97, y2: 0.80, width: 10, name: 'South Access' },
// === Vertical roads (full height) ===
{ x1: 0.08, y1: 0.20, x2: 0.08, y2: 0.82, width: 7 },
{ x1: 0.15, y1: 0.20, x2: 0.15, y2: 0.82, width: 10 },
{ x1: 0.30, y1: 0.20, x2: 0.30, y2: 0.82, width: 8 },
{ x1: 0.50, y1: 0.13, x2: 0.50, y2: 0.90, width: 16, name: 'Main Street' },
{ x1: 0.70, y1: 0.20, x2: 0.70, y2: 0.82, width: 8 },
{ x1: 0.85, y1: 0.20, x2: 0.85, y2: 0.82, width: 10 },
{ x1: 0.92, y1: 0.20, x2: 0.92, y2: 0.82, width: 7 },
];
// Slot grid for procedurally generated houses — all slots in VALID city ground (y≥0.20).
// Horizon is at y=0.14; sky/mountain occupy y<0.20. Slots cover the full map width.
const GEN_HOUSE_SLOTS = [
// North strip: gaps between named north houses (y=0.19) and commercial row (y=0.34)
{x:0.04,y:0.21},{x:0.16,y:0.21},{x:0.30,y:0.21},{x:0.54,y:0.21},{x:0.70,y:0.21},{x:0.86,y:0.21},{x:0.94,y:0.21},
{x:0.04,y:0.27},{x:0.16,y:0.27},{x:0.30,y:0.27},{x:0.44,y:0.27},{x:0.56,y:0.27},{x:0.70,y:0.27},{x:0.84,y:0.27},{x:0.94,y:0.27},
// West column: left edge clear of school (x=0.08, y=0.50)
{x:0.03,y:0.36},{x:0.03,y:0.42},{x:0.03,y:0.56},{x:0.03,y:0.62},
// East column: right edge clear of hospital (x=0.92, y=0.50)
{x:0.96,y:0.36},{x:0.96,y:0.42},{x:0.96,y:0.56},{x:0.96,y:0.62},
// Interior mid-north gaps (between commercial strip rows, y=0.40-0.46)
{x:0.14,y:0.40},{x:0.44,y:0.40},{x:0.72,y:0.40},{x:0.84,y:0.40},
// Interior mid-south gaps (y=0.54-0.62)
{x:0.14,y:0.56},{x:0.30,y:0.56},{x:0.44,y:0.56},{x:0.70,y:0.56},{x:0.82,y:0.56},
// South strip: gaps between south named houses (y=0.65) and south zone buildings (y=0.78)
{x:0.04,y:0.66},{x:0.30,y:0.66},{x:0.54,y:0.66},{x:0.70,y:0.66},{x:0.86,y:0.66},{x:0.94,y:0.66},
{x:0.04,y:0.72},{x:0.16,y:0.72},{x:0.30,y:0.72},{x:0.46,y:0.72},{x:0.56,y:0.72},{x:0.70,y:0.72},{x:0.84,y:0.72},{x:0.94,y:0.72},
// Bottom fringe (y=0.82)
{x:0.14,y:0.82},{x:0.30,y:0.82},{x:0.44,y:0.82},{x:0.56,y:0.82},{x:0.70,y:0.82},{x:0.86,y:0.82},{x:0.96,y:0.82},
];
// Building positions — spread across larger grid
const LOCATION_POSITIONS = {
// === RESIDENTIAL — Row 1: North houses ===
house_elena: { x: 0.08, y: 0.19, type: 'house', label: 'Elena & Lila' },
house_marcus: { x: 0.24, y: 0.19, type: 'house', label: 'Marcus & Zoe' },
house_helen: { x: 0.62, y: 0.19, type: 'house', label: 'Helen & Alice' },
house_diana: { x: 0.78, y: 0.19, type: 'house', label: 'Diana & Marco' },
// Row 2: Middle houses
house_kai: { x: 0.08, y: 0.36, type: 'house', label: "Kai's Studio" },
house_priya: { x: 0.92, y: 0.36, type: 'house', label: 'Priya & Nina' },
// Row 3: South houses
house_james: { x: 0.08, y: 0.65, type: 'house', label: 'James & Theo' },
house_rosa: { x: 0.24, y: 0.65, type: 'house', label: 'Rosa & Omar' },
house_yuki: { x: 0.62, y: 0.65, type: 'house', label: 'Yuki & Devon' },
house_frank: { x: 0.78, y: 0.65, type: 'house', label: 'Frank+George+Sam' },
// Apartment blocks
apartment_block_1: { x: 0.40, y: 0.19, type: 'apartment', label: 'Northside Apts' },
apartment_block_2: { x: 0.40, y: 0.65, type: 'apartment', label: 'Southside Apts' },
apartment_block_3: { x: 0.56, y: 0.50, type: 'apartment', label: 'Central Apts' },
// === COMMERCIAL ===
cafe: { x: 0.35, y: 0.34, type: 'shop', label: 'The Daily Grind' },
grocery: { x: 0.65, y: 0.34, type: 'shop', label: 'Green Basket' },
bakery: { x: 0.22, y: 0.34, type: 'shop', label: 'Golden Crust' },
restaurant: { x: 0.35, y: 0.50, type: 'shop', label: "Mama Rosa's" },
bar: { x: 0.65, y: 0.50, type: 'shop', label: 'Rusty Anchor' },
cinema: { x: 0.78, y: 0.50, type: 'cinema', label: 'Starlight Cinema' },
// === WORK ===
office: { x: 0.50, y: 0.34, type: 'office', label: 'The Hive' },
office_tower: { x: 0.80, y: 0.34, type: 'tower', label: 'Pinnacle Tower' },
factory: { x: 0.92, y: 0.65, type: 'factory', label: 'Ironworks' },
school: { x: 0.08, y: 0.50, type: 'school', label: 'Soci School' },
hospital: { x: 0.92, y: 0.50, type: 'hospital', label: 'City Hospital' },
// === PUBLIC ===
park: { x: 0.50, y: 0.19, type: 'park', label: 'Willow Park' },
gym: { x: 0.22, y: 0.50, type: 'public', label: 'Iron & Grit' },
library: { x: 0.78, y: 0.78, type: 'public', label: 'Public Library' },
church: { x: 0.08, y: 0.78, type: 'church', label: "St. Mary's" },
town_square: { x: 0.50, y: 0.50, type: 'square', label: 'Town Square' },
sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' },
// === NEW BUILDINGS (corner apartments + east-side commercial) ===
apt_northeast: { x: 0.93, y: 0.22, type: 'apartment', label: 'Eastview Terrace' },
apt_northwest: { x: 0.07, y: 0.22, type: 'apartment', label: 'Hilltop Gardens' },
apt_southeast: { x: 0.93, y: 0.78, type: 'apartment', label: 'Riverside Commons' },
apt_southwest: { x: 0.07, y: 0.78, type: 'apartment', label: 'Orchard Hill Flats' },
diner: { x: 0.92, y: 0.36, type: 'shop', label: 'Blue Moon Diner' },
pharmacy: { x: 0.35, y: 0.78, type: 'shop', label: 'SociMed Pharmacy' },
// === STREETS ===
street_north: { x: 0.50, y: 0.28, type: 'street', label: 'N. Main St' },
street_south: { x: 0.50, y: 0.58, type: 'street', label: 'S. Main St' },
street_east: { x: 0.85, y: 0.43, type: 'street', label: 'East Ave' },
street_west: { x: 0.15, y: 0.43, type: 'street', label: 'West Ave' },
};
const AGENT_COLORS = [
'#e94560','#4ecca3','#f0c040','#4e9eca','#9b59b6',
'#e67e22','#1abc9c','#e74c3c','#3498db','#2ecc71',
'#f39c12','#8e44ad','#16a085','#c0392b','#2980b9',
'#27ae60','#d35400','#7d3c98','#148f77','#cb4335',
];
const WEATHER_ICONS = {
sunny:'\u2600\uFE0F', clear:'\u2600\uFE0F', cloudy:'\u2601\uFE0F',
rainy:'\uD83C\uDF27\uFE0F', stormy:'\u26C8\uFE0F', foggy:'\uD83C\uDF2B\uFE0F',
};
const SKY = {
dawn: { top:'#2d1b4e', bot:'#e8a860', stars:false, sun:'low' },
morning: { top:'#4a90c8', bot:'#c8e0f0', stars:false, sun:'mid' },
afternoon: { top:'#2878b8', bot:'#88c8e8', stars:false, sun:'high' },
evening: { top:'#1a1040', bot:'#e07840', stars:false, sun:'low' },
night: { top:'#060610', bot:'#101028', stars:true, sun:'moon' },
};
const GROUND_TINT = {
dawn: { base:'#3a5a2a', shade: 0.7 },
morning: { base:'#4a7a38', shade: 1.0 },
afternoon: { base:'#4a7a38', shade: 1.0 },
evening: { base:'#3a4a28', shade: 0.6 },
night: { base:'#0e1a0e', shade: 0.25 },
};
// ============================================================
// STATE
// ============================================================
let canvas, ctx;
let locations = {};
let agents = {};
let agentPositions = {};
let agentTargets = {};
let selectedAgentId = null;
let eventLog = [];
let conversationData = { active: [], recent: [] };
let connected = false;
let hoveredAgent = null;
let currentTimeOfDay = 'morning';
let currentWeather = 'sunny';
let animFrame = 0;
let raindrops = [];
let clouds = [];
let stars = [];
let activeTab = 'agents';
let agentIdxMap = {};
// Pan & zoom state
let panX = 0, panY = 0;
let zoom = 1.0;
// Movement tracking — previous location + road waypoints for smooth walking
let agentPrevLocations = {}; // {id: locationId} last known location
let agentWaypoints = {}; // {id: [{x,y},...]} L-shaped road waypoint queue
let agentFacingRight = {}; // {id: bool} last known horizontal facing direction
let agentMovingUp = {}; // {id: bool} true when moving toward top of screen (back view)
let locCrowdCount = {}; // {locationId: count} — used by drawPerson to scale agents
let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0;
// Rectangle-zoom state
let rectZoomMode = false, isRectDragging = false, rectStart = null, rectEnd = null;
let _blockNextClick = false;
// Tree / decorations cache
let trees = [];
let streetLamps = [];
function initParticles() {
for (let i = 0; i < 120; i++)
raindrops.push({x:Math.random(), y:Math.random(), speed:0.012+Math.random()*0.015, len:8+Math.random()*10});
for (let i = 0; i < 5; i++)
clouds.push({x:Math.random(), y:0.02+Math.random()*0.08, w:60+Math.random()*50, speed:0.00005+Math.random()*0.0001});
for (let i = 0; i < 70; i++)
stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
// Scatter trees in green spaces between buildings
const treeZones = [
{cx:0.50, cy:0.19, rx:0.10, ry:0.03, count:8}, // park
{cx:0.22, cy:0.78, rx:0.06, ry:0.03, count:5}, // sports field
{cx:0.50, cy:0.50, rx:0.04, ry:0.03, count:4}, // town square
{cx:0.08, cy:0.78, rx:0.03, ry:0.02, count:3}, // church garden
{cx:0.50, cy:0.85, rx:0.15, ry:0.03, count:5}, // south green
];
for (const z of treeZones) {
for (let i = 0; i < z.count; i++) {
trees.push({
x: z.cx + (Math.random()-0.5)*2*z.rx,
y: z.cy + (Math.random()-0.5)*2*z.ry,
size: 6 + Math.random()*5,
type: Math.random() > 0.3 ? 'round' : 'pine',
});
}
}
// Street lamps along roads
for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.16 + i*0.10 });
for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.28 });
for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.58 });
for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.15, y: 0.25 + i*0.18 });
for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.85, y: 0.25 + i*0.18 });
}
// ============================================================
// TABS
// ============================================================
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + tab));
if (tab === 'conversations') fetchConversations();
}
// ============================================================
// CANVAS SETUP
// ============================================================
function initCanvas() {
canvas = document.getElementById('cityCanvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
canvas.addEventListener('click', onCanvasClick);
canvas.addEventListener('mousemove', onCanvasMouseMove);
canvas.addEventListener('mousedown', onCanvasDragStart);
canvas.addEventListener('mousemove', onCanvasDrag);
canvas.addEventListener('mouseup', onCanvasDragEnd);
canvas.addEventListener('mouseleave', onCanvasDragEnd);
canvas.addEventListener('wheel', onCanvasWheel, {passive: false});
initParticles();
requestAnimationFrame(animate);
}
function resizeCanvas() {
const c = document.getElementById('canvas-container');
canvas.width = c.clientWidth;
canvas.height = c.clientHeight;
}
// World size — base is canvas size; zoom applied via ctx.scale
function worldW() { return canvas.width; }
function worldH() { return canvas.height; }
function maxPanX() { return Math.max(0, canvas.width * (1 - 1 / zoom)); }
function maxPanY() { return Math.max(0, canvas.height * (1 - 1 / zoom)); }
function onPanSlider() {
const sx = document.getElementById('pan-x');
const sy = document.getElementById('pan-y');
panX = (sx.value / 100) * maxPanX();
panY = (sy.value / 100) * maxPanY();
}
function syncSliders() {
const sx = document.getElementById('pan-x');
const sy = document.getElementById('pan-y');
if (sx) sx.value = maxPanX() > 0 ? (panX / maxPanX()) * 100 : 0;
if (sy) sy.value = maxPanY() > 0 ? (panY / maxPanY()) * 100 : 0;
}
function onCanvasDragStart(e) {
if (e.button !== 0) return;
if (rectZoomMode) {
const r = canvas.getBoundingClientRect();
rectStart = {x: e.clientX - r.left, y: e.clientY - r.top};
rectEnd = {...rectStart};
isRectDragging = true;
return;
}
isDragging = true;
dragStartX = e.clientX; dragStartY = e.clientY;
dragPanStartX = panX; dragPanStartY = panY;
}
function onCanvasDrag(e) {
if (isRectDragging) {
const r = canvas.getBoundingClientRect();
rectEnd = {x: e.clientX - r.left, y: e.clientY - r.top};
return;
}
if (!isDragging) return;
const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY;
if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx / zoom));
panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy / zoom));
syncSliders();
}
function onCanvasDragEnd() {
if (isRectDragging) {
isRectDragging = false;
if (rectStart && rectEnd) {
const rw = Math.abs(rectEnd.x - rectStart.x), rh = Math.abs(rectEnd.y - rectStart.y);
if (rw > 10 && rh > 10) { applyRectZoom(); _blockNextClick = true; }
}
rectStart = null; rectEnd = null;
rectZoomMode = false;
const btn = document.getElementById('btn-rect-zoom');
if (btn) btn.classList.remove('active');
canvas.style.cursor = 'default';
return;
}
isDragging = false;
}
// ============================================================
// ZOOM FUNCTIONS
// ============================================================
function onCanvasWheel(e) {
e.preventDefault();
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left, sy = e.clientY - r.top;
zoomAround(e.deltaY < 0 ? 1.15 : 1 / 1.15, sx, sy);
}
function zoomAround(factor, sx, sy) {
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * factor));
if (newZoom === zoom) return;
// World point under screen pixel (sx, sy): wx = sx/zoom + panX
const wx = sx / zoom + panX, wy = sy / zoom + panY;
zoom = newZoom;
panX = Math.max(0, Math.min(maxPanX(), wx - sx / zoom));
panY = Math.max(0, Math.min(maxPanY(), wy - sy / zoom));
syncSliders();
}
function zoomBy(factor) { zoomAround(factor, canvas.width / 2, canvas.height / 2); }
function zoomFit() { zoom = 1.0; panX = 0; panY = 0; syncSliders(); }
function applyRectZoom() {
if (!rectStart || !rectEnd) return;
const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y);
const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y);
const rw = x2 - x1, rh = y2 - y1;
if (rw < 10 || rh < 10) return;
// Convert screen rect corners to world coords
const wx1 = x1 / zoom + panX, wy1 = y1 / zoom + panY;
const wx2 = x2 / zoom + panX, wy2 = y2 / zoom + panY;
const newZoom = Math.min(MAX_ZOOM, Math.min(canvas.width / (wx2 - wx1), canvas.height / (wy2 - wy1)));
zoom = Math.max(MIN_ZOOM, newZoom);
panX = Math.max(0, Math.min(maxPanX(), wx1));
panY = Math.max(0, Math.min(maxPanY(), wy1));
syncSliders();
}
function toggleRectZoom() {
rectZoomMode = !rectZoomMode;
const btn = document.getElementById('btn-rect-zoom');
if (btn) btn.classList.toggle('active', rectZoomMode);
canvas.style.cursor = rectZoomMode ? 'crosshair' : 'default';
}
function animate() {
animFrame++;
for (const [id, target] of Object.entries(agentTargets)) {
if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; }
const p = agentPositions[id];
const agent = agents[id];
// Consume road waypoint queue (L-shaped routing)
const wps = agentWaypoints[id];
if (wps && wps.length > 0) {
const d = Math.hypot(p.x - wps[0].x, p.y - wps[0].y);
if (d < 4) { wps.shift(); if (wps.length === 0) delete agentWaypoints[id]; }
}
const dest = (agentWaypoints[id] && agentWaypoints[id].length) ? agentWaypoints[id][0] : target;
// Moving agents travel slower so the walk is visible; others snap faster
const isMoving = agent && (agent.state === 'moving');
const lerpRate = isMoving ? 0.022 : 0.07;
const prevX = p.x, prevY = p.y;
p.x += (dest.x - p.x) * lerpRate;
p.y += (dest.y - p.y) * lerpRate;
// Track facing direction from actual pixel delta — reliable for all path types
const mdx = p.x - prevX, mdy = p.y - prevY;
if (Math.abs(mdx) > 0.1 || Math.abs(mdy) > 0.1) {
if (Math.abs(mdy) > Math.abs(mdx)) {
agentMovingUp[id] = mdy < 0; // moving up = back view
} else {
agentFacingRight[id] = mdx > 0; // moving horizontally = profile
agentMovingUp[id] = false;
}
}
}
draw();
requestAnimationFrame(animate);
}
// ============================================================
// MAIN DRAW
// ============================================================
function draw() {
if (!ctx) return;
const W = worldW(), H = worldH();
const cW = canvas.width, cH = canvas.height;
// Background fill (covers any gap between sky and zoomed ground)
const skyCfg = SKY[currentTimeOfDay] || SKY.morning;
ctx.fillStyle = skyCfg.bot;
ctx.fillRect(0, 0, cW, cH);
// Sky drawn at canvas size (no zoom transform — always fills background)
drawSky(cW, cH);
ctx.save();
ctx.scale(zoom, zoom);
ctx.translate(-panX, -panY);
drawGround(W, H);
drawZones(W, H);
drawWeather(W, H);
drawRoads(W, H);
drawSidewalks(W, H);
drawTrees(W, H);
// Draw buildings — known positions + auto-generated houses
const allPositions = getEffectivePositions();
for (const [id, pos] of Object.entries(allPositions)) {
if (pos.type !== 'street') drawBuilding(id, pos, W, H);
}
drawStreetLamps(W, H);
// Compute agent positions
const byLoc = {};
for (const [id, a] of Object.entries(agents)) {
const loc = a.location || 'house_elena';
if (!byLoc[loc]) byLoc[loc] = [];
byLoc[loc].push({id, ...a});
}
for (const [id, a] of Object.entries(agents)) {
computeAgentTarget(id, a, getAgentIdx(id), byLoc, W, H);
}
drawCoupleLines(W, H);
drawConversationBubbles(W, H);
for (const [id, a] of Object.entries(agents)) {
drawPerson(id, a, getAgentIdx(id), W, H);
}
if (currentWeather === 'foggy') {
ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
ctx.fillRect(0, 0, W, H);
}
ctx.restore();
// Rect-zoom selection overlay (screen coords, after restore)
if (isRectDragging && rectStart && rectEnd) {
const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y);
const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y);
ctx.fillStyle = 'rgba(78,204,163,0.08)';
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
ctx.strokeStyle = 'rgba(78,204,163,0.9)';
ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
ctx.setLineDash([]);
}
// Zoom level indicator
if (zoom !== 1.0 || rectZoomMode) {
ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
const label = rectZoomMode ? '⬚ draw rect to zoom' : `${zoom.toFixed(1)}x`;
const tw = ctx.measureText(label).width + 14;
ctx.fillStyle = 'rgba(15,52,96,0.75)';
ctx.fillRect(cW - tw - 4, cH - 22, tw + 4, 18);
ctx.fillStyle = rectZoomMode ? '#f0c040' : '#4ecca3';
ctx.fillText(label, cW - 6, cH - 6);
}
}
// Auto-compute positions for generated houses not in LOCATION_POSITIONS
let _genPosCache = {};
function getEffectivePositions() {
const result = {...LOCATION_POSITIONS};
for (const locId of Object.keys(locations)) {
if (!result[locId]) {
if (!_genPosCache[locId]) {
let h = 0;
for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0;
const genMatch = locId.match(/house_gen_(\d+)/);
if (genMatch) {
// Deterministic slot — no jitter so houses never drift into sky/mountain
const slotIdx = (parseInt(genMatch[1]) - 1) % GEN_HOUSE_SLOTS.length;
const sl = GEN_HOUSE_SLOTS[slotIdx];
_genPosCache[locId] = {
x: sl.x,
y: sl.y,
type: 'house',
label: (locations[locId]?.name || locId).slice(0, 14),
};
} else {
// Unknown location: spread evenly across the whole map via hash
_genPosCache[locId] = {
x: 0.05 + ((h >>> 0) % 18) / 18 * 0.90,
y: 0.05 + ((h >>> 4) % 16) / 16 * 0.90,
type: 'house',
label: (locations[locId]?.name || locId).slice(0, 14),
};
}
}
result[locId] = _genPosCache[locId];
}
}
return result;
}
function getAgentIdx(id) {
if (!(id in agentIdxMap)) agentIdxMap[id] = Object.keys(agentIdxMap).length;
return agentIdxMap[id];
}
// ============================================================
// SKY
// ============================================================
function drawSky(W, H) {
// Sky is always drawn at canvas size (before pan transform)
const s = SKY[currentTimeOfDay] || SKY.morning;
const hLine = H * HORIZON;
const grad = ctx.createLinearGradient(0, 0, 0, hLine);
grad.addColorStop(0, s.top);
grad.addColorStop(1, s.bot);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, hLine);
if (s.stars) {
for (const st of stars) {
const tw = 0.3 + 0.7 * Math.abs(Math.sin(animFrame * 0.025 + st.tw));
ctx.fillStyle = `rgba(255,255,240,${tw})`;
ctx.beginPath(); ctx.arc(st.x*W, st.y*H, st.size, 0, 6.28); ctx.fill();
}
}
if (s.sun === 'moon') drawMoon(W*0.82, hLine*0.35, 18);
else if (s.sun === 'high') drawSun(W*0.78, hLine*0.25, 16);
else if (s.sun === 'mid') drawSun(W*0.80, hLine*0.45, 16);
else if (s.sun === 'low') drawSun(W*0.82, hLine*0.7, 16);
// Mountains along the horizon
drawMountains(W, hLine);
}
function drawMountains(W, hLine) {
const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening';
const isDawn = currentTimeOfDay === 'dawn';
// Far mountains (larger, darker)
const farPeaks = [
{x:0.00,h:0.55},{x:0.08,h:0.85},{x:0.18,h:0.65},{x:0.28,h:0.95},
{x:0.38,h:0.70},{x:0.48,h:1.0},{x:0.58,h:0.75},{x:0.68,h:0.90},
{x:0.78,h:0.80},{x:0.88,h:0.70},{x:0.98,h:0.60},{x:1.05,h:0.50},
];
ctx.fillStyle = isDark ? '#0a0e18' : (isDawn ? '#4a3858' : '#5a6878');
ctx.beginPath();
ctx.moveTo(0, hLine);
for (const p of farPeaks) {
ctx.lineTo(p.x * W, hLine - p.h * hLine * 0.6);
}
ctx.lineTo(W, hLine);
ctx.closePath();
ctx.fill();
// Near mountains (smaller, lighter)
const nearPeaks = [
{x:-0.02,h:0.30},{x:0.05,h:0.55},{x:0.14,h:0.40},{x:0.22,h:0.65},
{x:0.32,h:0.45},{x:0.42,h:0.60},{x:0.52,h:0.50},{x:0.60,h:0.70},
{x:0.70,h:0.55},{x:0.80,h:0.50},{x:0.90,h:0.62},{x:1.02,h:0.35},
];
ctx.fillStyle = isDark ? '#141828' : (isDawn ? '#5a4868' : '#6a7888');
ctx.beginPath();
ctx.moveTo(0, hLine);
for (const p of nearPeaks) {
ctx.lineTo(p.x * W, hLine - p.h * hLine * 0.45);
}
ctx.lineTo(W, hLine);
ctx.closePath();
ctx.fill();
// Snow caps on far peaks (daytime only)
if (!isDark) {
ctx.fillStyle = isDawn ? 'rgba(220,200,220,0.5)' : 'rgba(240,245,255,0.6)';
for (const p of farPeaks) {
if (p.h > 0.75) {
const px = p.x * W;
const py = hLine - p.h * hLine * 0.6;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(px - 12, py + hLine * 0.06);
ctx.lineTo(px + 12, py + hLine * 0.06);
ctx.closePath();
ctx.fill();
}
}
}
// Haze at base of mountains
const hazeGrad = ctx.createLinearGradient(0, hLine - 15, 0, hLine + 5);
hazeGrad.addColorStop(0, 'rgba(0,0,0,0)');
hazeGrad.addColorStop(1, isDark ? 'rgba(10,15,25,0.5)' : (isDawn ? 'rgba(180,140,120,0.3)' : 'rgba(140,160,180,0.25)'));
ctx.fillStyle = hazeGrad;
ctx.fillRect(0, hLine - 15, W, 20);
}
function drawSun(x, y, r) {
const glow = ctx.createRadialGradient(x, y, r*0.5, x, y, r*4);
glow.addColorStop(0, 'rgba(255,220,100,0.35)');
glow.addColorStop(1, 'rgba(255,220,100,0)');
ctx.fillStyle = glow; ctx.fillRect(x-r*4, y-r*4, r*8, r*8);
ctx.save(); ctx.translate(x, y); ctx.rotate(animFrame*0.005);
for (let i = 0; i < 8; i++) { ctx.rotate(Math.PI/4); ctx.fillStyle='rgba(255,220,100,0.25)'; ctx.fillRect(-1.5,r+3,3,8); }
ctx.restore();
ctx.fillStyle='#ffe066'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill();
}
function drawMoon(x, y, r) {
const glow = ctx.createRadialGradient(x,y,r*0.5,x,y,r*3);
glow.addColorStop(0,'rgba(200,210,240,0.15)');
glow.addColorStop(1,'rgba(200,210,240,0)');
ctx.fillStyle=glow; ctx.fillRect(x-r*3,y-r*3,r*6,r*6);
ctx.fillStyle='#d8dff0'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill();
ctx.fillStyle=SKY.night.top; ctx.beginPath(); ctx.arc(x+7,y-2,r*0.85,0,6.28); ctx.fill();
}
// ============================================================
// GROUND
// ============================================================
function drawGround(W, H) {
const hLine = (canvas.height * HORIZON);
const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
const bc = hexToRgb(gt.base);
const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening';
const isDawn = currentTimeOfDay === 'dawn';
// Base gradient — three-band ground for depth
const grad = ctx.createLinearGradient(0, hLine, 0, H);
grad.addColorStop(0, `rgb(${bc.r+28},${bc.g+40},${bc.b+12})`);
grad.addColorStop(0.4, `rgb(${bc.r+10},${bc.g+20},${bc.b+5})`);
grad.addColorStop(1, `rgb(${Math.max(0,bc.r-20)},${Math.max(0,bc.g-20)},${Math.max(0,bc.b-14)})`);
ctx.fillStyle = grad;
ctx.fillRect(0, hLine, W, H - hLine);
// Horizontal band of slightly different shade — mid-ground variation
if (!isDark) {
const bandY = hLine + (H - hLine) * 0.5;
const bandGrad = ctx.createLinearGradient(0, bandY - 12, 0, bandY + 12);
bandGrad.addColorStop(0, 'rgba(0,0,0,0)');
bandGrad.addColorStop(0.5, isDawn ? 'rgba(180,120,60,0.06)' : 'rgba(80,130,40,0.06)');
bandGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = bandGrad;
ctx.fillRect(0, bandY - 12, W, 24);
}
// Rain/storm overlay
if (currentWeather === 'rainy' || currentWeather === 'stormy') {
ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
ctx.fillRect(0, hLine, W, H - hLine);
}
// Scattered grass tufts for texture
ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.14)`;
for (let i = 0; i < 120; i++) {
const gx = (i * 37 + 13) % W;
const gy = hLine + 10 + ((i * 53 + 7) % (H - hLine - 15));
ctx.fillRect(gx, gy, 2, 3);
}
// Horizon blend
const horizGrad = ctx.createLinearGradient(0, hLine - 4, 0, hLine + 8);
const s = SKY[currentTimeOfDay] || SKY.morning;
horizGrad.addColorStop(0, s.bot);
horizGrad.addColorStop(1, gt.base);
ctx.fillStyle = horizGrad;
ctx.fillRect(0, hLine - 4, W, 12);
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return {r,g,b};
}
// ============================================================
// ZONE COLORS — colored neighborhood tints
// ============================================================
function drawZones(W, H) {
const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening';
const isDawn = currentTimeOfDay === 'dawn';
const zones = [
// Park — bright grass green
{ cx: 0.50, cy: 0.19, rx: 0.11, ry: 0.055, c: isDark ? '#1a4a18' : (isDawn ? '#4a7a30' : '#7ecf50'), a: isDark ? 0.22 : 0.35 },
// Sports field — turf green
{ cx: 0.22, cy: 0.78, rx: 0.07, ry: 0.055, c: isDark ? '#1a3818' : '#5ab838', a: isDark ? 0.20 : 0.30 },
// Town square — warm cobblestone gold
{ cx: 0.50, cy: 0.50, rx: 0.07, ry: 0.045, c: isDark ? '#3a3010' : '#e8c86a', a: isDark ? 0.18 : 0.32 },
// Commercial row — warm terracotta orange
{ cx: 0.48, cy: 0.42, rx: 0.32, ry: 0.075, c: isDark ? '#2a1808' : '#e8b880', a: isDark ? 0.15 : 0.20 },
// North residential — soft warm cream
{ cx: 0.48, cy: 0.20, rx: 0.44, ry: 0.060, c: isDark ? '#28201a' : '#eedcb0', a: isDark ? 0.13 : 0.22 },
// South residential — light sandy beige
{ cx: 0.48, cy: 0.66, rx: 0.44, ry: 0.065, c: isDark ? '#28201a' : '#e8d8a0', a: isDark ? 0.13 : 0.22 },
// Office / business district — cool steel blue
{ cx: 0.66, cy: 0.34, rx: 0.22, ry: 0.075, c: isDark ? '#182030' : '#b0cce8', a: isDark ? 0.18 : 0.24 },
// Industrial — brownish rust
{ cx: 0.91, cy: 0.63, rx: 0.07, ry: 0.08, c: isDark ? '#201408' : '#c89860', a: isDark ? 0.20 : 0.28 },
// Hospital — clean pale cyan
{ cx: 0.91, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#182828' : '#b8e8e0', a: isDark ? 0.18 : 0.26 },
// School — warm learning yellow
{ cx: 0.08, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#28200a' : '#f0e890', a: isDark ? 0.16 : 0.26 },
// Church garden — soft lavender-green
{ cx: 0.08, cy: 0.78, rx: 0.06, ry: 0.045, c: isDark ? '#1a2030' : '#d0c8e8', a: isDark ? 0.18 : 0.28 },
];
for (const z of zones) {
const grd = ctx.createRadialGradient(z.cx*W, z.cy*H, 0, z.cx*W, z.cy*H, Math.max(z.rx*W, z.ry*H));
grd.addColorStop(0, z.c);
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = z.a;
ctx.fillStyle = grd;
ctx.beginPath();
ctx.ellipse(z.cx*W, z.cy*H, z.rx*W, z.ry*H, 0, 0, 6.28);
ctx.fill();
}
ctx.globalAlpha = 1.0;
}
// ============================================================
// WEATHER
// ============================================================
function drawWeather(W, H) {
const w = currentWeather;
if (w==='cloudy'||w==='rainy'||w==='stormy') {
const op = w==='stormy'?0.65:(w==='rainy'?0.45:0.30);
for (const c of clouds) {
c.x+=c.speed; if(c.x>1.15) c.x=-0.15;
drawCloud(c.x*W, c.y*H, c.w, op);
}
}
if (w==='rainy'||w==='stormy') {
ctx.strokeStyle = w==='stormy'?'rgba(180,200,255,0.5)':'rgba(150,180,220,0.35)';
ctx.lineWidth=1;
for (const r of raindrops) {
r.y+=r.speed; if(r.y>1){r.y=-0.03;r.x=Math.random();}
const rx=r.x*W, ry=r.y*H;
ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(rx-2,ry+r.len); ctx.stroke();
}
}
if (w==='stormy'&&animFrame%120<3) {
ctx.fillStyle=`rgba(255,255,255,${0.12+Math.random()*0.08})`;
ctx.fillRect(0,0,W,H);
}
}
function drawCloud(x,y,w,op) {
ctx.fillStyle=`rgba(200,210,220,${op})`;
const h=w*0.32;
ctx.beginPath(); ctx.ellipse(x,y,w*0.5,h*0.6,0,0,6.28); ctx.fill();
ctx.beginPath(); ctx.ellipse(x-w*0.25,y+h*0.15,w*0.3,h*0.45,0,0,6.28); ctx.fill();
ctx.beginPath(); ctx.ellipse(x+w*0.28,y+h*0.1,w*0.28,h*0.4,0,0,6.28); ctx.fill();
}
// ============================================================
// ROADS — drawn as realistic asphalt with lane markings
// ============================================================
function drawRoads(W, H) {
const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
const asphalt = isDark ? '#2a2820' : '#6b6560';
const asphaltEdge = isDark ? '#1a1815' : '#555048';
for (const road of ROADS) {
const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H;
const w = road.width || 10;
const dx = x2-x1, dy = y2-y1;
const len = Math.hypot(dx, dy);
const nx = -dy/len * w/2, ny = dx/len * w/2;
// Road surface
ctx.fillStyle = asphalt;
ctx.beginPath();
ctx.moveTo(x1+nx, y1+ny);
ctx.lineTo(x2+nx, y2+ny);
ctx.lineTo(x2-nx, y2-ny);
ctx.lineTo(x1-nx, y1-ny);
ctx.closePath();
ctx.fill();
// Edge lines
ctx.strokeStyle = asphaltEdge;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x1+nx,y1+ny); ctx.lineTo(x2+nx,y2+ny); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x1-nx,y1-ny); ctx.lineTo(x2-nx,y2-ny); ctx.stroke();
// Center dashed line (for wider roads)
if (w >= 12) {
ctx.strokeStyle = isDark ? 'rgba(200,180,80,0.25)' : 'rgba(255,220,100,0.6)';
ctx.lineWidth = 1.5;
ctx.setLineDash([8, 10]);
ctx.beginPath();
ctx.moveTo((x1+x2)/2 - (x2-x1)*0.48 + (x2-x1)*0.02, (y1+y2)/2 - (y2-y1)*0.48 + (y2-y1)*0.02);
ctx.moveTo(x1, y1); ctx.lineTo(x2, y2);
ctx.stroke();
ctx.setLineDash([]);
}
}
}
// Sidewalks next to roads
function drawSidewalks(W, H) {
const isDark = currentTimeOfDay==='night';
ctx.fillStyle = isDark ? 'rgba(90,85,75,0.3)' : 'rgba(180,175,160,0.35)';
for (const road of ROADS) {
const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H;
const w = (road.width || 10) + 6;
const dx = x2-x1, dy = y2-y1;
const len = Math.hypot(dx, dy);
const nx = -dy/len * w/2, ny = dx/len * w/2;
const sw = 3; // sidewalk width
// Draw thin sidewalk strips
ctx.fillRect(
Math.min(x1+nx, x2+nx) - sw/2,
Math.min(y1+ny, y2+ny) - sw/2,
Math.abs(x2+nx - x1-nx) + sw,
Math.abs(y2+ny - y1-ny) + sw
);
}
}
// ============================================================
// TREES
// ============================================================
function drawTrees(W, H) {
const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
for (const t of trees) {
const tx = t.x * W, ty = t.y * H;
// Trunk
ctx.fillStyle = isDark ? '#3a2a15' : '#6b4226';
ctx.fillRect(tx-2, ty-2, 4, 10);
// Canopy
if (t.type === 'pine') {
ctx.fillStyle = isDark ? '#1a3a18' : '#2a7a28';
ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 6); ctx.lineTo(tx - t.size*0.6, ty); ctx.lineTo(tx + t.size*0.6, ty); ctx.closePath(); ctx.fill();
ctx.fillStyle = isDark ? '#1e4a1c' : '#3a8a30';
ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 2); ctx.lineTo(tx - t.size*0.4, ty - 3); ctx.lineTo(tx + t.size*0.4, ty - 3); ctx.closePath(); ctx.fill();
} else {
ctx.fillStyle = isDark ? '#1a4018' : '#3a8a30';
ctx.beginPath(); ctx.arc(tx, ty - t.size*0.5, t.size*0.7, 0, 6.28); ctx.fill();
ctx.fillStyle = isDark ? '#2a5028' : '#55aa45';
ctx.beginPath(); ctx.arc(tx - 2, ty - t.size*0.6, t.size*0.35, 0, 6.28); ctx.fill();
}
}
}
// ============================================================
// STREET LAMPS
// ============================================================
function drawStreetLamps(W, H) {
const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
for (const l of streetLamps) {
const lx = l.x*W + 12, ly = l.y*H;
ctx.fillStyle = '#555'; ctx.fillRect(lx-1, ly-14, 2, 14);
ctx.fillStyle = '#666'; ctx.fillRect(lx-3, ly-16, 6, 3);
if (isDark) {
const glow = ctx.createRadialGradient(lx, ly-14, 2, lx, ly-14, 30);
glow.addColorStop(0, 'rgba(255,220,130,0.25)');
glow.addColorStop(1, 'rgba(255,220,130,0)');
ctx.fillStyle = glow;
ctx.fillRect(lx-30, ly-44, 60, 60);
ctx.fillStyle = 'rgba(255,220,130,0.8)';
ctx.beginPath(); ctx.arc(lx, ly-14, 2, 0, 6.28); ctx.fill();
}
}
}
// ============================================================
// BUILDINGS
// ============================================================
function drawBuilding(id, pos, W, H) {
const x = pos.x*W, y = pos.y*H;
const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
const loc = locations[id] || {};
const occ = (loc.occupants || []).length;
if (pos.type === 'house') drawHouse(x, y, isDark, id);
else if (pos.type === 'shop') drawShop(x, y, isDark);
else if (pos.type === 'office') drawOffice(x, y, isDark);
else if (pos.type === 'tower') drawTower(x, y, isDark);
else if (pos.type === 'park') drawPark(x, y, isDark);
else if (pos.type === 'public') drawPublicBuilding(x, y, isDark);
else if (pos.type === 'factory') drawFactory(x, y, isDark);
else if (pos.type === 'school') drawSchool(x, y, isDark);
else if (pos.type === 'hospital') drawHospital(x, y, isDark);
else if (pos.type === 'church') drawChurch(x, y, isDark);
else if (pos.type === 'cinema') drawCinema(x, y, isDark);
else if (pos.type === 'apartment') drawApartment(x, y, isDark);
else if (pos.type === 'square') drawSquare(x, y, isDark);
else if (pos.type === 'sports') drawSportsField(x, y, isDark);
// Label
if (pos.type !== 'park' && pos.type !== 'square' && pos.type !== 'sports') {
const label = pos.label || id;
const short = label.length > 16 ? label.slice(0, 14) + '..' : label;
ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
const ly = pos.type === 'house' ? y + 18 : (pos.type === 'tower' ? y + 35 : y + 25);
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1);
ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly);
}
// Occupant count badge
if (occ > 0) {
const byOff = pos.type === 'house' ? 14 : (pos.type === 'tower' ? 32 : (pos.type === 'church' ? 40 : 18));
const bx = x + (pos.type === 'house' ? 22 : 28), by = y - byOff;
ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(occ.toString(), bx, by);
}
}
// --- 2.5D PERSPECTIVE HELPERS ---
const ISO_DX = 6; // horizontal offset for side face
const ISO_DY = 4; // vertical offset for top face
function draw25DBox(x, y, w, h, frontColor, sideColor, topColor, dk) {
// Front face
ctx.fillStyle = dk ? dim(frontColor, 0.4) : frontColor;
ctx.fillRect(x - w/2, y - h, w, h);
// Right side face (perspective)
ctx.fillStyle = dk ? dim(sideColor, 0.35) : sideColor;
ctx.beginPath();
ctx.moveTo(x + w/2, y - h);
ctx.lineTo(x + w/2 + ISO_DX, y - h - ISO_DY);
ctx.lineTo(x + w/2 + ISO_DX, y - ISO_DY);
ctx.lineTo(x + w/2, y);
ctx.closePath(); ctx.fill();
// Top face
ctx.fillStyle = dk ? dim(topColor, 0.45) : topColor;
ctx.beginPath();
ctx.moveTo(x - w/2, y - h);
ctx.lineTo(x - w/2 + ISO_DX, y - h - ISO_DY);
ctx.lineTo(x + w/2 + ISO_DX, y - h - ISO_DY);
ctx.lineTo(x + w/2, y - h);
ctx.closePath(); ctx.fill();
}
function drawHouse(x, y, dk, id) {
const w = 36, h = 24;
const hues = [
{wall:'#c8a882', side:'#a88862', roof:'#8b4513', door:'#6b3410'},
{wall:'#a0b8a0', side:'#809880', roof:'#4a6a4a', door:'#3a4a3a'},
{wall:'#b8a0a0', side:'#988080', roof:'#7a3a3a', door:'#5a2a2a'},
{wall:'#a0a8c0', side:'#8088a0', roof:'#4a5a7a', door:'#3a4a6a'},
{wall:'#c8b878', side:'#a89858', roof:'#8a7a40', door:'#6a5a30'},
];
const allPos = getEffectivePositions();
const idx = Object.keys(allPos).indexOf(id) % hues.length;
const c = hues[idx];
const baseY = y + h/2;
// 2.5D wall
draw25DBox(x, baseY, w, h, c.wall, c.side, c.roof, dk);
// Pitched roof
ctx.fillStyle = dk ? dim(c.roof, 0.4) : c.roof;
ctx.beginPath();
ctx.moveTo(x - w/2 - 3, baseY - h);
ctx.lineTo(x, baseY - h - 14);
ctx.lineTo(x + w/2 + 3, baseY - h);
ctx.closePath(); ctx.fill();
// Roof right side
ctx.fillStyle = dk ? dim(c.roof, 0.3) : dim(c.roof, 0.8);
ctx.beginPath();
ctx.moveTo(x + w/2 + 3, baseY - h);
ctx.lineTo(x, baseY - h - 14);
ctx.lineTo(x + ISO_DX, baseY - h - 14 - ISO_DY);
ctx.lineTo(x + w/2 + 3 + ISO_DX, baseY - h - ISO_DY);
ctx.closePath(); ctx.fill();
// Door
ctx.fillStyle = dk ? dim(c.door, 0.4) : c.door;
ctx.fillRect(x - 3, baseY - 10, 6, 10);
ctx.fillStyle = dk ? '#aa9060' : '#d4b070';
ctx.beginPath(); ctx.arc(x + 2, baseY - 4, 1, 0, 6.28); ctx.fill();
// Windows
const wc = dk ? 'rgba(255,210,100,0.75)' : 'rgba(180,220,255,0.55)';
ctx.fillStyle = wc;
ctx.fillRect(x - w/2 + 4, baseY - h + 5, 8, 6);
ctx.fillRect(x + w/2 - 12, baseY - h + 5, 8, 6);
ctx.strokeStyle = dk ? 'rgba(100,80,50,0.4)' : 'rgba(80,70,60,0.3)';
ctx.lineWidth = 0.5;
ctx.strokeRect(x - w/2 + 4, baseY - h + 5, 8, 6);
ctx.strokeRect(x + w/2 - 12, baseY - h + 5, 8, 6);
// Chimney
ctx.fillStyle = dk ? dim(c.roof, 0.35) : dim(c.roof, 0.8);
ctx.fillRect(x + 8, baseY - h - 10, 5, 8);
}
function drawShop(x, y, dk) {
const w = 48, h = 32;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#d4c4a8', '#b4a488', '#e0d4c0', dk);
// Awning with 2.5D
const awningColors = ['#c44', '#4a8', '#48a', '#a84'];
const ci = Math.floor(x*7 + y*3) % awningColors.length;
const ac = awningColors[ci];
ctx.fillStyle = dk ? dim(ac, 0.35) : ac;
ctx.beginPath();
ctx.moveTo(x-w/2-3, baseY-h);
ctx.lineTo(x-w/2-6, baseY-h+12);
ctx.lineTo(x+w/2+6, baseY-h+12);
ctx.lineTo(x+w/2+3, baseY-h);
ctx.closePath(); ctx.fill();
// Awning side
ctx.fillStyle = dk ? dim(ac, 0.25) : dim(ac, 0.7);
ctx.beginPath();
ctx.moveTo(x+w/2+3, baseY-h);
ctx.lineTo(x+w/2+6, baseY-h+12);
ctx.lineTo(x+w/2+6+ISO_DX, baseY-h+12-ISO_DY);
ctx.lineTo(x+w/2+3+ISO_DX, baseY-h-ISO_DY);
ctx.closePath(); ctx.fill();
// Awning stripes
ctx.strokeStyle = dk ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
for (let i = 0; i < 5; i++) {
const sx = x-w/2 + i*(w/4);
ctx.beginPath(); ctx.moveTo(sx, baseY-h); ctx.lineTo(sx-1.5, baseY-h+12); ctx.stroke();
}
// Door
ctx.fillStyle = dk ? '#2a2520' : '#5a4a3a';
ctx.fillRect(x-4, baseY-12, 8, 12);
// Windows
const wc = dk ? 'rgba(255,210,100,0.65)' : 'rgba(200,230,255,0.55)';
ctx.fillStyle = wc;
ctx.fillRect(x-w/2+4, baseY-h+14, w/2-10, 10);
ctx.fillRect(x+5, baseY-h+14, w/2-10, 10);
}
function drawOffice(x, y, dk) {
const w = 50, h = 40;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#8090a8', '#607088', '#90a0b8', dk);
// Door
ctx.fillStyle = dk ? '#1a1a25' : '#4a5a6a';
ctx.fillRect(x-4, baseY-12, 8, 12);
// Windows grid
const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(180,220,255,0.5)';
ctx.fillStyle = wc;
for (let r = 0; r < 3; r++)
for (let c = 0; c < 3; c++)
ctx.fillRect(x-w/2+6+c*15, baseY-h+5+r*10, 9, 6);
// Side windows
const swc = dk ? 'rgba(255,200,80,0.4)' : 'rgba(160,200,240,0.35)';
ctx.fillStyle = swc;
for (let r = 0; r < 3; r++) {
const wy = baseY - h + 6 + r*10;
ctx.beginPath();
ctx.moveTo(x+w/2+1, wy);
ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY);
ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+6);
ctx.lineTo(x+w/2+1, wy+6);
ctx.closePath(); ctx.fill();
}
}
function drawPublicBuilding(x, y, dk) {
const w = 46, h = 34;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#7a9a6a', '#5a7a4a', '#8aaa7a', dk);
ctx.fillStyle = dk ? '#1a2018' : '#4a6a3a';
ctx.fillRect(x-4, baseY-12, 8, 12);
const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(200,240,200,0.5)';
ctx.fillStyle = wc;
for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, baseY-h+6, 8, 8);
for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, baseY-h+18, 8, 8);
}
function drawPark(x, y, dk) {
// Green area with slight elevation
ctx.fillStyle = dk ? '#1a3018' : '#4a9040';
ctx.beginPath(); ctx.ellipse(x, y, 55, 25, 0, 0, 6.28); ctx.fill();
// Raised edge (2.5D)
ctx.fillStyle = dk ? '#122810' : '#3a7030';
ctx.beginPath(); ctx.ellipse(x, y+3, 55, 25, 0, 0, Math.PI); ctx.fill();
ctx.strokeStyle = dk ? '#2a4a25' : '#6ab850';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.ellipse(x, y, 55, 25, 0, 0, 6.28); ctx.stroke();
// Walking path
ctx.strokeStyle = dk ? '#2a2820' : '#c8c0a8';
ctx.lineWidth = 3; ctx.setLineDash([6,4]);
ctx.beginPath(); ctx.ellipse(x, y, 32, 14, 0, 0, 6.28); ctx.stroke();
ctx.setLineDash([]);
// Pond
ctx.fillStyle = dk ? '#1a2a3a' : '#5a9ac0';
ctx.beginPath(); ctx.ellipse(x+15, y+4, 12, 6, 0.2, 0, 6.28); ctx.fill();
ctx.strokeStyle = dk ? '#2a3a4a' : '#80b0d0'; ctx.lineWidth = 1; ctx.stroke();
// Water shimmer
if (!dk) {
ctx.fillStyle = `rgba(255,255,255,${0.15+Math.sin(animFrame*0.05)*0.1})`;
ctx.beginPath(); ctx.ellipse(x+17, y+2, 4, 2, 0.3, 0, 6.28); ctx.fill();
}
// Benches (2.5D)
ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30';
ctx.fillRect(x-22, y+7, 14, 3);
ctx.fillStyle = dk ? '#2a1a08' : '#5a3a18';
ctx.fillRect(x-22, y+10, 14, 2); // bench shadow/depth
// Trees in park
for (let i = -1; i <= 1; i++) {
const tx = x + i*22;
ctx.fillStyle = dk ? '#3a2a15' : '#6b4226'; ctx.fillRect(tx-2, y-4, 4, 10);
ctx.fillStyle = dk ? '#1a4a18' : '#3a8a30'; ctx.beginPath(); ctx.arc(tx, y-10, 8+Math.abs(i)*2, 0, 6.28); ctx.fill();
ctx.fillStyle = dk ? '#2a5a28' : '#55aa45'; ctx.beginPath(); ctx.arc(tx-2, y-12, 4, 0, 6.28); ctx.fill();
}
// Label
ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Willow Park', x+1, y+19);
ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18);
}
function drawTower(x, y, dk) {
const w = 36, h = 56;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#6880a0', '#486078', '#7890b0', dk);
// Antenna
ctx.fillStyle = '#888'; ctx.fillRect(x-1, baseY-h-10, 2, 10);
ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(x, baseY-h-10, 2, 0, 6.28); ctx.fill();
// Door
ctx.fillStyle = dk ? '#0a0e18' : '#384858';
ctx.fillRect(x-5, baseY-14, 10, 14);
// Front windows (5 rows x 4 cols)
const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.5)';
ctx.fillStyle = wc;
for (let r = 0; r < 5; r++)
for (let c = 0; c < 4; c++)
ctx.fillRect(x-w/2+3+c*8.5, baseY-h+5+r*10, 6, 6);
// Side windows
const swc = dk ? 'rgba(255,200,80,0.35)' : 'rgba(160,200,240,0.3)';
ctx.fillStyle = swc;
for (let r = 0; r < 5; r++) {
const wy = baseY-h+6+r*10;
ctx.beginPath();
ctx.moveTo(x+w/2+1, wy);
ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY);
ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+5);
ctx.lineTo(x+w/2+1, wy+5);
ctx.closePath(); ctx.fill();
}
}
function drawFactory(x, y, dk) {
const w = 56, h = 34;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#8a7a6a', '#6a5a4a', '#9a8a78', dk);
// Saw-tooth roof
ctx.fillStyle = dk ? '#1a1815' : '#6a5a4a';
for (let i = 0; i < 3; i++) {
const rx = x - w/2 + i*(w/3);
ctx.beginPath();
ctx.moveTo(rx, baseY-h); ctx.lineTo(rx+w/6, baseY-h-10); ctx.lineTo(rx+w/3, baseY-h);
ctx.closePath(); ctx.fill();
}
// Chimney with smoke
ctx.fillStyle = dk ? '#3a3020' : '#706050';
ctx.fillRect(x+w/2-10, baseY-h-16, 8, 14);
// 2.5D chimney side
ctx.fillStyle = dk ? '#2a2018' : '#605040';
ctx.beginPath();
ctx.moveTo(x+w/2-2, baseY-h-16); ctx.lineTo(x+w/2-2+ISO_DX, baseY-h-16-ISO_DY);
ctx.lineTo(x+w/2-2+ISO_DX, baseY-h-2-ISO_DY); ctx.lineTo(x+w/2-2, baseY-h-2);
ctx.closePath(); ctx.fill();
// Smoke
ctx.fillStyle = `rgba(180,180,180,${dk?0.15:0.25})`;
for (let i = 0; i < 3; i++) {
const sy = baseY-h-20-i*8+Math.sin(animFrame*0.03+i)*3;
ctx.beginPath(); ctx.arc(x+w/2-6+i*3, sy, 4+i*2, 0, 6.28); ctx.fill();
}
// Loading door
ctx.fillStyle = dk ? '#1a1815' : '#5a4a3a';
ctx.fillRect(x-8, baseY-16, 16, 16);
// Windows
const wc = dk ? 'rgba(255,180,80,0.5)' : 'rgba(200,220,240,0.4)';
ctx.fillStyle = wc;
for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*14, baseY-h+6, 10, 8);
}
function drawSchool(x, y, dk) {
const w = 52, h = 36;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#c4a088', '#a48068', '#d4b098', dk);
// Flagpole
ctx.fillStyle = '#888'; ctx.fillRect(x+w/2-6, baseY-h-18, 2, 18);
ctx.fillStyle = '#e94560';
ctx.beginPath();
ctx.moveTo(x+w/2-4, baseY-h-18); ctx.lineTo(x+w/2+8, baseY-h-14); ctx.lineTo(x+w/2-4, baseY-h-10);
ctx.closePath(); ctx.fill();
// Door
ctx.fillStyle = dk ? '#1a1218' : '#6a4a3a';
ctx.fillRect(x-5, baseY-14, 10, 14);
// Windows (2 rows)
const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(200,230,255,0.5)';
ctx.fillStyle = wc;
for (let r = 0; r < 2; r++)
for (let c = 0; c < 4; c++)
ctx.fillRect(x-w/2+4+c*12, baseY-h+5+r*12, 8, 8);
}
function drawHospital(x, y, dk) {
const w = 50, h = 40;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#c8ccd0', '#a0a4a8', '#d8dce0', dk);
// Red cross on front
ctx.fillStyle = '#e94560';
ctx.fillRect(x-3, baseY-h+4, 6, 14);
ctx.fillRect(x-7, baseY-h+8, 14, 6);
// Door
ctx.fillStyle = dk ? '#0a1018' : '#4a5a6a';
ctx.fillRect(x-5, baseY-12, 10, 12);
// Windows
const wc = dk ? 'rgba(200,240,255,0.55)' : 'rgba(200,230,255,0.5)';
ctx.fillStyle = wc;
for (let r = 0; r < 2; r++)
for (let c = 0; c < 4; c++)
ctx.fillRect(x-w/2+4+c*12, baseY-h+20+r*8, 8, 5);
}
function drawChurch(x, y, dk) {
const w = 40, h = 36;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#c0b8a8', '#a09888', '#d0c8b8', dk);
// Steeple (2.5D)
const stW = 12, stH = 18;
draw25DBox(x, baseY-h, stW, stH, '#908880', '#706860', '#a09890', dk);
// Steeple pointed top
ctx.fillStyle = dk ? '#1a1818' : '#908880';
ctx.beginPath();
ctx.moveTo(x-8, baseY-h-stH); ctx.lineTo(x, baseY-h-stH-12); ctx.lineTo(x+8, baseY-h-stH);
ctx.closePath(); ctx.fill();
// Cross
ctx.fillStyle = dk ? '#888' : '#d4c8a0';
ctx.fillRect(x-1.5, baseY-h-stH-20, 3, 10);
ctx.fillRect(x-4, baseY-h-stH-18, 8, 3);
// Stained glass (rose window)
ctx.fillStyle = dk ? 'rgba(100,150,255,0.5)' : 'rgba(80,120,200,0.4)';
ctx.beginPath(); ctx.arc(x, baseY-h-8, 4, 0, 6.28); ctx.fill();
// Arched door
ctx.fillStyle = dk ? '#1a1518' : '#5a4a3a';
ctx.fillRect(x-5, baseY-14, 10, 14);
ctx.beginPath(); ctx.arc(x, baseY-14, 5, Math.PI, 0); ctx.fill();
// Side windows
const wc = dk ? 'rgba(255,200,100,0.5)' : 'rgba(200,180,120,0.45)';
ctx.fillStyle = wc;
ctx.fillRect(x-w/2+4, baseY-h+6, 6, 10);
ctx.fillRect(x+w/2-10, baseY-h+6, 6, 10);
// Label
ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText("St. Mary's", x+1, baseY+3);
ctx.fillStyle = dk ? '#a0a8c0' : '#fff'; ctx.fillText("St. Mary's", x, baseY+2);
}
function drawCinema(x, y, dk) {
const w = 48, h = 34;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#5a3060', '#3a1840', '#6a4070', dk);
// Marquee sign
ctx.fillStyle = dk ? '#4a2040' : '#8a4080';
ctx.fillRect(x-w/2+4, baseY-h-8, w-8, 10);
// Marquee lights
ctx.fillStyle = dk ? '#f0c040' : '#ffe880';
for (let i = 0; i < 8; i++) {
const lx = x-w/2+8+i*5;
const flicker = Math.sin(animFrame*0.1+i*0.8) > 0;
if (flicker || !dk) {
ctx.beginPath(); ctx.arc(lx, baseY-h-3, 1.5, 0, 6.28); ctx.fill();
}
}
ctx.fillStyle = dk ? '#f0c040' : '#fff';
ctx.font = 'bold 7px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('CINEMA', x, baseY-h-2);
// Door
ctx.fillStyle = dk ? '#1a0818' : '#3a1830';
ctx.fillRect(x-5, baseY-12, 10, 12);
// Poster frames
const wc = dk ? 'rgba(255,200,100,0.4)' : 'rgba(200,180,240,0.5)';
ctx.fillStyle = wc;
ctx.fillRect(x-w/2+4, baseY-h+8, 12, 16);
ctx.fillRect(x+w/2-16, baseY-h+8, 12, 16);
}
function drawApartment(x, y, dk) {
const w = 38, h = 48;
const baseY = y + h/2;
draw25DBox(x, baseY, w, h, '#a09890', '#807870', '#b0a8a0', dk);
// Door
ctx.fillStyle = dk ? '#1a1520' : '#605850';
ctx.fillRect(x-4, baseY-10, 8, 10);
// Front windows (4 rows x 3 cols)
const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.45)';
for (let r = 0; r < 4; r++)
for (let c = 0; c < 3; c++) {
if (dk && ((r*3+c+animFrame) % 7 < 2)) continue;
ctx.fillStyle = wc;
ctx.fillRect(x-w/2+4+c*12, baseY-h+5+r*11, 8, 7);
}
// Side windows
const swc = dk ? 'rgba(255,200,80,0.35)' : 'rgba(160,200,240,0.3)';
for (let r = 0; r < 4; r++) {
if (dk && ((r+animFrame) % 5 < 2)) continue;
ctx.fillStyle = swc;
const wy = baseY-h+6+r*11;
ctx.beginPath();
ctx.moveTo(x+w/2+1, wy); ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY);
ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+6); ctx.lineTo(x+w/2+1, wy+6);
ctx.closePath(); ctx.fill();
}
}
function drawSquare(x, y, dk) {
// Cobblestone plaza with raised edge
ctx.fillStyle = dk ? '#2a2820' : '#b0a890';
ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.fill();
// Raised edge (2.5D)
ctx.fillStyle = dk ? '#1a1810' : '#908870';
ctx.beginPath(); ctx.ellipse(x, y+3, 50, 30, 0, 0, Math.PI); ctx.fill();
ctx.strokeStyle = dk ? '#3a3828' : '#c0b898';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.stroke();
// Cobblestone pattern
ctx.strokeStyle = dk ? 'rgba(60,55,45,0.3)' : 'rgba(160,150,130,0.3)';
ctx.lineWidth = 0.5;
for (let i = -3; i <= 3; i++) {
ctx.beginPath(); ctx.moveTo(x+i*12, y-20); ctx.lineTo(x+i*12, y+20); ctx.stroke();
}
// Fountain (2.5D — circular base + column + basin)
ctx.fillStyle = dk ? '#4a5868' : '#788898';
ctx.beginPath(); ctx.ellipse(x, y+4, 14, 7, 0, 0, 6.28); ctx.fill();
ctx.fillStyle = dk ? '#1a2a3a' : '#708898';
ctx.beginPath(); ctx.ellipse(x, y+1, 12, 6, 0, 0, 6.28); ctx.fill();
// Fountain column
ctx.fillStyle = dk ? '#3a4858' : '#8098a8';
ctx.fillRect(x-2, y-8, 4, 10);
// Water basin top
ctx.fillStyle = dk ? '#2a3a5a' : '#88b0d0';
ctx.beginPath(); ctx.ellipse(x, y-8, 7, 3, 0, 0, 6.28); ctx.fill();
// Water spray
if (!dk || animFrame % 3 < 2) {
ctx.fillStyle = `rgba(100,180,220,${dk?0.3:0.5})`;
const wy = y-14+Math.sin(animFrame*0.06)*2;
ctx.beginPath(); ctx.arc(x, wy, 2, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.arc(x-3, wy+2, 1.5, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.arc(x+3, wy+2, 1.5, 0, 6.28); ctx.fill();
}
// Benches
ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30';
ctx.fillRect(x-30, y+12, 12, 3);
ctx.fillRect(x+18, y+12, 12, 3);
// Label
ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Town Square', x+1, y+23);
ctx.fillStyle = dk ? '#a0a8b0' : '#fff'; ctx.fillText('Town Square', x, y+22);
}
function drawSportsField(x, y, dk) {
// Large green field
ctx.fillStyle = dk ? '#1a3018' : '#4a9a38';
ctx.beginPath(); ctx.ellipse(x, y, 48, 24, 0, 0, 6.28); ctx.fill();
// Field lines
ctx.strokeStyle = dk ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.ellipse(x, y, 44, 20, 0, 0, 6.28); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, y-20); ctx.lineTo(x, y+20); ctx.stroke();
ctx.beginPath(); ctx.arc(x, y, 8, 0, 6.28); ctx.stroke();
// Goals
ctx.strokeStyle = dk ? '#555' : '#ddd'; ctx.lineWidth = 2;
ctx.strokeRect(x-46, y-6, 4, 12);
ctx.strokeRect(x+42, y-6, 4, 12);
// Running track
ctx.strokeStyle = dk ? '#3a2828' : '#c87850';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.ellipse(x, y, 52, 28, 0, 0, 6.28); ctx.stroke();
// Bleachers
ctx.fillStyle = dk ? '#2a2828' : '#888';
ctx.fillRect(x-20, y+26, 40, 5);
// Label
ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Sports Field', x+1, y+33);
ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Sports Field', x, y+32);
}
function dim(hex, f) {
if (hex.startsWith('rgb')) return hex; // already rgb
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
return `rgb(${~~(r*f)},${~~(g*f)},${~~(b*f)})`;
}
// ============================================================
// COUPLE LINES
// ============================================================
function drawCoupleLines(W, H) {
const drawn = new Set();
for (const [id, a] of Object.entries(agents)) {
if (!a.partner_id || drawn.has(id)) continue;
const other = agents[a.partner_id];
if (!other) continue;
drawn.add(id); drawn.add(a.partner_id);
const p1 = agentPositions[id], p2 = agentPositions[a.partner_id];
if (!p1 || !p2) continue;
ctx.strokeStyle = 'rgba(233, 30, 144, 0.4)';
ctx.lineWidth = 1.5; ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
ctx.setLineDash([]);
const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2 - 10;
drawHeart(mx, my + Math.sin(animFrame*0.05)*3, 6, 'rgba(233,30,144,0.7)');
}
}
function drawHeart(x, y, s, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y + s*0.4);
ctx.bezierCurveTo(x, y - s*0.2, x - s, y - s*0.5, x - s, y + s*0.1);
ctx.bezierCurveTo(x - s, y + s*0.6, x, y + s, x, y + s*1.2);
ctx.bezierCurveTo(x, y + s, x + s, y + s*0.6, x + s, y + s*0.1);
ctx.bezierCurveTo(x + s, y - s*0.5, x, y - s*0.2, x, y + s*0.4);
ctx.fill();
}
// ============================================================
// CONVERSATION BUBBLES
// ============================================================
function drawConversationBubbles(W, H) {
if (!conversationData.active) return;
for (const conv of conversationData.active) {
if (!conv.turns || conv.turns.length === 0) continue;
const lastTurn = conv.turns[conv.turns.length - 1];
const speakerId = lastTurn.speaker_id;
const pos = agentPositions[speakerId];
if (!pos) continue;
const msg = (lastTurn.message || '').slice(0, 40) + (lastTurn.message.length > 40 ? '...' : '');
ctx.font = '9px Segoe UI';
const tw = ctx.measureText(msg).width;
const bw = tw + 14, bh = 18;
const bx = pos.x - bw/2, by = pos.y - 48;
ctx.fillStyle = 'rgba(240,192,64,0.9)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 4); ctx.fill();
ctx.beginPath();
ctx.moveTo(pos.x - 4, by + bh); ctx.lineTo(pos.x, by + bh + 6); ctx.lineTo(pos.x + 4, by + bh);
ctx.fill();
ctx.fillStyle = '#1a1a2e';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(msg, pos.x, by + bh/2);
}
}
// ============================================================
// AGENT POSITIONS
// ============================================================
function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
const loc = agent.location || 'house_elena';
const allPos = getEffectivePositions();
const pos = allPos[loc];
if (!pos) return;
const atLoc = byLoc[loc] || [];
const localIdx = atLoc.findIndex(a => a.id === id);
const count = atLoc.length;
// For streets, spread agents along the road
if (pos.type === 'street') {
const row = Math.floor(localIdx / 10);
const col = localIdx % 10;
const spread = Math.min(count, 10);
const step = 0.025;
const startX = pos.x - (spread-1)*step/2;
agentTargets[id] = {
x: (startX + col * step) * W,
y: pos.y * H + 16 + row * 18 + (col % 2) * 10
};
} else {
// Multi-row arrangement for large crowds
locCrowdCount[loc] = count; // store for drawPerson scale-down
let ox, oy;
if (count <= 10) {
// Small crowd: natural semicircle arc
const baseR = pos.type === 'house' ? 18 : (pos.type === 'park' || pos.type === 'square' || pos.type === 'sports' ? 40 : 28);
const mpr = pos.type === 'house' ? 4 : 6;
const iRow = Math.floor(localIdx / mpr);
const iCol = localIdx % mpr;
const rCnt = Math.min(count - iRow * mpr, mpr);
const rad = baseR + iRow * 18;
const stp = Math.PI / Math.max(rCnt + 1, 2);
const ang = stp * (iCol + 1);
ox = Math.cos(ang) * rad - rad / 3;
oy = Math.sin(ang) * rad * 0.5 + (pos.type === 'house' ? 20 : 28) + iRow * 8;
} else {
// Large crowd: grid layout — fills a rectangle so no dense blob
const isWide = pos.type === 'park' || pos.type === 'square' || pos.type === 'sports';
const areaW = isWide ? 290 : 210;
const areaH = isWide ? 210 : 160;
const cols = Math.ceil(Math.sqrt(count * 1.3));
const rows = Math.ceil(count / cols);
const cellW = areaW / cols;
const cellH = areaH / rows;
const col = localIdx % cols;
const iRow = Math.floor(localIdx / cols);
// Deterministic per-agent jitter so grid looks organic, not military
const jx = ((globalIdx * 7 + 3) % 11) - 5;
const jy = ((globalIdx * 11 + 7) % 9) - 4;
ox = -(cols - 1) * cellW / 2 + col * cellW + jx;
oy = 26 + iRow * cellH + jy;
}
agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy };
}
if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
// ── L-shaped road routing for moving agents ──────────────────
// On destination change, compute a 2-waypoint L-shaped path via the
// road grid so agents walk along streets instead of cutting diagonals.
const prevLoc = agentPrevLocations[id];
if (agent.state === 'moving' && prevLoc && prevLoc !== loc) {
const prevPos = allPos[prevLoc];
if (prevPos && agentPositions[id]) {
const srcX = prevPos.x, srcY = prevPos.y;
const dstX = pos.x, dstY = pos.y;
const hRoads = [0.22, 0.28, 0.43, 0.58, 0.72, 0.80];
const vRoads = [0.08, 0.15, 0.30, 0.50, 0.70, 0.85, 0.92];
const midY = (srcY + dstY) / 2, midX = (srcX + dstX) / 2;
const bestH = hRoads.reduce((b, r) => Math.abs(midY - r) < Math.abs(midY - b) ? r : b, hRoads[0]);
const bestV = vRoads.reduce((b, r) => Math.abs(midX - r) < Math.abs(midX - b) ? r : b, vRoads[0]);
const lo = (localIdx % 3 - 1) * 5; // lane spread keeps agents from stacking
if (Math.abs(dstX - srcX) >= Math.abs(dstY - srcY)) {
// Primarily horizontal: exit to H-road, slide across, drop to destination
agentWaypoints[id] = [
{ x: srcX * W + lo, y: bestH * H },
{ x: dstX * W + lo, y: bestH * H },
];
} else {
// Primarily vertical: exit to V-road, travel up/down, approach destination
agentWaypoints[id] = [
{ x: bestV * W, y: srcY * H + lo },
{ x: bestV * W, y: dstY * H + lo },
];
}
}
}
agentPrevLocations[id] = loc;
}
// ============================================================
// PERSON DRAWING
// ============================================================
function drawPerson(id, agent, globalIdx, W, H) {
const pos = agentPositions[id];
if (!pos) return;
const ax = pos.x, ay = pos.y;
const color = AGENT_COLORS[globalIdx % AGENT_COLORS.length];
const isSel = id === selectedAgentId;
const isHov = id === hoveredAgent;
const gender = agent.gender || 'unknown';
// Shrink agents proportionally when many share a location — sqrt falloff, min 48%
const crowdN = locCrowdCount[agent.location] || 1;
const crowdShrink = Math.max(0.48, Math.sqrt(12 / Math.max(12, crowdN)));
const scale = isSel ? 1.15 : (isHov ? 1.0 : 0.82 * crowdShrink);
const isMoving = agent.state === 'moving';
const isSleeping = agent.state === 'sleeping';
// Visual movement = interpolated position is far from target
const tgt = agentTargets[id];
const movingVisually = tgt && Math.hypot(ax - tgt.x, ay - tgt.y) > 4;
const walkAnim = isMoving || movingVisually;
const walkPhase = animFrame * 0.28;
const bounce = walkAnim ? Math.sin(walkPhase) * 2.5 : 0;
const legSwing = walkAnim ? Math.sin(walkPhase) * 12 : 0;
const armSwing = walkAnim ? Math.sin(walkPhase) * 10 : 0;
const tY = -10 + bounce; // torso top Y — hoisted so profile view can use it
// Facing direction — maintained by animate() from position delta
const facingRight = agentFacingRight[id] !== false; // default right
const movingUp = agentMovingUp[id] === true;
// Skin & hair
const skinTones = ['#f5dbb8','#d4a574','#c68642','#8d5524','#e8c4a0','#f0c090'];
const skin = skinTones[(globalIdx * 7 + 3) % skinTones.length];
const hairColor = dim(color, 0.45);
// Pants = darker variant of shirt, shoes = very dark
const pantsColor = dim(color, 0.55);
const shoeColor = currentTimeOfDay === 'night' ? '#1a1a1a' : '#2a2010';
ctx.save();
ctx.translate(ax, ay);
ctx.scale(scale, scale);
if (isSel) { ctx.shadowColor = color; ctx.shadowBlur = 14; }
// Gold ring for player-controlled agent
if (agent.is_player) {
ctx.beginPath();
ctx.arc(0, 0, 22, 0, Math.PI * 2);
ctx.strokeStyle = '#f0c040';
ctx.lineWidth = 2.5;
ctx.globalAlpha = 0.8;
ctx.stroke();
ctx.globalAlpha = 1.0;
}
// ══════════════════════════════════════════════════════════════
// SLEEPING VIEW — agent lying on a bed
// ══════════════════════════════════════════════════════════════
if (isSleeping) {
const bw = 22, bh = 10;
// Bed frame
ctx.fillStyle = '#5c3d1a';
ctx.beginPath(); ctx.roundRect(-bw/2 - 1, -bh/2 - 1, bw + 2, bh + 2, 2); ctx.fill();
// Mattress
ctx.fillStyle = '#c9a97a';
ctx.beginPath(); ctx.roundRect(-bw/2, -bh/2, bw, bh, 1); ctx.fill();
// Pillow at head-end (right side)
ctx.fillStyle = '#f0e8e0';
ctx.beginPath(); ctx.roundRect(bw/2 - 9, -bh/2 + 1, 8, bh - 2, 2); ctx.fill();
// Blanket covering body (left portion)
ctx.fillStyle = color;
ctx.beginPath(); ctx.roundRect(-bw/2 + 1, -bh/2 + 1.5, bw - 11, bh - 3, 1); ctx.fill();
// Blanket fold highlight
ctx.fillStyle = dim(color, 1.25);
ctx.beginPath(); ctx.roundRect(-bw/2 + 1, -bh/2 + 1.5, bw - 11, 2.5, 1); ctx.fill();
// Head resting on pillow
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(bw/2 - 5, 0, 3.5, 0, 6.28); ctx.fill();
// Hair
ctx.fillStyle = hairColor;
ctx.beginPath(); ctx.arc(bw/2 - 5, -0.5, 4.2, Math.PI * 0.85, Math.PI * 2.15); ctx.fill();
// Closed eyes (two small arcs)
ctx.strokeStyle = dim(skin, 0.45); ctx.lineWidth = 0.7; ctx.lineCap = 'round';
ctx.beginPath(); ctx.arc(bw/2 - 3.5, -0.3, 1.0, Math.PI * 1.1, Math.PI * 0.0); ctx.stroke();
ctx.beginPath(); ctx.arc(bw/2 - 6.5, -0.3, 1.0, Math.PI * 1.1, Math.PI * 0.0); ctx.stroke();
// ZZZ bubbles
const zt = animFrame * 0.04;
ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left';
for (let i = 0; i < 3; i++) {
ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8';
ctx.fillText('z', bw/2 - 2 + i * 5, -bh/2 - 4 - i * 5 + Math.sin(zt + i) * 2);
}
ctx.globalAlpha = 1;
if (agent.partner_id) drawHeart(bw/2 - 5, -bh/2 - 14 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)');
if (isSel) {
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.roundRect(-bw/2 - 4, -bh/2 - 4, bw + 8, bh + 8, 4); ctx.stroke();
ctx.setLineDash([]);
}
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.restore();
const fnS = (agent.name || id).split(' ')[0];
ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnS, ax + 1, ay + 8 * scale + 1);
ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnS, ax, ay + 8 * scale);
return;
}
if (movingUp) {
// ══════════════════════════════════════════════════════════
// BACK VIEW — agent moving/facing toward top of screen
// Same figure as front view but no face; hair covers back.
// ══════════════════════════════════════════════════════════
const twB = gender === 'female' ? 7 : 8;
const bwB = gender === 'female' ? 4.5 : 6;
const hxB = 1, hyB = tY - 10, hrB = 6.5;
// Ground shadow
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath(); ctx.ellipse(3, 13, 8, 3, 0.2, 0, 6.28); ctx.fill();
// SHOES
const lFBX = -2.5 - legSwing * 0.35, lFBY = 12 + bounce + legSwing;
const rFBX = 2.5 + legSwing * 0.35, rFBY = 12 + bounce - legSwing;
ctx.fillStyle = shoeColor;
ctx.beginPath(); ctx.roundRect(lFBX - 3, lFBY - 1, 5, 2.5, 1); ctx.fill();
ctx.beginPath(); ctx.roundRect(rFBX - 1.5, rFBY - 1, 5, 2.5, 1); ctx.fill();
// LEGS
ctx.lineWidth = 2.8; ctx.lineCap = 'round';
ctx.strokeStyle = pantsColor;
ctx.beginPath(); ctx.moveTo(-1.5, 4 + bounce);
ctx.lineTo(-1.5 - legSwing * 0.18, 8 + bounce + legSwing * 0.5);
ctx.lineTo(lFBX, lFBY); ctx.stroke();
ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce);
ctx.lineTo(1.5 + legSwing * 0.18, 8 + bounce - legSwing * 0.5);
ctx.lineTo(rFBX, rFBY); ctx.stroke();
// TORSO (back — darker gradient, spine crease)
const tGradB = ctx.createLinearGradient(-twB - 1, tY, twB + 1, tY + 13);
tGradB.addColorStop(0.00, dim(color, 0.62));
tGradB.addColorStop(0.50, dim(color, 0.78));
tGradB.addColorStop(1.00, dim(color, 0.50));
ctx.fillStyle = tGradB;
ctx.beginPath();
ctx.moveTo(-bwB, tY + 13); ctx.lineTo(-twB, tY + 2);
ctx.quadraticCurveTo(-twB, tY - 1, -twB + 3, tY - 1);
ctx.lineTo(twB - 3, tY - 1);
ctx.quadraticCurveTo(twB, tY - 1, twB, tY + 2);
ctx.lineTo(bwB, tY + 13); ctx.closePath(); ctx.fill();
ctx.strokeStyle = dim(color, 0.38); ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(0, tY + 2); ctx.lineTo(0, tY + 12); ctx.stroke();
if (gender === 'female') {
const sGB = ctx.createLinearGradient(-bwB - 2, tY + 13, bwB + 2, tY + 17);
sGB.addColorStop(0, dim(color, 0.65)); sGB.addColorStop(1, dim(color, 0.50));
ctx.fillStyle = sGB;
ctx.beginPath();
ctx.moveTo(-bwB, tY + 13); ctx.lineTo(-bwB - 2.5, tY + 17 + bounce);
ctx.lineTo(bwB + 2.5, tY + 17 + bounce); ctx.lineTo(bwB, tY + 13);
ctx.closePath(); ctx.fill();
}
// ARMS (back — slightly dimmed skin)
const sBy = tY + 2;
ctx.strokeStyle = dim(skin, 0.78); ctx.lineWidth = 1.8; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(-twB + 1, sBy);
ctx.lineTo(-twB - 3 + armSwing * 0.3, sBy + 5 + armSwing * 0.4);
ctx.lineTo(-twB - 4.5 + armSwing * 0.7, sBy + 9 + armSwing * 0.85); ctx.stroke();
ctx.beginPath(); ctx.moveTo(twB - 1, sBy);
ctx.lineTo(twB + 3 - armSwing * 0.3, sBy + 5 - armSwing * 0.4);
ctx.lineTo(twB + 4.5 - armSwing * 0.7, sBy + 9 - armSwing * 0.85); ctx.stroke();
ctx.fillStyle = dim(skin, 0.78);
ctx.beginPath(); ctx.arc(-twB - 4.5 + armSwing * 0.7, sBy + 9 + armSwing * 0.85, 1.1, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.arc(twB + 4.5 - armSwing * 0.7, sBy + 9 - armSwing * 0.85, 1.1, 0, 6.28); ctx.fill();
// NECK (back)
ctx.fillStyle = dim(skin, 0.72); ctx.fillRect(-1.5, tY - 3.5, 3, 4);
// HEAD (back of head — darker, no face features)
ctx.fillStyle = dim(skin, 0.78);
ctx.beginPath(); ctx.arc(hxB, hyB, hrB, 0, 6.28); ctx.fill();
// HAIR (back view — covers more of head than front)
ctx.fillStyle = hairColor;
if (gender === 'female') {
ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 1.0, Math.PI * 1.0, Math.PI * 2.0); ctx.fill();
ctx.beginPath(); ctx.ellipse(hxB - hrB + 0.5, hyB + 4, 2.5, hrB * 0.95, -0.1, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.ellipse(hxB + hrB - 0.5, hyB + 4, 2.2, hrB * 0.90, 0.1, 0, 6.28); ctx.fill();
} else if (gender === 'male') {
ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 0.5, Math.PI * 1.0, Math.PI * 2.0); ctx.fill();
ctx.fillRect(hxB - hrB - 0.5, hyB - 0.5, 3, hrB * 0.6);
} else {
ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 0.8, Math.PI * 1.0, Math.PI * 2.0); ctx.fill();
ctx.beginPath(); ctx.ellipse(hxB - hrB + 0.5, hyB + 3, 2.2, hrB * 0.75, -0.1, 0, 6.28); ctx.fill();
}
if (agent.partner_id) drawHeart(hxB + 1, hyB - hrB - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)');
if (isSel) {
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.arc(hxB, hyB, hrB + 3, 0, 6.28); ctx.stroke(); ctx.setLineDash([]);
}
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.restore();
const fnB = (agent.name || id).split(' ')[0];
ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnB, ax + 1, ay + 14 * scale + 1);
ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnB, ax, ay + 14 * scale);
const mVB = agent.mood || 0, mwBL = 16, mxbB = ax - 8, mybB = ay + 14 * scale + 11;
ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxbB, mybB, mwBL, 2.5);
const mfB = (mVB + 1) / 2;
ctx.fillStyle = mfB > 0.6 ? '#4ecca3' : (mfB > 0.3 ? '#f0c040' : '#e94560');
ctx.fillRect(mxbB, mybB, mwBL * mfB, 2.5);
return;
}
if (walkAnim) {
// ══════════════════════════════════════════════════════════
// PROFILE VIEW — side-on silhouette while walking
// Positive X = forward (direction of travel).
// Flip the context for left-facing agents.
// ══════════════════════════════════════════════════════════
if (!facingRight) ctx.scale(-1, 1);
const hr = 6.5;
const hx = 2, hy = tY - 11;
const pStride = Math.sin(walkPhase) * 12; // foot X swing (±12)
// Ground shadow
ctx.fillStyle = 'rgba(0,0,0,0.20)';
ctx.beginPath(); ctx.ellipse(2, 13, 10, 2.5, 0, 0, 6.28); ctx.fill();
// ── BACK LEG (behind body, drawn first) ───────────────────
const bKX = -pStride * 0.42, bKY = 8 + bounce;
const bFX = -pStride * 0.75, bFY = 14 + bounce;
ctx.strokeStyle = dim(pantsColor, 0.62); ctx.lineWidth = 2.5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce); ctx.lineTo(bKX, bKY); ctx.lineTo(bFX, bFY); ctx.stroke();
ctx.fillStyle = dim(shoeColor, 0.62);
ctx.beginPath(); ctx.roundRect(bFX - 2, bFY - 1, 6.5, 2.5, 1); ctx.fill();
// ── BACK ARM (behind body) ────────────────────────────────
const bAS = -Math.sin(walkPhase) * 8;
const bEX = 1.5 + bAS * 0.42, bEY = tY + 7;
const bHX = bEX + bAS * 0.5, bHY = bEY + 4;
ctx.strokeStyle = dim(skin, 0.65); ctx.lineWidth = 1.7;
ctx.beginPath(); ctx.moveTo(2, tY + 2); ctx.lineTo(bEX, bEY); ctx.lineTo(bHX, bHY); ctx.stroke();
ctx.fillStyle = dim(skin, 0.65);
ctx.beginPath(); ctx.arc(bHX, bHY, 1.0, 0, 6.28); ctx.fill();
// ── TORSO (narrow side profile, lit-front → dark-back) ───
const pGrad = ctx.createLinearGradient(-0.5, tY, 5.5, tY);
pGrad.addColorStop(0.00, dim(color, 1.22));
pGrad.addColorStop(0.45, color);
pGrad.addColorStop(1.00, dim(color, 0.50));
ctx.fillStyle = pGrad;
ctx.beginPath();
ctx.moveTo(-0.5, tY + 13); ctx.lineTo(-0.5, tY + 2);
ctx.quadraticCurveTo(-0.5, tY - 1, 1, tY - 1);
ctx.lineTo(3.5, tY - 1); ctx.quadraticCurveTo(5.5, tY - 1, 5.5, tY + 2);
ctx.lineTo(5.5, tY + 13); ctx.closePath(); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.11)';
ctx.beginPath(); ctx.ellipse(0.5, tY + 4, 1.3, 4, 0, 0, 6.28); ctx.fill();
ctx.strokeStyle = dim(color, 0.42); ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(-0.5, tY + 13); ctx.lineTo(5.5, tY + 13); ctx.stroke();
if (gender === 'female') {
const sG = ctx.createLinearGradient(0, tY + 13, 0, tY + 19);
sG.addColorStop(0, color); sG.addColorStop(1, dim(color, 0.68));
ctx.fillStyle = sG;
ctx.beginPath();
ctx.moveTo(-0.5, tY + 13); ctx.lineTo(-2.5, tY + 19 + bounce);
ctx.lineTo(7.5, tY + 19 + bounce); ctx.lineTo(5.5, tY + 13);
ctx.closePath(); ctx.fill();
}
// ── FRONT LEG (in front of body) ─────────────────────────
const fKX = pStride * 0.42 + 1.5, fKY = 8 + bounce;
const fFX = pStride * 0.75 + 1.5, fFY = 14 + bounce;
ctx.strokeStyle = pantsColor; ctx.lineWidth = 2.9;
ctx.beginPath(); ctx.moveTo(2, 4 + bounce); ctx.lineTo(fKX, fKY); ctx.lineTo(fFX, fFY); ctx.stroke();
ctx.fillStyle = shoeColor;
ctx.beginPath(); ctx.roundRect(fFX - 1.5, fFY - 1, 8, 2.5, 1); ctx.fill();
// ── FRONT ARM ─────────────────────────────────────────────
const fAS = Math.sin(walkPhase) * 8;
const fEX = 2.5 + fAS * 0.42, fEY = tY + 7;
const fHX = fEX + fAS * 0.5, fHY = fEY + 4;
ctx.strokeStyle = skin; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo(2.5, tY + 2); ctx.lineTo(fEX, fEY); ctx.lineTo(fHX, fHY); ctx.stroke();
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(fHX, fHY, 1.1, 0, 6.28); ctx.fill();
// ── NECK ──────────────────────────────────────────────────
ctx.fillStyle = skin; ctx.fillRect(0.5, tY - 4, 2.5, 4);
// ── HEAD (profile) ────────────────────────────────────────
// Back of head (whole circle, slightly dark)
ctx.fillStyle = dim(skin, 0.80);
ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.28); ctx.fill();
// Front face hemisphere (right semicircle, skin-tone)
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(hx, hy, hr, -Math.PI * 0.55, Math.PI * 0.55);
ctx.lineTo(hx, hy); ctx.closePath(); ctx.fill();
// Face highlight (lit from front-upper)
ctx.fillStyle = 'rgba(255,255,255,0.10)';
ctx.beginPath(); ctx.arc(hx + 2.5, hy - 2, hr * 0.48, 0, 6.28); ctx.fill();
// Ear (back of head, visible in profile)
ctx.fillStyle = dim(skin, 0.86);
ctx.beginPath(); ctx.ellipse(hx - hr + 1.5, hy + 0.5, 1.8, 2.5, 0.1, 0, 6.28); ctx.fill();
// ── HAIR (profile) ────────────────────────────────────────
ctx.fillStyle = hairColor;
ctx.beginPath(); ctx.arc(hx, hy - 0.5, hr + 0.8, Math.PI * 1.08, Math.PI * 0.1); ctx.fill();
if (gender === 'female') {
ctx.beginPath(); ctx.ellipse(hx - hr + 0.5, hy + 3, 2.5, hr * 0.82, 0.15, 0, 6.28); ctx.fill();
} else {
ctx.fillRect(hx - hr - 0.5, hy - 1.5, 3, hr * 0.50);
}
// ── FACE FEATURES (profile) ───────────────────────────────
const moodVP = agent.mood || 0;
// Eyebrow (one, angle reacts to mood)
const pBrY = hy - 3.5;
const pSadT = moodVP < 0 ? Math.min(1, -moodVP) * 1.5 : 0;
const pHapR = moodVP > 0 ? moodVP * 0.7 : 0;
ctx.strokeStyle = hairColor; ctx.lineWidth = 1.0; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(hx + 1.5, pBrY - pHapR); ctx.lineTo(hx + 5.0, pBrY - pHapR + pSadT); ctx.stroke();
// Eye (one, oval)
ctx.fillStyle = '#1a1010';
ctx.beginPath(); ctx.ellipse(hx + 3.5, hy - 0.8, 0.85, 1.1, 0.12, 0, 6.28); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(hx + 3.8, hy - 1.1, 0.35, 0, 6.28); ctx.fill();
// Nose (small bump at front edge)
ctx.fillStyle = dim(skin, 0.73);
ctx.beginPath(); ctx.moveTo(hx + hr - 3, hy - 2);
ctx.quadraticCurveTo(hx + hr + 1.8, hy - 0.5, hx + hr - 1.5, hy + 2);
ctx.closePath(); ctx.fill();
// Mouth (small profile curve: happy=up ∩, frown=down ∪)
const pMC = Math.max(-2.5, Math.min(2.5, moodVP * 6));
const pMX0 = hx + hr - 4.2, pMX1 = hx + hr - 1.3, pMY = hy + 3.6;
ctx.strokeStyle = moodVP > 0 ? 'rgba(210,90,90,0.85)' : 'rgba(140,70,80,0.85)';
ctx.lineWidth = 0.9;
ctx.beginPath(); ctx.moveTo(pMX0, pMY);
ctx.quadraticCurveTo((pMX0 + pMX1) / 2, pMY - pMC, pMX1, pMY); ctx.stroke();
// ── STATE EFFECTS ─────────────────────────────────────────
if (agent.state === 'in_conversation') {
ctx.fillStyle = 'rgba(240,192,64,0.9)';
ctx.beginPath(); ctx.roundRect(hr + 3, hy - hr - 6, 16, 11, 3); ctx.fill();
ctx.beginPath(); ctx.moveTo(hr + 3, hy - hr - 2); ctx.lineTo(hr - 1, hy); ctx.lineTo(hr + 7, hy - hr - 2); ctx.fill();
ctx.fillStyle = '#1a1a2e'; ctx.font = 'bold 6px Segoe UI';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('...', hr + 11, hy - hr);
}
if (isSleeping) {
const t = animFrame * 0.04; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left';
for (let i = 0; i < 3; i++) {
ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8';
ctx.fillText('z', hr + 2 + i * 4, hy - hr - i * 6 + Math.sin(t + i) * 2);
}
ctx.globalAlpha = 1;
}
if (agent.partner_id) drawHeart(hx + 1, hy - hr - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)');
if (isSel) {
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.arc(hx, hy, hr + 3, 0, 6.28); ctx.stroke(); ctx.setLineDash([]);
}
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.restore();
// Name label + mood bar (world coords, shared with front view)
const fnP = (agent.name || id).split(' ')[0];
ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnP, ax + 1, ay + 14 * scale + 1);
ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnP, ax, ay + 14 * scale);
const mVP = agent.mood || 0, mwP = 16, mxbP = ax - 8, mybP = ay + 14 * scale + 11;
ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxbP, mybP, mwP, 2.5);
const mfP = (mVP + 1) / 2;
ctx.fillStyle = mfP > 0.6 ? '#4ecca3' : (mfP > 0.3 ? '#f0c040' : '#e94560');
ctx.fillRect(mxbP, mybP, mwP * mfP, 2.5);
return;
}
// ── Ground shadow (elongated, ISO-angled) ─────────────────
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath(); ctx.ellipse(3, 13, 8, 3, 0.2, 0, 6.28); ctx.fill();
// ── SHOES ────────────────────────────────────────────────
const lKneeX = walkAnim ? -1.5 - legSwing * 0.18 : -1.5;
const rKneeX = walkAnim ? 1.5 + legSwing * 0.18 : 1.5;
const lFootX = walkAnim ? -2.5 - legSwing * 0.35 : -2.5;
const rFootX = walkAnim ? 2.5 + legSwing * 0.35 : 2.5;
const lFootY = 12 + bounce + legSwing;
const rFootY = 12 + bounce - legSwing;
// Shoe body
ctx.fillStyle = shoeColor;
ctx.beginPath(); ctx.roundRect(lFootX - 3, lFootY - 1, 5, 2.5, 1); ctx.fill();
ctx.beginPath(); ctx.roundRect(rFootX - 1.5, rFootY - 1, 5, 2.5, 1); ctx.fill();
// ── LEGS (two-segment: thigh + shin with knee) ───────────
ctx.lineWidth = walkAnim ? 2.8 : 2.2;
ctx.lineCap = 'round';
// Left leg
ctx.strokeStyle = gender === 'female' ? dim(pantsColor, 0.85) : pantsColor;
ctx.beginPath(); ctx.moveTo(-1.5, 4 + bounce);
ctx.lineTo(lKneeX, 8 + bounce + legSwing * 0.5);
ctx.lineTo(lFootX, lFootY); ctx.stroke();
// Right leg
ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce);
ctx.lineTo(rKneeX, 8 + bounce - legSwing * 0.5);
ctx.lineTo(rFootX, rFootY); ctx.stroke();
// Right leg 2.5D depth (slightly lighter)
ctx.strokeStyle = dim(gender === 'female' ? dim(pantsColor, 0.85) : pantsColor, 1.2);
ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(1.5 + 1.5, 4 + bounce);
ctx.lineTo(rKneeX + 1.5, 8 + bounce - legSwing * 0.5);
ctx.lineTo(rFootX + 1.5, rFootY); ctx.stroke();
// ── TORSO (smooth, rounded, gradient-shaded) ──────────────
const tw = gender === 'female' ? 7 : 8; // shoulder half-width
const bw = gender === 'female' ? 4.5 : 6; // waist half-width
// Helper to build the torso path (reused for fill + highlight)
function torsoPath() {
ctx.beginPath();
ctx.moveTo(-bw, tY + 13);
ctx.lineTo(-tw, tY + 2);
ctx.quadraticCurveTo(-tw, tY - 1, -tw + 3, tY - 1); // left shoulder round
ctx.lineTo(tw - 3, tY - 1);
ctx.quadraticCurveTo(tw, tY - 1, tw, tY + 2); // right shoulder round
ctx.lineTo(bw, tY + 13);
ctx.closePath();
}
// Main fill: horizontal gradient — lit left, shadow right (cylindrical form)
const tGrad = ctx.createLinearGradient(-tw - 1, tY, tw + 1, tY + 13);
tGrad.addColorStop(0.00, dim(color, 1.30)); // highlight left shoulder
tGrad.addColorStop(0.22, dim(color, 1.08)); // upper-centre
tGrad.addColorStop(0.52, color); // mid
tGrad.addColorStop(0.78, dim(color, 0.72)); // right shading
tGrad.addColorStop(1.00, dim(color, 0.45)); // right edge deep shadow
ctx.fillStyle = tGrad;
torsoPath(); ctx.fill();
// Chest highlight — soft radial glow on upper-left
const chGrad = ctx.createRadialGradient(-2.5, tY + 3.5, 0, -2.5, tY + 3.5, 6.5);
chGrad.addColorStop(0, 'rgba(255,255,255,0.20)');
chGrad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = chGrad;
torsoPath(); ctx.fill();
// Subtle right-edge depth line
ctx.strokeStyle = dim(color, 0.35); ctx.lineWidth = 1; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(tw, tY + 2); ctx.lineTo(bw, tY + 13); ctx.stroke();
// Belt / waist crease
ctx.strokeStyle = dim(color, 0.45); ctx.lineWidth = 0.9;
ctx.beginPath(); ctx.moveTo(-bw + 0.5, tY + 13); ctx.lineTo(bw - 0.5, tY + 13); ctx.stroke();
// Female: skirt flare with gradient
if (gender === 'female') {
const skirtGrad = ctx.createLinearGradient(-bw - 2, tY + 13, bw + 2, tY + 17);
skirtGrad.addColorStop(0, color);
skirtGrad.addColorStop(1, dim(color, 0.72));
ctx.fillStyle = skirtGrad;
ctx.beginPath();
ctx.moveTo(-bw, tY + 13); ctx.lineTo(-bw - 2.5, tY + 17 + bounce);
ctx.lineTo(bw + 2.5, tY + 17 + bounce); ctx.lineTo(bw, tY + 13);
ctx.closePath(); ctx.fill();
}
// ── ARMS (two-segment with elbow when walking) ────────────
const shoulderY = tY + 2;
// Elbow positions
const lElbowX = walkAnim ? -tw - 3 + armSwing * 0.3 : -tw - 3;
const lElbowY = walkAnim ? shoulderY + 5 + armSwing * 0.4 : shoulderY + 5;
const rElbowX = walkAnim ? tw + 3 - armSwing * 0.3 : tw + 3;
const rElbowY = walkAnim ? shoulderY + 5 - armSwing * 0.4 : shoulderY + 5;
// Hand positions
const lHandX = walkAnim ? lElbowX - 1.5 + armSwing * 0.4 : lElbowX - 1;
const lHandY = walkAnim ? lElbowY + 4 + armSwing * 0.45 : lElbowY + 4;
const rHandX = walkAnim ? rElbowX + 1.5 - armSwing * 0.4 : rElbowX + 1;
const rHandY = walkAnim ? rElbowY + 4 - armSwing * 0.45 : rElbowY + 4;
ctx.strokeStyle = skin; ctx.lineWidth = 1.8; ctx.lineCap = 'round';
// Left arm (upper arm + forearm)
ctx.beginPath(); ctx.moveTo(-tw + 1, shoulderY);
ctx.lineTo(lElbowX, lElbowY); ctx.lineTo(lHandX, lHandY); ctx.stroke();
// Right arm
ctx.beginPath(); ctx.moveTo(tw - 1, shoulderY);
ctx.lineTo(rElbowX, rElbowY); ctx.lineTo(rHandX, rHandY); ctx.stroke();
// Hands (small filled circles)
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(lHandX, lHandY, 1.1, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.arc(rHandX, rHandY, 1.1, 0, 6.28); ctx.fill();
// ── NECK ─────────────────────────────────────────────────
ctx.fillStyle = skin;
ctx.fillRect(-1.5, tY - 3.5, 3, 4);
// ── HEAD (2.5D — sphere-like with side shading) ───────────
const hx = 1, hy = tY - 10; // head center (slightly right for 2.5D)
const hr = 6.5;
// Back-of-head / shading arc
ctx.fillStyle = dim(skin, 0.78);
ctx.beginPath(); ctx.arc(hx + 1.5, hy, hr - 0.5, 0, 6.28); ctx.fill();
// Main face
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.28); ctx.fill();
// Right cheek highlight (2.5D lit from upper-left)
ctx.fillStyle = `rgba(255,255,255,0.12)`;
ctx.beginPath(); ctx.arc(hx - 2, hy - 2, hr * 0.55, 0, 6.28); ctx.fill();
// Ear (right side, 2.5D)
ctx.fillStyle = dim(skin, 0.88);
ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 1, 1.5, 2, 0, 0, 6.28); ctx.fill();
// ── HAIR ─────────────────────────────────────────────────
ctx.fillStyle = hairColor;
if (gender === 'female') {
// Long hair — top cap + side curtains
ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.1, Math.PI * 0.05); ctx.fill();
ctx.beginPath(); ctx.ellipse(hx - hr + 1, hy + 3, 2.2, hr * 0.85, -0.2, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 3, 1.8, hr * 0.7, 0.2, 0, 6.28); ctx.fill();
} else if (gender === 'male') {
// Short crop
ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.15, Math.PI * -0.1); ctx.fill();
ctx.fillRect(hx - hr - 0.5, hy - 1, 3, hr * 0.6); // left side
} else {
// Medium / non-binary
ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.08, Math.PI * 0.0); ctx.fill();
ctx.beginPath(); ctx.ellipse(hx - hr + 0.5, hy + 2, 2, hr * 0.65, -0.15, 0, 6.28); ctx.fill();
}
// ── FACE ─────────────────────────────────────────────────
const moodVal = agent.mood || 0;
// Eyebrows — angle and height react to mood
const brBase = hy - 3.4;
const sadTilt = moodVal < 0 ? Math.min(1, -moodVal) * 2.0 : 0; // inner corner rises
const happyRaise = moodVal > 0 ? moodVal * 0.8 : 0; // whole brow lifts
ctx.strokeStyle = hairColor; ctx.lineWidth = 1.0; ctx.lineCap = 'round';
// Left brow: outer=(hx-3.8), inner=(hx-1.0)
ctx.beginPath();
ctx.moveTo(hx - 3.8, brBase - happyRaise);
ctx.lineTo(hx - 1.0, brBase - happyRaise - sadTilt);
ctx.stroke();
// Right brow: inner=(hx+1.0), outer=(hx+3.8)
ctx.beginPath();
ctx.moveTo(hx + 1.0, brBase - happyRaise - sadTilt);
ctx.lineTo(hx + 3.8, brBase - happyRaise);
ctx.stroke();
// Eyes
ctx.fillStyle = '#1a1010';
ctx.beginPath(); ctx.arc(hx - 2.2, hy - 1, 1.1, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.arc(hx + 1.5, hy - 1, 1.1, 0, 6.28); ctx.fill();
// Eye shine
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.beginPath(); ctx.arc(hx - 1.8, hy - 1.5, 0.45, 0, 6.28); ctx.fill();
ctx.beginPath(); ctx.arc(hx + 1.9, hy - 1.5, 0.45, 0, 6.28); ctx.fill();
// Mouth — small proportional bezier curve
// happy (moodVal>0): control point ABOVE endpoints → ∩ = smile (corners up)
// neutral (≈0): control point at same level → flat line
// unhappy (moodVal<0): control point BELOW endpoints → ∪ = frown (corners down)
const mouthW = 1.9;
const mCurve = Math.max(-5, Math.min(5, moodVal * 9));
const mouthY = hy + 3.0;
ctx.strokeStyle = moodVal > 0 ? 'rgba(210,90,90,0.88)' : 'rgba(130,70,80,0.85)';
ctx.lineWidth = 1.0; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(hx - mouthW, mouthY);
ctx.quadraticCurveTo(hx, mouthY - mCurve, hx + mouthW, mouthY);
ctx.stroke();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
// ── STATE EFFECTS ─────────────────────────────────────────
if (agent.state === 'in_conversation') {
ctx.fillStyle = 'rgba(240,192,64,0.9)';
ctx.beginPath(); ctx.roundRect(hr + 3, hy - hr - 6, 16, 11, 3); ctx.fill();
// speech tail
ctx.beginPath(); ctx.moveTo(hr + 3, hy - hr - 2); ctx.lineTo(hr - 1, hy); ctx.lineTo(hr + 7, hy - hr - 2); ctx.fill();
ctx.fillStyle = '#1a1a2e'; ctx.font = 'bold 6px Segoe UI';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('...', hr + 11, hy - hr);
}
if (isSleeping) {
const t = animFrame * 0.04;
ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left';
for (let i = 0; i < 3; i++) {
ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8';
ctx.fillText('z', hr + 2 + i * 4, hy - hr - i * 6 + Math.sin(t + i) * 2);
}
ctx.globalAlpha = 1;
}
if (agent.partner_id) {
drawHeart(1, hy - hr - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)');
}
// Selected ring
if (isSel) {
ctx.strokeStyle = color; ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.arc(hx, hy, hr + 3, 0, 6.28); ctx.stroke();
ctx.setLineDash([]);
}
ctx.restore();
// ── Name label ───────────────────────────────────────────
const firstName = (agent.name || id).split(' ')[0];
ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(firstName, ax + 1, ay + 14 * scale + 1);
ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(firstName, ax, ay + 14 * scale);
// ── Mood bar ─────────────────────────────────────────────
const moodV = agent.mood || 0;
const mw = 16, mxb = ax - mw / 2, myb = ay + 14 * scale + 11;
ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxb, myb, mw, 2.5);
const mf = (moodV + 1) / 2;
ctx.fillStyle = mf > 0.6 ? '#4ecca3' : (mf > 0.3 ? '#f0c040' : '#e94560');
ctx.fillRect(mxb, myb, mw * mf, 2.5);
}
// ============================================================
// INTERACTION
// ============================================================
function onCanvasClick(e) {
if (isDragging) return;
if (_blockNextClick) { _blockNextClick = false; return; }
const rect=canvas.getBoundingClientRect();
const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY;
let clicked=null, minD=24;
for (const [id,pos] of Object.entries(agentPositions)) {
const d=Math.hypot(mx-pos.x,my-pos.y);
if(d<minD){minD=d;clicked=id;}
}
if(clicked){
selectedAgentId=clicked;
switchTab('agents');
fetchAgentDetail(clicked);
} else {
selectedAgentId=null;
showDefaultDetail();
}
}
function onCanvasMouseMove(e) {
const rect=canvas.getBoundingClientRect();
const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY;
const W=worldW(), H=worldH();
const tt=document.getElementById('tooltip');
if (isDragging || rectZoomMode) return;
let foundAgent=null;
for (const [id,pos] of Object.entries(agentPositions)) {
if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
}
let foundLoc=null;
if (!foundAgent) {
const allPos = getEffectivePositions();
for (const [id, pos] of Object.entries(allPos)) {
const lx=pos.x*W, ly=pos.y*H;
const hitR = pos.type === 'house' ? 25 : 35;
if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;}
}
}
if(foundAgent!==hoveredAgent){
hoveredAgent=foundAgent;
canvas.style.cursor=(foundAgent||foundLoc)?'pointer':'default';
if(foundAgent&&agents[foundAgent]){
const a=agents[foundAgent];
let extra='';
if(a.partner_id&&agents[a.partner_id]) extra=`<br><span style="color:#e91e90">Partner: ${agents[a.partner_id].name}</span>`;
tt.innerHTML=`<b>${a.name}</b><br><span style="color:#a0a0c0">${a.action||'idle'}</span>${extra}`;
tt.style.display='block';
} else if (!foundLoc) {
tt.style.display='none';
}
}
if (!foundAgent && foundLoc && locations[foundLoc]) {
const loc = locations[foundLoc];
const occ = (loc.occupants||[]);
const occNames = occ.map(o => (typeof o === 'object' ? o.name : (agents[o]?.name || o))).slice(0, 8);
const hasConv = conversationData.active?.some(c => c.location === foundLoc);
tt.innerHTML = `<b>${loc.name||foundLoc}</b> <span style="color:#666">(${loc.zone||''})</span>
${loc.description ? `<br><span style="color:#888;font-size:10px">${esc(loc.description)}</span>` : ''}
<br><span style="color:#a0a0c0">${occ.length} occupant${occ.length!==1?'s':''}</span>
${occNames.length > 0 ? `<br><span style="font-size:10px;color:#b0b0c0">${occNames.join(', ')}${occ.length>8?'...':''}</span>` : ''}
${hasConv ? '<br><span style="color:#f0c040;font-size:10px">Active conversation here</span>' : ''}`;
tt.style.display = 'block';
canvas.style.cursor = 'pointer';
} else if (!foundAgent && !foundLoc) {
canvas.style.cursor = 'default';
if (!hoveredAgent) tt.style.display = 'none';
}
if(foundAgent||foundLoc){
tt.style.left=(e.clientX-rect.left+15)+'px';
tt.style.top=(e.clientY-rect.top+15)+'px';
}
}
// ============================================================
// AGENT DETAIL (Agents Tab)
// ============================================================
function showDefaultDetail() {
const el = document.getElementById('agent-detail');
const sorted = Object.entries(agents).sort((a,b) => {
const aConv = a[1].state === 'in_conversation' ? 0 : 1;
const bConv = b[1].state === 'in_conversation' ? 0 : 1;
if (aConv !== bConv) return aConv - bConv;
return (a[1].name || '').localeCompare(b[1].name || '');
});
el.innerHTML = `
<div class="section-header">Population (${Object.keys(agents).length})</div>
${sorted.map(([aid,a]) => {
const color = AGENT_COLORS[getAgentIdx(aid) % AGENT_COLORS.length];
const gi = a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
const stateIcon = a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':''));
const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : '';
const pregIcon = a.pregnant ? ' \uD83E\uDD30' : '';
const kidIcon = a.children_count > 0 ? ` \uD83D\uDC76${a.children_count}` : '';
return `
<div class="agent-list-item" onclick="selectedAgentId='${aid}';fetchAgentDetail('${aid}');">
<span class="agent-dot" style="background:${color}"></span>
<span style="font-size:13px">${gi}</span>
<div class="agent-info">
<div class="agent-name" style="color:${color}">${a.name}${partner}${pregIcon}${kidIcon}${stateIcon}</div>
<div class="agent-action">${esc(a.action||'idle')}</div>
</div>
</div>`;
}).join('')}`;
}
async function fetchAgentDetail(agentId) {
try {
const res=await fetch(`${API_BASE}/agents/${agentId}`);
if(!res.ok) return;
renderAgentDetail(await res.json());
} catch(e){}
}
function renderAgentDetail(data) {
const needs=data.needs||{};
const needBars=[
{name:'Hunger',key:'hunger',color:'orange'},
{name:'Energy',key:'energy',color:'blue'},
{name:'Social',key:'social',color:'purple'},
{name:'Purpose',key:'purpose',color:'green'},
{name:'Fun',key:'fun',color:'yellow'},
];
const mood=data.mood||0;
const moodPct=((mood+1)/2*100).toFixed(0);
const moodColor=moodPct>60?'green':(moodPct>30?'yellow':'red');
const moodLabel=mood>0.3?'Happy':(mood>-0.3?'Okay':'Unhappy');
const gi=data.gender==='female'?'\uD83D\uDC69':data.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
const memories=(data.recent_memories||[]).slice(-6).reverse();
const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none');
const locName = data.location ? (typeof data.location === 'object' ? data.location.name : data.location) : '?';
// Life events
const lifeEvents = (data.life_events || []).slice(-10).reverse();
const evtIcon = {
origin:'\uD83C\uDFE0', career:'\uD83D\uDCBC', dating:'\uD83D\uDC95', engaged:'\uD83D\uDC8D',
married:'\uD83D\uDC92', pregnant:'\uD83E\uDD30', child_born:'\uD83D\uDC76',
promotion:'\uD83D\uDCC8', graduated:'\uD83C\uDF93', achievement:'\u2B50',
milestone:'\uD83C\uDFC6', new_job:'\uD83D\uDCBC', moved:'\uD83D\uDE9A',
breakup:'\uD83D\uDC94', friendship:'\uD83E\uDD1D',
};
// Goals
const goals = data.goals || [];
const activeGoals = goals.filter(g => g.status === 'active');
const completedGoals = goals.filter(g => g.status === 'completed');
// Children & pregnancy
const children = data.children || [];
const isPregnant = data.pregnant || false;
const pregnantBadge = isPregnant ? ' \uD83E\uDD30' : '';
const childrenBadge = children.length > 0 ? ` \uD83D\uDC76${children.length}` : '';
document.getElementById('agent-detail').innerHTML=`
<div style="margin-bottom:6px">
<span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">&larr; All Agents</span>
</div>
<h2>${gi} ${data.name||'?'}${pregnantBadge}${childrenBadge}</h2>
<p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}</p>
<p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p>
<p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} &mdash; ${esc(data.action||'idle')}</p>
${romanceRels.length>0?`<div style="margin:6px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border:1px solid rgba(233,30,144,0.2);border-radius:4px;font-size:11px;color:#e91e90">
${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
</div>`:''}
${children.length>0?`<div style="margin:4px 0;padding:4px 8px;background:rgba(78,204,163,0.08);border:1px solid rgba(78,204,163,0.2);border-radius:4px;font-size:11px;color:#4ecca3">
\uD83D\uDC76 Children: ${children.map(c=>esc(c)).join(', ')}
</div>`:''}
<div class="bar-container">
<div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div>
<div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div>
</div>
${needBars.map(n=>{
const v=(needs[n.key]||0)*100;
return `<div class="bar-container">
<div class="bar-label"><span>${n.name}</span><span>${v.toFixed(0)}%</span></div>
<div class="bar-bg"><div class="bar-fill ${n.color}" style="width:${v}%"></div></div>
</div>`;
}).join('')}
<div style="margin-top:6px">
<div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
</div>
${activeGoals.length>0||completedGoals.length>0?`
<div class="section-header">Goals</div>
${activeGoals.map(g=>{
const pct=Math.round((g.progress||0)*100);
return `<div style="margin:3px 0;font-size:11px">
<div style="display:flex;justify-content:space-between;color:#e0e0f0">
<span>\uD83C\uDFAF ${esc(g.description)}</span><span style="color:#888;font-size:9px">${pct}%</span>
</div>
<div style="height:3px;background:#1a1a3e;border-radius:2px;margin-top:2px">
<div style="height:100%;width:${pct}%;background:#4ecca3;border-radius:2px"></div>
</div>
</div>`;
}).join('')}
${completedGoals.slice(-3).map(g=>`<div style="margin:2px 0;font-size:10px;color:#555">\u2705 ${esc(g.description)}</div>`).join('')}
`:''}
${lifeEvents.length>0?`
<div class="section-header">Life History</div>
<div style="max-height:160px;overflow-y:auto">
${lifeEvents.map(e=>{
const icon = evtIcon[e.type] || '\uD83D\uDD39';
const dayLabel = e.day > 0 ? `Day ${e.day}` : 'Background';
return `<div style="margin:3px 0;font-size:11px;display:flex;gap:6px;align-items:start">
<span style="flex-shrink:0">${icon}</span>
<div><span style="color:#555;font-size:9px">${dayLabel}</span><br><span style="color:#c0c0d0">${esc(e.description)}</span></div>
</div>`;
}).join('')}
</div>`:''}
${(data.relationships||[]).length>0?`
<div class="section-header">Relationships</div>
${data.relationships.slice(0,8).map(r=>{
const fam = (r.familiarity||0)*100;
const trust = (r.trust||0)*100;
const sent = (r.sentiment||0)*100;
const rom = (r.romantic_interest||0)*100;
const statusBadge = r.relationship_status && r.relationship_status !== 'none'
? `<span style="color:#e91e90;font-size:9px"> \u2764 ${r.relationship_status}</span>` : '';
return `
<div class="rel-item" onclick="selectedAgentId='${r.agent_id}';fetchAgentDetail('${r.agent_id}');">
<div><span class="rel-name">${esc(r.name)}</span>${statusBadge}
<span style="color:#555;font-size:9px">(${r.interaction_count||0} talks)</span></div>
<div class="rel-bars">
<div class="rel-mini-bar" title="Familiarity ${fam.toFixed(0)}%"><div class="rel-mini-fill" style="width:${fam}%;background:#4ecca3"></div></div>
<div class="rel-mini-bar" title="Trust ${trust.toFixed(0)}%"><div class="rel-mini-fill" style="width:${trust}%;background:#4e9eca"></div></div>
<div class="rel-mini-bar" title="Sentiment ${sent.toFixed(0)}%"><div class="rel-mini-fill" style="width:${sent}%;background:#f0c040"></div></div>
${rom > 0 ? `<div class="rel-mini-bar" title="Romance ${rom.toFixed(0)}%"><div class="rel-mini-fill" style="width:${rom}%;background:#e91e90"></div></div>` : ''}
</div>
</div>`;
}).join('')}`:''}
<div class="section-header">Recent Memories</div>
${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'}
${playerToken && playerAgentId && data.id !== playerAgentId ? `
<div style="margin-top:10px">
<button class="btn-primary" style="width:100%" onclick="openChat('${data.id}')">Talk to ${esc(data.name||'them')}</button>
</div>` : ''}
`;
}
// ============================================================
// CONVERSATIONS TAB
// ============================================================
async function fetchConversations() {
try {
const res = await fetch(`${API_BASE}/conversations?include_history=true&limit=15`);
if (!res.ok) return;
conversationData = await res.json();
renderConversations();
} catch(e) {}
}
function renderConversations() {
const panel = document.getElementById('conversations-panel');
const all = [
...(conversationData.active||[]).map(c => ({...c, _active: true})),
...(conversationData.recent||[]).map(c => ({...c, _active: false})),
];
if (all.length === 0) {
panel.innerHTML = '<div class="conv-empty">No conversations yet.<br>Agents will start talking when they meet!</div>';
return;
}
panel.innerHTML = all.map(conv => {
const turns = conv.turns || [];
const badge = conv._active
? '<span class="conv-badge live">LIVE</span>'
: '<span class="conv-badge ended">ended</span>';
const names = (conv.participant_names || []).join(' & ');
return `
<div class="conv-card ${conv._active ? 'active-conv' : ''}">
<div class="conv-header">
<span class="conv-topic">${esc(conv.topic || 'Chat')}</span>
${badge}
</div>
<div class="conv-participants">${esc(names)} &mdash; ${esc(conv.location || '')}</div>
${turns.slice(-6).map(t => `
<div class="conv-turn">
<span class="conv-speaker" style="color:${getAgentColor(t.speaker_id)}">${esc(t.speaker_name)}:</span>
<span class="conv-message">${esc(t.message)}</span>
</div>
`).join('')}
</div>`;
}).join('');
}
function getAgentColor(agentId) {
return AGENT_COLORS[getAgentIdx(agentId) % AGENT_COLORS.length];
}
// ============================================================
// EVENT LOG TAB
// ============================================================
function renderEventLog() {
const c = document.getElementById('event-log-panel');
c.innerHTML = eventLog.slice(-80).map(line => {
let cls = 'event-line';
if (line.includes('[PLAN]')) cls += ' plan';
else if (line.includes('[CONV]')) cls += ' conv';
else if (line.includes('[ROMANCE]')) cls += ' romance';
else if (line.includes('[MOVE]')) cls += ' move';
else if (line.includes('[REFLECT]')) cls += ' reflect';
else if (line.includes('[EVENT]') || line.includes('[ENTROPY]')) cls += ' event';
else if (line.startsWith('---') || line.startsWith('\n---')) cls += ' time';
return `<div class="${cls}">${esc(line)}</div>`;
}).join('');
c.scrollTop = c.scrollHeight;
}
function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
// ============================================================
// DATA FETCHING — WebSocket + polling fallback
// ============================================================
let lastTick = -1;
let ws = null;
let wsRetryTimer = null;
function processStateData(data) {
const clock = data.clock || {};
currentTimeOfDay = clock.time_of_day || 'morning';
currentWeather = (data.weather || 'sunny').toLowerCase();
const dayNum = clock.day || 1;
const dayOfWeek = ((dayNum - 1) % 7) + 1; // 1=Mon … 7=Sun
const isWeekend = dayOfWeek >= 6;
document.getElementById('clock').textContent = `Day ${dayNum}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
const badge = document.getElementById('weekend-badge');
badge.style.display = isWeekend ? 'flex' : 'none';
document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F';
document.getElementById('weather').textContent = currentWeather;
document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;
document.getElementById('conv-count').textContent = `${data.active_conversations||0} convos`;
const usage = data.llm_usage || '';
const cm = usage.match(/calls:\s*(\d+)/i), $m = usage.match(/\$([0-9.]+)/);
document.getElementById('api-calls').textContent = `API: ${cm?cm[1]:'0'}`;
document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00';
if (data.llm_provider && data.llm_model) {
// Compact model label: strip long date suffixes and version noise
let label = data.llm_model
.replace(/-\d{8}$/, '') // remove trailing date e.g. -20251001
.replace(/-instant$/, '') // groq suffix
.replace(/^gemini-/, ''); // "gemini-2.0-flash" → "2.0-flash"
const providerIcon = { nn: '🧠', gemini: '✦', groq: '⚡', claude: '◆', ollama: '🦙' };
const icon = providerIcon[data.llm_provider] || '⚡';
// Status: nokey > limited > skipped > idle > active
const status = data.llm_status;
const isNoKey = status === 'nokey';
const isLimited = status === 'limited';
const isSkipped = data.llm_skipped === true;
const hasCalls = (data.llm_calls_last_tick || 0) > 0;
let dotColor, statusTip;
if (isNoKey) { dotColor = '#f0a040'; statusTip = 'no API token — set HF_TOKEN env var'; }
else if (isLimited) { dotColor = '#e94560'; statusTip = 'quota / rate limit hit'; }
else if (isSkipped) { dotColor = '#666'; statusTip = 'LLM skipped (fast mode)'; }
else if (hasCalls) { dotColor = '#4ecca3'; statusTip = `${data.llm_calls_last_tick} calls this tick`; }
else { dotColor = '#f0c040'; statusTip = 'idle — no calls needed'; }
const calls = data.llm_calls_last_tick || 0;
const callBadge = calls > 0 ? ` <span style="font-size:10px;color:#4ecca3;opacity:0.85">×${calls}</span>` : '';
const el = document.getElementById('llm-model');
el.innerHTML = `${icon} ${label}${callBadge} <span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${dotColor};vertical-align:middle;margin-left:2px"></span>`;
el.title = `${data.llm_provider}: ${data.llm_model}${statusTip}`;
}
agents = data.agents || {};
renderPlayerPanel();
const tick = clock.total_ticks || 0;
if (tick !== lastTick) fetchSecondaryData();
lastTick = tick;
if (activeTab === 'agents') {
if (selectedAgentId) fetchAgentDetail(selectedAgentId);
else showDefaultDetail();
}
}
async function fetchSecondaryData() {
try {
const locRes = await fetch(`${API_BASE}/city/locations`);
if (locRes.ok) locations = await locRes.json();
} catch(e) {}
try {
const er = await fetch(`${API_BASE}/events`);
if (er.ok) {
const d2 = await er.json();
eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim());
checkForNotableEvents();
if (activeTab === 'events') renderEventLog();
}
} catch(e) {}
if (activeTab === 'conversations') {
fetchConversations();
} else {
try {
const cr = await fetch(`${API_BASE}/conversations?include_history=false`);
if (cr.ok) {
const cd = await cr.json();
conversationData.active = cd.active || [];
}
} catch(e) {}
}
}
function connectWebSocket() {
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${window.location.host}/ws/stream`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
connected = true;
document.getElementById('status').innerHTML = '<span class="dot green"></span> Live (WS)';
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === 'tick' && msg.state) {
processStateData(msg.state);
} else if (msg.type === 'event') {
eventLog.push(msg.message);
if (eventLog.length > 200) eventLog = eventLog.slice(-200);
checkForNotableEvents();
if (activeTab === 'events') renderEventLog();
}
} catch(e) {}
};
ws.onclose = () => {
connected = false;
document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected';
wsRetryTimer = setTimeout(connectWebSocket, 3000);
};
ws.onerror = () => { ws.close(); };
}
async function fetchState() {
try {
const res = await fetch(`${API_BASE}/city`);
if (!res.ok) throw new Error();
const data = await res.json();
if (!connected) {
connected = true;
document.getElementById('status').innerHTML = '<span class="dot green"></span> Connected';
}
processStateData(data);
} catch(e) {
if (connected) {
connected = false;
document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected';
}
}
}
// ============================================================
// CONTROLS
// ============================================================
let simPaused = false;
let simSpeed = 1.0;
let llmCallProbability = 0.10;
async function togglePause() {
try {
const endpoint = simPaused ? 'resume' : 'pause';
const res = await fetch(`${API_BASE}/controls/${endpoint}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
simPaused = data.paused;
updateControlsUI();
}
} catch(e) {}
}
async function setSpeed(mult) {
try {
const res = await fetch(`${API_BASE}/controls/speed?multiplier=${mult}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
simSpeed = data.speed;
updateControlsUI();
}
} catch(e) {}
}
function updateControlsUI() {
const pauseBtn = document.getElementById('btn-pause');
pauseBtn.className = 'ctrl-btn' + (simPaused ? ' paused' : '');
pauseBtn.title = simPaused ? 'Resume' : 'Pause';
// Highlight the active speed button
const speedBtns = [
{ id: 'btn-slow', min: 2.0, max: Infinity },
{ id: 'btn-1x', min: 0.8, max: 2.0 },
{ id: 'btn-2x', min: 0.35, max: 0.8 },
{ id: 'btn-5x', min: 0.15, max: 0.35 },
{ id: 'btn-10x', min: 0.05, max: 0.15 },
{ id: 'btn-50x', min: 0, max: 0.05 },
];
for (const btn of speedBtns) {
const el = document.getElementById(btn.id);
if (el) el.className = 'ctrl-btn' + (simSpeed >= btn.min && simSpeed < btn.max ? ' active' : '');
}
// Speed label
let label = 'PAUSED';
if (!simPaused) {
if (simSpeed >= 2.0) label = '0.3x';
else if (simSpeed >= 0.8) label = '1x';
else if (simSpeed >= 0.35) label = '2x';
else if (simSpeed >= 0.15) label = '5x';
else if (simSpeed >= 0.05) label = '10x';
else label = '50x';
}
document.getElementById('speed-label').textContent = label;
}
async function onLlmProbSlider(val) {
const pct = parseInt(val);
document.getElementById('llm-prob-label').textContent = pct + '%';
llmCallProbability = pct / 100;
try {
await fetch(`${API_BASE}/controls/llm_probability?value=${llmCallProbability}`, { method: 'POST' });
} catch(e) {}
}
function updateLlmProbUI(prob) {
llmCallProbability = prob;
const pct = Math.round(prob * 100);
const slider = document.getElementById('llm-prob-slider');
const label = document.getElementById('llm-prob-label');
if (slider) slider.value = pct;
if (label) label.textContent = pct + '%';
}
async function fetchControls() {
try {
const res = await fetch(`${API_BASE}/controls`);
if (res.ok) {
const data = await res.json();
simPaused = data.paused;
simSpeed = data.speed;
if (data.llm_call_probability !== undefined) updateLlmProbUI(data.llm_call_probability);
updateControlsUI();
}
} catch(e) {}
}
// ============================================================
// TOAST NOTIFICATIONS
// ============================================================
let lastEventCount = 0;
function showToast(message, type='info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
while (container.children.length > 5) container.firstChild.remove();
}
function checkForNotableEvents() {
if (eventLog.length <= lastEventCount) return;
const newEvents = eventLog.slice(lastEventCount);
lastEventCount = eventLog.length;
for (const msg of newEvents) {
if (msg.includes('[ROMANCE]')) showToast(msg.replace(/\s*\[ROMANCE\]\s*/, ''), 'romance');
else if (msg.includes('[EVENT]') && !msg.includes('Weather')) showToast(msg.replace(/\s*\[EVENT\]\s*/, ''), 'event');
else if (msg.includes('[GOSSIP]')) showToast(msg.replace(/\s*\[GOSSIP\]\s*/, ''), 'gossip');
else if (msg.includes('[CONV]') && msg.includes('starts talking')) showToast(msg.replace(/\s*\[CONV\]\s*/, ''), 'conv');
}
}
// ============================================================
// PLAYER / AUTH
// ============================================================
let playerToken = localStorage.getItem('soci_token') || null;
let playerUsername = localStorage.getItem('soci_username') || null;
let playerAgentId = localStorage.getItem('soci_agent_id') || null;
let chatTargetId = null;
let chatHistory = [];
let authMode = 'login'; // 'login' or 'register'
function closeLoginModal() {
document.getElementById('login-modal').style.display = 'none';
}
function toggleAuthMode() {
authMode = authMode === 'login' ? 'register' : 'login';
document.getElementById('auth-title').textContent = authMode === 'login' ? 'Welcome to Soci City' : 'Create Account';
document.getElementById('auth-btn').textContent = authMode === 'login' ? 'Login' : 'Register';
document.getElementById('auth-switch-text').innerHTML = authMode === 'login'
? 'No account? <a onclick="toggleAuthMode()">Register</a>'
: 'Have an account? <a onclick="toggleAuthMode()">Login</a>';
document.getElementById('auth-error').style.display = 'none';
}
async function authSubmit() {
const username = document.getElementById('auth-username').value.trim();
const password = document.getElementById('auth-password').value;
const errEl = document.getElementById('auth-error');
if (!username || !password) { errEl.textContent = 'Please fill in both fields'; errEl.style.display = 'block'; return; }
const endpoint = authMode === 'login' ? '/api/auth/login' : '/api/auth/register';
try {
const res = await fetch(window.location.origin + endpoint, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({username, password})
});
const data = await res.json();
if (!res.ok) { errEl.textContent = data.detail || 'Error'; errEl.style.display = 'block'; return; }
playerToken = data.token;
playerUsername = data.username;
playerAgentId = data.agent_id || null;
localStorage.setItem('soci_token', playerToken);
localStorage.setItem('soci_username', playerUsername);
if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId);
closeLoginModal();
renderPlayerPanel();
} catch(e) { errEl.textContent = 'Connection error'; errEl.style.display = 'block'; }
}
async function authLogout() {
if (playerToken) {
try { await fetch(window.location.origin + '/api/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+playerToken}}); } catch(e){}
}
playerToken = playerUsername = playerAgentId = null;
localStorage.removeItem('soci_token'); localStorage.removeItem('soci_username'); localStorage.removeItem('soci_agent_id');
document.getElementById('player-panel').style.display = 'none';
document.getElementById('chat-panel') && (document.getElementById('chat-panel').style.display = 'none');
}
function renderPlayerPanel() {
const panel = document.getElementById('player-panel');
if (!playerToken) { panel.style.display = 'none'; return; }
panel.style.display = 'block';
const agent = playerAgentId ? agents[playerAgentId] : null;
document.getElementById('pp-name').textContent = agent ? agent.name : (playerUsername || 'You');
const locName = agent ? (locations[agent.location]?.name || agent.location || '') : '';
document.getElementById('pp-loc').textContent = locName ? `@ ${locName}` : '';
// Populate move dropdown from known locations (only rebuild if empty)
const sel = document.getElementById('pp-move-select');
const locKeys = Object.keys(locations);
if (locKeys.length > 0 && sel.options.length === 0) {
sel.innerHTML = locKeys.map(lid => {
const ln = locations[lid]?.name || lid;
const selected = agent && agent.location === lid ? ' selected' : '';
return `<option value="${lid}"${selected}>${ln}</option>`;
}).join('');
}
}
async function playerMove() {
if (!playerToken || !playerAgentId) return;
const location = document.getElementById('pp-move-select').value;
try {
const res = await fetch(window.location.origin + '/api/player/move', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({token: playerToken, location})
});
if (res.ok) renderPlayerPanel();
} catch(e){}
}
async function openProfileEditor() {
if (!playerAgentId) return;
// Fetch full agent detail to get all fields (WS summary may not have everything)
try {
const res = await fetch(`${API_BASE}/agents/${playerAgentId}`);
if (res.ok) {
const data = await res.json();
document.getElementById('pe-name').value = data.name || '';
document.getElementById('pe-age').value = data.age || 30;
document.getElementById('pe-occupation').value = data.occupation || '';
document.getElementById('pe-gender').value = data.gender || 'unknown';
document.getElementById('pe-background').value = data.background || '';
const pers = data.personality || {};
const extVal = pers.extraversion || 5;
const agrVal = pers.agreeableness || 7;
const opnVal = pers.openness || 6;
document.getElementById('pe-extraversion').value = extVal;
document.getElementById('pe-extra-val').textContent = extVal;
document.getElementById('pe-agreeableness').value = agrVal;
document.getElementById('pe-agree-val').textContent = agrVal;
document.getElementById('pe-openness').value = opnVal;
document.getElementById('pe-open-val').textContent = opnVal;
}
} catch(e) {
// Fallback to WS data
const agent = agents[playerAgentId];
if (agent) {
document.getElementById('pe-name').value = agent.name || '';
document.getElementById('pe-age').value = agent.age || 30;
document.getElementById('pe-occupation').value = agent.occupation || '';
document.getElementById('pe-gender').value = agent.gender || 'unknown';
}
}
document.getElementById('profile-modal').style.display = 'flex';
}
async function saveProfile() {
if (!playerToken) return;
const body = {
token: playerToken,
name: document.getElementById('pe-name').value.trim(),
age: parseInt(document.getElementById('pe-age').value)||30,
gender: document.getElementById('pe-gender').value,
occupation: document.getElementById('pe-occupation').value.trim(),
background: document.getElementById('pe-background').value.trim(),
extraversion: parseInt(document.getElementById('pe-extraversion').value),
agreeableness: parseInt(document.getElementById('pe-agreeableness').value),
openness: parseInt(document.getElementById('pe-openness').value),
};
try {
const res = await fetch(window.location.origin + '/api/player/update', {
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
});
if (res.ok) { document.getElementById('profile-modal').style.display='none'; renderPlayerPanel(); }
} catch(e){}
}
function openPlansModal() {
if (!playerAgentId) return;
const agent = agents[playerAgentId];
const plans = agent?.daily_plan || [];
const listEl = document.getElementById('plans-list');
listEl.innerHTML = plans.length > 0
? plans.map((p,i) => `<div style="padding:3px 0;border-bottom:1px solid #1a3a6e">${i+1}. ${esc(p)}</div>`).join('')
: '<div style="color:#555">No plans yet.</div>';
document.getElementById('plans-modal').style.display = 'flex';
}
async function addPlan() {
if (!playerToken) return;
const input = document.getElementById('plans-input');
const item = input.value.trim();
if (!item) return;
try {
const res = await fetch(window.location.origin + '/api/player/plan', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({token: playerToken, plan_item: item})
});
if (res.ok) { input.value = ''; openPlansModal(); }
} catch(e){}
}
// ── StockEx Integration ──────────────────────────────────────────
let stockexMarketData = {};
async function openStockExModal() {
if (!playerToken) return;
document.getElementById('stockex-modal').style.display = 'flex';
document.getElementById('sx-result').innerHTML = '';
// Load market data and symbols
try {
const res = await fetch(`${API_BASE}/stockex/market`);
if (res.ok) {
stockexMarketData = await res.json();
const sel = document.getElementById('sx-symbol');
const symbols = Object.keys(stockexMarketData).sort();
sel.innerHTML = symbols.map(s => `<option value="${s}">${s}</option>`).join('');
sel.value = 'EXAE'; // Default
updateStockExPrice();
}
} catch(e) {
document.getElementById('sx-market-info').textContent = 'Could not load market data';
}
loadStockExPortfolio();
}
function updateStockExPrice() {
const sym = document.getElementById('sx-symbol').value;
const mkt = stockexMarketData[sym];
if (mkt) {
const bid = mkt.bid_price || 0;
const ask = mkt.ask_price || 0;
const mid = mkt.mid || ((bid + ask) / 2);
document.getElementById('sx-price').value = mid.toFixed(2);
document.getElementById('sx-market-info').textContent =
`Bid: ${bid.toFixed(2)} | Ask: ${ask.toFixed(2)} | Mid: ${mid.toFixed(2)}`;
}
}
async function loadStockExPortfolio() {
const mid = document.getElementById('sx-member').value.trim().toUpperCase();
if (!mid) return;
try {
const res = await fetch(`${API_BASE}/stockex/portfolio/${mid}`);
if (res.ok) {
const data = await res.json();
const cap = data.capital || 0;
const pnl = data.pnl || 0;
const pnlColor = pnl >= 0 ? '#4ecca3' : '#e74c3c';
let html = `<div style="margin-bottom:4px"><b>${data.member_id}</b> Capital: <b>${cap.toFixed(2)}</b> | P&L: <span style="color:${pnlColor}">${pnl.toFixed(2)}</span></div>`;
const holdings = data.holdings || [];
if (holdings.length > 0) {
html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
for (const h of holdings) {
const pnlH = h.unrealized_pnl || 0;
const c = pnlH >= 0 ? '#4ecca3' : '#e74c3c';
html += `<span style="padding:1px 4px;background:#1a1a3e;border-radius:3px;font-size:10px">${h.symbol}: ${h.quantity} <span style="color:${c}">(${pnlH > 0 ? '+' : ''}${pnlH.toFixed(0)})</span></span>`;
}
html += '</div>';
}
document.getElementById('stockex-portfolio').innerHTML = html;
document.getElementById('stockex-status').textContent = `${holdings.length} positions | Obligation: ${data.obligation || 20}/day`;
} else {
document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Member not found</span>';
}
} catch(e) {
document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Could not load portfolio</span>';
}
}
async function submitStockExOrder() {
if (!playerToken) return;
const resultEl = document.getElementById('sx-result');
const body = {
token: playerToken,
member_id: document.getElementById('sx-member').value.trim().toUpperCase(),
symbol: document.getElementById('sx-symbol').value,
side: document.getElementById('sx-side').value,
quantity: parseInt(document.getElementById('sx-qty').value) || 0,
price: parseFloat(document.getElementById('sx-price').value) || 0,
};
if (body.quantity <= 0 || body.price <= 0) {
resultEl.innerHTML = '<span style="color:#e74c3c">Invalid quantity or price</span>';
return;
}
resultEl.innerHTML = '<span style="color:#888">Placing order...</span>';
try {
const res = await fetch(`${API_BASE}/stockex/order`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
});
const data = await res.json();
if (res.ok) {
resultEl.innerHTML = `<span style="color:#4ecca3">${esc(data.detail)}${data.cl_ord_id}</span>`;
loadStockExPortfolio(); // Refresh
} else {
resultEl.innerHTML = `<span style="color:#e74c3c">${esc(data.detail || data.error || 'Order failed')}</span>`;
}
} catch(e) {
resultEl.innerHTML = '<span style="color:#e74c3c">Connection error</span>';
}
}
// Update price when symbol changes
document.getElementById('sx-symbol')?.addEventListener('change', updateStockExPrice);
document.getElementById('sx-member')?.addEventListener('change', loadStockExPortfolio);
// Chat with an NPC
function openChat(targetId) {
chatTargetId = targetId;
chatHistory = [];
const target = agents[targetId];
let chatPanel = document.getElementById('chat-panel');
if (!chatPanel) {
chatPanel = document.createElement('div');
chatPanel.id = 'chat-panel';
document.getElementById('agent-detail').after(chatPanel);
}
chatPanel.style.display = 'block';
chatPanel.innerHTML = `
<div class="chat-header">
<span>Talking to ${esc(target?.name || targetId)}</span>
<span style="cursor:pointer;color:#888" onclick="closeChat()">✕ Close</span>
</div>
<div id="chat-messages"></div>
<div id="chat-input-row">
<input id="chat-input" type="text" placeholder="Say something..." onkeydown="if(event.key==='Enter')sendChat()">
<button id="chat-send" onclick="sendChat()">Send</button>
</div>`;
}
function closeChat() {
chatTargetId = null;
const p = document.getElementById('chat-panel');
if (p) p.style.display = 'none';
}
function appendChatMsg(cls, speaker, text) {
const el = document.getElementById('chat-messages');
if (!el) return;
const div = document.createElement('div');
div.className = cls;
div.innerHTML = `<b>${esc(speaker)}:</b> ${esc(text)}`;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
}
async function sendChat() {
if (!playerToken || !playerAgentId || !chatTargetId) return;
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) return;
input.value = '';
input.disabled = true;
document.getElementById('chat-send').disabled = true;
appendChatMsg('chat-msg-you', playerUsername || 'You', message);
appendChatMsg('chat-msg-thinking', agents[chatTargetId]?.name || '...', '...');
try {
const res = await fetch(window.location.origin + '/api/player/talk', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({token: playerToken, target_id: chatTargetId, message})
});
const el = document.getElementById('chat-messages');
if (el && el.lastChild) el.lastChild.remove(); // remove thinking...
if (res.ok) {
const data = await res.json();
appendChatMsg('chat-msg-npc', agents[chatTargetId]?.name || chatTargetId, data.reply || '(no reply)');
} else {
appendChatMsg('chat-msg-thinking', 'System', 'Could not get a response right now.');
}
} catch(e) {
const el = document.getElementById('chat-messages');
if (el && el.lastChild) el.lastChild.remove();
appendChatMsg('chat-msg-thinking', 'System', 'Connection error.');
}
input.disabled = false;
document.getElementById('chat-send').disabled = false;
input.focus();
}
async function checkSession() {
if (!playerToken) { document.getElementById('login-modal').style.display = 'flex'; return; }
try {
const res = await fetch(window.location.origin + '/api/auth/me', {headers:{'Authorization':'Bearer '+playerToken}});
if (res.ok) {
const data = await res.json();
playerUsername = data.username;
playerAgentId = data.agent_id;
if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId);
renderPlayerPanel();
} else {
// Token expired
playerToken = null; localStorage.removeItem('soci_token');
document.getElementById('login-modal').style.display = 'flex';
}
} catch(e) { renderPlayerPanel(); }
}
// ============================================================
// LLM PROVIDER SWITCHER
// ============================================================
let _llmPopupOpen = false;
document.getElementById('llm-model').addEventListener('click', async (e) => {
e.stopPropagation();
const popup = document.getElementById('llm-popup');
if (_llmPopupOpen) { popup.style.display = 'none'; _llmPopupOpen = false; return; }
// Position below the clicked element
const rect = e.currentTarget.getBoundingClientRect();
popup.style.left = rect.left + 'px';
popup.style.top = (rect.bottom + 6) + 'px';
// Fetch available providers + quota info
try {
const [res, quotaRes] = await Promise.all([
fetch(`${API_BASE}/llm/providers`),
fetch(`${API_BASE}/llm/quota`).catch(() => null),
]);
const data = await res.json();
const quota = quotaRes && quotaRes.ok ? await quotaRes.json() : null;
const existing = popup.querySelectorAll('.llm-opt,.llm-quota-panel');
existing.forEach(el => el.remove());
// Estimate remaining runtime based on RPM (the real bottleneck, not probability).
// With 50 agents, even low probability saturates the RPM rate limiter.
function estimateRuntime(q) {
if (!q || q.remaining <= 0) return 'exhausted';
const callsPerHour = q.max_calls_per_hour || (q.rpm || 4) * 60;
if (callsPerHour <= 0) return '∞';
const hours = q.remaining / callsPerHour;
if (hours >= 48) return `~${Math.round(hours / 24)}d`;
if (hours >= 1) return `~${hours.toFixed(1)}h`;
return `~${Math.round(hours * 60)}min`;
}
const isRateLimited = (id) => id === 'gemini' || id === 'groq';
for (const p of data.providers) {
const isActive = p.id === data.current && (p.model === data.current_model || (!p.model && p.id !== 'hf'));
const row = document.createElement('div');
row.className = 'llm-opt' + (isActive ? ' active' : '');
// Quota badge for rate-limited providers
let badge = '';
const pQuota = (quota && quota.providers && quota.providers[p.id]) || null;
if (isRateLimited(p.id) && pQuota) {
const isLimited = pQuota.status === 'limited';
const rpm = pQuota.rpm || 4;
if (isLimited) {
badge = `<span style="font-size:10px;color:#e94560;margin-left:auto">exhausted</span>`;
} else {
const rt = estimateRuntime({remaining: pQuota.daily_limit, rpm, max_calls_per_hour: rpm * 60});
badge = `<span style="font-size:10px;color:#8899aa;margin-left:auto">${pQuota.daily_limit}/day · ${rt}</span>`;
}
}
row.innerHTML = `<span class="llm-check">${isActive ? '✔' : ''}</span>
<span style="font-size:15px">${p.icon}</span>
<span>${p.label}</span>${badge}`;
if (isRateLimited(p.id) && !isActive) {
// Rate-limited: show quota panel on click instead of switching immediately
row.addEventListener('click', (ev) => {
ev.stopPropagation();
// Remove any existing quota panel
popup.querySelectorAll('.llm-quota-panel').forEach(el => el.remove());
const panel = document.createElement('div');
panel.className = 'llm-quota-panel';
panel.style.cssText = 'padding:8px 14px;background:#0a1628;border-top:1px solid #0f3460;border-bottom:1px solid #0f3460;font-size:11px;color:#c8c8d8';
// IMPORTANT: stop clicks inside panel from closing the popup
panel.addEventListener('click', (e) => e.stopPropagation());
panel.addEventListener('mousedown', (e) => e.stopPropagation());
// Use per-provider quota data
const pq = (quota && quota.providers && quota.providers[p.id]) || null;
const lim = pq ? (pq.daily_limit || 0) : 0;
const rpm = (pq && pq.rpm) || 4;
const maxPerHour = (pq && pq.max_calls_per_hour) || (rpm * 60);
// Runtime based on full daily quota (counter is unreliable across restarts)
const pqForCalc = {remaining: lim, rpm: rpm, max_calls_per_hour: maxPerHour};
const isExhausted = pq && pq.status === 'limited';
if (isExhausted) {
panel.innerHTML = `<div style="color:#e94560;margin-bottom:6px">Quota exhausted. Resets daily (10:00 AM).</div>`;
row.after(panel);
return;
}
const runtime = estimateRuntime(pqForCalc);
const numAgents = quota.num_agents || 50;
// Default probability for rate-limited providers (not inherited from NN's 100%)
const defaultProb = 10;
// Calculate usage stats for a given probability
function usageStats(probPct) {
const prob = probPct / 100;
// ~40% of agents are idle (not busy/sleeping) and eligible for LLM each tick
const idleFraction = 0.4;
const eligibleAgents = Math.round(numAgents * idleFraction);
// Expected LLM attempts per tick: eligible agents × prob (action) + prob (social)
const attemptsPerTick = eligibleAgents * prob + prob;
// Budget caps at 2 calls/tick for rate-limited providers
const callsPerTick = Math.min(attemptsPerTick, 2);
// Ticks per hour (4s tick delay at 1x speed)
const ticksH = 900;
const callsPerHour = Math.min(callsPerTick * ticksH, rpm * 60);
// What % of agent decisions go through LLM vs routine
const llmPct = Math.min(prob * 100, 100);
return { callsPerHour: Math.round(callsPerHour), llmPct: Math.round(llmPct) };
}
const initStats = usageStats(defaultProb);
panel.innerHTML =
`<div style="color:#4ecca3;font-weight:600;margin-bottom:4px">${p.icon} ${p.label}</div>` +
`<div style="margin-bottom:4px">Daily limit: <b>${lim}</b> requests · ${rpm} req/min</div>` +
`<div style="display:flex;align-items:center;gap:8px;margin-top:6px">` +
`<label style="font-size:11px;color:#8899aa">Probability:</label>` +
`<input type="range" min="1" max="100" value="${defaultProb}" style="flex:1;accent-color:#4ecca3" class="popup-prob-slider">` +
`<span class="popup-prob-val" style="font-size:12px;color:#4ecca3;min-width:32px">${defaultProb}%</span>` +
`</div>` +
`<div class="popup-usage-info" style="font-size:10px;color:#8899aa;margin:4px 0 8px 0;line-height:1.5">` +
`<span style="color:#c8c8d8">${initStats.llmPct}%</span> of decisions via LLM · ` +
`<span style="color:#c8c8d8">~${initStats.callsPerHour}</span> calls/hour` +
`</div>` +
`<button class="popup-switch-btn" style="width:100%;padding:6px;border:none;border-radius:4px;` +
`background:#4ecca3;color:#0a0a23;font-weight:600;cursor:pointer;font-size:12px">` +
`Switch to ${p.label} at ${defaultProb}%</button>`;
row.after(panel);
// Wire up slider
const slider = panel.querySelector('.popup-prob-slider');
const valLabel = panel.querySelector('.popup-prob-val');
const usageInfo = panel.querySelector('.popup-usage-info');
const btn = panel.querySelector('.popup-switch-btn');
slider.addEventListener('input', (e) => {
e.stopPropagation();
const pv = parseInt(slider.value);
valLabel.textContent = pv + '%';
const stats = usageStats(pv);
usageInfo.innerHTML =
`<span style="color:#c8c8d8">${stats.llmPct}%</span> of decisions via LLM · ` +
`<span style="color:#c8c8d8">~${stats.callsPerHour}</span> calls/hour`;
btn.textContent = `Switch to ${p.label} at ${pv}%`;
});
btn.addEventListener('click', async (ev2) => {
ev2.stopPropagation();
const probVal = parseInt(slider.value) / 100;
popup.style.display = 'none'; _llmPopupOpen = false;
try {
// 1. Set probability first
await fetch(`${API_BASE}/controls/llm_probability?value=${probVal}`, {method: 'POST'});
// 2. Then switch provider
const body = {provider: p.id};
if (p.model) body.model = p.model;
const r = await fetch(`${API_BASE}/llm/provider`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
if (!r.ok) { const err = await r.json(); showToast(`LLM switch failed: ${err.detail}`, 'event'); return; }
// 3. Sync the top probability slider
updateLlmProbUI(probVal);
showToast(`Switched to ${p.label} at ${Math.round(probVal*100)}% · ${runtime}`, 'conv');
} catch (err) { showToast('LLM switch error', 'event'); }
});
});
} else if (!isActive) {
// Non-rate-limited (NN, Claude, Ollama): switch immediately, set probability to 100%
row.addEventListener('click', async () => {
popup.style.display = 'none'; _llmPopupOpen = false;
try {
// Set probability to 100% for local/unlimited providers
const autoProb = (p.id === 'nn') ? 1.0 : 0.10;
await fetch(`${API_BASE}/controls/llm_probability?value=${autoProb}`, {method: 'POST'});
const body = {provider: p.id};
if (p.model) body.model = p.model;
const r = await fetch(`${API_BASE}/llm/provider`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
if (!r.ok) { const err = await r.json(); showToast(`LLM switch failed: ${err.detail}`, 'event'); return; }
updateLlmProbUI(autoProb);
showToast(`Switched to ${p.label} at ${Math.round(autoProb*100)}%`, 'conv');
} catch (err) { showToast('LLM switch error', 'event'); }
});
}
popup.appendChild(row);
}
// Test button
const sep = document.createElement('div');
sep.style.cssText = 'border-top:1px solid #0f3460;margin:4px 0';
popup.appendChild(sep);
const testRow = document.createElement('div');
testRow.className = 'llm-opt';
testRow.innerHTML = `<span class="llm-check"></span><span style="font-size:15px">🔬</span><span>Test current LLM…</span>`;
testRow.addEventListener('click', async (ev) => {
ev.stopPropagation();
popup.style.display = 'none'; _llmPopupOpen = false;
showToast('Testing LLM…', 'event');
try {
const r = await fetch(`${API_BASE}/llm/test`);
const d = await r.json();
if (d.ok) {
showToast(`✔ LLM OK — "${d.raw.slice(0,60)}"`, 'conv');
} else {
const msg = (d.error || d.raw || 'empty response — model may not be on HF serverless API').slice(0, 120);
showToast(`✘ ${d.model}: ${msg}`, 'event');
}
} catch (err) { showToast('Test request failed', 'event'); }
});
popup.appendChild(testRow);
popup.style.display = 'block';
_llmPopupOpen = true;
} catch { showToast('Could not fetch providers', 'event'); }
});
document.addEventListener('click', () => {
if (_llmPopupOpen) {
document.getElementById('llm-popup').style.display = 'none';
_llmPopupOpen = false;
}
});
// ============================================================
// INIT
// ============================================================
initCanvas();
showDefaultDetail();
fetchState();
fetchControls();
connectWebSocket();
setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL);
checkSession();
</script>
</body>
</html>