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
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
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;
+}