/*
* 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
};
/* ---------- Heat Map Globals ---------- */
let heatmapCanvas, heatmapCtx;
let heatmapEnabled = false;
let heatmapData = null;
let heatTimeout = null;
const HEAT_PADDING = 50; // px beyond viewport to still draw
const HEAT_THROTTLE = 16; // ~60 fps
/**
* ---------- 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 };
}
/* ---------- Heat Map Functions ---------- */
function initHeatMap() {
heatmapCanvas = document.getElementById('heatmapCanvas');
if (!heatmapCanvas) {
console.error('Heat map canvas not found');
return;
}
heatmapCtx = heatmapCanvas.getContext('2d');
const toggle = document.getElementById('heatmapToggle');
if (toggle) {
toggle.addEventListener('change', e => {
heatmapEnabled = e.target.checked;
if (heatmapEnabled) {
fetchHeatmapData();
} else {
clearHeatmap();
}
});
}
window.addEventListener('resize', debounce(() => {
if (heatmapEnabled && heatmapData) {
renderHeatmap();
}
}, 250));
}
async function fetchHeatmapData() {
try {
const response = await fetch(`${API_BASE}/spawns/heatmap?hours=24&limit=50000`);
if (!response.ok) {
throw new Error(`Heat map API error: ${response.status}`);
}
const data = await response.json();
heatmapData = data.spawn_points; // [{ew, ns, intensity}]
console.log(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`);
renderHeatmap();
} catch (err) {
console.error('Failed to fetch heat map data:', err);
}
}
function renderHeatmap() {
if (!heatmapEnabled || !heatmapData || !heatmapCanvas || !imgW || !imgH) {
return;
}
// Set canvas size to match map dimensions (1:1 DPI)
heatmapCanvas.width = imgW;
heatmapCanvas.height = imgH;
heatmapCtx.clearRect(0, 0, imgW, imgH);
// Current visible map rect in px for viewport culling
const vw = wrap.clientWidth;
const vh = wrap.clientHeight;
const viewL = -offX / scale;
const viewT = -offY / scale;
const viewR = viewL + vw / scale;
const viewB = viewT + vh / scale;
// Render heat map points with viewport culling
for (const point of heatmapData) {
const { x, y } = worldToPx(point.ew, point.ns);
// Skip points outside visible area (with padding for smooth edges)
if (x < viewL - HEAT_PADDING || x > viewR + HEAT_PADDING ||
y < viewT - HEAT_PADDING || y > viewB + HEAT_PADDING) {
continue;
}
// Smaller, more precise spots to clearly show individual spawn locations
const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5)));
// Sharp gradient with distinct boundaries between spawn points
const gradient = heatmapCtx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`); // Bright red center
gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`); // Quick fade to orange
gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
heatmapCtx.fillStyle = gradient;
heatmapCtx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
}
}
function clearHeatmap() {
if (heatmapCtx && heatmapCanvas) {
heatmapCtx.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height);
}
}
function debounce(fn, ms) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), ms);
};
}
// 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 = `
${name}
`;
// Basic stats
tooltipHTML += ``;
// 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 += ``;
}
// 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 += ``;
}
// 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 += ``;
}
// 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 += ``;
}
// 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 += ``;
}
// 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 += ``;
}
// Mana and Spellcraft section
if (enhanced.mana_display || enhanced.spellcraft) {
tooltipHTML += ``;
}
// Detailed Spell Descriptions section
if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
tooltipHTML += ``;
}
// Object class info
if (enhanced.object_class_name) {
tooltipHTML += ``;
}
} // 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();
// Throttled heat map re-rendering during pan/zoom
if (heatmapEnabled && heatmapData && !heatTimeout) {
heatTimeout = setTimeout(() => {
renderHeatmap();
heatTimeout = null;
}, HEAT_THROTTLE);
}
}
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);
}
}
async function pollTotalRares() {
try {
const response = await fetch(`${API_BASE}/total-rares/`);
const data = await response.json();
updateTotalRaresDisplay(data);
} catch (e) {
console.error('Total rares fetch failed:', e);
}
}
function updateTotalRaresDisplay(data) {
const countElement = document.getElementById('totalRaresCount');
if (countElement && data.all_time !== undefined && data.today !== undefined) {
const allTimeFormatted = data.all_time.toLocaleString();
const todayFormatted = data.today.toLocaleString();
countElement.textContent = `${allTimeFormatted} (Today: ${todayFormatted})`;
}
}
function startPolling() {
if (pollID !== null) return;
pollLive();
pollTotalRares(); // Initial fetch
pollID = setInterval(pollLive, POLL_MS);
// Poll total rares every 5 minutes (300,000 ms)
setInterval(pollTotalRares, 300000);
}
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();
initHeatMap();
};
/* ---------- 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 = '';
// Update header with active player count
const header = document.getElementById('activePlayersHeader');
if (header) {
header.textContent = `Active Mosswart Enjoyers (${players.length})`;
}
// Calculate and update server KPH
const totalKPH = players.reduce((sum, p) => sum + (p.kills_per_hour || 0), 0);
const kphElement = document.getElementById('serverKphCount');
if (kphElement) {
// Format with commas and one decimal place for EPIC display
const formattedKPH = totalKPH.toLocaleString('en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1
});
kphElement.textContent = formattedKPH;
// Add extra epic effect for high KPH
const container = document.getElementById('serverKphCounter');
if (container) {
if (totalKPH > 5000) {
container.classList.add('ultra-epic');
} else {
container.classList.remove('ultra-epic');
}
}
}
// Calculate and update total kills
const totalKills = players.reduce((sum, p) => sum + (p.total_kills || 0), 0);
const killsElement = document.getElementById('totalKillsCount');
if (killsElement) {
// Format with commas for readability
const formattedKills = totalKills.toLocaleString();
killsElement.textContent = formattedKills;
}
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 = `
${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)}
${createVitalsHTML(p.character_name)}
${p.kills}
${p.total_kills || 0}
${p.kills_per_hour}
${p.session_rares}/${p.total_rares}
${kpr}
${p.vt_state}
${p.onlinetime}
${p.deaths}/${p.total_deaths || 0}
${p.prismatic_taper_count || 0}
`;
// 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);
} else if (msg.type === 'vitals') {
updateVitalsDisplay(msg);
} else if (msg.type === 'rare') {
triggerEpicRareNotification(msg.character_name, msg.name);
}
});
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';
});
/* ---------- vitals display functions ----------------------------- */
// Store vitals data per character
const characterVitals = {};
function updateVitalsDisplay(vitalsMsg) {
// Store the vitals data for this character
characterVitals[vitalsMsg.character_name] = {
health_percentage: vitalsMsg.health_percentage,
stamina_percentage: vitalsMsg.stamina_percentage,
mana_percentage: vitalsMsg.mana_percentage,
vitae: vitalsMsg.vitae
};
// Re-render the player list to update vitals in the UI
renderList();
}
function createVitalsHTML(characterName) {
const vitals = characterVitals[characterName];
if (!vitals) {
return ''; // No vitals data available
}
return `
`;
}
function createVitaeIndicator(characterName) {
const vitals = characterVitals[characterName];
if (!vitals || !vitals.vitae || vitals.vitae >= 100) {
return ''; // No vitae penalty
}
return `⚰️ ${vitals.vitae}%`;
}
function getVitalClass(percentage) {
if (percentage <= 25) {
return 'critical-vital';
} else if (percentage <= 50) {
return 'low-vital';
}
return '';
}
/* ---------- epic rare notification system ------------------------ */
// Track previous rare count to detect increases
let lastRareCount = 0;
let notificationQueue = [];
let isShowingNotification = false;
function triggerEpicRareNotification(characterName, rareName) {
// Add to queue
notificationQueue.push({ characterName, rareName });
// Process queue if not already showing a notification
if (!isShowingNotification) {
processNotificationQueue();
}
// Trigger fireworks immediately
createFireworks();
// Highlight the player in the list
highlightRareFinder(characterName);
}
function processNotificationQueue() {
if (notificationQueue.length === 0) {
isShowingNotification = false;
return;
}
isShowingNotification = true;
const notification = notificationQueue.shift();
// Create notification element
const container = document.getElementById('rareNotifications');
const notifEl = document.createElement('div');
notifEl.className = 'rare-notification';
notifEl.innerHTML = `
🎆 LEGENDARY RARE! 🎆
${notification.rareName}
found by
⚔️ ${notification.characterName} ⚔️
`;
container.appendChild(notifEl);
// Remove notification after 6 seconds and process next
setTimeout(() => {
notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards';
setTimeout(() => {
notifEl.remove();
processNotificationQueue();
}, 500);
}, 6000);
}
// Add slide out animation
const style = document.createElement('style');
style.textContent = `
@keyframes notification-slide-out {
to {
transform: translateY(-100px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
function createFireworks() {
const container = document.getElementById('fireworksContainer');
const rareCounter = document.getElementById('totalRaresCounter');
const rect = rareCounter.getBoundingClientRect();
// Create 30 particles
const particleCount = 30;
const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue'];
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`;
// Start position at rare counter
particle.style.left = `${rect.left + rect.width / 2}px`;
particle.style.top = `${rect.top + rect.height / 2}px`;
// Random explosion direction
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const velocity = 100 + Math.random() * 200;
const dx = Math.cos(angle) * velocity;
const dy = Math.sin(angle) * velocity - 50; // Slight upward bias
// Create custom animation for this particle
const animName = `particle-${Date.now()}-${i}`;
const keyframes = `
@keyframes ${animName} {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(${dx}px, ${dy + 200}px) scale(0);
opacity: 0;
}
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = keyframes;
document.head.appendChild(styleEl);
particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
container.appendChild(particle);
// Clean up particle and animation after completion
setTimeout(() => {
particle.remove();
styleEl.remove();
}, 2000);
}
}
function highlightRareFinder(characterName) {
// Find the player in the list
const playerItems = document.querySelectorAll('#playerList li');
playerItems.forEach(item => {
const nameSpan = item.querySelector('.player-name');
if (nameSpan && nameSpan.textContent.includes(characterName)) {
item.classList.add('rare-finder-glow');
// Remove glow after 5 seconds
setTimeout(() => {
item.classList.remove('rare-finder-glow');
}, 5000);
}
});
}
// Update total rares display to trigger fireworks on increase
const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay;
updateTotalRaresDisplay = function(data) {
originalUpdateTotalRaresDisplay(data);
// Check if total increased
const newTotal = data.all_time || 0;
if (newTotal > lastRareCount && lastRareCount > 0) {
// Don't trigger on initial load
createFireworks();
// Check for milestones when count increases
if (newTotal > 0 && newTotal % 100 === 0) {
triggerMilestoneCelebration(newTotal);
}
}
lastRareCount = newTotal;
}
function triggerMilestoneCelebration(rareNumber) {
console.log(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`);
// Create full-screen milestone overlay
const overlay = document.createElement('div');
overlay.className = 'milestone-overlay';
overlay.innerHTML = `
#${rareNumber}
🏆 EPIC MILESTONE! 🏆
Server Achievement Unlocked
`;
document.body.appendChild(overlay);
// Add screen shake effect
document.body.classList.add('screen-shake');
// Create massive firework explosion
createMilestoneFireworks();
// Remove milestone overlay after 5 seconds
setTimeout(() => {
overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse';
document.body.classList.remove('screen-shake');
setTimeout(() => {
overlay.remove();
}, 500);
}, 5000);
}
function createMilestoneFireworks() {
const container = document.getElementById('fireworksContainer');
// Create multiple bursts across the screen
const burstCount = 5;
const particlesPerBurst = 50;
const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00'];
for (let burst = 0; burst < burstCount; burst++) {
setTimeout(() => {
// Random position for each burst
const x = Math.random() * window.innerWidth;
const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15);
for (let i = 0; i < particlesPerBurst; i++) {
const particle = document.createElement('div');
particle.className = 'milestone-particle';
particle.style.background = colors[Math.floor(Math.random() * colors.length)];
particle.style.boxShadow = `0 0 12px ${particle.style.background}`;
// Start position
particle.style.left = `${x}px`;
particle.style.top = `${y}px`;
// Random explosion direction
const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8;
const velocity = 200 + Math.random() * 300;
const dx = Math.cos(angle) * velocity;
const dy = Math.sin(angle) * velocity - 100; // Upward bias
// Create custom animation
const animName = `milestone-particle-${Date.now()}-${burst}-${i}`;
const keyframes = `
@keyframes ${animName} {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(${dx}px, ${dy + 400}px) scale(0);
opacity: 0;
}
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = keyframes;
document.head.appendChild(styleEl);
particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
container.appendChild(particle);
// Clean up
setTimeout(() => {
particle.remove();
styleEl.remove();
}, 3000);
}
}, burst * 200); // Stagger bursts
}
}
/* ==================== INVENTORY SEARCH FUNCTIONALITY ==================== */
/**
* Opens the dedicated inventory search page in a new browser tab.
*/
function openInventorySearch() {
// Open the dedicated inventory search page in a new tab
window.open('/inventory.html', '_blank');
}