diff --git a/docs/plans/2026-02-26-script-js-cleanup-plan.md b/docs/plans/2026-02-26-script-js-cleanup-plan.md new file mode 100644 index 00000000..2debfee8 --- /dev/null +++ b/docs/plans/2026-02-26-script-js-cleanup-plan.md @@ -0,0 +1,607 @@ +# script.js Code Review Cleanup - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix memory leaks, reduce console noise, eliminate code duplication, and improve rendering performance in the main frontend script. + +**Architecture:** Targeted fixes to a single 2,310-line vanilla JS file. No module splitting, no architectural changes. Each task modifies `static/script.js` and can be tested by refreshing the browser. + +**Tech Stack:** Vanilla JavaScript, served directly by FastAPI StaticFiles. No build system. + +**CRITICAL:** Do NOT push to git until all tasks pass manual testing. + +--- + +### Task 1: Add DEBUG flag and debugLog helper + +**Files:** +- Modify: `static/script.js:1-3` (top of file) + +**Step 1: Add DEBUG flag and helper after first line** + +At the top of the file, after `console.log('...')` on line 1, add: + +```javascript +const DEBUG = false; +function debugLog(...args) { if (DEBUG) console.log(...args); } +``` + +**Step 2: Replace all console.log calls with debugLog** + +Replace every `console.log(` with `debugLog(` throughout the file. There are ~35 instances. + +Keep `console.error(` unchanged - those should always be visible. + +Lines to change (all `console.log` → `debugLog`): +- Lines 100, 104, 106, 109 (chat button clicks) +- Lines 117, 121, 123, 126 (stats button clicks) +- Lines 134, 138, 140, 143 (inventory button clicks) +- Line 563 (heatmap loaded) +- Line 648 (portals loaded) +- Line 710 (portals rendered) +- Lines 729, 732, 739, 742, 786, 789 (stats window) +- Lines 824, 827, 834, 837, 1029, 1032 (inventory window) +- Line 1433 (server status) +- Lines 1511, 1515, 1733 (render cycle) +- Lines 1801, 1804, 1811, 1814, 1851, 1854 (chat window) +- Line 2173 (milestone) + +**Step 3: Verify** + +Open browser, load map page. Console should be clean (no log spam). Temporarily set `DEBUG = true`, refresh - logs should reappear. + +**Step 4: Commit** + +```bash +git add static/script.js +git commit -m "Add DEBUG flag and gate console.log behind debugLog helper" +``` + +--- + +### Task 2: Add named constants for magic numbers + +**Files:** +- Modify: `static/script.js:298-300` (constants block) +- Modify: `static/script.js:556` (heatmap fetch) +- Modify: `static/script.js:1448-1452` (polling intervals) +- Modify: `static/script.js:2069` (notification duration) +- Modify: `static/script.js:2148` (glow duration) + +**Step 1: Add constants after existing POLL_MS definition (line 300)** + +```javascript +const POLL_RARES_MS = 300000; // 5 minutes +const POLL_KILLS_MS = 300000; // 5 minutes +const POLL_HEALTH_MS = 30000; // 30 seconds +const NOTIFICATION_DURATION_MS = 6000; // Rare notification display time +const GLOW_DURATION_MS = 5000; // Player glow after rare find +const MAX_HEATMAP_POINTS = 50000; +const HEATMAP_HOURS = 24; +``` + +**Step 2: Replace hardcoded values** + +Line 556 - heatmap fetch URL: +```javascript +// Before: +const response = await fetch(`${API_BASE}/spawns/heatmap?hours=24&limit=50000`); +// After: +const response = await fetch(`${API_BASE}/spawns/heatmap?hours=${HEATMAP_HOURS}&limit=${MAX_HEATMAP_POINTS}`); +``` + +Lines 1448-1452 - polling intervals: +```javascript +// Before: +setInterval(pollTotalRares, 300000); +setInterval(pollTotalKills, 300000); +setInterval(pollServerHealth, 30000); +// After: +setInterval(pollTotalRares, POLL_RARES_MS); +setInterval(pollTotalKills, POLL_KILLS_MS); +setInterval(pollServerHealth, POLL_HEALTH_MS); +``` + +Line 2069 - notification timeout: +```javascript +// Before: +}, 6000); +// After: +}, NOTIFICATION_DURATION_MS); +``` + +Line 2148 - glow timeout: +```javascript +// Before: +}, 5000); +// After: +}, GLOW_DURATION_MS); +``` + +**Step 3: Verify** + +Refresh page. Players load, positions update every 2s. Heatmap toggle works. (These confirm intervals and heatmap constants are correct.) + +**Step 4: Commit** + +```bash +git add static/script.js +git commit -m "Replace magic numbers with named constants" +``` + +--- + +### Task 3: Fix polling interval memory leak + +**Files:** +- Modify: `static/script.js:1440-1453` (startPolling function) + +**Step 1: Replace startPolling function** + +Replace the current `startPolling()` function (lines 1440-1453) with: + +```javascript +const pollIntervals = []; + +function startPolling() { + // Clear any existing intervals first (prevents leak on re-init) + pollIntervals.forEach(id => clearInterval(id)); + pollIntervals.length = 0; + + // Initial fetches + pollLive(); + pollTotalRares(); + pollTotalKills(); + pollServerHealth(); + + // Set up recurring polls + pollIntervals.push(setInterval(pollLive, POLL_MS)); + pollIntervals.push(setInterval(pollTotalRares, POLL_RARES_MS)); + pollIntervals.push(setInterval(pollTotalKills, POLL_KILLS_MS)); + pollIntervals.push(setInterval(pollServerHealth, POLL_HEALTH_MS)); +} +``` + +Also remove the old `pollID` variable and the `if (pollID !== null) return;` guard since the new function handles re-initialization properly. + +**Step 2: Verify** + +Refresh page. Players load, update every 2s. Open DevTools > Performance Monitor - verify no interval accumulation. Refresh several times rapidly - should not see multiplied polling. + +**Step 3: Commit** + +```bash +git add static/script.js +git commit -m "Fix polling interval memory leak - store all interval IDs" +``` + +--- + +### Task 4: Fix highlightRareFinder DOM query + +**Files:** +- Modify: `static/script.js:2138-2151` (highlightRareFinder function) + +**Step 1: Replace highlightRareFinder function** + +Replace lines 2138-2151 with: + +```javascript +function highlightRareFinder(characterName) { + // Use element pool for O(1) lookup instead of querySelectorAll + for (const item of elementPools.activeListItems) { + if (item.playerData && item.playerData.character_name === characterName) { + item.classList.add('rare-finder-glow'); + setTimeout(() => { + item.classList.remove('rare-finder-glow'); + }, GLOW_DURATION_MS); + break; + } + } +} +``` + +**Step 2: Verify** + +This fires on rare notifications. If not easily testable live, verify by temporarily adding to console: +```javascript +highlightRareFinder('SomeOnlinePlayerName'); +``` +Confirm the player glows briefly in the list. + +**Step 3: Commit** + +```bash +git add static/script.js +git commit -m "Fix highlightRareFinder to use element pool instead of DOM query" +``` + +--- + +### Task 5: Extract createWindow helper + +**Files:** +- Modify: `static/script.js` - add helper before showStatsWindow (~line 725) +- Modify: `static/script.js:728-794` (showStatsWindow) +- Modify: `static/script.js:823-1036` (showInventoryWindow) +- Modify: `static/script.js:1800-1858` (showChatWindow) + +**Step 1: Add createWindow helper function** + +Insert before `showStatsWindow` (around line 725): + +```javascript +/** + * Create or show a draggable window. Returns { win, content, isNew }. + * If window already exists, brings it to front and returns isNew: false. + */ +function createWindow(id, title, className, options = {}) { + const { width = '400px', height = '300px', onClose } = options; + + // Check if window already exists - bring to front + const existing = document.getElementById(id); + if (existing) { + existing.style.display = 'flex'; + if (!window.__chatZ) window.__chatZ = 10000; + window.__chatZ += 1; + existing.style.zIndex = window.__chatZ; + return { win: existing, content: existing.querySelector('.window-content'), isNew: false }; + } + + // Create new window + if (!window.__chatZ) window.__chatZ = 10000; + window.__chatZ += 1; + + const win = document.createElement('div'); + win.id = id; + win.className = className; + win.style.display = 'flex'; + win.style.zIndex = window.__chatZ; + win.style.width = width; + win.style.height = height; + + const header = document.createElement('div'); + header.className = 'window-header'; + + const titleSpan = document.createElement('span'); + titleSpan.textContent = title; + header.appendChild(titleSpan); + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '\u00D7'; + closeBtn.addEventListener('click', () => { + win.style.display = 'none'; + if (onClose) onClose(); + }); + header.appendChild(closeBtn); + + const content = document.createElement('div'); + content.className = 'window-content'; + + win.appendChild(header); + win.appendChild(content); + document.body.appendChild(win); + makeDraggable(win, header); + + return { win, content, isNew: true }; +} +``` + +**Step 2: Refactor showStatsWindow to use createWindow** + +Replace the window creation boilerplate in `showStatsWindow` with: + +```javascript +function showStatsWindow(name) { + debugLog('showStatsWindow called for:', name); + const windowId = `statsWindow-${name}`; + + const { content, isNew } = createWindow( + windowId, `Stats: ${name}`, 'stats-window', + { width: '800px', height: '600px' } + ); + + if (!isNew) { + debugLog('Existing stats window found, showing it'); + return; + } + + // Stats-specific content (time range buttons + iframes) + // ... keep existing content creation logic from original function ... +} +``` + +Keep ALL the stats-specific logic (time range buttons, iframe creation, updateStatsTimeRange) - only remove the duplicated window setup code. + +**Step 3: Refactor showChatWindow to use createWindow** + +Same pattern - replace window boilerplate, keep chat-specific content (message list, input field, send button). + +**Step 4: Refactor showInventoryWindow to use createWindow** + +Same pattern - replace window boilerplate, keep inventory-specific content (grid, icon rendering, tooltips). + +**Step 5: Verify** + +Test each window type: +1. Click a player's chat button - window opens, is draggable, close works +2. Click a player's stats button - window opens, iframes load, time range works +3. Click a player's inventory button - window opens, items display, tooltips work +4. Open multiple windows - z-index stacking works (latest on top) +5. Close and reopen each - brings to front correctly + +**Step 6: Commit** + +```bash +git add static/script.js +git commit -m "Extract createWindow helper to eliminate window creation duplication" +``` + +--- + +### Task 6: Add requestAnimationFrame batching for pan/zoom + +**Files:** +- Modify: `static/script.js` - add scheduleViewUpdate near updateView (~line 1295) +- Modify: `static/script.js:1908` (wheel handler) +- Modify: `static/script.js:1919` (mousemove drag handler) +- Modify: `static/script.js:1935` (touchmove handler) + +**Step 1: Add scheduleViewUpdate after updateView function** + +After the `updateView()` function (line 1295), add: + +```javascript +let pendingFrame = null; +function scheduleViewUpdate() { + if (!pendingFrame) { + pendingFrame = requestAnimationFrame(() => { + updateView(); + pendingFrame = null; + }); + } +} +``` + +**Step 2: Replace updateView() calls in interaction handlers** + +Line 1908 (wheel zoom): +```javascript +// Before: updateView(); +// After: scheduleViewUpdate(); +``` + +Line 1919 (mousemove drag): +```javascript +// Before: updateView(); +// After: scheduleViewUpdate(); +``` + +Line 1935 (touchmove): +```javascript +// Before: updateView(); +// After: scheduleViewUpdate(); +``` + +**Keep** the direct `updateView()` calls at: +- Line 1301 (fitToWindow) - one-time call, not a hot path +- Line 1767 (selectPlayer) - one-time call on player click + +**Step 3: Verify** + +Pan the map by dragging - should feel smooth, no jitter. Zoom with scroll wheel - smooth. Toggle heatmap on, zoom/pan - heatmap updates correctly. Overall responsiveness should be same or better. + +**Step 4: Commit** + +```bash +git add static/script.js +git commit -m "Add requestAnimationFrame batching for pan/zoom updates" +``` + +--- + +### Task 7: Optimize renderTrails + +**Files:** +- Modify: `static/script.js:1737-1757` (renderTrails function) + +**Step 1: Replace renderTrails function** + +Replace lines 1737-1757 with: + +```javascript +function renderTrails(trailData) { + trailsContainer.innerHTML = ''; + // Build point strings directly - avoid intermediate arrays + const byChar = {}; + for (const pt of trailData) { + const { x, y } = worldToPx(pt.ew, pt.ns); + const key = pt.character_name; + if (!byChar[key]) byChar[key] = { points: `${x},${y}`, count: 1 }; + else { byChar[key].points += ` ${x},${y}`; byChar[key].count++; } + } + for (const name in byChar) { + if (byChar[name].count < 2) continue; + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + poly.setAttribute('points', byChar[name].points); + poly.setAttribute('stroke', getColorFor(name)); + poly.setAttribute('fill', 'none'); + poly.setAttribute('class', 'trail-path'); + trailsContainer.appendChild(poly); + } +} +``` + +**Step 2: Verify** + +Refresh page. Player trails should render as colored lines following player positions. Verify trails match player colors. Multiple players should each have distinct trail lines. + +**Step 3: Commit** + +```bash +git add static/script.js +git commit -m "Optimize renderTrails to build SVG point strings directly" +``` + +--- + +### Task 8: Remove redundant .slice() in renderList + +**Files:** +- Modify: `static/script.js:1487` (renderList function) + +**Step 1: Remove .slice()** + +Line 1487: +```javascript +// Before: +const sorted = filtered.slice().sort(currentSort.comparator); +// After: +filtered.sort(currentSort.comparator); +const sorted = filtered; +``` + +**Step 2: Verify** + +Refresh page. Player list should sort correctly. Click each sort button (name, KPH, kills, rares, total kills, KPR) - all should work correctly. + +**Step 3: Commit** + +```bash +git add static/script.js +git commit -m "Remove redundant .slice() before .sort() in renderList" +``` + +--- + +### Task 9: Add centralized error handling + +**Files:** +- Modify: `static/script.js` - add handleError near top (~after debugLog) +- Modify: `static/style.css` - add .error-toast CSS +- Modify: `static/script.js` - update catch blocks throughout + +**Step 1: Add handleError function after debugLog** + +```javascript +function handleError(context, error, showUI = false) { + console.error(`[${context}]`, error); + if (showUI) { + const msg = document.createElement('div'); + msg.className = 'error-toast'; + msg.textContent = `${context}: ${error.message || 'Unknown error'}`; + document.body.appendChild(msg); + setTimeout(() => msg.remove(), GLOW_DURATION_MS); + } +} +``` + +**Step 2: Add CSS for error toast** + +In `static/style.css`, add at the end: + +```css +/* Error Toast */ +.error-toast { + position: fixed; + bottom: 20px; + right: 20px; + background: rgba(220, 38, 38, 0.9); + color: white; + padding: 12px 20px; + border-radius: 8px; + font-size: 13px; + z-index: 99999; + animation: toastFadeIn 0.3s ease; + max-width: 400px; +} + +@keyframes toastFadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +``` + +**Step 3: Update catch blocks** + +Background polling (showUI = false): +```javascript +// pollLive catch (~line 1329): +// Before: console.error('Live or trails fetch failed:', e); +// After: handleError('Player update', e); + +// pollTotalRares catch (~line 1339): +// Before: console.error('Total rares fetch failed:', e); +// After: handleError('Rare counter', e); + +// pollTotalKills catch (~line 1358): +// Before: console.error('Total kills fetch failed:', e); +// After: handleError('Kill counter', e); + +// pollServerHealth catch (~line 1375): +// Before: console.error('Server health fetch failed:', e); +// After: handleError('Server health', e); +``` + +User-triggered actions (showUI = true): +```javascript +// showInventoryWindow catch (~line 1024): +// Before: content.innerHTML = `
Failed to load...
`; +// After: handleError('Inventory', err, true); +// content.innerHTML = `
Failed to load inventory
`; + +// fetchHeatmapData catch (~line 565): +// Before: console.error('Heatmap fetch error:', err); +// After: handleError('Heatmap', err); + +// fetchPortalData catch (~line 650): +// Before: (similar) +// After: handleError('Portals', err); +``` + +**Step 4: Verify** + +1. Normal operation - no toasts appear +2. Temporarily break a URL (e.g., change API_BASE) - verify console.error appears with `[context]` prefix +3. For user-triggered errors (inventory of offline player) - verify red toast appears bottom-right and auto-dismisses + +**Step 5: Commit** + +```bash +git add static/script.js static/style.css +git commit -m "Add centralized error handling with UI toast for user-facing errors" +``` + +--- + +### Task 10: Full Manual Testing Pass + +**Files:** None modified - testing only + +**CRITICAL: Do not push to git until ALL tests pass.** + +**Test Checklist:** + +| # | Test | How to Verify | Status | +|---|------|---------------|--------| +| 1 | Page load | Map renders, players appear, no console errors | | +| 2 | Live tracking | Players update positions every ~2 seconds | | +| 3 | Player filter | Type name in filter, list updates correctly | | +| 4 | Sort buttons | Each of 6 sort options works (name, KPH, kills, rares, total_kills, KPR) | | +| 5 | Player selection | Click player in list, map pans and zooms to them | | +| 6 | Pan/zoom | Mouse drag smooth, scroll wheel zoom smooth, no jitter or lag | | +| 7 | Heatmap | Toggle on - renders points. Pan/zoom - updates. Toggle off - clears | | +| 8 | Portals | Toggle on - portal icons appear. Toggle off - clears | | +| 9 | Chat window | Opens, draggable, close button works, receives WebSocket messages | | +| 10 | Stats window | Opens, iframes load, time range buttons work, draggable | | +| 11 | Inventory window | Opens, items display with icons, hover tooltip works, draggable | | +| 12 | Multiple windows | Open several windows - latest always on top (z-index) | | +| 13 | Console (DEBUG=false) | Console is clean, only errors/warnings visible | | +| 14 | Console (DEBUG=true) | Set DEBUG=true in code, refresh - verbose logs appear | | +| 15 | Page reload x5 | Reload 5 times rapidly - no interval accumulation, consistent behavior | | +| 16 | Trail rendering | Player trails visible as colored lines matching player dot colors | | +| 17 | Error handling | Briefly disconnect network - no crashes, console shows `[context]` errors | | + +**If all pass:** Ready to push. Inform user and await approval. + +**If any fail:** Fix the specific issue, re-test that item, then re-run full checklist. diff --git a/docs/plans/2026-02-26-script-js-review-design.md b/docs/plans/2026-02-26-script-js-review-design.md new file mode 100644 index 00000000..e16b3a51 --- /dev/null +++ b/docs/plans/2026-02-26-script-js-review-design.md @@ -0,0 +1,206 @@ +# script.js Code Review & Performance Cleanup + +## Overview + +Comprehensive cleanup of `static/script.js` (2,310 lines, 54 functions) - the main frontend controller for the live player tracking map. Approach B: surgical fixes + performance pass without full refactor. + +**File:** `static/script.js` (+ minor CSS for error toast) +**Risk:** Low - targeted changes only, no architectural restructuring +**IMPORTANT:** Do NOT push to git until extensive manual testing is complete. + +--- + +## Changes + +### 1. Bug Fixes + +#### 1a. Store All Polling Interval IDs (Memory Leak Fix) + +Currently only `pollID` is stored. The other 3 intervals (`pollTotalRares`, `pollTotalKills`, `pollServerHealth`) leak on page reload. + +**Fix:** Use a `pollIntervals` array, clear all before restarting. + +```javascript +const pollIntervals = []; +function startPolling() { + pollIntervals.forEach(id => clearInterval(id)); + pollIntervals.length = 0; + pollIntervals.push(setInterval(pollLive, POLL_MS)); + pollIntervals.push(setInterval(pollTotalRares, POLL_RARES_MS)); + pollIntervals.push(setInterval(pollTotalKills, POLL_KILLS_MS)); + pollIntervals.push(setInterval(pollServerHealth, POLL_HEALTH_MS)); +} +``` + +#### 1b. Fix `highlightRareFinder()` DOM Query + +Replace expensive `querySelectorAll` + string search with existing element pool lookup. + +```javascript +// O(1) lookup from existing pool instead of O(n) DOM query +for (const item of elementPools.activeListItems) { + if (item.playerData?.character_name === characterName) { + item.classList.add('rare-finder-glow'); + setTimeout(() => item.classList.remove('rare-finder-glow'), GLOW_DURATION_MS); + break; + } +} +``` + +--- + +### 2. Console Logging & Constants Cleanup + +#### 2a. DEBUG Flag + +Gate all `console.log` calls behind a flag. Errors and warnings stay always-visible. + +```javascript +const DEBUG = false; +function debugLog(...args) { if (DEBUG) console.log(...args); } +``` + +Replace all `console.log(...)` with `debugLog(...)` throughout. Keep `console.error(...)` as-is. + +#### 2b. Named Constants + +Add to existing constants block: + +```javascript +const POLL_RARES_MS = 300000; +const POLL_KILLS_MS = 300000; +const POLL_HEALTH_MS = 30000; +const NOTIFICATION_DURATION_MS = 6000; +const GLOW_DURATION_MS = 5000; +const MAX_HEATMAP_POINTS = 50000; +const HEATMAP_HOURS = 24; +``` + +Replace hardcoded values where they appear. + +--- + +### 3. Extract Window Creation Helper + +Three functions (`showChatWindow`, `showStatsWindow`, `showInventoryWindow`) duplicate ~50 lines of window setup. + +**Extract `createWindow(id, title, className, options)`** that handles: +- Existing window detection and bring-to-front +- Z-index management +- DOM structure (header, close button, content area) +- `makeDraggable()` setup + +Each `show*Window()` function then contains only its unique content logic. + +--- + +### 4. Performance Pass + +#### 4a. requestAnimationFrame for Pan/Zoom + +Batch `updateView()` calls during mousemove/wheel into animation frames. + +```javascript +let pendingFrame = null; +function scheduleViewUpdate() { + if (!pendingFrame) { + pendingFrame = requestAnimationFrame(() => { + updateView(); + pendingFrame = null; + }); + } +} +``` + +Replace direct `updateView()` calls in mousemove and wheel handlers with `scheduleViewUpdate()`. + +#### 4b. Optimize renderTrails() + +Build SVG point strings directly instead of creating intermediate arrays: + +```javascript +const byChar = {}; +for (const pt of trailData) { + const { x, y } = worldToPx(pt.ew, pt.ns); + const key = pt.character_name; + if (!byChar[key]) byChar[key] = `${x},${y}`; + else byChar[key] += ` ${x},${y}`; +} +for (const name in byChar) { + const poly = document.createElementNS(...); + poly.setAttribute('points', byChar[name]); + ... +} +``` + +Eliminates: `Object.entries()` allocation, per-character `.map()`, per-point `.join()`. + +#### 4c. Remove Redundant .slice() + +```javascript +// filtered is already a local copy from .filter(), no need to .slice() +filtered.sort(currentSort.comparator); +const sorted = filtered; +``` + +--- + +### 5. Centralized Error Handling + +Add `handleError(context, error, showUI)` for consistent error logging. Background polling uses `showUI = false`, user-triggered actions use `showUI = true` with a brief toast notification. + +```javascript +function handleError(context, error, showUI = false) { + console.error(`[${context}]`, error); + if (showUI) { + const msg = document.createElement('div'); + msg.className = 'error-toast'; + msg.textContent = `${context}: ${error.message || 'Unknown error'}`; + document.body.appendChild(msg); + setTimeout(() => msg.remove(), 5000); + } +} +``` + +Requires minor CSS addition for `.error-toast` styling. + +--- + +## Testing Plan + +**CRITICAL: All changes must be manually tested before any git push.** + +### Test Checklist +1. **Page load** - Map renders, players appear, no console errors +2. **Live tracking** - Players update positions every 2 seconds +3. **Player list** - Filter by name works, all 6 sort options work +4. **Player selection** - Click player in list, map centers on them +5. **Pan/zoom** - Mouse drag panning smooth, scroll wheel zoom smooth, no jitter +6. **Heatmap** - Toggle on/off, renders correctly, updates during pan/zoom +7. **Portals** - Toggle on/off, portals appear at correct positions +8. **Chat window** - Opens, receives messages via WebSocket, draggable +9. **Stats window** - Opens, iframes load, time range buttons work, draggable +10. **Inventory window** - Opens, items display, tooltip on hover, draggable +11. **Rare notifications** - If testable, fireworks + player glow appear +12. **Page reload** - No interval accumulation (check DevTools memory) +13. **Console output** - Clean when DEBUG=false, verbose when DEBUG=true +14. **Error scenarios** - Disconnect network briefly, verify graceful handling + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `static/script.js` | All changes above | +| `static/style.css` | Add `.error-toast` CSS | + +--- + +## What's NOT Changing + +- File stays as single script.js (no module split) +- WebSocket reconnection logic stays as-is (fixed 2s retry) +- Element pooling system untouched (already optimized) +- Rendering pipeline structure stays the same +- No architectural changes diff --git a/static/script.js b/static/script.js index 20b926f4..ecbf64ac 100644 --- a/static/script.js +++ b/static/script.js @@ -22,6 +22,21 @@ * 6. Rendering functions for list and map * 7. Event listeners for map interactions and WebSocket messages */ +/* ---------- Debug configuration ---------------------------------- */ +const DEBUG = false; +function debugLog(...args) { if (DEBUG) console.log(...args); } + +function handleError(context, error, showUI = false) { + console.error(`[${context}]`, error); + if (showUI) { + const msg = document.createElement('div'); + msg.className = 'error-toast'; + msg.textContent = `${context}: ${error.message || 'Unknown error'}`; + document.body.appendChild(msg); + setTimeout(() => msg.remove(), GLOW_DURATION_MS); + } +} + /* ---------- DOM references --------------------------------------- */ const wrap = document.getElementById('mapContainer'); const group = document.getElementById('mapGroup'); @@ -97,16 +112,16 @@ function createNewListItem() { chatBtn.className = 'chat-btn'; chatBtn.textContent = 'Chat'; chatBtn.addEventListener('click', (e) => { - console.log('🔥 CHAT BUTTON CLICKED!', e.target, e.currentTarget); + debugLog('🔥 CHAT BUTTON CLICKED!', e.target, e.currentTarget); e.stopPropagation(); // Try button's own playerData first, fallback to DOM traversal const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - console.log('🔥 Player data found:', playerData); + debugLog('🔥 Player data found:', playerData); if (playerData) { - console.log('🔥 Opening chat for:', playerData.character_name); + debugLog('🔥 Opening chat for:', playerData.character_name); showChatWindow(playerData.character_name); } else { - console.log('🔥 No player data found!'); + debugLog('🔥 No player data found!'); } }); @@ -114,16 +129,16 @@ function createNewListItem() { statsBtn.className = 'stats-btn'; statsBtn.textContent = 'Stats'; statsBtn.addEventListener('click', (e) => { - console.log('📊 STATS BUTTON CLICKED!', e.target, e.currentTarget); + debugLog('📊 STATS BUTTON CLICKED!', e.target, e.currentTarget); e.stopPropagation(); // Try button's own playerData first, fallback to DOM traversal const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - console.log('📊 Player data found:', playerData); + debugLog('📊 Player data found:', playerData); if (playerData) { - console.log('📊 Opening stats for:', playerData.character_name); + debugLog('📊 Opening stats for:', playerData.character_name); showStatsWindow(playerData.character_name); } else { - console.log('📊 No player data found!'); + debugLog('📊 No player data found!'); } }); @@ -131,16 +146,16 @@ function createNewListItem() { inventoryBtn.className = 'inventory-btn'; inventoryBtn.textContent = 'Inventory'; inventoryBtn.addEventListener('click', (e) => { - console.log('🎒 INVENTORY BUTTON CLICKED!', e.target, e.currentTarget); + debugLog('🎒 INVENTORY BUTTON CLICKED!', e.target, e.currentTarget); e.stopPropagation(); // Try button's own playerData first, fallback to DOM traversal const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - console.log('🎒 Player data found:', playerData); + debugLog('🎒 Player data found:', playerData); if (playerData) { - console.log('🎒 Opening inventory for:', playerData.character_name); + debugLog('🎒 Opening inventory for:', playerData.character_name); showInventoryWindow(playerData.character_name); } else { - console.log('🎒 No player data found!'); + debugLog('🎒 No player data found!'); } }); @@ -298,6 +313,13 @@ const inventoryWindows = {}; const MAX_Z = 20; const FOCUS_ZOOM = 3; // zoom level when you click a name const POLL_MS = 2000; +const POLL_RARES_MS = 300000; // 5 minutes +const POLL_KILLS_MS = 300000; // 5 minutes +const POLL_HEALTH_MS = 30000; // 30 seconds +const NOTIFICATION_DURATION_MS = 6000; // Rare notification display time +const GLOW_DURATION_MS = 5000; // Player glow after rare find +const MAX_HEATMAP_POINTS = 50000; +const HEATMAP_HOURS = 24; // UtilityBelt's more accurate coordinate bounds const MAP_BOUNDS = { west: -102.1, @@ -488,7 +510,7 @@ let imgW = 0, imgH = 0; let scale = 1, offX = 0, offY = 0, minScale = 1; let dragging = false, sx = 0, sy = 0; let selected = ""; -let pollID = null; +const pollIntervals = []; /* ---------- utility functions ----------------------------------- */ const hue = name => { @@ -553,17 +575,17 @@ function initHeatMap() { async function fetchHeatmapData() { try { - const response = await fetch(`${API_BASE}/spawns/heatmap?hours=24&limit=50000`); + const response = await fetch(`${API_BASE}/spawns/heatmap?hours=${HEATMAP_HOURS}&limit=${MAX_HEATMAP_POINTS}`); if (!response.ok) { throw new Error(`Heat map API error: ${response.status}`); } const data = await response.json(); heatmapData = data.spawn_points; // [{ew, ns, intensity}] - console.log(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`); + debugLog(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`); renderHeatmap(); } catch (err) { - console.error('Failed to fetch heat map data:', err); + handleError('Heatmap', err); } } @@ -645,10 +667,10 @@ async function fetchPortalData() { const data = await response.json(); portalData = data.portals; // [{portal_name, coordinates: {ns, ew, z}, discovered_by, discovered_at}] - console.log(`Loaded ${portalData.length} portals from last hour`); + debugLog(`Loaded ${portalData.length} portals from last hour`); renderPortals(); } catch (err) { - console.error('Failed to fetch portal data:', err); + handleError('Portals', err); } } @@ -707,7 +729,7 @@ function renderPortals() { portalContainer.appendChild(icon); } - console.log(`Rendered ${portalData.length} portal icons`); + debugLog(`Rendered ${portalData.length} portal icons`); } function clearPortals() { @@ -724,37 +746,77 @@ function debounce(fn, ms) { }; } -// Show or create a stats window for a character -function showStatsWindow(name) { - console.log('📊 showStatsWindow called for:', name); - if (statsWindows[name]) { - const existing = statsWindows[name]; - console.log('📊 Existing stats window found, showing it:', existing); - // Always show the window (no toggle) - existing.style.display = 'flex'; - // Bring to front when opening +/** + * Create or show a draggable window. Returns { win, content, isNew }. + * If window already exists, brings it to front and returns isNew: false. + */ +function createWindow(id, title, className, options = {}) { + const { onClose } = options; + + // Check if window already exists - bring to front + const existing = document.getElementById(id); + if (existing) { + existing.style.display = 'flex'; + if (!window.__chatZ) window.__chatZ = 10000; + window.__chatZ += 1; + existing.style.zIndex = window.__chatZ; + return { win: existing, content: existing.querySelector('.window-content'), isNew: false }; + } + + // Create new window if (!window.__chatZ) window.__chatZ = 10000; window.__chatZ += 1; - existing.style.zIndex = window.__chatZ; - console.log('📊 Stats window shown with zIndex:', window.__chatZ); + + const win = document.createElement('div'); + win.id = id; + win.className = className; + win.style.display = 'flex'; + win.style.zIndex = window.__chatZ; + + const header = document.createElement('div'); + header.className = 'chat-header'; + + const titleSpan = document.createElement('span'); + titleSpan.textContent = title; + header.appendChild(titleSpan); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'chat-close-btn'; + closeBtn.textContent = '\u00D7'; + closeBtn.addEventListener('click', () => { + win.style.display = 'none'; + if (onClose) onClose(); + }); + header.appendChild(closeBtn); + + const content = document.createElement('div'); + content.className = 'window-content'; + + win.appendChild(header); + win.appendChild(content); + document.body.appendChild(win); + makeDraggable(win, header); + + return { win, content, isNew: true }; +} + +// Show or create a stats window for a character +function showStatsWindow(name) { + debugLog('showStatsWindow called for:', name); + const windowId = `statsWindow-${name}`; + + const { win, content, isNew } = createWindow( + windowId, `Stats: ${name}`, 'stats-window' + ); + + if (!isNew) { + debugLog('Existing stats window found, showing it'); return; } - console.log('📊 Creating new stats window for:', name); - const win = document.createElement('div'); - win.className = 'stats-window'; + win.dataset.character = name; - // Header (reuses chat-header styling) - const header = document.createElement('div'); - header.className = 'chat-header'; - const title = document.createElement('span'); - title.textContent = `Stats: ${name}`; - const closeBtn = document.createElement('button'); - closeBtn.className = 'chat-close-btn'; - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', () => { win.style.display = 'none'; }); - header.appendChild(title); - header.appendChild(closeBtn); - win.appendChild(header); + statsWindows[name] = win; + // Time period controls const controls = document.createElement('div'); controls.className = 'stats-controls'; @@ -764,6 +826,12 @@ function showStatsWindow(name) { { label: '24H', value: 'now-24h' }, { label: '7D', value: 'now-7d' } ]; + + // Stats content container (iframes grid) + const statsContent = document.createElement('div'); + statsContent.className = 'chat-messages'; + statsContent.textContent = 'Loading stats...'; + timeRanges.forEach(range => { const btn = document.createElement('button'); btn.className = 'time-range-btn'; @@ -772,25 +840,17 @@ function showStatsWindow(name) { btn.addEventListener('click', () => { controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); - updateStatsTimeRange(content, name, range.value); + updateStatsTimeRange(statsContent, name, range.value); }); controls.appendChild(btn); }); - win.appendChild(controls); - - // Content container - const content = document.createElement('div'); - content.className = 'chat-messages'; - content.textContent = 'Loading stats...'; - win.appendChild(content); - console.log('📊 Appending stats window to DOM:', win); - document.body.appendChild(win); - statsWindows[name] = win; - console.log('📊 Stats window added to DOM, total children:', document.body.children.length); + + content.appendChild(controls); + content.appendChild(statsContent); + + debugLog('Stats window created for:', name); // Load initial stats with default 24h range - updateStatsTimeRange(content, name, 'now-24h'); - // Enable dragging using the global drag system - makeDraggable(win, header); + updateStatsTimeRange(statsContent, name, 'now-24h'); } function updateStatsTimeRange(content, name, timeRange) { @@ -821,47 +881,33 @@ function updateStatsTimeRange(content, name, timeRange) { // Show or create an inventory window for a character function showInventoryWindow(name) { - console.log('🎒 showInventoryWindow called for:', name); - if (inventoryWindows[name]) { - const existing = inventoryWindows[name]; - console.log('🎒 Existing inventory window found, showing it:', existing); - // Always show the window (no toggle) - existing.style.display = 'flex'; - // Bring to front when opening - if (!window.__chatZ) window.__chatZ = 10000; - window.__chatZ += 1; - existing.style.zIndex = window.__chatZ; - console.log('🎒 Inventory window shown with zIndex:', window.__chatZ); + debugLog('showInventoryWindow called for:', name); + const windowId = `inventoryWindow-${name}`; + + const { win, content, isNew } = createWindow( + windowId, `Inventory: ${name}`, 'inventory-window' + ); + + if (!isNew) { + debugLog('Existing inventory window found, showing it'); return; } - console.log('🎒 Creating new inventory window for:', name); - const win = document.createElement('div'); - win.className = 'inventory-window'; + win.dataset.character = name; - // Header (reuses chat-header styling) - const header = document.createElement('div'); - header.className = 'chat-header'; - const title = document.createElement('span'); - title.textContent = `Inventory: ${name}`; - const closeBtn = document.createElement('button'); - closeBtn.className = 'chat-close-btn'; - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', () => { win.style.display = 'none'; }); - header.appendChild(title); - header.appendChild(closeBtn); - win.appendChild(header); + inventoryWindows[name] = win; + // Loading message const loading = document.createElement('div'); loading.className = 'inventory-loading'; loading.textContent = 'Loading inventory...'; - win.appendChild(loading); - - // Content container - const content = document.createElement('div'); - content.className = 'inventory-content'; - content.style.display = 'none'; - win.appendChild(content); - + content.appendChild(loading); + + // Inventory content container + const invContent = document.createElement('div'); + invContent.className = 'inventory-content'; + invContent.style.display = 'none'; + content.appendChild(invContent); + // Fetch inventory data from main app (which will proxy to inventory service) fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`) .then(response => { @@ -870,38 +916,38 @@ function showInventoryWindow(name) { }) .then(data => { loading.style.display = 'none'; - content.style.display = 'block'; - + invContent.style.display = 'block'; + // Create inventory grid const grid = document.createElement('div'); grid.className = 'inventory-grid'; - + // Render each item data.items.forEach(item => { - + const slot = document.createElement('div'); slot.className = 'inventory-slot'; - + // Create layered icon container const iconContainer = document.createElement('div'); iconContainer.className = 'item-icon-composite'; - + // Get base icon ID with portal.dat offset const baseIconId = (item.icon + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - + // Check for overlay and underlay from enhanced format or legacy format let overlayIconId = null; let underlayIconId = null; - + // Enhanced format (inventory service) - check for proper icon overlay/underlay properties if (item.icon_overlay_id && item.icon_overlay_id > 0) { overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } - + if (item.icon_underlay_id && item.icon_underlay_id > 0) { underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } - + // Fallback: Enhanced format (inventory service) - check spells object for decal info if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') { // Icon overlay (using the actual property names from the data) @@ -909,8 +955,8 @@ function showInventoryWindow(name) { if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) { overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } - - // Icon underlay + + // Icon underlay if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) { underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } @@ -918,13 +964,13 @@ function showInventoryWindow(name) { // Legacy format - parse item_data try { const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data; - + if (itemData.IntValues) { // Icon overlay (ID 218103849) - only use valid icon IDs if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) { overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } - + // Icon underlay (ID 218103850) - only use valid icon IDs if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) { underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); @@ -934,7 +980,7 @@ function showInventoryWindow(name) { console.warn('Failed to parse item data for', item.name); } } - + // Create underlay (bottom layer) if (underlayIconId) { const underlayImg = document.createElement('img'); @@ -944,7 +990,7 @@ function showInventoryWindow(name) { underlayImg.onerror = function() { this.style.display = 'none'; }; iconContainer.appendChild(underlayImg); } - + // Create base icon (middle layer) const baseImg = document.createElement('img'); baseImg.className = 'icon-base'; @@ -955,7 +1001,7 @@ function showInventoryWindow(name) { this.src = '/icons/06000133.png'; }; iconContainer.appendChild(baseImg); - + // Create overlay (top layer) if (overlayIconId) { const overlayImg = document.createElement('img'); @@ -965,74 +1011,68 @@ function showInventoryWindow(name) { overlayImg.onerror = function() { this.style.display = 'none'; }; iconContainer.appendChild(overlayImg); } - + // Create tooltip data slot.dataset.name = item.name || 'Unknown Item'; slot.dataset.value = item.value || 0; slot.dataset.burden = item.burden || 0; - + // Store enhanced data for tooltips // All data now comes from inventory service (no more local fallback) if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) { // Inventory service provides clean, structured data with translations // Only include properties that actually exist on the item const enhancedData = {}; - + // Check all possible enhanced properties from inventory service const possibleProps = [ 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus', 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name', - 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', - 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', + 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', + 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells', 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage', 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana', - 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', + 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id' ]; - + // Only add properties that exist and have meaningful values possibleProps.forEach(prop => { if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) { enhancedData[prop] = item[prop]; } }); - + slot.dataset.enhancedData = JSON.stringify(enhancedData); } else { // No enhanced data available slot.dataset.enhancedData = JSON.stringify({}); } - + // Add tooltip on hover slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot)); slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot)); slot.addEventListener('mouseleave', hideInventoryTooltip); - + slot.appendChild(iconContainer); grid.appendChild(slot); }); - - content.appendChild(grid); - + + invContent.appendChild(grid); + // Add item count const count = document.createElement('div'); count.className = 'inventory-count'; count.textContent = `${data.item_count} items`; - content.appendChild(count); + invContent.appendChild(count); }) .catch(err => { + handleError('Inventory', err, true); loading.textContent = `Failed to load inventory: ${err.message}`; - console.error('Inventory fetch failed:', err); }); - - console.log('🎒 Appending inventory window to DOM:', win); - document.body.appendChild(win); - inventoryWindows[name] = win; - console.log('🎒 Inventory window added to DOM, total children:', document.body.children.length); - - // Enable dragging using the global drag system - makeDraggable(win, header); + + debugLog('Inventory window created for:', name); } // Inventory tooltip functions @@ -1294,6 +1334,16 @@ function updateView() { } } +let pendingFrame = null; +function scheduleViewUpdate() { + if (!pendingFrame) { + pendingFrame = requestAnimationFrame(() => { + updateView(); + pendingFrame = null; + }); + } +} + function fitToWindow() { const r = wrap.getBoundingClientRect(); scale = Math.min(r.width / imgW, r.height / imgH); @@ -1326,7 +1376,7 @@ async function pollLive() { renderTrails(trails); renderList(); } catch (e) { - console.error('Live or trails fetch failed:', e); + handleError('Player update', e); } } @@ -1336,7 +1386,7 @@ async function pollTotalRares() { const data = await response.json(); updateTotalRaresDisplay(data); } catch (e) { - console.error('Total rares fetch failed:', e); + handleError('Rare counter', e); } } @@ -1355,7 +1405,7 @@ async function pollTotalKills() { const data = await response.json(); updateTotalKillsDisplay(data); } catch (e) { - console.error('Total kills fetch failed:', e); + handleError('Kill counter', e); } } @@ -1372,7 +1422,7 @@ async function pollServerHealth() { const data = await response.json(); updateServerStatusDisplay(data); } catch (e) { - console.error('Server health fetch failed:', e); + handleError('Server health', e); updateServerStatusDisplay({ status: 'error' }); } } @@ -1430,7 +1480,7 @@ function handleServerStatusUpdate(msg) { // Handle real-time server status updates via WebSocket if (msg.status === 'up' && msg.message) { // Show notification for server coming back online - console.log(`Server Status: ${msg.message}`); + debugLog(`Server Status: ${msg.message}`); } // Trigger an immediate server health poll to refresh the display @@ -1438,18 +1488,21 @@ function handleServerStatusUpdate(msg) { } function startPolling() { - if (pollID !== null) return; + // Clear any existing intervals first (prevents leak on re-init) + pollIntervals.forEach(id => clearInterval(id)); + pollIntervals.length = 0; + + // Initial fetches pollLive(); - pollTotalRares(); // Initial fetch - pollTotalKills(); // Initial fetch - pollServerHealth(); // Initial server health check - pollID = setInterval(pollLive, POLL_MS); - // Poll total rares every 5 minutes (300,000 ms) - setInterval(pollTotalRares, 300000); - // Poll total kills every 5 minutes (300,000 ms) - setInterval(pollTotalKills, 300000); - // Poll server health every 30 seconds (30,000 ms) - setInterval(pollServerHealth, 30000); + pollTotalRares(); + pollTotalKills(); + pollServerHealth(); + + // Set up recurring polls + pollIntervals.push(setInterval(pollLive, POLL_MS)); + pollIntervals.push(setInterval(pollTotalRares, POLL_RARES_MS)); + pollIntervals.push(setInterval(pollTotalKills, POLL_KILLS_MS)); + pollIntervals.push(setInterval(pollServerHealth, POLL_HEALTH_MS)); } img.onload = () => { @@ -1484,7 +1537,8 @@ function renderList() { p.character_name.toLowerCase().startsWith(currentFilter) ); // Sort filtered list - const sorted = filtered.slice().sort(currentSort.comparator); + filtered.sort(currentSort.comparator); + const sorted = filtered; render(sorted); } @@ -1508,11 +1562,11 @@ document.addEventListener('mouseup', () => { function render(players) { const startTime = performance.now(); - console.log('🔄 RENDER STARTING:', new Date().toISOString()); + debugLog('🔄 RENDER STARTING:', new Date().toISOString()); // If user is actively clicking, defer this render briefly if (userInteracting) { - console.log('🔄 RENDER DEFERRED: User interaction detected'); + debugLog('🔄 RENDER DEFERRED: User interaction detected'); setTimeout(() => render(players), 100); return; } @@ -1730,30 +1784,29 @@ function render(players) { // Optimization is achieving 100% element reuse consistently const renderTime = performance.now() - startTime; - console.log('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms'); + debugLog('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms'); } /* ---------- rendering trails ------------------------------- */ function renderTrails(trailData) { - trailsContainer.innerHTML = ''; - const byChar = trailData.reduce((acc, pt) => { - (acc[pt.character_name] = acc[pt.character_name] || []).push(pt); - return acc; - }, {}); - for (const [name, pts] of Object.entries(byChar)) { - if (pts.length < 2) continue; - const points = pts.map(pt => { - const { x, y } = worldToPx(pt.ew, pt.ns); - return `${x},${y}`; - }).join(' '); - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); - poly.setAttribute('points', points); - // Use the same color as the player dot for consistency - poly.setAttribute('stroke', getColorFor(name)); - poly.setAttribute('fill', 'none'); - poly.setAttribute('class', 'trail-path'); - trailsContainer.appendChild(poly); - } + trailsContainer.innerHTML = ''; + // Build point strings directly - avoid intermediate arrays + const byChar = {}; + for (const pt of trailData) { + const { x, y } = worldToPx(pt.ew, pt.ns); + const key = pt.character_name; + if (!byChar[key]) byChar[key] = { points: `${x},${y}`, count: 1 }; + else { byChar[key].points += ` ${x},${y}`; byChar[key].count++; } + } + for (const name in byChar) { + if (byChar[name].count < 2) continue; + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + poly.setAttribute('points', byChar[name].points); + poly.setAttribute('stroke', getColorFor(name)); + poly.setAttribute('fill', 'none'); + poly.setAttribute('class', 'trail-path'); + trailsContainer.appendChild(poly); + } } /* ---------- selection centering, focus zoom & blink ------------ */ function selectPlayer(p, x, y) { @@ -1793,44 +1846,31 @@ function initWebSocket() { } }); socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); - socket.addEventListener('error', e => console.error('WebSocket error:', e)); + socket.addEventListener('error', e => handleError('WebSocket', e)); } // Display or create a chat window for a character function showChatWindow(name) { - console.log('💬 showChatWindow called for:', name); - if (chatWindows[name]) { - const existing = chatWindows[name]; - console.log('💬 Existing chat window found, showing it:', existing); - // Always show the window (no toggle) - existing.style.display = 'flex'; - // Bring to front when opening - if (!window.__chatZ) window.__chatZ = 10000; - window.__chatZ += 1; - existing.style.zIndex = window.__chatZ; - console.log('💬 Chat window shown with zIndex:', window.__chatZ); + debugLog('showChatWindow called for:', name); + const windowId = `chatWindow-${name}`; + + const { win, content, isNew } = createWindow( + windowId, `Chat: ${name}`, 'chat-window' + ); + + if (!isNew) { + debugLog('Existing chat window found, showing it'); return; } - console.log('💬 Creating new chat window for:', name); - const win = document.createElement('div'); - win.className = 'chat-window'; + win.dataset.character = name; - // Header - const header = document.createElement('div'); - header.className = 'chat-header'; - const title = document.createElement('span'); - title.textContent = `Chat: ${name}`; - const closeBtn = document.createElement('button'); - closeBtn.className = 'chat-close-btn'; - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', () => { win.style.display = 'none'; }); - header.appendChild(title); - header.appendChild(closeBtn); - win.appendChild(header); + chatWindows[name] = win; + // Messages container const msgs = document.createElement('div'); msgs.className = 'chat-messages'; - win.appendChild(msgs); + content.appendChild(msgs); + // Input form const form = document.createElement('form'); form.className = 'chat-form'; @@ -1847,14 +1887,9 @@ function showChatWindow(name) { socket.send(JSON.stringify({ player_name: name, command: text })); input.value = ''; }); - win.appendChild(form); - console.log('💬 Appending chat window to DOM:', win); - document.body.appendChild(win); - chatWindows[name] = win; - console.log('💬 Chat window added to DOM, total children:', document.body.children.length); + content.appendChild(form); - // Enable dragging using the global drag system - makeDraggable(win, header); + debugLog('Chat window created for:', name); } // Append a chat message to the correct window @@ -1905,7 +1940,7 @@ wrap.addEventListener('wheel', e => { offX -= mx * (ns - scale); offY -= my * (ns - scale); scale = ns; - updateView(); + scheduleViewUpdate(); }, { passive: false }); wrap.addEventListener('mousedown', e => { @@ -1916,7 +1951,7 @@ window.addEventListener('mousemove', e => { if (!dragging) return; offX += e.clientX - sx; offY += e.clientY - sy; sx = e.clientX; sy = e.clientY; - updateView(); + scheduleViewUpdate(); }); window.addEventListener('mouseup', () => { dragging = false; wrap.classList.remove('dragging'); @@ -1932,7 +1967,7 @@ wrap.addEventListener('touchmove', e => { const t = e.touches[0]; offX += t.clientX - sx; offY += t.clientY - sy; sx = t.clientX; sy = t.clientY; - updateView(); + scheduleViewUpdate(); }); wrap.addEventListener('touchend', () => { dragging = false; @@ -2059,14 +2094,14 @@ function processNotificationQueue() { container.appendChild(notifEl); - // Remove notification after 6 seconds and process next + // Remove notification after display duration and process next setTimeout(() => { notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards'; setTimeout(() => { notifEl.remove(); processNotificationQueue(); }, 500); - }, 6000); + }, NOTIFICATION_DURATION_MS); } // Add slide out animation @@ -2136,18 +2171,16 @@ function createFireworks() { } function highlightRareFinder(characterName) { - // Find the player in the list - const playerItems = document.querySelectorAll('#playerList li'); - playerItems.forEach(item => { - const nameSpan = item.querySelector('.player-name'); - if (nameSpan && nameSpan.textContent.includes(characterName)) { - item.classList.add('rare-finder-glow'); - // Remove glow after 5 seconds - setTimeout(() => { - item.classList.remove('rare-finder-glow'); - }, 5000); + // Use element pool for O(1) lookup instead of querySelectorAll + for (const item of elementPools.activeListItems) { + if (item.playerData && item.playerData.character_name === characterName) { + item.classList.add('rare-finder-glow'); + setTimeout(() => { + item.classList.remove('rare-finder-glow'); + }, GLOW_DURATION_MS); + break; + } } - }); } // Update total rares display to trigger fireworks on increase @@ -2170,7 +2203,7 @@ updateTotalRaresDisplay = function(data) { } function triggerMilestoneCelebration(rareNumber) { - console.log(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`); + debugLog(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`); // Create full-screen milestone overlay const overlay = document.createElement('div'); diff --git a/static/style-ac.css b/static/style-ac.css index 7d30d4cf..a25e1680 100644 --- a/static/style-ac.css +++ b/static/style-ac.css @@ -363,6 +363,14 @@ body { border-radius: 2px; } +.window-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + .chat-header { display: flex; justify-content: space-between; diff --git a/static/style.css b/static/style.css index d1b8c82f..b17b8d70 100644 --- a/static/style.css +++ b/static/style.css @@ -540,6 +540,14 @@ body { z-index: 10000; } +.window-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + .chat-header { display: flex; justify-content: space-between; @@ -1562,3 +1570,23 @@ body.noselect, body.noselect * { color: #88ccff; } +/* Error Toast */ +.error-toast { + position: fixed; + bottom: 20px; + right: 20px; + background: rgba(220, 38, 38, 0.9); + color: white; + padding: 12px 20px; + border-radius: 8px; + font-size: 13px; + z-index: 99999; + animation: toastFadeIn 0.3s ease; + max-width: 400px; +} + +@keyframes toastFadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +