Major overhaul of db -> hypertable conversion, updated GUI, added inventory

This commit is contained in:
erik 2025-06-08 20:51:06 +00:00
parent fdf9f04bc6
commit f218350959
8 changed files with 1565 additions and 210 deletions

View file

@ -7,12 +7,12 @@ import os
import sqlalchemy
from databases import Database
from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text
from sqlalchemy import Index
from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint
# Environment: Postgres/TimescaleDB connection URL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth")
# Async database client
database = Database(DATABASE_URL)
# Async database client with explicit connection pool configuration
database = Database(DATABASE_URL, min_size=5, max_size=20)
# Metadata for SQLAlchemy Core
# SQLAlchemy metadata container for table definitions
metadata = MetaData()
@ -35,6 +35,7 @@ telemetry_events = Table(
Column("kills_per_hour", Float, nullable=True),
Column("onlinetime", String, nullable=True),
Column("deaths", Integer, nullable=False),
Column("total_deaths", Integer, nullable=True),
Column("rares_found", Integer, nullable=False),
Column("prismatic_taper_count", Integer, nullable=False),
Column("vt_state", String, nullable=True),
@ -104,6 +105,27 @@ rare_events = Table(
Column("z", Float, nullable=False),
)
character_inventories = Table(
# Stores complete character inventory snapshots with searchable fields
"character_inventories",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False, index=True),
Column("item_id", BigInteger, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False),
# Extracted searchable fields
Column("name", String),
Column("icon", Integer),
Column("object_class", Integer, index=True),
Column("value", Integer, index=True),
Column("burden", Integer),
Column("has_id_data", Boolean),
# Complete item data as JSONB
Column("item_data", JSON, nullable=False),
# Unique constraint to prevent duplicate items per character
UniqueConstraint("character_name", "item_id", name="uq_char_item"),
)
async def init_db_async():
"""Initialize PostgreSQL/TimescaleDB schema and hypertable.

View file

@ -4,7 +4,11 @@
"title": "Dereth Tracker Dashboard",
"schemaVersion": 30,
"version": 1,
"refresh": "10s",
"refresh": "30s",
"time": {
"from": "now-24h",
"to": "now"
},
"panels": [
{
"type": "timeseries",
@ -17,7 +21,7 @@
"refId": "A",
"format": "time_series",
"datasource": { "uid": "dereth-db" },
"rawSql": "SELECT $__time(timestamp), kills_per_hour AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp"
"rawSql": "SELECT $__time(timestamp), kills_per_hour AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp"
}
]
},
@ -32,7 +36,7 @@
"refId": "A",
"format": "time_series",
"datasource": { "uid": "dereth-db" },
"rawSql": "SELECT $__time(timestamp), mem_mb AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp"
"rawSql": "SELECT $__time(timestamp), mem_mb AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp"
}
]
},
@ -47,7 +51,7 @@
"refId": "A",
"format": "time_series",
"datasource": { "uid": "dereth-db" },
"rawSql": "SELECT $__time(timestamp), cpu_pct AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp"
"rawSql": "SELECT $__time(timestamp), cpu_pct AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp"
}
]
},
@ -62,7 +66,7 @@
"refId": "A",
"format": "time_series",
"datasource": { "uid": "dereth-db" },
"rawSql": "SELECT $__time(timestamp), mem_handles AS value FROM telemetry_events WHERE character_name = '$character' ORDER BY timestamp"
"rawSql": "SELECT $__time(timestamp), mem_handles AS value FROM telemetry_events WHERE character_name = '$character' AND $__timeFilter(timestamp) ORDER BY timestamp"
}
]
}

439
main.py
View file

@ -10,7 +10,8 @@ import json
import logging
import os
import sys
from typing import Dict
from typing import Dict, List, Any
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
@ -30,6 +31,7 @@ from db_async import (
rare_stats_sessions,
spawn_events,
rare_events,
character_inventories,
init_db_async
)
import asyncio
@ -54,6 +56,9 @@ _cache_task: asyncio.Task | None = None
async def _refresh_cache_loop() -> None:
"""Background task: refresh `/live` and `/trails` caches every 5 seconds."""
consecutive_failures = 0
max_consecutive_failures = 5
while True:
try:
# Recompute live players (last 30s)
@ -61,7 +66,8 @@ async def _refresh_cache_loop() -> None:
sql_live = """
SELECT sub.*,
COALESCE(rs.total_rares, 0) AS total_rares,
COALESCE(rss.session_rares, 0) AS session_rares
COALESCE(rss.session_rares, 0) AS session_rares,
COALESCE(cs.total_kills, 0) AS total_kills
FROM (
SELECT DISTINCT ON (character_name) *
FROM telemetry_events
@ -73,25 +79,52 @@ async def _refresh_cache_loop() -> None:
LEFT JOIN rare_stats_sessions rss
ON sub.character_name = rss.character_name
AND sub.session_id = rss.session_id
LEFT JOIN char_stats cs
ON sub.character_name = cs.character_name
"""
rows = await database.fetch_all(sql_live, {"cutoff": cutoff})
_cached_live["players"] = [dict(r) for r in rows]
# Recompute trails (last 600s)
cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600)
sql_trail = """
SELECT timestamp, character_name, ew, ns, z
FROM telemetry_events
WHERE timestamp >= :cutoff
ORDER BY character_name, timestamp
"""
rows2 = await database.fetch_all(sql_trail, {"cutoff": cutoff2})
_cached_trails["trails"] = [
{"timestamp": r["timestamp"], "character_name": r["character_name"],
"ew": r["ew"], "ns": r["ns"], "z": r["z"]}
for r in rows2
]
# Use a single connection for both queries to reduce connection churn
async with database.connection() as conn:
rows = await conn.fetch_all(sql_live, {"cutoff": cutoff})
_cached_live["players"] = [dict(r) for r in rows]
# Recompute trails (last 600s)
cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600)
sql_trail = """
SELECT timestamp, character_name, ew, ns, z
FROM telemetry_events
WHERE timestamp >= :cutoff
ORDER BY character_name, timestamp
"""
rows2 = await conn.fetch_all(sql_trail, {"cutoff": cutoff2})
_cached_trails["trails"] = [
{"timestamp": r["timestamp"], "character_name": r["character_name"],
"ew": r["ew"], "ns": r["ns"], "z": r["z"]}
for r in rows2
]
# Reset failure counter on success
consecutive_failures = 0
logger.debug(f"Cache refreshed: {len(_cached_live['players'])} players, {len(_cached_trails['trails'])} trail points")
except Exception as e:
logger.error(f"Cache refresh failed: {e}", exc_info=True)
consecutive_failures += 1
logger.error(f"Cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", exc_info=True)
# If too many consecutive failures, wait longer and try to reconnect
if consecutive_failures >= max_consecutive_failures:
logger.warning(f"Too many consecutive cache refresh failures. Attempting database reconnection...")
try:
await database.disconnect()
await asyncio.sleep(2)
await database.connect()
logger.info("Database reconnected successfully")
consecutive_failures = 0
except Exception as reconnect_error:
logger.error(f"Database reconnection failed: {reconnect_error}")
await asyncio.sleep(10) # Wait longer before retrying
continue
await asyncio.sleep(5)
# ------------------------------------------------------------------
@ -127,6 +160,7 @@ class TelemetrySnapshot(BaseModel):
kills_per_hour: Optional[float] = None
onlinetime: Optional[str] = None
deaths: int
total_deaths: Optional[int] = None
# Removed from telemetry payload; always enforced to 0 and tracked via rare events
rares_found: int = 0
prismatic_taper_count: int
@ -163,6 +197,17 @@ class RareEvent(BaseModel):
z: float = 0.0
class FullInventoryMessage(BaseModel):
"""
Model for the full_inventory WebSocket message type.
Contains complete character inventory snapshot with raw item data.
"""
character_name: str
timestamp: datetime
item_count: int
items: List[Dict[str, Any]]
@app.on_event("startup")
async def on_startup():
"""Event handler triggered when application starts up.
@ -176,6 +221,11 @@ async def on_startup():
await database.connect()
await init_db_async()
logger.info(f"Database connected successfully on attempt {attempt}")
# Log connection pool configuration
try:
logger.info(f"Database connection established with pool configuration")
except Exception as pool_error:
logger.debug(f"Could not access pool details: {pool_error}")
break
except Exception as e:
logger.warning(f"Database connection failed (attempt {attempt}/{max_attempts}): {e}")
@ -239,6 +289,140 @@ async def get_trails(
logger.error(f"Failed to get trails: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# --- GET Inventory Endpoints ---------------------------------
@app.get("/inventory/{character_name}")
async def get_character_inventory(character_name: str):
"""Get the complete inventory for a specific character from the database."""
try:
query = """
SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp
FROM character_inventories
WHERE character_name = :character_name
ORDER BY name
"""
rows = await database.fetch_all(query, {"character_name": character_name})
if not rows:
raise HTTPException(status_code=404, detail=f"No inventory found for character '{character_name}'")
items = []
for row in rows:
item = dict(row)
items.append(item)
return JSONResponse(content=jsonable_encoder({
"character_name": character_name,
"item_count": len(items),
"items": items
}))
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get inventory for {character_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/inventory/{character_name}/search")
async def search_character_inventory(
character_name: str,
name: str = Query(None, description="Search by item name (partial match)"),
object_class: int = Query(None, description="Filter by ObjectClass"),
min_value: int = Query(None, description="Minimum item value"),
max_value: int = Query(None, description="Maximum item value"),
min_burden: int = Query(None, description="Minimum burden"),
max_burden: int = Query(None, description="Maximum burden")
):
"""Search and filter inventory items for a character with various criteria."""
try:
conditions = ["character_name = :character_name"]
params = {"character_name": character_name}
if name:
conditions.append("name ILIKE :name")
params["name"] = f"%{name}%"
if object_class is not None:
conditions.append("object_class = :object_class")
params["object_class"] = object_class
if min_value is not None:
conditions.append("value >= :min_value")
params["min_value"] = min_value
if max_value is not None:
conditions.append("value <= :max_value")
params["max_value"] = max_value
if min_burden is not None:
conditions.append("burden >= :min_burden")
params["min_burden"] = min_burden
if max_burden is not None:
conditions.append("burden <= :max_burden")
params["max_burden"] = max_burden
query = f"""
SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp
FROM character_inventories
WHERE {' AND '.join(conditions)}
ORDER BY value DESC, name
"""
rows = await database.fetch_all(query, params)
items = []
for row in rows:
item = dict(row)
items.append(item)
return JSONResponse(content=jsonable_encoder({
"character_name": character_name,
"item_count": len(items),
"search_criteria": {
"name": name,
"object_class": object_class,
"min_value": min_value,
"max_value": max_value,
"min_burden": min_burden,
"max_burden": max_burden
},
"items": items
}))
except Exception as e:
logger.error(f"Failed to search inventory for {character_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/inventories")
async def list_characters_with_inventories():
"""List all characters that have stored inventories with item counts."""
try:
query = """
SELECT character_name, COUNT(*) as item_count, MAX(timestamp) as last_updated
FROM character_inventories
GROUP BY character_name
ORDER BY last_updated DESC
"""
rows = await database.fetch_all(query)
characters = []
for row in rows:
characters.append({
"character_name": row["character_name"],
"item_count": row["item_count"],
"last_updated": row["last_updated"]
})
return JSONResponse(content=jsonable_encoder({
"characters": characters,
"total_characters": len(characters)
}))
except Exception as e:
logger.error(f"Failed to list inventory characters: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# -------------------- WebSocket endpoints -----------------------
## WebSocket connection tracking
# Set of browser WebSocket clients subscribed to live updates
@ -250,15 +434,99 @@ async def _broadcast_to_browser_clients(snapshot: dict):
"""Broadcast a telemetry or chat message to all connected browser clients.
Converts any non-serializable types (e.g., datetime) before sending.
Handles connection errors gracefully and removes stale connections.
"""
# Convert snapshot payload to JSON-friendly types
data = jsonable_encoder(snapshot)
# Use list() to avoid "set changed size during iteration" errors
disconnected_clients = []
for ws in list(browser_conns):
try:
await ws.send_json(data)
except (WebSocketDisconnect, RuntimeError) as e:
browser_conns.discard(ws)
logger.debug(f"Removed disconnected browser client from broadcast list: {e}")
except (WebSocketDisconnect, RuntimeError, ConnectionAbortedError) as e:
# Collect disconnected clients for cleanup
disconnected_clients.append(ws)
logger.debug(f"Detected disconnected browser client: {e}")
except Exception as e:
# Handle any other unexpected errors
disconnected_clients.append(ws)
logger.warning(f"Unexpected error broadcasting to browser client: {e}")
# Clean up disconnected clients
for ws in disconnected_clients:
browser_conns.discard(ws)
async def _store_inventory(inventory_msg: FullInventoryMessage):
"""Store complete character inventory to database with extracted searchable fields.
Processes each item to extract key fields for indexing while preserving
complete item data in JSONB format. Uses UPSERT to handle item updates.
"""
try:
# Create inventory directory if it doesn't exist
inventory_dir = Path("./inventory")
inventory_dir.mkdir(exist_ok=True)
# Store as JSON file for backwards compatibility / debugging
file_path = inventory_dir / f"{inventory_msg.character_name}_inventory.json"
inventory_data = {
"character_name": inventory_msg.character_name,
"timestamp": inventory_msg.timestamp.isoformat(),
"item_count": inventory_msg.item_count,
"items": inventory_msg.items
}
with open(file_path, 'w') as f:
json.dump(inventory_data, f, indent=2)
# Store items in database
for item in inventory_msg.items:
# Extract searchable fields
item_id = item.get("Id")
if item_id is None:
continue # Skip items without ID
name = item.get("Name", "")
icon = item.get("Icon", 0)
object_class = item.get("ObjectClass", 0)
value = item.get("Value", 0)
burden = item.get("Burden", 0)
has_id_data = item.get("HasIdData", False)
# UPSERT item into database
stmt = pg_insert(character_inventories).values(
character_name=inventory_msg.character_name,
item_id=item_id,
timestamp=inventory_msg.timestamp,
name=name,
icon=icon,
object_class=object_class,
value=value,
burden=burden,
has_id_data=has_id_data,
item_data=item
).on_conflict_do_update(
constraint="uq_char_item",
set_={
"timestamp": inventory_msg.timestamp,
"name": name,
"icon": icon,
"object_class": object_class,
"value": value,
"burden": burden,
"has_id_data": has_id_data,
"item_data": item
}
)
await database.execute(stmt)
except Exception as e:
logger.error(f"Failed to store inventory for {inventory_msg.character_name}: {e}", exc_info=True)
raise
@app.websocket("/ws/position")
async def ws_receive_snapshots(
@ -336,7 +604,19 @@ async def ws_receive_snapshots(
db_data = snap.dict()
db_data['rares_found'] = 0
key = (snap.session_id, snap.character_name)
last = ws_receive_snapshots._last_kills.get(key, 0)
# Get last recorded kill count for this session
if key in ws_receive_snapshots._last_kills:
last = ws_receive_snapshots._last_kills[key]
else:
# Cache miss - check database for last kill count for this session
row = await database.fetch_one(
"SELECT kills FROM telemetry_events WHERE character_name = :char AND session_id = :session ORDER BY timestamp DESC LIMIT 1",
{"char": snap.character_name, "session": snap.session_id}
)
last = row["kills"] if row else 0
logger.debug(f"Cache miss for {snap.character_name} session {snap.session_id[:8]}: loaded last_kills={last} from database")
delta = snap.kills - last
# Persist snapshot and any kill delta in a single transaction
try:
@ -418,6 +698,17 @@ async def ws_receive_snapshots(
await _broadcast_to_browser_clients(data)
logger.debug(f"Broadcasted chat message from {data.get('character_name', 'unknown')}")
continue
# --- Full inventory message: store complete inventory snapshot ---
if msg_type == "full_inventory":
payload = data.copy()
payload.pop("type", None)
try:
inventory_msg = FullInventoryMessage.parse_obj(payload)
await _store_inventory(inventory_msg)
logger.info(f"Stored inventory for {inventory_msg.character_name}: {inventory_msg.item_count} items")
except Exception as e:
logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
continue
# Unknown message types are ignored
if msg_type:
logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}")
@ -425,9 +716,22 @@ async def ws_receive_snapshots(
# Clean up any plugin registrations for this socket
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
for n in to_remove:
del plugin_conns[n]
# Use pop() instead of del to avoid KeyError if already removed
plugin_conns.pop(n, None)
# Also clean up any entries in the kill tracking cache for this session
# Remove entries that might be associated with disconnected clients
stale_keys = []
for (session_id, char_name), _ in ws_receive_snapshots._last_kills.items():
if char_name in to_remove:
stale_keys.append((session_id, char_name))
for key in stale_keys:
ws_receive_snapshots._last_kills.pop(key, None)
if to_remove:
logger.info(f"Cleaned up plugin connections for characters: {to_remove} from {websocket.client}")
if stale_keys:
logger.debug(f"Cleaned up {len(stale_keys)} kill tracking cache entries")
else:
logger.debug(f"No plugin registrations to clean up for {websocket.client}")
@ -435,6 +739,44 @@ async def ws_receive_snapshots(
# Used to compute deltas for updating persistent kill statistics efficiently
ws_receive_snapshots._last_kills = {}
async def cleanup_stale_connections():
"""Periodic cleanup of stale WebSocket connections.
This function can be called periodically to clean up connections
that may have become stale but weren't properly cleaned up.
"""
# Clean up plugin connections that no longer have valid WebSockets
stale_plugins = []
for char_name, ws in list(plugin_conns.items()):
try:
# Test if the WebSocket is still alive by checking its state
if ws.client_state.name != 'CONNECTED':
stale_plugins.append(char_name)
except Exception:
# If we can't check the state, consider it stale
stale_plugins.append(char_name)
for char_name in stale_plugins:
plugin_conns.pop(char_name, None)
logger.info(f"Cleaned up stale plugin connection: {char_name}")
# Clean up browser connections
stale_browsers = []
for ws in list(browser_conns):
try:
if ws.client_state.name != 'CONNECTED':
stale_browsers.append(ws)
except Exception:
stale_browsers.append(ws)
for ws in stale_browsers:
browser_conns.discard(ws)
if stale_browsers:
logger.info(f"Cleaned up {len(stale_browsers)} stale browser connections")
logger.debug(f"Connection health check: {len(plugin_conns)} plugins, {len(browser_conns)} browsers")
@app.websocket("/ws/live")
async def ws_live_updates(websocket: WebSocket):
"""WebSocket endpoint for browser clients to receive live updates and send commands.
@ -478,6 +820,10 @@ async def ws_live_updates(websocket: WebSocket):
logger.warning(f"Failed to forward command to {target_name}: {e}")
# Remove stale connection
plugin_conns.pop(target_name, None)
except Exception as e:
logger.error(f"Unexpected error forwarding command to {target_name}: {e}")
# Remove potentially corrupted connection
plugin_conns.pop(target_name, None)
else:
logger.warning(f"No plugin connection found for target character: {target_name}")
except WebSocketDisconnect:
@ -507,32 +853,37 @@ async def get_stats(character_name: str):
Returns 404 if character has no recorded telemetry.
"""
try:
# Latest snapshot
sql_snap = (
"SELECT * FROM telemetry_events "
"WHERE character_name = :cn "
"ORDER BY timestamp DESC LIMIT 1"
)
snap = await database.fetch_one(sql_snap, {"cn": character_name})
if not snap:
# Single optimized query with LEFT JOINs to get all data in one round trip
sql = """
WITH latest AS (
SELECT * FROM telemetry_events
WHERE character_name = :cn
ORDER BY timestamp DESC LIMIT 1
)
SELECT
l.*,
COALESCE(cs.total_kills, 0) as total_kills,
COALESCE(rs.total_rares, 0) as total_rares
FROM latest l
LEFT JOIN char_stats cs ON l.character_name = cs.character_name
LEFT JOIN rare_stats rs ON l.character_name = rs.character_name
"""
row = await database.fetch_one(sql, {"cn": character_name})
if not row:
logger.warning(f"No telemetry data found for character: {character_name}")
raise HTTPException(status_code=404, detail="Character not found")
snap_dict = dict(snap)
# Total kills
sql_kills = "SELECT total_kills FROM char_stats WHERE character_name = :cn"
row_kills = await database.fetch_one(sql_kills, {"cn": character_name})
total_kills = row_kills["total_kills"] if row_kills else 0
# Total rares
sql_rares = "SELECT total_rares FROM rare_stats WHERE character_name = :cn"
row_rares = await database.fetch_one(sql_rares, {"cn": character_name})
total_rares = row_rares["total_rares"] if row_rares else 0
# Extract latest snapshot data (exclude the added total_kills/total_rares)
snap_dict = {k: v for k, v in dict(row).items()
if k not in ("total_kills", "total_rares")}
result = {
"character_name": character_name,
"latest_snapshot": snap_dict,
"total_kills": total_kills,
"total_rares": total_rares,
"total_kills": row["total_kills"],
"total_rares": row["total_rares"],
}
logger.debug(f"Retrieved stats for character: {character_name}")
logger.debug(f"Retrieved stats for character: {character_name} (optimized query)")
return JSONResponse(content=jsonable_encoder(result))
except HTTPException:
raise

View file

@ -31,6 +31,7 @@
<div id="dots"></div>
</div>
<div id="tooltip" class="tooltip"></div>
<div id="coordinates" class="coordinates"></div>
</div>
<!-- Main JavaScript file for WebSocket communication and UI logic -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

View file

@ -31,6 +31,74 @@ const trailsContainer = document.getElementById('trails');
const list = document.getElementById('playerList');
const btnContainer = document.getElementById('sortButtons');
const tooltip = document.getElementById('tooltip');
const coordinates = document.getElementById('coordinates');
// Global drag system to prevent event listener accumulation
let currentDragWindow = null;
let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0;
function makeDraggable(win, header) {
if (!window.__chatZ) window.__chatZ = 10000;
header.style.cursor = 'move';
const bringToFront = () => {
window.__chatZ += 1;
win.style.zIndex = window.__chatZ;
};
header.addEventListener('mousedown', e => {
if (e.target.closest('button')) return;
e.preventDefault();
currentDragWindow = win;
bringToFront();
dragStartX = e.clientX;
dragStartY = e.clientY;
dragStartLeft = win.offsetLeft;
dragStartTop = win.offsetTop;
document.body.classList.add('noselect');
});
// Touch support
header.addEventListener('touchstart', e => {
if (e.touches.length !== 1 || e.target.closest('button')) return;
currentDragWindow = win;
bringToFront();
const t = e.touches[0];
dragStartX = t.clientX;
dragStartY = t.clientY;
dragStartLeft = win.offsetLeft;
dragStartTop = win.offsetTop;
});
}
// Global mouse handlers (only added once)
window.addEventListener('mousemove', e => {
if (!currentDragWindow) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
currentDragWindow.style.left = `${dragStartLeft + dx}px`;
currentDragWindow.style.top = `${dragStartTop + dy}px`;
});
window.addEventListener('mouseup', () => {
if (currentDragWindow) {
currentDragWindow = null;
document.body.classList.remove('noselect');
}
});
window.addEventListener('touchmove', e => {
if (!currentDragWindow || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - dragStartX;
const dy = t.clientY - dragStartY;
currentDragWindow.style.left = `${dragStartLeft + dx}px`;
currentDragWindow.style.top = `${dragStartTop + dy}px`;
});
window.addEventListener('touchend', () => {
currentDragWindow = null;
});
// Filter input for player names (starts-with filter)
let currentFilter = '';
const filterInput = document.getElementById('playerFilter');
@ -47,6 +115,8 @@ let socket;
const chatWindows = {};
// Keep track of open stats windows: character_name -> DOM element
const statsWindows = {};
// Keep track of open inventory windows: character_name -> DOM element
const inventoryWindows = {};
/**
* ---------- Application Constants -----------------------------
@ -61,14 +131,15 @@ const statsWindows = {};
* CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code
*/
/* ---------- constants ------------------------------------------- */
const MAX_Z = 10;
const MAX_Z = 20;
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
// UtilityBelt's more accurate coordinate bounds
const MAP_BOUNDS = {
west: -102.1,
east: 102.1,
north: 102.1,
south: -102.1
};
// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
@ -119,8 +190,29 @@ const CHAT_COLOR_MAP = {
/* ---------- player/dot color assignment ------------------------- */
// A base palette of distinct, color-blind-friendly colors
const PALETTE = [
// Original colorblind-friendly base palette
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf',
// Extended high-contrast colors
'#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff',
'#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488',
// Darker variants
'#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc',
'#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366',
// Brighter variants
'#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff',
'#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa',
// Additional distinct colors
'#990099', '#009900', '#000099', '#990000', '#009999',
'#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055',
// Light pastels for contrast
'#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff',
'#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc'
];
// Map from character name to assigned color
const colorMap = {};
@ -158,23 +250,37 @@ function getColorFor(name) {
const sortOptions = [
{
value: "name",
label: "Name",
label: "Name",
comparator: (a, b) => a.character_name.localeCompare(b.character_name)
},
{
value: "kph",
label: "KPH",
label: "KPH",
comparator: (a, b) => b.kills_per_hour - a.kills_per_hour
},
{
value: "kills",
label: "Kills",
label: "S.Kills",
comparator: (a, b) => b.kills - a.kills
},
{
value: "rares",
label: "Session Rares ↓",
label: "S.Rares",
comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0)
},
{
value: "total_kills",
label: "T.Kills",
comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0)
},
{
value: "kpr",
label: "KPR",
comparator: (a, b) => {
const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity;
const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity;
return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding)
}
}
];
@ -226,11 +332,28 @@ function worldToPx(ew, ns) {
return { x, y };
}
function pxToWorld(x, y) {
// Convert screen coordinates to map image coordinates
const mapX = (x - offX) / scale;
const mapY = (y - offY) / scale;
// Convert map image coordinates to world coordinates
const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west);
const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south);
return { ew, ns };
}
// Show or create a stats window for a character
function showStatsWindow(name) {
if (statsWindows[name]) {
const existing = statsWindows[name];
existing.style.display = 'flex';
// Toggle: close if already visible, open if hidden
if (existing.style.display === 'flex') {
existing.style.display = 'none';
} else {
existing.style.display = 'flex';
}
return;
}
const win = document.createElement('div');
@ -248,6 +371,29 @@ function showStatsWindow(name) {
header.appendChild(title);
header.appendChild(closeBtn);
win.appendChild(header);
// Time period controls
const controls = document.createElement('div');
controls.className = 'stats-controls';
const timeRanges = [
{ label: '1H', value: 'now-1h' },
{ label: '6H', value: 'now-6h' },
{ label: '24H', value: 'now-24h' },
{ label: '7D', value: 'now-7d' }
];
timeRanges.forEach(range => {
const btn = document.createElement('button');
btn.className = 'time-range-btn';
btn.textContent = range.label;
if (range.value === 'now-24h') btn.classList.add('active');
btn.addEventListener('click', () => {
controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateStatsTimeRange(content, name, range.value);
});
controls.appendChild(btn);
});
win.appendChild(controls);
// Content container
const content = document.createElement('div');
content.className = 'chat-messages';
@ -255,7 +401,13 @@ function showStatsWindow(name) {
win.appendChild(content);
document.body.appendChild(win);
statsWindows[name] = win;
// Embed a 2×2 grid of Grafana solo-panel iframes for this character
// Load initial stats with default 24h range
updateStatsTimeRange(content, name, 'now-24h');
// Enable dragging using the global drag system
makeDraggable(win, header);
}
function updateStatsTimeRange(content, name, timeRange) {
content.innerHTML = '';
const panels = [
{ title: 'Kills per Hour', id: 1 },
@ -269,6 +421,8 @@ function showStatsWindow(name) {
`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` +
`?panelId=${p.id}` +
`&var-character=${encodeURIComponent(name)}` +
`&from=${timeRange}` +
`&to=now` +
`&theme=light`;
iframe.setAttribute('title', p.title);
iframe.width = '350';
@ -277,55 +431,48 @@ function showStatsWindow(name) {
iframe.allowFullscreen = true;
content.appendChild(iframe);
});
// Enable dragging of the stats window via its header
if (!window.__chatZ) window.__chatZ = 10000;
let drag = false;
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
header.style.cursor = 'move';
const bringToFront = () => {
window.__chatZ += 1;
win.style.zIndex = window.__chatZ;
};
header.addEventListener('mousedown', e => {
if (e.target.closest('button')) return;
e.preventDefault();
drag = true;
bringToFront();
startX = e.clientX; startY = e.clientY;
startLeft = win.offsetLeft; startTop = win.offsetTop;
document.body.classList.add('noselect');
});
window.addEventListener('mousemove', e => {
if (!drag) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('mouseup', () => {
drag = false;
document.body.classList.remove('noselect');
});
// Touch support for dragging
header.addEventListener('touchstart', e => {
if (e.touches.length !== 1 || e.target.closest('button')) return;
drag = true;
bringToFront();
const t = e.touches[0];
startX = t.clientX; startY = t.clientY;
startLeft = win.offsetLeft; startTop = win.offsetTop;
});
window.addEventListener('touchmove', e => {
if (!drag || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('touchend', () => { drag = false; });
}
// Show or create an inventory window for a character
function showInventoryWindow(name) {
if (inventoryWindows[name]) {
const existing = inventoryWindows[name];
// Toggle: close if already visible, open if hidden
if (existing.style.display === 'flex') {
existing.style.display = 'none';
} else {
existing.style.display = 'flex';
}
return;
}
const win = document.createElement('div');
win.className = 'inventory-window';
win.dataset.character = name;
// Header (reuses chat-header styling)
const header = document.createElement('div');
header.className = 'chat-header';
const title = document.createElement('span');
title.textContent = `Inventory: ${name}`;
const closeBtn = document.createElement('button');
closeBtn.className = 'chat-close-btn';
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
header.appendChild(title);
header.appendChild(closeBtn);
win.appendChild(header);
// Content container
const content = document.createElement('div');
content.className = 'inventory-content';
content.innerHTML = '<div class="inventory-placeholder">Inventory feature coming soon...</div>';
win.appendChild(content);
document.body.appendChild(win);
inventoryWindows[name] = win;
// Enable dragging using the global drag system
makeDraggable(win, header);
}
const applyTransform = () =>
group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`;
@ -446,15 +593,22 @@ function render(players) {
const color = getColorFor(p.character_name);
li.style.borderLeftColor = color;
li.className = 'player-item';
// Calculate KPR (Kills Per Rare)
const totalKills = p.total_kills || 0;
const totalRares = p.total_rares || 0;
const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞';
li.innerHTML = `
<span class="player-name">${p.character_name}</span>
<span class="player-loc">${loc(p.ns, p.ew)}</span>
<span class="player-name">${p.character_name} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span>
<span class="stat kills">${p.kills}</span>
<span class="stat total-kills">${p.total_kills || 0}</span>
<span class="stat kph">${p.kills_per_hour}</span>
<span class="stat rares">${p.session_rares}/${p.total_rares}</span>
<span class="stat kpr">${kpr}</span>
<span class="stat meta">${p.vt_state}</span>
<span class="stat onlinetime">${p.onlinetime}</span>
<span class="stat deaths">${p.deaths}</span>
<span class="stat deaths">${p.deaths}/${p.total_deaths || 0}</span>
<span class="stat tapers">${p.prismatic_taper_count || 0}</span>
`;
// Color the metastate pill according to its value
@ -489,6 +643,15 @@ function render(players) {
showStatsWindow(p.character_name);
});
li.appendChild(statsBtn);
// Inventory button
const inventoryBtn = document.createElement('button');
inventoryBtn.className = 'inventory-btn';
inventoryBtn.textContent = 'Inventory';
inventoryBtn.addEventListener('click', e => {
e.stopPropagation();
showInventoryWindow(p.character_name);
});
li.appendChild(inventoryBtn);
list.appendChild(li);
});
}
@ -553,12 +716,17 @@ function initWebSocket() {
// Display or create a chat window for a character
function showChatWindow(name) {
if (chatWindows[name]) {
// Restore flex layout when reopening & bring to front
const existing = chatWindows[name];
existing.style.display = 'flex';
if (!window.__chatZ) window.__chatZ = 10000;
window.__chatZ += 1;
existing.style.zIndex = window.__chatZ;
// Toggle: close if already visible, open if hidden
if (existing.style.display === 'flex') {
existing.style.display = 'none';
} else {
existing.style.display = 'flex';
// Bring to front when opening
if (!window.__chatZ) window.__chatZ = 10000;
window.__chatZ += 1;
existing.style.zIndex = window.__chatZ;
}
return;
}
const win = document.createElement('div');
@ -600,76 +768,8 @@ function showChatWindow(name) {
document.body.appendChild(win);
chatWindows[name] = win;
/* --------------------------------------------------------- */
/* enable dragging of the chat window via its header element */
/* --------------------------------------------------------- */
// keep a static counter so newer windows can be brought to front
if (!window.__chatZ) window.__chatZ = 10000;
let drag = false;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
header.style.cursor = 'move';
// bring to front when interacting
const bringToFront = () => {
window.__chatZ += 1;
win.style.zIndex = window.__chatZ;
};
header.addEventListener('mousedown', e => {
// don't initiate drag when pressing the close button (or other clickable controls)
if (e.target.closest('button')) return;
e.preventDefault();
drag = true;
bringToFront();
startX = e.clientX;
startY = e.clientY;
// current absolute position
startLeft = win.offsetLeft;
startTop = win.offsetTop;
document.body.classList.add('noselect');
});
window.addEventListener('mousemove', e => {
if (!drag) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('mouseup', () => {
drag = false;
document.body.classList.remove('noselect');
});
/* touch support */
header.addEventListener('touchstart', e => {
if (e.touches.length !== 1 || e.target.closest('button')) return;
drag = true;
bringToFront();
const t = e.touches[0];
startX = t.clientX;
startY = t.clientY;
startLeft = win.offsetLeft;
startTop = win.offsetTop;
});
window.addEventListener('touchmove', e => {
if (!drag || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
win.style.left = `${startLeft + dx}px`;
win.style.top = `${startTop + dy}px`;
});
window.addEventListener('touchend', () => {
drag = false;
});
// Enable dragging using the global drag system
makeDraggable(win, header);
}
// Append a chat message to the correct window
@ -752,3 +852,24 @@ wrap.addEventListener('touchmove', e => {
wrap.addEventListener('touchend', () => {
dragging = false;
});
/* ---------- coordinate display on hover ---------------------------- */
wrap.addEventListener('mousemove', e => {
if (!imgW) return;
const r = wrap.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
const { ew, ns } = pxToWorld(x, y);
// Display coordinates using the same format as the existing loc function
coordinates.textContent = loc(ns, ew);
coordinates.style.left = `${x + 10}px`;
coordinates.style.top = `${y + 10}px`;
coordinates.style.display = 'block';
});
wrap.addEventListener('mouseleave', () => {
coordinates.style.display = 'none';
});

731
static/style-ac.css Normal file
View file

@ -0,0 +1,731 @@
/*
* style-ac.css - Asheron's Call themed styles for Dereth Tracker
*
* Recreates the classic AC UI with stone textures, beveled edges,
* golden accents, and medieval fantasy aesthetics.
*/
/* CSS Custom Properties for AC theme colors and sizing */
:root {
--sidebar-width: 340px;
/* AC Color Palette */
--ac-black: #0a0a0a;
--ac-dark-stone: #1a1a1a;
--ac-medium-stone: #2a2a2a;
--ac-light-stone: #3a3a3a;
--ac-border-dark: #000;
--ac-border-light: #4a4a4a;
--ac-gold: #d4af37;
--ac-gold-bright: #ffd700;
--ac-gold-dark: #b8941f;
--ac-green: #00ff00;
--ac-cyan: #00ffff;
--ac-text: #e0e0e0;
--ac-text-dim: #a0a0a0;
/* Backgrounds */
--bg-main: var(--ac-black);
--bg-side: var(--ac-dark-stone);
--card: var(--ac-medium-stone);
--card-hov: var(--ac-light-stone);
--text: var(--ac-text);
--accent: var(--ac-gold);
}
/* Placeholder text in chat input */
.chat-input::placeholder {
color: var(--ac-text-dim);
opacity: 0.7;
}
html {
margin: 0;
height: 100%;
width: 100%;
}
body {
margin: 0;
height: 100%;
display: flex;
overflow: hidden;
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
background: var(--bg-main);
color: var(--text);
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.01) 10px,
rgba(255, 255, 255, 0.01) 20px
);
}
/* AC-style stone textured panels with beveled edges */
.ac-panel {
background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%);
border: 2px solid var(--ac-border-dark);
box-shadow:
inset 2px 2px 3px rgba(255, 255, 255, 0.1),
inset -2px -2px 3px rgba(0, 0, 0, 0.5),
0 2px 5px rgba(0, 0, 0, 0.8);
border-radius: 0;
}
/* Sort buttons - AC style */
.sort-buttons {
display: flex;
gap: 3px;
margin: 12px 16px 8px;
padding: 8px;
background: var(--ac-dark-stone);
border: 1px solid var(--ac-border-dark);
box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.5);
}
.sort-buttons .btn {
flex: 1;
padding: 5px 8px;
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
color: var(--ac-text-dim);
border: 1px solid var(--ac-border-dark);
border-radius: 2px;
text-align: center;
cursor: pointer;
user-select: none;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.15s;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
}
.sort-buttons .btn:hover {
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-light-stone) 100%);
color: var(--ac-gold-bright);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.2),
0 0 5px rgba(212, 175, 55, 0.3);
}
.sort-buttons .btn:active {
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.7),
inset -1px -1px 2px rgba(255, 255, 255, 0.1);
}
.sort-buttons .btn.active {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border-color: var(--ac-gold-dark);
font-weight: 700;
position: relative;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.3),
0 0 10px rgba(212, 175, 55, 0.5);
}
.sort-buttons .btn.active:hover {
background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%);
color: var(--ac-black);
}
/* Sort direction indicators */
.sort-buttons .btn.active::after {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
}
/* Most sorts are descending (down arrow) */
.sort-buttons .btn.active::after {
border-top: 4px solid var(--ac-black);
}
/* Name and KPR are ascending (up arrow) */
.sort-buttons .btn.active[data-value="name"]::after,
.sort-buttons .btn.active[data-value="kpr"]::after {
border-top: none;
border-bottom: 4px solid var(--ac-black);
}
/* Sidebar - AC stone panel style */
#sidebar {
width: var(--sidebar-width);
scrollbar-width: thin;
scrollbar-color: var(--ac-gold-dark) var(--ac-dark-stone);
background: linear-gradient(180deg, var(--ac-dark-stone) 0%, var(--ac-black) 100%);
border-right: 3px solid var(--ac-border-dark);
box-shadow:
inset -2px 0 5px rgba(0, 0, 0, 0.5),
2px 0 5px rgba(0, 0, 0, 0.8);
box-sizing: border-box;
padding: 18px 16px;
overflow-y: auto;
}
#sidebar h2 {
margin: 8px 0 12px;
font-size: 1.25rem;
color: var(--ac-gold);
text-shadow:
2px 2px 3px rgba(0, 0, 0, 0.8),
0 0 10px rgba(212, 175, 55, 0.3);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
#playerList {
list-style: none;
margin: 0;
padding: 0;
}
/* Filter input - AC style */
.player-filter {
width: 100%;
padding: 6px 10px;
margin-bottom: 12px;
background: var(--ac-dark-stone);
color: var(--ac-gold);
border: 2px solid var(--ac-border-dark);
border-radius: 2px;
font-size: 0.9rem;
box-sizing: border-box;
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
inset -1px -1px 2px rgba(255, 255, 255, 0.05);
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
}
.player-filter:focus {
outline: none;
border-color: var(--ac-gold-dark);
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
0 0 5px rgba(212, 175, 55, 0.5);
}
/* Map container */
#mapContainer {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg-main);
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.8);
}
/* Player list items - AC stone panels */
#playerList li {
display: grid;
grid-template-columns: 1fr auto auto auto auto auto;
grid-template-rows: auto auto auto auto;
grid-template-areas:
"name name name name name name"
"kills totalkills kph kph kph kph"
"rares kpr meta meta meta meta"
"onlinetime deaths tapers tapers tapers tapers";
gap: 4px 8px;
margin: 6px 0;
padding: 10px 12px;
background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%);
border: 2px solid var(--ac-border-dark);
border-left: 4px solid var(--ac-gold-dark);
transition: all 0.2s;
box-shadow:
1px 1px 3px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.05);
}
/* Grid assignments */
.player-name {
grid-area: name;
font-weight: 700;
color: var(--ac-gold);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.coordinates-inline {
font-size: 0.75rem;
color: var(--ac-text-dim);
font-weight: 400;
margin-left: 8px;
}
.stat.kills { grid-area: kills; }
.stat.total-kills { grid-area: totalkills; }
.stat.kph { grid-area: kph; }
.stat.rares { grid-area: rares; }
.stat.kpr { grid-area: kpr; }
.stat.meta { grid-area: meta; }
.stat.onlinetime { grid-area: onlinetime; }
.stat.deaths { grid-area: deaths; }
.stat.tapers { grid-area: tapers; }
/* Stat pills - AC style */
#playerList li .stat {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.5) 100%);
padding: 4px 8px;
border-radius: 2px;
display: inline-block;
font-size: 0.75rem;
white-space: nowrap;
color: var(--ac-text);
border: 1px solid rgba(0, 0, 0, 0.5);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.3),
inset 1px 1px 1px rgba(255, 255, 255, 0.05);
}
/* Icons & suffixes */
.stat.kills::before { content: "⚔️ "; }
.stat.total-kills::before { content: "🏆 "; }
.stat.kph::after { content: " KPH"; font-size:0.7em; color: var(--ac-text-dim); }
.stat.rares::before { content: "💎 "; }
.stat.rares::after { content: " Rares"; font-size:0.7em; color: var(--ac-text-dim); }
.stat.kpr::before { content: "📊 "; }
.stat.kpr::after { content: " KPR"; font-size:0.7em; color: var(--ac-text-dim); }
/* Metastate pills */
#playerList li .stat.meta {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border-color: var(--ac-gold-dark);
}
#playerList li .stat.meta.green {
background: linear-gradient(180deg, #4ade80 0%, #22c55e 100%);
color: var(--ac-black);
border-color: #16a34a;
}
#playerList li .stat.meta.red {
background: linear-gradient(180deg, #f87171 0%, #ef4444 100%);
color: #fff;
border-color: #dc2626;
}
/* Chat/Stats/Inventory buttons - AC style */
.chat-btn, .stats-btn, .inventory-btn {
margin-top: 4px;
margin-right: 4px;
padding: 3px 8px;
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border: 1px solid var(--ac-gold-dark);
border-radius: 2px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.3);
transition: all 0.15s;
}
.chat-btn:hover, .stats-btn:hover, .inventory-btn:hover {
background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.4),
0 0 5px rgba(212, 175, 55, 0.5);
}
/* Windows - AC stone panel style */
.chat-window, .stats-window, .inventory-window {
position: absolute;
top: 10px;
left: calc(var(--sidebar-width) + 10px);
width: 760px;
height: 300px;
background: linear-gradient(135deg, var(--ac-medium-stone) 0%, var(--ac-dark-stone) 100%);
border: 3px solid var(--ac-border-dark);
display: flex;
flex-direction: column;
z-index: 10000;
box-shadow:
0 5px 20px rgba(0, 0, 0, 0.8),
inset 2px 2px 3px rgba(255, 255, 255, 0.05);
border-radius: 2px;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
border-bottom: 2px solid var(--ac-border-dark);
color: var(--ac-gold);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5);
}
.chat-close-btn {
background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%);
color: #fff;
border: 1px solid #991b1b;
border-radius: 2px;
padding: 2px 8px;
font-size: 1rem;
cursor: pointer;
font-weight: 700;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.3);
}
.chat-close-btn:hover {
background: linear-gradient(180deg, #f87171 0%, #ef4444 100%);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.4),
0 0 5px rgba(239, 68, 68, 0.5);
}
.chat-messages {
flex: 1;
padding: 10px 15px;
overflow-y: auto;
background: rgba(0, 0, 0, 0.3);
color: var(--ac-green);
font-family: "Courier New", Courier, monospace;
font-size: 0.9rem;
line-height: 1.4;
box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.5);
}
.chat-messages::-webkit-scrollbar {
width: 10px;
}
.chat-messages::-webkit-scrollbar-track {
background: var(--ac-dark-stone);
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--ac-gold-dark);
border-radius: 2px;
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.chat-form {
display: flex;
padding: 10px 15px;
background: var(--ac-dark-stone);
border-top: 2px solid var(--ac-border-dark);
box-shadow: 0 -2px 3px rgba(0, 0, 0, 0.5);
}
.chat-input {
flex: 1;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.5);
color: var(--ac-green);
border: 2px solid var(--ac-border-dark);
border-radius: 2px;
font-family: "Courier New", Courier, monospace;
font-size: 0.9rem;
box-shadow: inset 2px 2px 3px rgba(0, 0, 0, 0.5);
}
.chat-input:focus {
outline: none;
border-color: var(--ac-gold-dark);
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
0 0 5px rgba(212, 175, 55, 0.3);
}
/* Map elements */
#mapGroup {
transform-origin: top left;
position: relative;
}
#map {
display: block;
user-select: none;
-webkit-user-drag: none;
filter: brightness(0.9) contrast(1.1);
}
.dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: auto;
cursor: pointer;
z-index: 10;
box-shadow:
0 0 5px rgba(0, 0, 0, 0.8),
0 0 10px currentColor;
border: 1px solid rgba(0, 0, 0, 0.5);
}
.dot.highlight {
animation: pulse 2s infinite;
z-index: 20;
}
@keyframes pulse {
0% { box-shadow: 0 0 5px rgba(0, 0, 0, 0.8), 0 0 10px currentColor; }
50% { box-shadow: 0 0 10px rgba(0, 0, 0, 0.8), 0 0 20px currentColor, 0 0 30px currentColor; }
100% { box-shadow: 0 0 5px rgba(0, 0, 0, 0.8), 0 0 10px currentColor; }
}
/* Tooltip - AC style */
.tooltip {
position: absolute;
display: none;
background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(10, 10, 10, 0.95) 100%);
color: var(--ac-gold);
padding: 6px 10px;
border: 2px solid var(--ac-gold-dark);
border-radius: 2px;
font-size: 0.8rem;
pointer-events: none;
white-space: nowrap;
z-index: 1000;
box-shadow:
0 2px 10px rgba(0, 0, 0, 0.8),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
/* Coordinate display - AC style */
.coordinates {
position: absolute;
display: none;
background: linear-gradient(135deg, rgba(0, 50, 100, 0.95) 0%, rgba(0, 30, 60, 0.95) 100%);
color: var(--ac-cyan);
padding: 4px 8px;
border: 2px solid rgba(0, 100, 150, 0.8);
border-radius: 2px;
font-size: 0.75rem;
font-family: "Courier New", Courier, monospace;
font-weight: 700;
pointer-events: none;
white-space: nowrap;
z-index: 999;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.8),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
/* Hover states */
#playerList li:hover {
background: linear-gradient(135deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
border-left-color: var(--ac-gold-bright);
box-shadow:
2px 2px 5px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.1),
0 0 10px rgba(212, 175, 55, 0.2);
}
#playerList li.selected {
background: linear-gradient(135deg, var(--ac-gold-dark) 0%, var(--ac-medium-stone) 100%);
border-left-color: var(--ac-gold-bright);
box-shadow:
2px 2px 5px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.2),
0 0 15px rgba(212, 175, 55, 0.3);
}
/* Trail paths */
#trails {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0.7;
}
.trail-path {
stroke-width: 2;
stroke-opacity: 0.8;
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8));
}
/* Stats window specific */
.stats-window {
height: auto;
}
.stats-window .chat-messages {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: auto;
gap: 10px;
padding: 10px;
overflow: visible;
background: var(--ac-dark-stone);
color: var(--ac-text);
}
.stats-window iframe {
width: 350px;
height: 200px;
border: 2px solid var(--ac-border-dark);
box-shadow:
inset 2px 2px 3px rgba(0, 0, 0, 0.5),
1px 1px 2px rgba(0, 0, 0, 0.3);
background: var(--ac-black);
}
/* Stats time controls - AC style */
.stats-controls {
display: flex;
gap: 8px;
padding: 10px 15px;
background: var(--ac-medium-stone);
border-bottom: 2px solid var(--ac-border-dark);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5);
}
.time-range-btn {
padding: 6px 12px;
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-medium-stone) 100%);
color: var(--ac-text-dim);
border: 1px solid var(--ac-border-dark);
border-radius: 2px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.1);
}
.time-range-btn:hover {
background: linear-gradient(180deg, var(--ac-light-stone) 0%, var(--ac-light-stone) 100%);
color: var(--ac-gold);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.2),
0 0 5px rgba(212, 175, 55, 0.2);
}
.time-range-btn.active {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
color: var(--ac-black);
border-color: var(--ac-gold-dark);
font-weight: 700;
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 2px rgba(255, 255, 255, 0.3),
0 0 10px rgba(212, 175, 55, 0.4);
}
/* Inventory window */
.inventory-content {
flex: 1;
padding: 15px;
background: var(--ac-dark-stone);
color: var(--ac-text);
overflow-y: auto;
box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.5);
}
.inventory-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 1.1rem;
color: var(--ac-text-dim);
font-style: italic;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.stat.onlinetime::before { content: "🕑 "; }
.stat.deaths::before { content: "💀 "; }
.stat.tapers::before {
content: "";
display: inline-block;
width: 16px;
height: 16px;
background-image: url('prismatic-taper-icon.png');
background-size: contain;
background-repeat: no-repeat;
margin-right: 4px;
vertical-align: text-bottom;
}
/* Disable text selection during drag */
.noselect {
user-select: none !important;
}
/* Custom scrollbar for sidebar */
#sidebar::-webkit-scrollbar {
width: 12px;
}
#sidebar::-webkit-scrollbar-track {
background: var(--ac-dark-stone);
box-shadow: inset 2px 2px 3px rgba(0, 0, 0, 0.5);
}
#sidebar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--ac-gold) 0%, var(--ac-gold-dark) 100%);
border-radius: 2px;
border: 1px solid var(--ac-gold-dark);
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.5),
inset 1px 1px 1px rgba(255, 255, 255, 0.3);
}
#sidebar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--ac-gold-bright) 0%, var(--ac-gold) 100%);
}
/* Map container special effects */
#mapContainer.dragging {
cursor: move;
}
/* Additional hover effects */
.player-item {
position: relative;
overflow: hidden;
}
.player-item::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(212, 175, 55, 0.2) 50%, transparent 100%);
transition: left 0.5s;
}
.player-item:hover::before {
left: 100%;
}

View file

@ -6,7 +6,7 @@
*/
/* CSS Custom Properties for theme colors and sizing */
:root {
--sidebar-width: 280px;
--sidebar-width: 340px;
--bg-main: #111;
--bg-side: #1a1a1a;
--card: #222;
@ -43,27 +43,68 @@ body {
.sort-buttons {
/* Container for sorting controls; uses flex layout to distribute buttons equally */
display: flex;
gap: 4px;
gap: 2px;
margin: 12px 16px 8px;
}
.sort-buttons .btn {
/* Base styling for each sort button: color, padding, border */
/* Compact styling for sort buttons to fit 6 options */
flex: 1;
padding: 6px 8px;
background: #222;
color: #eee;
border: 1px solid #555;
border-radius: 4px;
padding: 4px 6px;
background: #333;
color: #ccc;
border: 1px solid #666;
border-radius: 3px;
text-align: center;
cursor: pointer;
user-select: none;
font-size: 0.9rem;
font-size: 0.75rem;
font-weight: 500;
transition: all 0.15s;
min-width: 0;
white-space: nowrap;
overflow: hidden;
}
.sort-buttons .btn:hover {
background: #444;
color: #fff;
border-color: #777;
}
.sort-buttons .btn.active {
/* Active sort button highlighted with accent color */
background: var(--accent);
color: #111;
border-color: var(--accent);
position: relative;
}
.sort-buttons .btn.active:hover {
background: var(--accent);
color: #111;
}
/* Sort direction indicators */
.sort-buttons .btn.active::after {
content: '';
position: absolute;
top: 2px;
right: 2px;
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
}
/* Most sorts are descending (down arrow) */
.sort-buttons .btn.active::after {
border-top: 4px solid #111;
}
/* Name and KPR are ascending (up arrow) */
.sort-buttons .btn.active[data-value="name"]::after,
.sort-buttons .btn.active[data-value="kpr"]::after {
border-top: none;
border-bottom: 4px solid #111;
}
/* ---------- sidebar --------------------------------------------- */
@ -178,6 +219,22 @@ body {
white-space: nowrap;
z-index: 1000;
}
/* ---------- coordinate display ---------------------------------- */
.coordinates {
position: absolute;
display: none;
background: rgba(0, 50, 100, 0.9);
color: #fff;
padding: 3px 6px;
border-radius: 3px;
font-size: 0.75rem;
font-family: monospace;
pointer-events: none;
white-space: nowrap;
z-index: 999;
border: 1px solid rgba(100, 150, 200, 0.5);
}
/* make each row a flex container */
/* 2-column flex layout for each player row */
/* make each row a flex container */
@ -185,13 +242,13 @@ body {
/* make each player row into a 3×2 grid */
#playerList li {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto auto auto auto;
grid-template-rows: auto auto auto auto;
grid-template-areas:
"name loc"
"kills kph"
"rares meta"
"onlinetime deaths";
"name name name name name name"
"kills totalkills kph kph kph kph"
"rares kpr meta meta meta meta"
"onlinetime deaths tapers tapers tapers tapers";
gap: 4px 8px;
margin: 6px 0;
padding: 8px 10px;
@ -203,14 +260,17 @@ body {
/* 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; }
.coordinates-inline { font-size: 0.75rem; color: #aaa; font-weight: 400; margin-left: 8px; }
.stat.kills { grid-area: kills; }
.stat.total-kills { grid-area: totalkills; }
.stat.kph { grid-area: kph; }
.stat.rares { grid-area: rares; }
.stat.kpr { grid-area: kpr; }
.stat.meta { grid-area: meta; }
.stat.onlinetime { grid-area: onlinetime; }
.stat.deaths { grid-area: deaths; }
.stat.tapers { grid-area: tapers; }
/* pill styling */
#playerList li .stat {
@ -224,9 +284,13 @@ body {
}
/* 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.kills::before { content: "⚔️ "; }
.stat.total-kills::before { content: "🏆 "; }
.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; }
.stat.rares::before { content: "💎 "; }
.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; }
.stat.kpr::before { content: "📊 "; }
.stat.kpr::after { content: " KPR"; font-size:0.7em; color:#aaa; }
/* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */
#playerList li .stat.meta {
/* fallback */
@ -245,7 +309,7 @@ body {
}
/* ---------- chat window styling ------------------------------- */
.chat-btn, .stats-btn {
.chat-btn, .stats-btn, .inventory-btn {
margin-top: 4px;
padding: 2px 6px;
background: var(--accent);
@ -256,7 +320,7 @@ body {
cursor: pointer;
}
.chat-window, .stats-window {
.chat-window, .stats-window, .inventory-window {
position: absolute;
top: 10px;
/* position window to start just right of the sidebar */
@ -326,6 +390,17 @@ body.noselect, body.noselect * {
}
.stat.onlinetime::before { content: "🕑 "}
.stat.deaths::before { content: "💀 "}
.stat.tapers::before {
content: "";
display: inline-block;
width: 16px;
height: 16px;
background-image: url('prismatic-taper-icon.png');
background-size: contain;
background-repeat: no-repeat;
margin-right: 4px;
vertical-align: text-bottom;
}
/* hover & selected states */
#playerList li:hover { background: var(--card-hov); }
@ -365,3 +440,53 @@ body.noselect, body.noselect * {
height: 200px;
border: none;
}
/* ---------- stats window time controls --------------------------- */
.stats-controls {
display: flex;
gap: 8px;
padding: 10px 15px;
background: #333;
border-bottom: 1px solid #555;
}
.time-range-btn {
padding: 6px 12px;
background: #444;
color: #ccc;
border: 1px solid #666;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.time-range-btn:hover {
background: #555;
color: #fff;
}
.time-range-btn.active {
background: var(--accent);
color: #111;
border-color: var(--accent);
}
/* ---------- inventory window styling ----------------------------- */
.inventory-content {
flex: 1;
padding: 15px;
background: var(--card);
color: var(--text);
overflow-y: auto;
}
.inventory-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 1.1rem;
color: #888;
font-style: italic;
}