MosswartOverlord/static/script.js
2025-05-23 08:11:11 +00:00

604 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ---------- 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');
// 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 = {};
/* ---------- 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
};
/* ---------- 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);
});
}
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 ------------------------ */
function renderList() {
const sorted = [...currentPlayers].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 = hue(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 = hue(p.character_name);
li.style.borderLeftColor = color;
li.className = 'player-item';
li.innerHTML = `
<span class="player-name">${p.character_name}</span>
<span class="player-loc">${loc(p.ns, p.ew)}</span>
<span class="stat kills">${p.kills}</span>
<span class="stat kph">${p.kills_per_hour}</span>
<span class="stat rares">${p.session_rares}/${p.total_rares}</span>
<span class="stat meta">${p.vt_state}</span>
<span class="stat onlinetime">${p.onlinetime}</span>
<span class="stat deaths">${p.deaths}</span>
`;
// 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);
poly.setAttribute('stroke', hue(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 handlers ---------------------------- */
// Initialize WebSocket for chat and commands
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;
});