diff --git a/static_ws/dereth.png b/static_ws/dereth.png new file mode 100644 index 00000000..4722941c Binary files /dev/null and b/static_ws/dereth.png differ diff --git a/static_ws/favicon.ico b/static_ws/favicon.ico new file mode 100644 index 00000000..3be83f80 Binary files /dev/null and b/static_ws/favicon.ico differ diff --git a/static_ws/graphs.html b/static_ws/graphs.html new file mode 100644 index 00000000..644086dc --- /dev/null +++ b/static_ws/graphs.html @@ -0,0 +1,162 @@ + + + + + Dereth Tracker – Analytics + + + + + + + +
+

Session Analytics

+ +
+

Kills over Time

+
+
+ +
+

Kills per Hour

+
+
+
+ + + + diff --git a/static_ws/index.html b/static_ws/index.html new file mode 100644 index 00000000..8f1de30b --- /dev/null +++ b/static_ws/index.html @@ -0,0 +1,43 @@ + + + + + Dereth Tracker + + + + + + + + +
+
+ Dereth map + +
+
+
+
+ + + + + + diff --git a/static_ws/script.js b/static_ws/script.js new file mode 100644 index 00000000..576e5418 --- /dev/null +++ b/static_ws/script.js @@ -0,0 +1,383 @@ +/* ---------- 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 = ` + ${p.character_name} + ${loc(p.ns, p.ew)} + ${p.kills} + ${p.kills_per_hour} + ${p.rares_found} + ${p.vt_state} + `; + + // 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 = ''; +}); diff --git a/static_ws/style.css b/static_ws/style.css new file mode 100644 index 00000000..5deb8ea6 --- /dev/null +++ b/static_ws/style.css @@ -0,0 +1,354 @@ +:root { + --sidebar-width: 280px; + --bg-main: #111; + --bg-side: #1a1a1a; + --card: #222; + --card-hov:#333; + --text: #eee; + --accent: #88f; +} +.chat-form button { border: none; background: var(--accent); color: #111; padding: 8px 12px; cursor: pointer; } + +/* Chat overlay styling */ + .chat-overlay { + position: absolute; + top: 10px; + left: calc(var(--sidebar-width) + 10px); + width: 300px; + height: 400px; + background: rgba(20,20,20,0.95); + border: 1px solid #444; + border-radius: 4px; + display: flex; + flex-direction: column; + z-index: 1000; +} +.chat-overlay.hidden { + display: none; +} +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + background: #333; + color: #eee; + font-weight: bold; +} +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px; + color: #eee; + font-size: 0.85rem; +} +.chat-form { + display: flex; + border-top: 1px solid #444; +} +.chat-form input { + flex: 1; + border: none; + padding: 8px; + background: #222; + color: #eee; +} +.chat-form input:focus { + outline: none; +} +.chat-form button { + border: none; + background: var(--accent); + color: #111; + padding: 8px 12px; + cursor: pointer; +} +.chat-message { + margin-bottom: 6px; +} +.chat-message:nth-child(odd) { + background: rgba(255,255,255,0.05); + padding: 4px 6px; + border-radius: 3px; +} +.chat-message:nth-child(even) { + background: rgba(255,255,255,0.02); + padding: 4px 6px; + border-radius: 3px; +} + +html { + margin: 0; + height: 100%; + width: 100%; +} + +body { + margin: 0; + height: 100%; + display: flex; + overflow: hidden; + font-family: "Segoe UI", sans-serif; + background: var(--bg-main); + color: var(--text); +} + +/* ---------- sort buttons --------------------------------------- */ +.sort-buttons { + display: flex; + gap: 4px; + margin: 12px 16px 8px; +} +.sort-buttons .btn { + flex: 1; + padding: 6px 8px; + background: #222; + color: #eee; + border: 1px solid #555; + border-radius: 4px; + text-align: center; + cursor: pointer; + user-select: none; + font-size: 0.9rem; +} +.sort-buttons .btn.active { + background: var(--accent); + color: #111; + border-color: var(--accent); +} + +/* ---------- sidebar --------------------------------------------- */ +#sidebar { + width: var(--sidebar-width); + scrollbar-width: none; + background: var(--bg-side); + border-right: 2px solid #333; + box-sizing: border-box; + padding: 18px 16px; + overflow-y: auto; +} +#sidebar h2 { + margin: 8px 0 12px; + font-size: 1.25rem; + color: var(--accent); +} +#playerList { + list-style: none; + margin: 0; + padding: 0; +} +#playerList li { + margin: 4px 0; + padding: 6px 8px; + background: var(--card); + border-left: 4px solid #555; + cursor: pointer; +} +#playerList li:hover { + background: var(--card-hov); +} +#playerList li.selected { + background: #454545; +} + +/* ---------- map container --------------------------------------- */ +#mapContainer { + flex: 1; + min-width: 0; + min-height: 0; + position: relative; + overflow: hidden; + background: #000; + cursor: grab; +} +#mapContainer.dragging { + cursor: grabbing; +} +#mapGroup { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} +#map { + display: block; + user-select: none; + pointer-events: none; +} + +/* ---------- dots ------------------------------------------------ */ +#dots { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} +.dot { + position: absolute; + width: 6px; + height: 6px; + border-radius: 50%; + border: 1px solid #000; + transform: translate(-50%, -50%); + + /* enable events on each dot */ + pointer-events: auto; + cursor: pointer; +} +.dot.highlight { + width: 10px; + height: 10px; + animation: blink 0.6s step-end infinite; +} +@keyframes blink { + 50% { opacity: 0; } +} + +/* ---------- tooltip --------------------------------------------- */ +.tooltip { + position: absolute; + display: none; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + pointer-events: none; + white-space: nowrap; + z-index: 1000; +} +/* make each row a flex container */ +/* 2-column flex layout for each player row */ +/* make each row a flex container */ +/* make each row a vertical stack */ +/* make each player row into a 3×2 grid */ +#playerList li { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto auto; + grid-template-areas: + "name loc" + "kills kph" + "rares meta"; + gap: 4px 8px; + margin: 6px 0; + padding: 8px 10px; + background: var(--card); + border-left: 4px solid transparent; + transition: background 0.15s; + font-size: 0.85rem; +} + +/* assign each span into its grid cell */ +.player-name { grid-area: name; font-weight: 600; color: var(--text); } +.player-loc { grid-area: loc; font-size: 0.75rem; color: #aaa; } + +.stat.kills { grid-area: kills; } +.stat.kph { grid-area: kph; } +.stat.rares { grid-area: rares; } +.stat.meta { grid-area: meta; } + +/* pill styling */ +#playerList li .stat { + background: rgba(255,255,255,0.1); + padding: 4px 8px; + border-radius: 12px; + display: inline-block; + font-size: 0.75rem; + white-space: nowrap; + color: var(--text); +} + +/* icons & suffixes */ +.stat.kills::before { content: "⚔️ "; } +.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; } +.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; } +.stat.meta { + background: var(--accent); + color: #111; +} + +/* hover & selected states */ +#playerList li:hover { background: var(--card-hov); } +#playerList li.selected { background: #454545; } +/* trails paths */ +#trails { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} +.trail-path { + fill: none; + stroke-width: 2px; + stroke-opacity: 0.7; + stroke-linecap: round; + stroke-linejoin: round; +} +/* Chat overlay styling */ +.chat-overlay { + position: absolute; + top: 10px; + right: 10px; + width: 300px; + height: 400px; + background: rgba(20,20,20,0.95); + border: 1px solid #444; + border-radius: 4px; + display: flex; + flex-direction: column; + z-index: 1000; +} +.chat-overlay.hidden { + display: none; +} +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + background: #333; + color: #eee; + font-weight: bold; +} +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px; + color: #eee; + font-size: 0.85rem; +} +.chat-form { + display: flex; + border-top: 1px solid #444; +} +.chat-form input { + flex: 1; + border: none; + padding: 8px; + background: #222; + color: #eee; +} +.chat-form input:focus { + outline: none; +} +.chat-form button { + border: none; + background: var(--accent); + color: #111; + padding: 8px 12px; + cursor: pointer; +} +.chat-message { + margin-bottom: 6px; +} +.chat-message:nth-child(odd) { + background: rgba(255,255,255,0.05); + padding: 4px 6px; + border-radius: 3px; +} +.chat-message:nth-child(even) { + background: rgba(255,255,255,0.02); + padding: 4px 6px; + border-radius: 3px; +}