Overlord sees all
This commit is contained in:
commit
a2089efa02
7 changed files with 724 additions and 0 deletions
0
README.md
Normal file
0
README.md
Normal file
121
db.py
Normal file
121
db.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import sqlite3
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
DB_FILE = "dereth.db"
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Create tables if they do not exist (extended with kills_per_hour and onlinetime)."""
|
||||||
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# History log
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS telemetry_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
character_name TEXT NOT NULL,
|
||||||
|
char_tag TEXT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
ew REAL,
|
||||||
|
ns REAL,
|
||||||
|
z REAL,
|
||||||
|
kills INTEGER,
|
||||||
|
kills_per_hour TEXT,
|
||||||
|
onlinetime TEXT,
|
||||||
|
deaths INTEGER,
|
||||||
|
rares_found INTEGER,
|
||||||
|
prismatic_taper_count INTEGER,
|
||||||
|
vt_state TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Live snapshot (upsert)
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS live_state (
|
||||||
|
character_name TEXT PRIMARY KEY,
|
||||||
|
char_tag TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
timestamp TEXT,
|
||||||
|
ew REAL,
|
||||||
|
ns REAL,
|
||||||
|
z REAL,
|
||||||
|
kills INTEGER,
|
||||||
|
kills_per_hour TEXT,
|
||||||
|
onlinetime TEXT,
|
||||||
|
deaths INTEGER,
|
||||||
|
rares_found INTEGER,
|
||||||
|
prismatic_taper_count INTEGER,
|
||||||
|
vt_state TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def save_snapshot(data: Dict) -> None:
|
||||||
|
"""Insert snapshot into history and upsert into live_state (with new fields)."""
|
||||||
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Insert full history row
|
||||||
|
c.execute("""
|
||||||
|
INSERT INTO telemetry_log (
|
||||||
|
character_name, char_tag, session_id, timestamp,
|
||||||
|
ew, ns, z,
|
||||||
|
kills, kills_per_hour, onlinetime,
|
||||||
|
deaths, rares_found, prismatic_taper_count, vt_state
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
data["character_name"],
|
||||||
|
data.get("char_tag", ""),
|
||||||
|
data["session_id"],
|
||||||
|
data["timestamp"],
|
||||||
|
data["ew"], data["ns"], data.get("z", 0.0),
|
||||||
|
data["kills"],
|
||||||
|
data.get("kills_per_hour", ""),
|
||||||
|
data.get("onlinetime", ""),
|
||||||
|
data.get("deaths", 0),
|
||||||
|
data.get("rares_found", 0),
|
||||||
|
data.get("prismatic_taper_count", 0),
|
||||||
|
data.get("vt_state", "Unknown"),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Upsert into live_state
|
||||||
|
c.execute("""
|
||||||
|
INSERT INTO live_state (
|
||||||
|
character_name, char_tag, session_id, timestamp,
|
||||||
|
ew, ns, z,
|
||||||
|
kills, kills_per_hour, onlinetime,
|
||||||
|
deaths, rares_found, prismatic_taper_count, vt_state
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(character_name) DO UPDATE SET
|
||||||
|
char_tag = excluded.char_tag,
|
||||||
|
session_id = excluded.session_id,
|
||||||
|
timestamp = excluded.timestamp,
|
||||||
|
ew = excluded.ew,
|
||||||
|
ns = excluded.ns,
|
||||||
|
z = excluded.z,
|
||||||
|
kills = excluded.kills,
|
||||||
|
kills_per_hour = excluded.kills_per_hour,
|
||||||
|
onlinetime = excluded.onlinetime,
|
||||||
|
deaths = excluded.deaths,
|
||||||
|
rares_found = excluded.rares_found,
|
||||||
|
prismatic_taper_count = excluded.prismatic_taper_count,
|
||||||
|
vt_state = excluded.vt_state
|
||||||
|
""", (
|
||||||
|
data["character_name"],
|
||||||
|
data.get("char_tag", ""),
|
||||||
|
data["session_id"],
|
||||||
|
data["timestamp"],
|
||||||
|
data["ew"], data["ns"], data.get("z", 0.0),
|
||||||
|
data["kills"],
|
||||||
|
data.get("kills_per_hour", ""),
|
||||||
|
data.get("onlinetime", ""),
|
||||||
|
data.get("deaths", 0),
|
||||||
|
data.get("rares_found", 0),
|
||||||
|
data.get("prismatic_taper_count", 0),
|
||||||
|
data.get("vt_state", "Unknown"),
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
108
main.py
Normal file
108
main.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from db import init_db, save_snapshot, DB_FILE
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# In-memory store of the last packet per character
|
||||||
|
live_snapshots: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
SHARED_SECRET = "your_shared_secret"
|
||||||
|
#LOG_FILE = "telemetry_log.jsonl"
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
ACTIVE_WINDOW = timedelta(seconds=30) # player is “online” if seen in last 30 s
|
||||||
|
|
||||||
|
class TelemetrySnapshot(BaseModel):
|
||||||
|
character_name: str
|
||||||
|
char_tag: str
|
||||||
|
session_id: str
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
ew: float # +E / –W
|
||||||
|
ns: float # +N / –S
|
||||||
|
z: float
|
||||||
|
|
||||||
|
kills: int
|
||||||
|
kills_per_hour: Optional[str] = None # now optional
|
||||||
|
onlinetime: Optional[str] = None # now optional
|
||||||
|
deaths: int
|
||||||
|
rares_found: int
|
||||||
|
prismatic_taper_count: int
|
||||||
|
vt_state: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------ POST ----------------------------------
|
||||||
|
@app.post("/position")
|
||||||
|
@app.post("/position/")
|
||||||
|
async def receive_snapshot(
|
||||||
|
snapshot: TelemetrySnapshot,
|
||||||
|
x_plugin_secret: str = Header(None)
|
||||||
|
):
|
||||||
|
if x_plugin_secret != SHARED_SECRET:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
# cache for /live
|
||||||
|
live_snapshots[snapshot.character_name] = snapshot.dict()
|
||||||
|
|
||||||
|
# save in sqlite
|
||||||
|
save_snapshot(snapshot.dict())
|
||||||
|
|
||||||
|
# optional log-file append
|
||||||
|
#with open(LOG_FILE, "a") as f:
|
||||||
|
# f.write(json.dumps(snapshot.dict(), default=str) + "\n")
|
||||||
|
|
||||||
|
print(f"[{datetime.now()}] {snapshot.character_name} @ NS={snapshot.ns:+.2f}, EW={snapshot.ew:+.2f}")
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------ GET -----------------------------------
|
||||||
|
@app.get("/debug")
|
||||||
|
def debug():
|
||||||
|
return {"status": "OK"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/live")
|
||||||
|
@app.get("/live/")
|
||||||
|
def get_live_players():
|
||||||
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute("SELECT * FROM live_state").fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# aware cutoff (UTC)
|
||||||
|
cutoff = datetime.utcnow().replace(tzinfo=timezone.utc) - ACTIVE_WINDOW
|
||||||
|
|
||||||
|
players = [
|
||||||
|
dict(r) for r in rows
|
||||||
|
if datetime.fromisoformat(
|
||||||
|
r["timestamp"].replace('Z', '+00:00')
|
||||||
|
) > cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
return JSONResponse(content={"players": players})
|
||||||
|
|
||||||
|
# -------------------- static frontend ---------------------------
|
||||||
|
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|
||||||
|
# list routes for convenience
|
||||||
|
print("🔍 Registered routes:")
|
||||||
|
for route in app.routes:
|
||||||
|
if isinstance(route, APIRoute):
|
||||||
|
print(f"{route.path} -> {route.methods}")
|
||||||
BIN
static/dereth.png
Normal file
BIN
static/dereth.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 MiB |
30
static/index.html
Normal file
30
static/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<!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">
|
||||||
|
<div id="dots"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tooltip" class="tooltip"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
261
static/script.js
Normal file
261
static/script.js
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
/* ---------- DOM references --------------------------------------- */
|
||||||
|
const wrap = document.getElementById('mapContainer');
|
||||||
|
const group = document.getElementById('mapGroup');
|
||||||
|
const img = document.getElementById('map');
|
||||||
|
const dots = document.getElementById('dots');
|
||||||
|
const list = document.getElementById('playerList');
|
||||||
|
const btnContainer = document.getElementById('sortButtons');
|
||||||
|
const tooltip = document.getElementById('tooltip');
|
||||||
|
|
||||||
|
/* ---------- 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: "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 { players } = await (await fetch('/live/')).json();
|
||||||
|
currentPlayers = players;
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Live fetch failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollID !== null) return;
|
||||||
|
pollLive();
|
||||||
|
pollID = setInterval(pollLive, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
imgW = img.naturalWidth;
|
||||||
|
imgH = img.naturalHeight;
|
||||||
|
fitToWindow();
|
||||||
|
startPolling();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- 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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
li.addEventListener('click', () => selectPlayer(p, x, y));
|
||||||
|
if (p.character_name === selected) li.classList.add('selected');
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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;
|
||||||
|
});
|
||||||
204
static/style.css
Normal file
204
static/style.css
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--bg-main: #111;
|
||||||
|
--bg-side: #1a1a1a;
|
||||||
|
--card: #222;
|
||||||
|
--card-hov:#333;
|
||||||
|
--text: #eee;
|
||||||
|
--accent: #88f;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
Loading…
Add table
Add a link
Reference in a new issue