383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
/* ---------- 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');
|
|
// Chat UI elements
|
|
const chatOverlay = document.getElementById('chatOverlay');
|
|
const chatTitle = document.getElementById('chatTitle');
|
|
const chatClose = document.getElementById('chatClose');
|
|
const chatMessages = document.getElementById('chatMessages');
|
|
const chatForm = document.getElementById('chatForm');
|
|
const chatInput = document.getElementById('chatInput');
|
|
let chatSocket = null;
|
|
let currentChatName = null;
|
|
|
|
/* ---------- 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
|
|
};
|
|
|
|
/* ---------- 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: "Rares ↓",
|
|
comparator: (a, b) => (b.rares_found || 0) - (a.rares_found || 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 };
|
|
}
|
|
|
|
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('/live/'),
|
|
fetch('/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);
|
|
}
|
|
// -------------------- WebSocket live updates --------------------
|
|
let wsLive = null;
|
|
function startWebSocket() {
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${location.host}/ws/track`;
|
|
wsLive = new WebSocket(wsUrl);
|
|
wsLive.onopen = () => console.log('WS /ws/track connected');
|
|
wsLive.onmessage = evt => {
|
|
try {
|
|
const snap = JSON.parse(evt.data);
|
|
currentPlayers = currentPlayers.filter(p => p.character_name !== snap.character_name)
|
|
.concat(snap);
|
|
renderList();
|
|
} catch (e) {
|
|
console.error('Invalid WS message', e);
|
|
}
|
|
};
|
|
wsLive.onclose = () => console.log('WS /ws/track closed');
|
|
wsLive.onerror = e => console.error('WS error', e);
|
|
}
|
|
|
|
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();
|
|
// start live updates via WebSocket
|
|
startWebSocket();
|
|
};
|
|
|
|
/* ---------- 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.rares_found}</span>
|
|
<span class="stat meta">${p.vt_state}</span>
|
|
`;
|
|
|
|
// Add Chat button
|
|
const chatBtn = document.createElement('button');
|
|
chatBtn.textContent = 'Chat';
|
|
chatBtn.className = 'chat-btn';
|
|
chatBtn.addEventListener('click', e => { e.stopPropagation(); openChat(p.character_name); });
|
|
li.appendChild(chatBtn);
|
|
li.addEventListener('click', () => selectPlayer(p, x, y));
|
|
if (p.character_name === selected) li.classList.add('selected');
|
|
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
|
|
}
|
|
|
|
/* ---------- 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;
|
|
});
|
|
// Chat UI functions
|
|
function openChat(name) {
|
|
currentChatName = name;
|
|
chatTitle.textContent = `Chat: ${name}`;
|
|
chatMessages.innerHTML = '';
|
|
chatOverlay.classList.remove('hidden');
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${location.host}/ws/command?character_name=${encodeURIComponent(name)}`;
|
|
chatSocket = new WebSocket(wsUrl);
|
|
chatSocket.onmessage = evt => {
|
|
try {
|
|
const msg = JSON.parse(evt.data);
|
|
appendChatMessage(msg);
|
|
} catch (e) { console.error('Invalid chat msg', e); }
|
|
};
|
|
chatSocket.onopen = () => console.log(`Chat WS connected: ${wsUrl}`);
|
|
chatSocket.onclose = () => console.log('Chat WS closed');
|
|
chatSocket.onerror = e => console.error('Chat WS error', e);
|
|
}
|
|
|
|
function closeChat() {
|
|
if (chatSocket) { chatSocket.close(); chatSocket = null; }
|
|
chatOverlay.classList.add('hidden');
|
|
currentChatName = null;
|
|
}
|
|
|
|
function appendChatMessage(msg) {
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-message';
|
|
const time = new Date(msg.timestamp).toLocaleTimeString();
|
|
const who = msg.from === 'browser' ? 'You' : msg.from;
|
|
div.textContent = `[${time}] ${who}: ${msg.text}`;
|
|
chatMessages.appendChild(div);
|
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
}
|
|
|
|
chatClose.addEventListener('click', closeChat);
|
|
chatForm.addEventListener('submit', e => {
|
|
e.preventDefault();
|
|
if (!chatSocket || !currentChatName) return;
|
|
const text = chatInput.value.trim();
|
|
if (!text) return;
|
|
chatSocket.send(JSON.stringify({ command: text }));
|
|
appendChatMessage({ from: 'browser', text, timestamp: new Date().toISOString() });
|
|
chatInput.value = '';
|
|
});
|