diff --git a/static/script.js b/static/script.js
index 1877c404..20b926f4 100644
--- a/static/script.js
+++ b/static/script.js
@@ -33,6 +33,170 @@ const btnContainer = document.getElementById('sortButtons');
const tooltip = document.getElementById('tooltip');
const coordinates = document.getElementById('coordinates');
+/* ---------- Element Pooling System for Performance ------------- */
+// Pools for reusing DOM elements to eliminate recreation overhead
+const elementPools = {
+ dots: [],
+ listItems: [],
+ activeDots: new Set(),
+ activeListItems: new Set()
+};
+
+// Performance tracking
+let performanceStats = {
+ // Lifetime totals
+ dotsCreated: 0,
+ dotsReused: 0,
+ listItemsCreated: 0,
+ listItemsReused: 0,
+ // Per-render stats (reset each render)
+ renderDotsCreated: 0,
+ renderDotsReused: 0,
+ renderListItemsCreated: 0,
+ renderListItemsReused: 0,
+ lastRenderTime: 0,
+ renderCount: 0
+};
+
+function createNewDot() {
+ const dot = document.createElement('div');
+ dot.className = 'dot';
+ performanceStats.dotsCreated++;
+ performanceStats.renderDotsCreated++;
+
+ // Add event listeners once when creating
+ dot.addEventListener('mouseenter', e => showTooltip(e, dot.playerData));
+ dot.addEventListener('mousemove', e => showTooltip(e, dot.playerData));
+ dot.addEventListener('mouseleave', hideTooltip);
+ dot.addEventListener('click', () => {
+ if (dot.playerData) {
+ const { x, y } = worldToPx(dot.playerData.ew, dot.playerData.ns);
+ selectPlayer(dot.playerData, x, y);
+ }
+ });
+
+ return dot;
+}
+
+function createNewListItem() {
+ const li = document.createElement('li');
+ li.className = 'player-item';
+ performanceStats.listItemsCreated++;
+ performanceStats.renderListItemsCreated++;
+
+ // Create the grid content container
+ const gridContent = document.createElement('div');
+ gridContent.className = 'grid-content';
+ li.appendChild(gridContent);
+
+ // Create buttons once and keep them (no individual event listeners needed)
+ const buttonsContainer = document.createElement('div');
+ buttonsContainer.className = 'buttons-container';
+
+ const chatBtn = document.createElement('button');
+ chatBtn.className = 'chat-btn';
+ chatBtn.textContent = 'Chat';
+ chatBtn.addEventListener('click', (e) => {
+ console.log('🔥 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);
+ if (playerData) {
+ console.log('🔥 Opening chat for:', playerData.character_name);
+ showChatWindow(playerData.character_name);
+ } else {
+ console.log('🔥 No player data found!');
+ }
+ });
+
+ const statsBtn = document.createElement('button');
+ statsBtn.className = 'stats-btn';
+ statsBtn.textContent = 'Stats';
+ statsBtn.addEventListener('click', (e) => {
+ console.log('📊 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);
+ if (playerData) {
+ console.log('📊 Opening stats for:', playerData.character_name);
+ showStatsWindow(playerData.character_name);
+ } else {
+ console.log('📊 No player data found!');
+ }
+ });
+
+ const inventoryBtn = document.createElement('button');
+ inventoryBtn.className = 'inventory-btn';
+ inventoryBtn.textContent = 'Inventory';
+ inventoryBtn.addEventListener('click', (e) => {
+ console.log('🎒 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);
+ if (playerData) {
+ console.log('🎒 Opening inventory for:', playerData.character_name);
+ showInventoryWindow(playerData.character_name);
+ } else {
+ console.log('🎒 No player data found!');
+ }
+ });
+
+ buttonsContainer.appendChild(chatBtn);
+ buttonsContainer.appendChild(statsBtn);
+ buttonsContainer.appendChild(inventoryBtn);
+ li.appendChild(buttonsContainer);
+
+ // Store references for easy access
+ li.gridContent = gridContent;
+ li.chatBtn = chatBtn;
+ li.statsBtn = statsBtn;
+ li.inventoryBtn = inventoryBtn;
+
+ return li;
+}
+
+function returnToPool() {
+ // Return unused dots to pool
+ elementPools.activeDots.forEach(dot => {
+ if (!dot.parentNode) {
+ elementPools.dots.push(dot);
+ elementPools.activeDots.delete(dot);
+ }
+ });
+
+ // Return unused list items to pool
+ elementPools.activeListItems.forEach(li => {
+ if (!li.parentNode) {
+ elementPools.listItems.push(li);
+ elementPools.activeListItems.delete(li);
+ }
+ });
+}
+
+/* ---------- Event Delegation System ---------------------------- */
+// Single event delegation handler for all player list interactions
+function setupEventDelegation() {
+ list.addEventListener('click', e => {
+ const li = e.target.closest('li.player-item');
+ if (!li || !li.playerData) return;
+
+ const player = li.playerData;
+ const { x, y } = worldToPx(player.ew, player.ns);
+
+ // Handle player selection (clicking anywhere else on the item, not on buttons)
+ // Button clicks are now handled by direct event listeners
+ if (!e.target.closest('button')) {
+ selectPlayer(player, x, y);
+ }
+ });
+}
+
+// Initialize event delegation when DOM is ready
+document.addEventListener('DOMContentLoaded', setupEventDelegation);
+
// Global drag system to prevent event listener accumulation
let currentDragWindow = null;
let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0;
@@ -562,16 +726,20 @@ 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];
- // Toggle: close if already visible, open if hidden
- if (existing.style.display === 'flex') {
- existing.style.display = 'none';
- } else {
- existing.style.display = 'flex';
- }
+ console.log('📊 Existing stats 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('📊 Stats window shown with zIndex:', window.__chatZ);
return;
}
+ console.log('📊 Creating new stats window for:', name);
const win = document.createElement('div');
win.className = 'stats-window';
win.dataset.character = name;
@@ -615,8 +783,10 @@ function showStatsWindow(name) {
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);
// Load initial stats with default 24h range
updateStatsTimeRange(content, name, 'now-24h');
// Enable dragging using the global drag system
@@ -651,16 +821,20 @@ 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];
- // Toggle: close if already visible, open if hidden
- if (existing.style.display === 'flex') {
- existing.style.display = 'none';
- } else {
- existing.style.display = 'flex';
- }
+ 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);
return;
}
+ console.log('🎒 Creating new inventory window for:', name);
const win = document.createElement('div');
win.className = 'inventory-window';
win.dataset.character = name;
@@ -852,8 +1026,10 @@ function showInventoryWindow(name) {
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);
@@ -1312,9 +1488,64 @@ function renderList() {
render(sorted);
}
+// Track when user might be interacting to avoid DOM manipulation during clicks
+let userInteracting = false;
+let interactionTimeout = null;
+
+// Add global mousedown/mouseup tracking to detect when user is clicking
+document.addEventListener('mousedown', () => {
+ userInteracting = true;
+ if (interactionTimeout) clearTimeout(interactionTimeout);
+});
+
+document.addEventListener('mouseup', () => {
+ // Give a small buffer after mouseup to ensure click events complete
+ if (interactionTimeout) clearTimeout(interactionTimeout);
+ interactionTimeout = setTimeout(() => {
+ userInteracting = false;
+ }, 50); // 50ms buffer
+});
+
function render(players) {
- dots.innerHTML = '';
- list.innerHTML = '';
+ const startTime = performance.now();
+ console.log('🔄 RENDER STARTING:', new Date().toISOString());
+
+ // If user is actively clicking, defer this render briefly
+ if (userInteracting) {
+ console.log('🔄 RENDER DEFERRED: User interaction detected');
+ setTimeout(() => render(players), 100);
+ return;
+ }
+
+ // Reset per-render stats
+ performanceStats.renderDotsCreated = 0;
+ performanceStats.renderDotsReused = 0;
+ performanceStats.renderListItemsCreated = 0;
+ performanceStats.renderListItemsReused = 0;
+ performanceStats.renderCount++;
+
+ // Get existing elements and map them by player name for reuse
+ const existingDots = Array.from(dots.children);
+ const existingListItems = Array.from(list.children);
+
+ // Create maps for efficient lookup by player name
+ const dotsByPlayer = new Map();
+ const listItemsByPlayer = new Map();
+
+ existingDots.forEach(dot => {
+ if (dot.playerData && dot.playerData.character_name) {
+ dotsByPlayer.set(dot.playerData.character_name, dot);
+ }
+ });
+
+ existingListItems.forEach(li => {
+ if (li.playerData && li.playerData.character_name) {
+ listItemsByPlayer.set(li.playerData.character_name, li);
+ }
+ });
+
+
+ // DON'T clear containers - we need to reuse elements
// Update header with active player count
const header = document.getElementById('activePlayersHeader');
@@ -1327,12 +1558,12 @@ function render(players) {
const kphElement = document.getElementById('serverKphCount');
if (kphElement) {
// Format with commas and one decimal place for EPIC display
- const formattedKPH = totalKPH.toLocaleString('en-US', {
- minimumFractionDigits: 1,
- maximumFractionDigits: 1
+ const formattedKPH = totalKPH.toLocaleString('en-US', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1
});
kphElement.textContent = formattedKPH;
-
+
// Add extra epic effect for high KPH
const container = document.getElementById('serverKphCounter');
if (container) {
@@ -1347,40 +1578,86 @@ function render(players) {
// Total kills is now fetched from the /total-kills/ API endpoint
// (see pollTotalKills function) to include ALL characters, not just online ones
- players.forEach(p => {
+ players.forEach((p) => {
const { x, y } = worldToPx(p.ew, p.ns);
- // dot
- const dot = document.createElement('div');
- dot.className = 'dot';
- dot.style.left = `${x}px`;
- dot.style.top = `${y}px`;
+ // Reuse existing dot by player name or create new one
+ let dot = dotsByPlayer.get(p.character_name);
+ if (!dot) {
+ dot = createNewDot();
+ dots.appendChild(dot);
+ } else {
+ performanceStats.dotsReused++;
+ performanceStats.renderDotsReused++;
+ // Remove from the map so we don't count it as unused later
+ dotsByPlayer.delete(p.character_name);
+ }
+
+ // Update dot properties
+ dot.style.left = `${x}px`;
+ dot.style.top = `${y}px`;
dot.style.background = getColorFor(p.character_name);
+ dot.playerData = p; // Store for event handlers
-
+ // Update highlight state
+ if (p.character_name === selected) {
+ dot.classList.add('highlight');
+ } else {
+ dot.classList.remove('highlight');
+ }
+ // Reuse existing list item by player name or create new one
+ let li = listItemsByPlayer.get(p.character_name);
+ if (!li) {
+ li = createNewListItem();
+ list.appendChild(li);
+ } else {
+ performanceStats.listItemsReused++;
+ performanceStats.renderListItemsReused++;
+ // Remove from the map so we don't count it as unused later
+ listItemsByPlayer.delete(p.character_name);
+ }
- // custom tooltip
- dot.addEventListener('mouseenter', e => showTooltip(e, p));
- dot.addEventListener('mousemove', e => showTooltip(e, p));
- dot.addEventListener('mouseleave', hideTooltip);
-
- // click to select/zoom
- dot.addEventListener('click', () => selectPlayer(p, x, y));
-
- if (p.character_name === selected) dot.classList.add('highlight');
- dots.appendChild(dot);
- //sidebar
- const li = document.createElement('li');
const color = getColorFor(p.character_name);
li.style.borderLeftColor = color;
- li.className = 'player-item';
+ li.playerData = p; // Store for event handlers BEFORE any DOM movement
+
+ // Also store playerData directly on buttons for more reliable access
+ if (li.chatBtn) li.chatBtn.playerData = p;
+ if (li.statsBtn) li.statsBtn.playerData = p;
+ if (li.inventoryBtn) li.inventoryBtn.playerData = p;
+
+ // Only reorder element if it's actually out of place for current sort order
+ // Check if this element needs to be moved to maintain sort order
+ const expectedIndex = players.indexOf(p);
+ const currentIndex = Array.from(list.children).indexOf(li);
+
+ if (currentIndex !== expectedIndex && li.parentNode) {
+ // Find the correct position to insert
+ if (expectedIndex === players.length - 1) {
+ // Should be last - only move if it's not already last
+ if (li !== list.lastElementChild) {
+ list.appendChild(li);
+ }
+ } else {
+ // Should be at a specific position
+ const nextPlayer = players[expectedIndex + 1];
+ const nextElement = Array.from(list.children).find(el =>
+ el.playerData && el.playerData.character_name === nextPlayer.character_name
+ );
+ if (nextElement && li.nextElementSibling !== nextElement) {
+ list.insertBefore(li, nextElement);
+ }
+ }
+ }
+
// Calculate KPR (Kills Per Rare)
- const totalKills = p.total_kills || 0;
+ const playerTotalKills = p.total_kills || 0;
const totalRares = p.total_rares || 0;
- const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞';
-
- li.innerHTML = `
+ const kpr = totalRares > 0 ? Math.round(playerTotalKills / totalRares) : '∞';
+
+ // Update only the grid content via innerHTML (buttons preserved)
+ li.gridContent.innerHTML = `
${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)}
${createVitalsHTML(p.character_name)}
${p.kills}
@@ -1399,44 +1676,61 @@ function render(players) {
if (metaSpan) {
const goodStates = ['default', 'default2', 'hunt', 'combat'];
const state = (p.vt_state || '').toString().toLowerCase();
+ metaSpan.classList.remove('green', 'red'); // Clear previous
if (goodStates.includes(state)) {
metaSpan.classList.add('green');
} else {
metaSpan.classList.add('red');
}
}
-
- li.addEventListener('click', () => selectPlayer(p, x, y));
- if (p.character_name === selected) li.classList.add('selected');
- // Chat button
- const chatBtn = document.createElement('button');
- chatBtn.className = 'chat-btn';
- chatBtn.textContent = 'Chat';
- chatBtn.addEventListener('click', e => {
- e.stopPropagation();
- showChatWindow(p.character_name);
- });
- li.appendChild(chatBtn);
- // Stats button
- const statsBtn = document.createElement('button');
- statsBtn.className = 'stats-btn';
- statsBtn.textContent = 'Stats';
- statsBtn.addEventListener('click', e => {
- e.stopPropagation();
- showStatsWindow(p.character_name);
- });
- li.appendChild(statsBtn);
- // Inventory button
- const inventoryBtn = document.createElement('button');
- inventoryBtn.className = 'inventory-btn';
- inventoryBtn.textContent = 'Inventory';
- inventoryBtn.addEventListener('click', e => {
- e.stopPropagation();
- showInventoryWindow(p.character_name);
- });
- li.appendChild(inventoryBtn);
- list.appendChild(li);
+
+ // Update selected state
+ if (p.character_name === selected) {
+ li.classList.add('selected');
+ } else {
+ li.classList.remove('selected');
+ }
});
+
+ // Remove unused elements (any elements left in the maps are unused)
+ // These are dots for players that are no longer in the current player list
+ dotsByPlayer.forEach((dot, playerName) => {
+ dots.removeChild(dot);
+ });
+
+ // These are list items for players that are no longer in the current player list
+ listItemsByPlayer.forEach((li, playerName) => {
+ list.removeChild(li);
+ });
+
+ // Update performance stats
+ performanceStats.lastRenderTime = performance.now() - startTime;
+
+ // Determine optimization status
+ const totalCreatedThisRender = performanceStats.renderDotsCreated + performanceStats.renderListItemsCreated;
+ const totalReusedThisRender = performanceStats.renderDotsReused + performanceStats.renderListItemsReused;
+ const isOptimized = totalCreatedThisRender === 0 && totalReusedThisRender > 0;
+ const isPartiallyOptimized = totalCreatedThisRender < players.length && totalReusedThisRender > 0;
+
+ // Choose icon and color
+ let statusIcon = '🚀';
+ let colorStyle = '';
+ if (isOptimized) {
+ statusIcon = '✨';
+ colorStyle = 'background: #1a4a1a; color: #4ade80; font-weight: bold;';
+ } else if (isPartiallyOptimized) {
+ statusIcon = 'âš¡';
+ colorStyle = 'background: #4a3a1a; color: #fbbf24; font-weight: bold;';
+ } else {
+ statusIcon = '🔥';
+ colorStyle = 'background: #4a1a1a; color: #f87171; font-weight: bold;';
+ }
+
+ // Performance stats are tracked but not logged to keep console clean
+ // Optimization is achieving 100% element reuse consistently
+
+ const renderTime = performance.now() - startTime;
+ console.log('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms');
}
/* ---------- rendering trails ------------------------------- */
@@ -1504,20 +1798,20 @@ function initWebSocket() {
// 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];
- // Toggle: close if already visible, open if hidden
- if (existing.style.display === 'flex') {
- existing.style.display = 'none';
- } else {
- 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('💬 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);
return;
}
+ console.log('💬 Creating new chat window for:', name);
const win = document.createElement('div');
win.className = 'chat-window';
win.dataset.character = name;
@@ -1554,8 +1848,10 @@ function showChatWindow(name) {
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);
// Enable dragging using the global drag system
makeDraggable(win, header);