MosswartOverlord/static/script.js
2025-06-10 19:21:21 +00:00

1278 lines
45 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.

/*
* 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);
// 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);
// Fetch inventory data from main app (which will proxy to inventory service)
fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
loading.style.display = 'none';
content.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)
// Only use valid icon IDs (must be > 100 to avoid invalid small IDs)
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
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');
}
} else if (item.item_data) {
// 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');
}
}
} catch (e) {
console.warn('Failed to parse item data for', item.name);
}
}
// Create underlay (bottom layer)
if (underlayIconId) {
const underlayImg = document.createElement('img');
underlayImg.className = 'icon-underlay';
underlayImg.src = `/icons/${underlayIconId}.png`;
underlayImg.alt = 'underlay';
underlayImg.onerror = function() { this.style.display = 'none'; };
iconContainer.appendChild(underlayImg);
}
// Create base icon (middle layer)
const baseImg = document.createElement('img');
baseImg.className = 'icon-base';
baseImg.src = `/icons/${baseIconId}.png`;
baseImg.alt = item.name || 'Unknown Item';
baseImg.onerror = function() {
// Final fallback
this.src = '/icons/06000133.png';
};
iconContainer.appendChild(baseImg);
// Create overlay (top layer)
if (overlayIconId) {
const overlayImg = document.createElement('img');
overlayImg.className = 'icon-overlay';
overlayImg.src = `/icons/${overlayIconId}.png`;
overlayImg.alt = 'overlay';
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',
'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',
'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);
// Add item count
const count = document.createElement('div');
count.className = 'inventory-count';
count.textContent = `${data.item_count} items`;
content.appendChild(count);
})
.catch(err => {
loading.textContent = `Failed to load inventory: ${err.message}`;
console.error('Inventory fetch failed:', err);
});
document.body.appendChild(win);
inventoryWindows[name] = win;
// Enable dragging using the global drag system
makeDraggable(win, header);
}
// Inventory tooltip functions
let inventoryTooltip = null;
function showInventoryTooltip(e, slot) {
if (!inventoryTooltip) {
inventoryTooltip = document.createElement('div');
inventoryTooltip.className = 'inventory-tooltip';
document.body.appendChild(inventoryTooltip);
}
const name = slot.dataset.name;
const value = parseInt(slot.dataset.value) || 0;
const burden = parseInt(slot.dataset.burden) || 0;
// Build enhanced tooltip
let tooltipHTML = `<div class="tooltip-name">${name}</div>`;
// Basic stats
tooltipHTML += `<div class="tooltip-section">`;
tooltipHTML += `<div class="tooltip-value">Value: ${value.toLocaleString()}</div>`;
tooltipHTML += `<div class="tooltip-burden">Burden: ${burden}</div>`;
// Add workmanship right after basic stats for weapons/items
if (slot.dataset.enhancedData) {
try {
const enhanced = JSON.parse(slot.dataset.enhancedData);
if (enhanced.workmanship_text) {
tooltipHTML += `<div class="tooltip-workmanship">Workmanship: ${enhanced.workmanship_text}</div>`;
}
} catch (e) {
// Ignore parsing errors for this section
}
}
tooltipHTML += `</div>`;
// Enhanced data from inventory service
if (slot.dataset.enhancedData) {
try {
const enhanced = JSON.parse(slot.dataset.enhancedData);
// Only show enhanced sections if we have enhanced data
if (Object.keys(enhanced).length > 0) {
// Helper function to check for valid values
const isValid = (val) => val !== undefined && val !== null && val !== -1 && val !== -1.0;
// Weapon-specific stats section (for weapons)
if (enhanced.damage_range || enhanced.speed_text || enhanced.equip_skill_name) {
tooltipHTML += `<div class="tooltip-section">`;
// Skill requirement
if (enhanced.equip_skill_name) {
tooltipHTML += `<div class="tooltip-property">Skill: ${enhanced.equip_skill_name}</div>`;
}
// Damage with type
if (enhanced.damage_range && enhanced.damage_type) {
const damageText = enhanced.damage_type !== 'Physical' ?
`${enhanced.damage_range}, ${enhanced.damage_type}` :
enhanced.damage_range;
tooltipHTML += `<div class="tooltip-property">Damage: ${damageText}</div>`;
}
// Speed
if (enhanced.speed_text) {
tooltipHTML += `<div class="tooltip-property">Speed: ${enhanced.speed_text}</div>`;
}
// Attack and defense bonuses (as percentages)
if (isValid(enhanced.attack_bonus)) {
const attackPercent = ((enhanced.attack_bonus - 1) * 100).toFixed(1);
if (attackPercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Bonus to Attack Skill: ${attackPercent > 0 ? '+' : ''}${attackPercent}%</div>`;
}
}
// Defense bonuses
if (enhanced.melee_defense_bonus && isValid(enhanced.melee_defense_bonus)) {
const defensePercent = ((enhanced.melee_defense_bonus - 1) * 100).toFixed(1);
if (defensePercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Bonus to Melee Defense: ${defensePercent > 0 ? '+' : ''}${defensePercent}%</div>`;
}
}
// Magic defense bonus
if (enhanced.magic_defense_bonus && isValid(enhanced.magic_defense_bonus)) {
const magicDefensePercent = ((enhanced.magic_defense_bonus - 1) * 100).toFixed(1);
if (magicDefensePercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Bonus to Magic Defense: ${magicDefensePercent > 0 ? '+' : ''}${magicDefensePercent}%</div>`;
}
}
// Elemental damage vs monsters
if (enhanced.elemental_damage_vs_monsters && isValid(enhanced.elemental_damage_vs_monsters)) {
const elementalPercent = ((enhanced.elemental_damage_vs_monsters - 1) * 100).toFixed(1);
if (elementalPercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Elemental Damage vs Monsters: ${elementalPercent > 0 ? '+' : ''}${elementalPercent}%</div>`;
}
}
tooltipHTML += `</div>`;
}
// Traditional combat stats section (for non-weapons or additional stats)
const combatProps = [];
if (isValid(enhanced.armor_level)) combatProps.push(`Armor Level: ${enhanced.armor_level}`);
if (!enhanced.damage_range && isValid(enhanced.max_damage)) combatProps.push(`Max Damage: ${enhanced.max_damage}`);
if (!enhanced.attack_bonus && isValid(enhanced.damage_bonus)) combatProps.push(`Damage Bonus: ${enhanced.damage_bonus.toFixed(1)}`);
if (combatProps.length > 0) {
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Combat Stats</div>`;
combatProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// Requirements section
const reqProps = [];
if (isValid(enhanced.wield_level)) reqProps.push(`Level Required: ${enhanced.wield_level}`);
if (isValid(enhanced.skill_level)) reqProps.push(`Skill Level: ${enhanced.skill_level}`);
if (enhanced.equip_skill_name) reqProps.push(`Skill: ${enhanced.equip_skill_name}`);
if (isValid(enhanced.lore_requirement)) reqProps.push(`Lore: ${enhanced.lore_requirement}`);
if (reqProps.length > 0) {
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Requirements</div>`;
reqProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// Enhancement section
const enhanceProps = [];
if (enhanced.material_name) enhanceProps.push(`Material: ${enhanced.material_name}`);
if (enhanced.imbue) enhanceProps.push(`Imbue: ${enhanced.imbue}`);
if (enhanced.item_set) enhanceProps.push(`Set: ${enhanced.item_set}`);
if (isValid(enhanced.tinks)) enhanceProps.push(`Tinks: ${enhanced.tinks}`);
// Use workmanship_text if available, otherwise numeric value
if (enhanced.workmanship_text) {
enhanceProps.push(`Workmanship: ${enhanced.workmanship_text}`);
} else if (isValid(enhanced.workmanship)) {
enhanceProps.push(`Workmanship: ${enhanced.workmanship.toFixed(1)}`);
}
if (enhanceProps.length > 0) {
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Enhancements</div>`;
enhanceProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// Ratings section
const ratingProps = [];
if (isValid(enhanced.damage_rating)) ratingProps.push(`Damage Rating: ${enhanced.damage_rating}`);
if (isValid(enhanced.crit_rating)) ratingProps.push(`Crit Rating: ${enhanced.crit_rating}`);
if (isValid(enhanced.heal_boost_rating)) ratingProps.push(`Heal Boost: ${enhanced.heal_boost_rating}`);
if (ratingProps.length > 0) {
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Ratings</div>`;
ratingProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// Spells section (condensed list)
if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
const spellNames = enhanced.spells.spells.map(spell => spell.name).join(', ');
tooltipHTML += `<div class="tooltip-section">`;
tooltipHTML += `<div class="tooltip-property">Spells: ${spellNames}</div>`;
tooltipHTML += `</div>`;
}
// Mana and Spellcraft section
if (enhanced.mana_display || enhanced.spellcraft) {
tooltipHTML += `<div class="tooltip-section">`;
if (enhanced.spellcraft) {
tooltipHTML += `<div class="tooltip-property">Spellcraft: ${enhanced.spellcraft}</div>`;
}
if (enhanced.mana_display) {
tooltipHTML += `<div class="tooltip-property">Mana: ${enhanced.mana_display}</div>`;
}
tooltipHTML += `</div>`;
}
// Detailed Spell Descriptions section
if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Spell Descriptions</div>`;
enhanced.spells.spells.forEach(spell => {
tooltipHTML += `<div class="tooltip-spell">`;
tooltipHTML += `<div class="spell-name">${spell.name}</div>`;
if (spell.description) {
tooltipHTML += `<div class="spell-description">${spell.description}</div>`;
}
tooltipHTML += `</div>`;
});
tooltipHTML += `</div>`;
}
// Object class info
if (enhanced.object_class_name) {
tooltipHTML += `<div class="tooltip-section">`;
tooltipHTML += `<div class="tooltip-info">Type: ${enhanced.object_class_name}</div>`;
tooltipHTML += `</div>`;
}
} // End of enhanced data check
} catch (e) {
console.warn('Failed to parse enhanced tooltip data', e);
}
}
inventoryTooltip.innerHTML = tooltipHTML;
// Position tooltip near cursor
const x = e.clientX + 10;
const y = e.clientY + 10;
inventoryTooltip.style.left = `${x}px`;
inventoryTooltip.style.top = `${y}px`;
inventoryTooltip.style.display = 'block';
}
function hideInventoryTooltip() {
if (inventoryTooltip) {
inventoryTooltip.style.display = 'none';
}
}
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';
// Calculate KPR (Kills Per Rare)
const totalKills = p.total_kills || 0;
const totalRares = p.total_rares || 0;
const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞';
li.innerHTML = `
<span class="player-name">${p.character_name} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span>
<span class="stat kills">${p.kills}</span>
<span class="stat total-kills">${p.total_kills || 0}</span>
<span class="stat kph">${p.kills_per_hour}</span>
<span class="stat rares">${p.session_rares}/${p.total_rares}</span>
<span class="stat kpr">${kpr}</span>
<span class="stat meta">${p.vt_state}</span>
<span class="stat onlinetime">${p.onlinetime}</span>
<span class="stat deaths">${p.deaths}/${p.total_deaths || 0}</span>
<span class="stat tapers">${p.prismatic_taper_count || 0}</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);
// 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);
});
}
/* ---------- 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]) {
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;
}
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 using the global drag system
makeDraggable(win, header);
}
// 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;
});
/* ---------- coordinate display on hover ---------------------------- */
wrap.addEventListener('mousemove', e => {
if (!imgW) return;
const r = wrap.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
const { ew, ns } = pxToWorld(x, y);
// Display coordinates using the same format as the existing loc function
coordinates.textContent = loc(ns, ew);
coordinates.style.left = `${x + 10}px`;
coordinates.style.top = `${y + 10}px`;
coordinates.style.display = 'block';
});
wrap.addEventListener('mouseleave', () => {
coordinates.style.display = 'none';
});