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); }
+}
+