WIP: snapshot of all local changes
This commit is contained in:
parent
4f9fdb911e
commit
dc774beb6b
6 changed files with 942 additions and 0 deletions
BIN
static_ws/dereth.png
Normal file
BIN
static_ws/dereth.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 MiB |
BIN
static_ws/favicon.ico
Normal file
BIN
static_ws/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
162
static_ws/graphs.html
Normal file
162
static_ws/graphs.html
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dereth Tracker – Analytics</title>
|
||||
|
||||
<!-- D3.js -->
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
#content { flex: 1; padding: 16px; overflow: auto; }
|
||||
h1 { margin-bottom: 24px; color: var(--accent); }
|
||||
section { margin-bottom: 48px; }
|
||||
.chart-svg { max-width: 100%; height: 300px; }
|
||||
.axis path, .axis line { stroke: #eee; }
|
||||
.axis text { fill: #eee; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<h1>Session Analytics</h1>
|
||||
|
||||
<section>
|
||||
<h2>Kills over Time</h2>
|
||||
<div id="chartKills"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kills per Hour</h2>
|
||||
<div id="chartKPH"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// D3.js-based stacked area charts with optimized grouping
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Fetch and prepare data
|
||||
const resp = await fetch('/history');
|
||||
const { data } = await resp.json();
|
||||
data.forEach(d => {
|
||||
d.timestampMs = new Date(d.timestamp).getTime();
|
||||
d.kills = +d.kills;
|
||||
d.kph = +d.kph;
|
||||
});
|
||||
// Pre-group by timestamp and character for O(1) lookups
|
||||
const nested = d3.rollup(
|
||||
data,
|
||||
recs => recs[recs.length - 1],
|
||||
d => d.timestampMs,
|
||||
d => d.character_name
|
||||
);
|
||||
// Sorted list of times and player names
|
||||
const times = Array.from(nested.keys()).sort((a, b) => a - b);
|
||||
const names = Array.from(new Set(data.map(d => d.character_name))).sort();
|
||||
// Draw charts using precomputed structures
|
||||
drawStackedAreaChart({
|
||||
container: '#chartKills',
|
||||
times,
|
||||
names,
|
||||
nested,
|
||||
valueKey: 'kills',
|
||||
yLabel: 'Total Kills'
|
||||
});
|
||||
drawStackedAreaChart({
|
||||
container: '#chartKPH',
|
||||
times,
|
||||
names,
|
||||
nested,
|
||||
valueKey: 'kph',
|
||||
yLabel: 'Kills per Hour'
|
||||
});
|
||||
});
|
||||
|
||||
function drawStackedAreaChart({ container, times, names, nested, valueKey, yLabel }) {
|
||||
const margin = { top: 20, right: 80, bottom: 30, left: 50 };
|
||||
const width = 800 - margin.left - margin.right;
|
||||
const height = 300 - margin.top - margin.bottom;
|
||||
|
||||
const svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('class', 'chart-svg')
|
||||
.attr('width', width + margin.left + margin.right)
|
||||
.attr('height', height + margin.top + margin.bottom)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// Build array of points per series using nested map
|
||||
const dataPoints = times.map(ms => {
|
||||
const pt = { timestamp: new Date(ms) };
|
||||
const mapChar = nested.get(ms);
|
||||
names.forEach(name => {
|
||||
const rec = mapChar && mapChar.get(name);
|
||||
pt[name] = rec ? rec[valueKey] : 0;
|
||||
});
|
||||
return pt;
|
||||
});
|
||||
const series = d3.stack()
|
||||
.keys(names)(dataPoints);
|
||||
const xScale = d3.scaleTime()
|
||||
.domain([new Date(times[0]), new Date(times[times.length - 1])])
|
||||
.range([0, width]);
|
||||
|
||||
const yScale = d3.scaleLinear()
|
||||
.domain([0, d3.max(series, s => d3.max(s, pts => pts[1]))]).nice()
|
||||
.range([height, 0]);
|
||||
|
||||
const color = d3.scaleOrdinal(d3.schemeCategory10).domain(names);
|
||||
|
||||
const area = d3.area()
|
||||
.x(d => xScale(d.data.timestamp))
|
||||
.y0(d => yScale(d[0]))
|
||||
.y1(d => yScale(d[1]))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
svg.selectAll('.area')
|
||||
.data(series)
|
||||
.enter().append('path')
|
||||
.attr('class', 'area')
|
||||
.attr('d', d => area(d))
|
||||
.attr('fill', d => color(d.key))
|
||||
.attr('opacity', 0.8);
|
||||
|
||||
svg.append('g')
|
||||
.attr('class', 'axis x-axis')
|
||||
.attr('transform', `translate(0,${height})`)
|
||||
.call(d3.axisBottom(xScale).tickFormat(d3.timeFormat('%H:%M')));
|
||||
|
||||
svg.append('g')
|
||||
.attr('class', 'axis y-axis')
|
||||
.call(d3.axisLeft(yScale));
|
||||
|
||||
svg.append('text')
|
||||
.attr('transform', 'rotate(-90)')
|
||||
.attr('y', 0 - margin.left)
|
||||
.attr('x', 0 - height / 2)
|
||||
.attr('dy', '1em')
|
||||
.style('text-anchor', 'middle')
|
||||
.style('fill', 'var(--text)')
|
||||
.text(yLabel);
|
||||
|
||||
const legend = svg.append('g')
|
||||
.attr('transform', `translate(${width + 20},0)`);
|
||||
|
||||
names.forEach((key, i) => {
|
||||
const row = legend.append('g')
|
||||
.attr('transform', `translate(0,${i * 20})`);
|
||||
row.append('rect')
|
||||
.attr('width', 10)
|
||||
.attr('height', 10)
|
||||
.attr('fill', color(key));
|
||||
row.append('text')
|
||||
.attr('x', 15)
|
||||
.attr('y', 10)
|
||||
.text(key)
|
||||
.attr('text-anchor', 'start')
|
||||
.style('alignment-baseline', 'middle')
|
||||
.style('fill', 'var(--text)');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
43
static_ws/index.html
Normal file
43
static_ws/index.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dereth Tracker</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar">
|
||||
<!-- Segmented sort buttons -->
|
||||
<div id="sortButtons" class="sort-buttons"></div>
|
||||
|
||||
<h2>Active Players</h2>
|
||||
<ul id="playerList"></ul>
|
||||
</aside>
|
||||
|
||||
<!-- MAP -->
|
||||
<div id="mapContainer">
|
||||
<div id="mapGroup">
|
||||
<img id="map" src="dereth.png" alt="Dereth map">
|
||||
<svg id="trails"></svg>
|
||||
<div id="dots"></div>
|
||||
</div>
|
||||
<div id="tooltip" class="tooltip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Overlay -->
|
||||
<div id="chatOverlay" class="chat-overlay hidden">
|
||||
<div class="chat-header">
|
||||
<span id="chatTitle">Chat</span>
|
||||
<button id="chatClose">×</button>
|
||||
</div>
|
||||
<div id="chatMessages" class="chat-messages"></div>
|
||||
<form id="chatForm" class="chat-form">
|
||||
<input type="text" id="chatInput" placeholder="Type a message..." autocomplete="off"/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="script.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
383
static_ws/script.js
Normal file
383
static_ws/script.js
Normal file
|
|
@ -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 = `
|
||||
<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 = '';
|
||||
});
|
||||
354
static_ws/style.css
Normal file
354
static_ws/style.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue