Add notification toasts and location hover tooltips to web UI
Browse files- Floating toast notifications for notable events: romance milestones,
world events, gossip, and new conversations
- Toasts auto-fade after 5 seconds, max 5 stacked from bottom-left
- Color-coded borders: pink for romance, red for events, purple for gossip,
yellow for conversations
- Location hover tooltips showing name, zone, description, occupant list,
and active conversation indicator
- Toasts triggered from both WebSocket events and polling updates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- web/index.html +103 -14
web/index.html
CHANGED
|
@@ -111,8 +111,26 @@
|
|
| 111 |
#tooltip {
|
| 112 |
position: absolute; background: #16213eee; border: 1px solid #4ecca3;
|
| 113 |
border-radius: 6px; padding: 8px 12px; font-size: 12px;
|
| 114 |
-
pointer-events: none; display: none; z-index: 100; max-width:
|
| 115 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
/* Section headers */
|
| 117 |
.section-header {
|
| 118 |
font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
|
|
@@ -172,6 +190,7 @@
|
|
| 172 |
<div id="canvas-container">
|
| 173 |
<canvas id="cityCanvas"></canvas>
|
| 174 |
<div id="tooltip"></div>
|
|
|
|
| 175 |
</div>
|
| 176 |
<div id="sidebar">
|
| 177 |
<div class="sidebar-tabs">
|
|
@@ -846,26 +865,59 @@ function onCanvasClick(e) {
|
|
| 846 |
function onCanvasMouseMove(e) {
|
| 847 |
const rect=canvas.getBoundingClientRect();
|
| 848 |
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
|
| 849 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 851 |
-
if(Math.hypot(mx-pos.x,my-pos.y)<22){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
}
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
if(
|
| 858 |
-
const a=agents[
|
| 859 |
let extra='';
|
| 860 |
if(a.partner_id&&agents[a.partner_id]) extra=`<br><span style="color:#e91e90">Partner: ${agents[a.partner_id].name}</span>`;
|
| 861 |
tt.innerHTML=`<b>${a.name}</b><br><span style="color:#a0a0c0">${a.action||'idle'}</span>${extra}`;
|
| 862 |
tt.style.display='block';
|
| 863 |
-
} else
|
|
|
|
|
|
|
| 864 |
}
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
}
|
| 870 |
}
|
| 871 |
|
|
@@ -1107,6 +1159,7 @@ async function fetchSecondaryData() {
|
|
| 1107 |
if (er.ok) {
|
| 1108 |
const d2 = await er.json();
|
| 1109 |
eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim());
|
|
|
|
| 1110 |
if (activeTab === 'events') renderEventLog();
|
| 1111 |
}
|
| 1112 |
} catch(e) {}
|
|
@@ -1142,6 +1195,7 @@ function connectWebSocket() {
|
|
| 1142 |
} else if (msg.type === 'event') {
|
| 1143 |
eventLog.push(msg.message);
|
| 1144 |
if (eventLog.length > 200) eventLog = eventLog.slice(-200);
|
|
|
|
| 1145 |
if (activeTab === 'events') renderEventLog();
|
| 1146 |
}
|
| 1147 |
} catch(e) {}
|
|
@@ -1232,6 +1286,41 @@ async function fetchControls() {
|
|
| 1232 |
} catch(e) {}
|
| 1233 |
}
|
| 1234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1235 |
// ============================================================
|
| 1236 |
// INIT
|
| 1237 |
// ============================================================
|
|
|
|
| 111 |
#tooltip {
|
| 112 |
position: absolute; background: #16213eee; border: 1px solid #4ecca3;
|
| 113 |
border-radius: 6px; padding: 8px 12px; font-size: 12px;
|
| 114 |
+
pointer-events: none; display: none; z-index: 100; max-width: 280px;
|
| 115 |
}
|
| 116 |
+
/* NOTIFICATION TOASTS */
|
| 117 |
+
#toast-container {
|
| 118 |
+
position: absolute; bottom: 12px; left: 12px; z-index: 200;
|
| 119 |
+
display: flex; flex-direction: column-reverse; gap: 6px;
|
| 120 |
+
pointer-events: none; max-width: 360px;
|
| 121 |
+
}
|
| 122 |
+
.toast {
|
| 123 |
+
background: #16213eee; border-radius: 6px; padding: 8px 14px;
|
| 124 |
+
font-size: 12px; color: #e0e0e0; border-left: 3px solid #4ecca3;
|
| 125 |
+
animation: toastIn 0.3s ease, toastOut 0.5s ease 4.5s forwards;
|
| 126 |
+
backdrop-filter: blur(4px); line-height: 1.4;
|
| 127 |
+
}
|
| 128 |
+
.toast.romance { border-left-color: #e91e90; }
|
| 129 |
+
.toast.event { border-left-color: #e94560; }
|
| 130 |
+
.toast.conv { border-left-color: #f0c040; }
|
| 131 |
+
.toast.gossip { border-left-color: #9b59b6; }
|
| 132 |
+
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
| 133 |
+
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
|
| 134 |
/* Section headers */
|
| 135 |
.section-header {
|
| 136 |
font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
|
|
|
|
| 190 |
<div id="canvas-container">
|
| 191 |
<canvas id="cityCanvas"></canvas>
|
| 192 |
<div id="tooltip"></div>
|
| 193 |
+
<div id="toast-container"></div>
|
| 194 |
</div>
|
| 195 |
<div id="sidebar">
|
| 196 |
<div class="sidebar-tabs">
|
|
|
|
| 865 |
function onCanvasMouseMove(e) {
|
| 866 |
const rect=canvas.getBoundingClientRect();
|
| 867 |
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
|
| 868 |
+
const W=canvas.width, H=canvas.height;
|
| 869 |
+
const tt=document.getElementById('tooltip');
|
| 870 |
+
|
| 871 |
+
// Check agents first
|
| 872 |
+
let foundAgent=null;
|
| 873 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 874 |
+
if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
// Check locations if no agent
|
| 878 |
+
let foundLoc=null;
|
| 879 |
+
if (!foundAgent) {
|
| 880 |
+
for (const [id, pos] of Object.entries(LOCATION_POSITIONS)) {
|
| 881 |
+
const lx=pos.x*W, ly=pos.y*H;
|
| 882 |
+
if(Math.hypot(mx-lx,my-ly)<35){foundLoc=id;break;}
|
| 883 |
+
}
|
| 884 |
}
|
| 885 |
+
|
| 886 |
+
if(foundAgent!==hoveredAgent){
|
| 887 |
+
hoveredAgent=foundAgent;
|
| 888 |
+
canvas.style.cursor=(foundAgent||foundLoc)?'pointer':'default';
|
| 889 |
+
if(foundAgent&&agents[foundAgent]){
|
| 890 |
+
const a=agents[foundAgent];
|
| 891 |
let extra='';
|
| 892 |
if(a.partner_id&&agents[a.partner_id]) extra=`<br><span style="color:#e91e90">Partner: ${agents[a.partner_id].name}</span>`;
|
| 893 |
tt.innerHTML=`<b>${a.name}</b><br><span style="color:#a0a0c0">${a.action||'idle'}</span>${extra}`;
|
| 894 |
tt.style.display='block';
|
| 895 |
+
} else if (!foundLoc) {
|
| 896 |
+
tt.style.display='none';
|
| 897 |
+
}
|
| 898 |
}
|
| 899 |
+
|
| 900 |
+
// Location tooltip
|
| 901 |
+
if (!foundAgent && foundLoc && locations[foundLoc]) {
|
| 902 |
+
const loc = locations[foundLoc];
|
| 903 |
+
const occ = (loc.occupants||[]);
|
| 904 |
+
const occNames = occ.map(o => (typeof o === 'object' ? o.name : (agents[o]?.name || o))).slice(0, 8);
|
| 905 |
+
const hasConv = conversationData.active?.some(c => c.location === foundLoc);
|
| 906 |
+
tt.innerHTML = `<b>${loc.name||foundLoc}</b> <span style="color:#666">(${loc.zone||''})</span>
|
| 907 |
+
${loc.description ? `<br><span style="color:#888;font-size:10px">${esc(loc.description)}</span>` : ''}
|
| 908 |
+
<br><span style="color:#a0a0c0">${occ.length} occupant${occ.length!==1?'s':''}</span>
|
| 909 |
+
${occNames.length > 0 ? `<br><span style="font-size:10px;color:#b0b0c0">${occNames.join(', ')}${occ.length>8?'...':''}</span>` : ''}
|
| 910 |
+
${hasConv ? '<br><span style="color:#f0c040;font-size:10px">Active conversation here</span>' : ''}`;
|
| 911 |
+
tt.style.display = 'block';
|
| 912 |
+
canvas.style.cursor = 'pointer';
|
| 913 |
+
} else if (!foundAgent && !foundLoc) {
|
| 914 |
+
canvas.style.cursor = 'default';
|
| 915 |
+
if (!hoveredAgent) tt.style.display = 'none';
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
if(foundAgent||foundLoc){
|
| 919 |
+
tt.style.left=(e.clientX-rect.left+15)+'px';
|
| 920 |
+
tt.style.top=(e.clientY-rect.top+15)+'px';
|
| 921 |
}
|
| 922 |
}
|
| 923 |
|
|
|
|
| 1159 |
if (er.ok) {
|
| 1160 |
const d2 = await er.json();
|
| 1161 |
eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim());
|
| 1162 |
+
checkForNotableEvents();
|
| 1163 |
if (activeTab === 'events') renderEventLog();
|
| 1164 |
}
|
| 1165 |
} catch(e) {}
|
|
|
|
| 1195 |
} else if (msg.type === 'event') {
|
| 1196 |
eventLog.push(msg.message);
|
| 1197 |
if (eventLog.length > 200) eventLog = eventLog.slice(-200);
|
| 1198 |
+
checkForNotableEvents();
|
| 1199 |
if (activeTab === 'events') renderEventLog();
|
| 1200 |
}
|
| 1201 |
} catch(e) {}
|
|
|
|
| 1286 |
} catch(e) {}
|
| 1287 |
}
|
| 1288 |
|
| 1289 |
+
// ============================================================
|
| 1290 |
+
// TOAST NOTIFICATIONS
|
| 1291 |
+
// ============================================================
|
| 1292 |
+
let lastEventCount = 0;
|
| 1293 |
+
|
| 1294 |
+
function showToast(message, type='info') {
|
| 1295 |
+
const container = document.getElementById('toast-container');
|
| 1296 |
+
const toast = document.createElement('div');
|
| 1297 |
+
toast.className = 'toast ' + type;
|
| 1298 |
+
toast.textContent = message;
|
| 1299 |
+
container.appendChild(toast);
|
| 1300 |
+
// Remove after animation
|
| 1301 |
+
setTimeout(() => toast.remove(), 5000);
|
| 1302 |
+
// Max 5 toasts at once
|
| 1303 |
+
while (container.children.length > 5) container.firstChild.remove();
|
| 1304 |
+
}
|
| 1305 |
+
|
| 1306 |
+
function checkForNotableEvents() {
|
| 1307 |
+
if (eventLog.length <= lastEventCount) return;
|
| 1308 |
+
const newEvents = eventLog.slice(lastEventCount);
|
| 1309 |
+
lastEventCount = eventLog.length;
|
| 1310 |
+
|
| 1311 |
+
for (const msg of newEvents) {
|
| 1312 |
+
if (msg.includes('[ROMANCE]')) {
|
| 1313 |
+
showToast(msg.replace(/\s*\[ROMANCE\]\s*/, ''), 'romance');
|
| 1314 |
+
} else if (msg.includes('[EVENT]') && !msg.includes('Weather')) {
|
| 1315 |
+
showToast(msg.replace(/\s*\[EVENT\]\s*/, ''), 'event');
|
| 1316 |
+
} else if (msg.includes('[GOSSIP]')) {
|
| 1317 |
+
showToast(msg.replace(/\s*\[GOSSIP\]\s*/, ''), 'gossip');
|
| 1318 |
+
} else if (msg.includes('[CONV]') && msg.includes('starts talking')) {
|
| 1319 |
+
showToast(msg.replace(/\s*\[CONV\]\s*/, ''), 'conv');
|
| 1320 |
+
}
|
| 1321 |
+
}
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
// ============================================================
|
| 1325 |
// INIT
|
| 1326 |
// ============================================================
|