diff --git a/README.md b/README.md
index 0082d0a4..a4d1aaca 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ This project provides:
- **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
-- **Analytics Dashboard**: Charts for kills over time and kills per hour.
+ - **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
## Requirements
@@ -131,7 +131,7 @@ Response:
## Frontend
- **Live Map**: `static/index.html` – Real-time player positions on a map.
-- **Analytics**: `static/graphs.html` – Charts powered by [Chart.js](https://www.chartjs.org/).
+- **Analytics Dashboard**: `static/graphs.html` – Interactive charts powered by [D3.js](https://d3js.org/).
## Database Schema
diff --git a/main.py b/main.py
index d1995c4c..eb841192 100644
--- a/main.py
+++ b/main.py
@@ -151,6 +151,43 @@ def get_history(
]
return JSONResponse(content={"data": data})
+# ------------------------ GET Trails ---------------------------------
+@app.get("/trails")
+@app.get("/trails/")
+def get_trails(
+ seconds: int = Query(600, ge=0, description="Lookback window in seconds")
+):
+ """
+ Return position snapshots (timestamp, character_name, ew, ns, z)
+ for the past `seconds` seconds.
+ """
+ # match the same string format as stored timestamps (via str(datetime))
+ cutoff_dt = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=seconds)
+ cutoff = str(cutoff_dt)
+ conn = sqlite3.connect(DB_FILE)
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute(
+ """
+ SELECT timestamp, character_name, ew, ns, z
+ FROM telemetry_log
+ WHERE timestamp >= ?
+ ORDER BY character_name, timestamp
+ """,
+ (cutoff,)
+ ).fetchall()
+ conn.close()
+ trails = [
+ {
+ "timestamp": r["timestamp"],
+ "character_name": r["character_name"],
+ "ew": r["ew"],
+ "ns": r["ns"],
+ "z": r["z"],
+ }
+ for r in rows
+ ]
+ return JSONResponse(content={"trails": trails})
+
# -------------------- static frontend ---------------------------
app.mount("/", StaticFiles(directory="static", html=True), name="static")
diff --git a/static/index.html b/static/index.html
index 66e6da0f..94dc2ed3 100644
--- a/static/index.html
+++ b/static/index.html
@@ -20,6 +20,7 @@

+
diff --git a/static/script.js b/static/script.js
index ba7462e6..0b6b9aff 100644
--- a/static/script.js
+++ b/static/script.js
@@ -3,6 +3,7 @@ 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');
@@ -130,11 +131,17 @@ function hideTooltip() {
/* ---------- polling and initialization -------------------------- */
async function pollLive() {
try {
- const { players } = await (await fetch('/live/')).json();
+ 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 fetch failed:', e);
+ console.error('Live or trails fetch failed:', e);
}
}
@@ -147,6 +154,12 @@ function startPolling() {
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();
};
@@ -204,6 +217,27 @@ function render(players) {
});
}
+/* ---------- 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;
diff --git a/static/style.css b/static/style.css
index 25173869..031a41be 100644
--- a/static/style.css
+++ b/static/style.css
@@ -202,3 +202,17 @@ body {
/* 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;
+}