/* * 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'); // 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 = {}; /** * ---------- 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 = 10; const FOCUS_ZOOM = 3; // zoom level when you click a name const POLL_MS = 2000; const MAP_BOUNDS = { west : -102.04, east : 102.19, north: 102.16, south: -102.00 }; // 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 = [ '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' ]; // 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: "Kills ↓", comparator: (a, b) => b.kills - a.kills }, { value: "rares", label: "Session Rares ↓", comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0) } ]; 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 }; } // Show or create a stats window for a character function showStatsWindow(name) { if (statsWindows[name]) { const existing = statsWindows[name]; 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); // 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; // Embed a 2×2 grid of Grafana solo-panel iframes for this character 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)}` + `&theme=light`; iframe.setAttribute('title', p.title); iframe.width = '350'; iframe.height = '200'; iframe.frameBorder = '0'; iframe.allowFullscreen = true; content.appendChild(iframe); }); // Enable dragging of the stats window via its header if (!window.__chatZ) window.__chatZ = 10000; let drag = false; let startX = 0, startY = 0, startLeft = 0, startTop = 0; 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(); drag = true; bringToFront(); startX = e.clientX; startY = e.clientY; startLeft = win.offsetLeft; startTop = win.offsetTop; document.body.classList.add('noselect'); }); window.addEventListener('mousemove', e => { if (!drag) return; const dx = e.clientX - startX; const dy = e.clientY - startY; win.style.left = `${startLeft + dx}px`; win.style.top = `${startTop + dy}px`; }); window.addEventListener('mouseup', () => { drag = false; document.body.classList.remove('noselect'); }); // Touch support for dragging header.addEventListener('touchstart', e => { if (e.touches.length !== 1 || e.target.closest('button')) return; drag = true; bringToFront(); const t = e.touches[0]; startX = t.clientX; startY = t.clientY; startLeft = win.offsetLeft; startTop = win.offsetTop; }); window.addEventListener('touchmove', e => { if (!drag || e.touches.length !== 1) return; const t = e.touches[0]; const dx = t.clientX - startX; const dy = t.clientY - startY; win.style.left = `${startLeft + dx}px`; win.style.top = `${startTop + dy}px`; }); window.addEventListener('touchend', () => { drag = false; }); } const applyTransform = () => group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`; function clampPan() { if (!imgW) return; const r = wrap.getBoundingClientRect(); const vw = r.width, vh = r.height; const mw = imgW * scale, mh = imgH * scale; offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX)); offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY)); } function updateView() { clampPan(); applyTransform(); } function fitToWindow() { const r = wrap.getBoundingClientRect(); scale = Math.min(r.width / imgW, r.height / imgH); minScale = scale; updateView(); } /* ---------- tooltip handlers ------------------------------------ */ function showTooltip(evt, p) { tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`; const r = wrap.getBoundingClientRect(); tooltip.style.left = `${evt.clientX - r.left + 10}px`; tooltip.style.top = `${evt.clientY - r.top + 10}px`; tooltip.style.display = 'block'; } function hideTooltip() { tooltip.style.display = 'none'; } /* ---------- polling and initialization -------------------------- */ async function pollLive() { try { const [liveRes, trailsRes] = await Promise.all([ fetch(`${API_BASE}/live/`), fetch(`${API_BASE}/trails/?seconds=600`), ]); const { players } = await liveRes.json(); const { trails } = await trailsRes.json(); currentPlayers = players; renderTrails(trails); renderList(); } catch (e) { console.error('Live or trails fetch failed:', e); } } function startPolling() { if (pollID !== null) return; pollLive(); pollID = setInterval(pollLive, POLL_MS); } img.onload = () => { imgW = img.naturalWidth; imgH = img.naturalHeight; // size the SVG trails container to match the map dimensions if (trailsContainer) { trailsContainer.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`); trailsContainer.setAttribute('width', `${imgW}`); trailsContainer.setAttribute('height', `${imgH}`); } fitToWindow(); startPolling(); initWebSocket(); }; /* ---------- rendering sorted list & dots ------------------------ */ /** * Filter and sort the currentPlayers, then render them. */ function renderList() { // Filter by name prefix const filtered = currentPlayers.filter(p => p.character_name.toLowerCase().startsWith(currentFilter) ); // Sort filtered list const sorted = filtered.slice().sort(currentSort.comparator); render(sorted); } function render(players) { dots.innerHTML = ''; list.innerHTML = ''; 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`; dot.style.background = getColorFor(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.innerHTML = ` ${p.character_name} ${loc(p.ns, p.ew)} ${p.kills} ${p.kills_per_hour} ${p.session_rares}/${p.total_rares} ${p.vt_state} ${p.onlinetime} ${p.deaths} `; // Color the metastate pill according to its value const metaSpan = li.querySelector('.stat.meta'); if (metaSpan) { const goodStates = ['default', 'default2', 'hunt', 'combat']; const state = (p.vt_state || '').toString().toLowerCase(); 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); list.appendChild(li); }); } /* ---------- 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); } } /* ---------- selection centering, focus zoom & blink ------------ */ function selectPlayer(p, x, y) { selected = p.character_name; // set focus zoom scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM)); // center on the player const r = wrap.getBoundingClientRect(); offX = r.width / 2 - x * scale; offY = r.height / 2 - y * scale; updateView(); renderList(); // keep sorted + highlight } /* * ---------- Chat & Command WebSocket Handlers ------------------ * Maintains a persistent WebSocket connection to the /ws/live endpoint * for receiving chat messages and sending user commands to plugin clients. * Reconnects automatically on close and logs errors. */ // Initialize WebSocket for chat and command streams function initWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`; socket = new WebSocket(wsUrl); socket.addEventListener('message', evt => { let msg; try { msg = JSON.parse(evt.data); } catch { return; } if (msg.type === 'chat') { appendChatMessage(msg); } }); socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); socket.addEventListener('error', e => console.error('WebSocket error:', e)); } // Display or create a chat window for a character function showChatWindow(name) { if (chatWindows[name]) { // Restore flex layout when reopening & bring to front const existing = chatWindows[name]; existing.style.display = 'flex'; if (!window.__chatZ) window.__chatZ = 10000; window.__chatZ += 1; existing.style.zIndex = window.__chatZ; return; } 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); // Messages container const msgs = document.createElement('div'); msgs.className = 'chat-messages'; win.appendChild(msgs); // Input form const form = document.createElement('form'); form.className = 'chat-form'; const input = document.createElement('input'); input.type = 'text'; input.className = 'chat-input'; input.placeholder = 'Enter chat...'; form.appendChild(input); form.addEventListener('submit', e => { e.preventDefault(); const text = input.value.trim(); if (!text) return; // Send command envelope: player_name and command only socket.send(JSON.stringify({ player_name: name, command: text })); input.value = ''; }); win.appendChild(form); document.body.appendChild(win); chatWindows[name] = win; /* --------------------------------------------------------- */ /* enable dragging of the chat window via its header element */ /* --------------------------------------------------------- */ // keep a static counter so newer windows can be brought to front if (!window.__chatZ) window.__chatZ = 10000; let drag = false; let startX = 0, startY = 0; let startLeft = 0, startTop = 0; header.style.cursor = 'move'; // bring to front when interacting const bringToFront = () => { window.__chatZ += 1; win.style.zIndex = window.__chatZ; }; header.addEventListener('mousedown', e => { // don't initiate drag when pressing the close button (or other clickable controls) if (e.target.closest('button')) return; e.preventDefault(); drag = true; bringToFront(); startX = e.clientX; startY = e.clientY; // current absolute position startLeft = win.offsetLeft; startTop = win.offsetTop; document.body.classList.add('noselect'); }); window.addEventListener('mousemove', e => { if (!drag) return; const dx = e.clientX - startX; const dy = e.clientY - startY; win.style.left = `${startLeft + dx}px`; win.style.top = `${startTop + dy}px`; }); window.addEventListener('mouseup', () => { drag = false; document.body.classList.remove('noselect'); }); /* touch support */ header.addEventListener('touchstart', e => { if (e.touches.length !== 1 || e.target.closest('button')) return; drag = true; bringToFront(); const t = e.touches[0]; startX = t.clientX; startY = t.clientY; startLeft = win.offsetLeft; startTop = win.offsetTop; }); window.addEventListener('touchmove', e => { if (!drag || e.touches.length !== 1) return; const t = e.touches[0]; const dx = t.clientX - startX; const dy = t.clientY - startY; win.style.left = `${startLeft + dx}px`; win.style.top = `${startTop + dy}px`; }); window.addEventListener('touchend', () => { drag = false; }); } // Append a chat message to the correct window /** * Append a chat message to the correct window, optionally coloring the text. * msg: { type: 'chat', character_name, text, color? } */ function appendChatMessage(msg) { const { character_name: name, text, color } = msg; const win = chatWindows[name]; if (!win) return; const msgs = win.querySelector('.chat-messages'); const p = document.createElement('div'); if (color !== undefined) { let c = color; if (typeof c === 'number') { // map numeric chat code to configured color, or fallback to raw hex if (CHAT_COLOR_MAP.hasOwnProperty(c)) { c = CHAT_COLOR_MAP[c]; } else { c = '#' + c.toString(16).padStart(6, '0'); } } p.style.color = c; } p.textContent = text; msgs.appendChild(p); // Enforce max number of lines in scrollback while (msgs.children.length > MAX_CHAT_LINES) { msgs.removeChild(msgs.firstChild); } // Scroll to bottom msgs.scrollTop = msgs.scrollHeight; } /* ---------- pan & zoom handlers -------------------------------- */ wrap.addEventListener('wheel', e => { e.preventDefault(); if (!imgW) return; const r = wrap.getBoundingClientRect(); const mx = (e.clientX - r.left - offX) / scale; const my = (e.clientY - r.top - offY) / scale; const factor = e.deltaY > 0 ? 0.9 : 1.1; let ns = scale * factor; ns = Math.max(minScale, Math.min(MAX_Z, ns)); offX -= mx * (ns - scale); offY -= my * (ns - scale); scale = ns; updateView(); }, { passive: false }); wrap.addEventListener('mousedown', e => { dragging = true; sx = e.clientX; sy = e.clientY; wrap.classList.add('dragging'); }); window.addEventListener('mousemove', e => { if (!dragging) return; offX += e.clientX - sx; offY += e.clientY - sy; sx = e.clientX; sy = e.clientY; updateView(); }); window.addEventListener('mouseup', () => { dragging = false; wrap.classList.remove('dragging'); }); wrap.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; dragging = true; sx = e.touches[0].clientX; sy = e.touches[0].clientY; }); wrap.addEventListener('touchmove', e => { if (!dragging || e.touches.length !== 1) return; const t = e.touches[0]; offX += t.clientX - sx; offY += t.clientY - sy; sx = t.clientX; sy = t.clientY; updateView(); }); wrap.addEventListener('touchend', () => { dragging = false; });