/* * script.js - Frontend logic for Dereth Tracker Single-Page Application. * Handles WebSocket communication, UI rendering of player lists, map display, * and user interactions (filtering, sorting, chat, stats windows). */ /** * script.js - Frontend controller for Dereth Tracker SPA * * Responsibilities: * - Establish WebSocket connections to receive live telemetry and chat data * - Fetch and render live player lists, trails, and map dots * - Handle user interactions: filtering, sorting, selecting players * - Manage dynamic UI components: chat windows, stats panels, tooltips * - Provide smooth pan/zoom of map overlay using CSS transforms * * Structure: * 1. DOM references and constant definitions * 2. Color palette and assignment logic * 3. Sorting and filtering setup * 4. Utility functions (coordinate mapping, color hashing) * 5. UI window creation (stats, chat) * 6. Rendering functions for list and map * 7. Event listeners for map interactions and WebSocket messages */ /* ---------- DOM references --------------------------------------- */ const wrap = document.getElementById('mapContainer'); const group = document.getElementById('mapGroup'); const img = document.getElementById('map'); const dots = document.getElementById('dots'); const trailsContainer = document.getElementById('trails'); const list = document.getElementById('playerList'); const btnContainer = document.getElementById('sortButtons'); const tooltip = document.getElementById('tooltip'); const coordinates = document.getElementById('coordinates'); // Global drag system to prevent event listener accumulation let currentDragWindow = null; let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0; function makeDraggable(win, header) { if (!window.__chatZ) window.__chatZ = 10000; header.style.cursor = 'move'; const bringToFront = () => { window.__chatZ += 1; win.style.zIndex = window.__chatZ; }; header.addEventListener('mousedown', e => { if (e.target.closest('button')) return; e.preventDefault(); currentDragWindow = win; bringToFront(); dragStartX = e.clientX; dragStartY = e.clientY; dragStartLeft = win.offsetLeft; dragStartTop = win.offsetTop; document.body.classList.add('noselect'); }); // Touch support header.addEventListener('touchstart', e => { if (e.touches.length !== 1 || e.target.closest('button')) return; currentDragWindow = win; bringToFront(); const t = e.touches[0]; dragStartX = t.clientX; dragStartY = t.clientY; dragStartLeft = win.offsetLeft; dragStartTop = win.offsetTop; }); } // Global mouse handlers (only added once) window.addEventListener('mousemove', e => { if (!currentDragWindow) return; const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; currentDragWindow.style.left = `${dragStartLeft + dx}px`; currentDragWindow.style.top = `${dragStartTop + dy}px`; }); window.addEventListener('mouseup', () => { if (currentDragWindow) { currentDragWindow = null; document.body.classList.remove('noselect'); } }); window.addEventListener('touchmove', e => { if (!currentDragWindow || e.touches.length !== 1) return; const t = e.touches[0]; const dx = t.clientX - dragStartX; const dy = t.clientY - dragStartY; currentDragWindow.style.left = `${dragStartLeft + dx}px`; currentDragWindow.style.top = `${dragStartTop + dy}px`; }); window.addEventListener('touchend', () => { currentDragWindow = null; }); // Filter input for player names (starts-with filter) let currentFilter = ''; const filterInput = document.getElementById('playerFilter'); if (filterInput) { filterInput.addEventListener('input', e => { currentFilter = e.target.value.toLowerCase().trim(); renderList(); }); } // WebSocket for chat and commands let socket; // Keep track of open chat windows: character_name -> DOM element const chatWindows = {}; // Keep track of open stats windows: character_name -> DOM element const statsWindows = {}; // Keep track of open inventory windows: character_name -> DOM element const inventoryWindows = {}; /** * ---------- Application Constants ----------------------------- * Defines key parameters for map rendering, data polling, and UI limits. * * MAX_Z: Maximum altitude difference considered (filter out outliers by Z) * FOCUS_ZOOM: Zoom level when focusing on a selected character * POLL_MS: Millisecond interval to fetch live player data and trails * MAP_BOUNDS: World coordinate bounds for the game map (used for projection) * API_BASE: Prefix for AJAX endpoints (set when behind a proxy) * MAX_CHAT_LINES: Max number of lines per chat window to cap memory usage * CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code */ /* ---------- constants ------------------------------------------- */ const MAX_Z = 20; const FOCUS_ZOOM = 3; // zoom level when you click a name const POLL_MS = 2000; // UtilityBelt's more accurate coordinate bounds const MAP_BOUNDS = { west: -102.1, east: 102.1, north: 102.1, south: -102.1 }; // Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy // If serving APIs at root, leave empty const API_BASE = ''; // Maximum number of lines to retain in each chat window scrollback const MAX_CHAT_LINES = 1000; // Map numeric chat color codes to CSS hex colors const CHAT_COLOR_MAP = { 0: '#00FF00', // Broadcast 2: '#FFFFFF', // Speech 3: '#FFD700', // Tell 4: '#CCCC00', // OutgoingTell 5: '#FF00FF', // System 6: '#FF0000', // Combat 7: '#00CCFF', // Magic 8: '#DDDDDD', // Channel 9: '#FF9999', // ChannelSend 10: '#FFFF33', // Social 11: '#CCFF33', // SocialSend 12: '#FFFFFF', // Emote 13: '#00FFFF', // Advancement 14: '#66CCFF', // Abuse 15: '#FF0000', // Help 16: '#33FF00', // Appraisal 17: '#0099FF', // Spellcasting 18: '#FF6600', // Allegiance 19: '#CC66FF', // Fellowship 20: '#00FF00', // WorldBroadcast 21: '#FF0000', // CombatEnemy 22: '#FF33CC', // CombatSelf 23: '#00CC00', // Recall 24: '#00FF00', // Craft 25: '#00FF66', // Salvaging 27: '#FFFFFF', // General 28: '#33FF33', // Trade 29: '#CCCCCC', // LFG 30: '#CC00CC', // Roleplay 31: '#FFFF00' // AdminTell }; /** * ---------- Player Color Assignment ---------------------------- * Uses a predefined accessible color palette for player dots to ensure * high contrast and colorblind-friendly display. Once the palette * is exhausted, falls back to a deterministic hash-to-hue function. */ /* ---------- player/dot color assignment ------------------------- */ // A base palette of distinct, color-blind-friendly colors const PALETTE = [ // Original colorblind-friendly base palette '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', // Extended high-contrast colors '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', '#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488', // Darker variants '#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc', '#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366', // Brighter variants '#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff', '#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa', // Additional distinct colors '#990099', '#009900', '#000099', '#990000', '#009999', '#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055', // Light pastels for contrast '#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff', '#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc' ]; // Map from character name to assigned color const colorMap = {}; // Next index to pick from PALETTE let nextPaletteIndex = 0; /** * Assigns or returns a consistent color for a given name. * Uses a fixed palette first, then falls back to hue hashing. */ function getColorFor(name) { if (colorMap[name]) { return colorMap[name]; } let color; if (nextPaletteIndex < PALETTE.length) { color = PALETTE[nextPaletteIndex++]; } else { // Fallback: hash to HSL hue color = hue(name); } colorMap[name] = color; return color; } /* * ---------- Sort Configuration ------------------------------- * Defines available sort criteria for the active player list: * - name: alphabetical ascending * - kph: kills per hour descending * - kills: total kills descending * - rares: rare events found during current session descending * Each option includes a label for UI display and a comparator function. */ /* ---------- sort configuration ---------------------------------- */ const sortOptions = [ { value: "name", label: "Name", comparator: (a, b) => a.character_name.localeCompare(b.character_name) }, { value: "kph", label: "KPH", comparator: (a, b) => b.kills_per_hour - a.kills_per_hour }, { value: "kills", label: "S.Kills", comparator: (a, b) => b.kills - a.kills }, { value: "rares", label: "S.Rares", comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0) }, { value: "total_kills", label: "T.Kills", comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0) }, { value: "kpr", label: "KPR", comparator: (a, b) => { const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity; const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity; return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding) } } ]; let currentSort = sortOptions[0]; let currentPlayers = []; /* ---------- generate segmented buttons -------------------------- */ sortOptions.forEach(opt => { const btn = document.createElement('div'); btn.className = 'btn'; btn.textContent = opt.label; btn.dataset.value = opt.value; if (opt.value === currentSort.value) btn.classList.add('active'); btn.addEventListener('click', () => { btnContainer.querySelectorAll('.btn') .forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentSort = opt; renderList(); }); btnContainer.appendChild(btn); }); /* ---------- map & state variables ------------------------------- */ 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; /* ---------- utility functions ----------------------------------- */ const hue = name => { let h = 0; for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h); return `hsl(${Math.abs(h) % 360},72%,50%)`; }; const loc = (ns, ew) => `${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} ` + `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`; function worldToPx(ew, ns) { const x = ((ew - MAP_BOUNDS.west) / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW; const y = ((MAP_BOUNDS.north - ns) / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH; return { x, y }; } function pxToWorld(x, y) { // Convert screen coordinates to map image coordinates const mapX = (x - offX) / scale; const mapY = (y - offY) / scale; // Convert map image coordinates to world coordinates const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west); const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south); return { ew, ns }; } // Show or create a stats window for a character function showStatsWindow(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'; } return; } 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); // Time period controls const controls = document.createElement('div'); controls.className = 'stats-controls'; const timeRanges = [ { label: '1H', value: 'now-1h' }, { label: '6H', value: 'now-6h' }, { label: '24H', value: 'now-24h' }, { label: '7D', value: 'now-7d' } ]; timeRanges.forEach(range => { const btn = document.createElement('button'); btn.className = 'time-range-btn'; btn.textContent = range.label; if (range.value === 'now-24h') btn.classList.add('active'); btn.addEventListener('click', () => { controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); updateStatsTimeRange(content, 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); document.body.appendChild(win); statsWindows[name] = win; // Load initial stats with default 24h range updateStatsTimeRange(content, name, 'now-24h'); // Enable dragging using the global drag system makeDraggable(win, header); } function updateStatsTimeRange(content, name, timeRange) { content.innerHTML = ''; const panels = [ { title: 'Kills per Hour', id: 1 }, { title: 'Memory (MB)', id: 2 }, { title: 'CPU (%)', id: 3 }, { title: 'Mem Handles', id: 4 } ]; panels.forEach(p => { const iframe = document.createElement('iframe'); iframe.src = `/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` + `?panelId=${p.id}` + `&var-character=${encodeURIComponent(name)}` + `&from=${timeRange}` + `&to=now` + `&theme=light`; iframe.setAttribute('title', p.title); iframe.width = '350'; iframe.height = '200'; iframe.frameBorder = '0'; iframe.allowFullscreen = true; content.appendChild(iframe); }); } // Show or create an inventory window for a character function showInventoryWindow(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'; } return; } 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); // Content container const content = document.createElement('div'); content.className = 'inventory-content'; content.innerHTML = '