162 lines
4.8 KiB
HTML
162 lines
4.8 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Dereth Tracker – Analytics</title>
|
||
|
||
<!-- D3.js -->
|
||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||
<link rel="stylesheet" href="style.css">
|
||
<style>
|
||
#content { flex: 1; padding: 16px; overflow: auto; }
|
||
h1 { margin-bottom: 24px; color: var(--accent); }
|
||
section { margin-bottom: 48px; }
|
||
.chart-svg { max-width: 100%; height: 300px; }
|
||
.axis path, .axis line { stroke: #eee; }
|
||
.axis text { fill: #eee; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="content">
|
||
<h1>Session Analytics</h1>
|
||
|
||
<section>
|
||
<h2>Kills over Time</h2>
|
||
<div id="chartKills"></div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Kills per Hour</h2>
|
||
<div id="chartKPH"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
// D3.js-based stacked area charts with optimized grouping
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
// Fetch and prepare data
|
||
const resp = await fetch('/history');
|
||
const { data } = await resp.json();
|
||
data.forEach(d => {
|
||
d.timestampMs = new Date(d.timestamp).getTime();
|
||
d.kills = +d.kills;
|
||
d.kph = +d.kph;
|
||
});
|
||
// Pre-group by timestamp and character for O(1) lookups
|
||
const nested = d3.rollup(
|
||
data,
|
||
recs => recs[recs.length - 1],
|
||
d => d.timestampMs,
|
||
d => d.character_name
|
||
);
|
||
// Sorted list of times and player names
|
||
const times = Array.from(nested.keys()).sort((a, b) => a - b);
|
||
const names = Array.from(new Set(data.map(d => d.character_name))).sort();
|
||
// Draw charts using precomputed structures
|
||
drawStackedAreaChart({
|
||
container: '#chartKills',
|
||
times,
|
||
names,
|
||
nested,
|
||
valueKey: 'kills',
|
||
yLabel: 'Total Kills'
|
||
});
|
||
drawStackedAreaChart({
|
||
container: '#chartKPH',
|
||
times,
|
||
names,
|
||
nested,
|
||
valueKey: 'kph',
|
||
yLabel: 'Kills per Hour'
|
||
});
|
||
});
|
||
|
||
function drawStackedAreaChart({ container, times, names, nested, valueKey, yLabel }) {
|
||
const margin = { top: 20, right: 80, bottom: 30, left: 50 };
|
||
const width = 800 - margin.left - margin.right;
|
||
const height = 300 - margin.top - margin.bottom;
|
||
|
||
const svg = d3.select(container)
|
||
.append('svg')
|
||
.attr('class', 'chart-svg')
|
||
.attr('width', width + margin.left + margin.right)
|
||
.attr('height', height + margin.top + margin.bottom)
|
||
.append('g')
|
||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||
|
||
// Build array of points per series using nested map
|
||
const dataPoints = times.map(ms => {
|
||
const pt = { timestamp: new Date(ms) };
|
||
const mapChar = nested.get(ms);
|
||
names.forEach(name => {
|
||
const rec = mapChar && mapChar.get(name);
|
||
pt[name] = rec ? rec[valueKey] : 0;
|
||
});
|
||
return pt;
|
||
});
|
||
const series = d3.stack()
|
||
.keys(names)(dataPoints);
|
||
const xScale = d3.scaleTime()
|
||
.domain([new Date(times[0]), new Date(times[times.length - 1])])
|
||
.range([0, width]);
|
||
|
||
const yScale = d3.scaleLinear()
|
||
.domain([0, d3.max(series, s => d3.max(s, pts => pts[1]))]).nice()
|
||
.range([height, 0]);
|
||
|
||
const color = d3.scaleOrdinal(d3.schemeCategory10).domain(names);
|
||
|
||
const area = d3.area()
|
||
.x(d => xScale(d.data.timestamp))
|
||
.y0(d => yScale(d[0]))
|
||
.y1(d => yScale(d[1]))
|
||
.curve(d3.curveMonotoneX);
|
||
|
||
svg.selectAll('.area')
|
||
.data(series)
|
||
.enter().append('path')
|
||
.attr('class', 'area')
|
||
.attr('d', d => area(d))
|
||
.attr('fill', d => color(d.key))
|
||
.attr('opacity', 0.8);
|
||
|
||
svg.append('g')
|
||
.attr('class', 'axis x-axis')
|
||
.attr('transform', `translate(0,${height})`)
|
||
.call(d3.axisBottom(xScale).tickFormat(d3.timeFormat('%H:%M')));
|
||
|
||
svg.append('g')
|
||
.attr('class', 'axis y-axis')
|
||
.call(d3.axisLeft(yScale));
|
||
|
||
svg.append('text')
|
||
.attr('transform', 'rotate(-90)')
|
||
.attr('y', 0 - margin.left)
|
||
.attr('x', 0 - height / 2)
|
||
.attr('dy', '1em')
|
||
.style('text-anchor', 'middle')
|
||
.style('fill', 'var(--text)')
|
||
.text(yLabel);
|
||
|
||
const legend = svg.append('g')
|
||
.attr('transform', `translate(${width + 20},0)`);
|
||
|
||
names.forEach((key, i) => {
|
||
const row = legend.append('g')
|
||
.attr('transform', `translate(0,${i * 20})`);
|
||
row.append('rect')
|
||
.attr('width', 10)
|
||
.attr('height', 10)
|
||
.attr('fill', color(key));
|
||
row.append('text')
|
||
.attr('x', 15)
|
||
.attr('y', 10)
|
||
.text(key)
|
||
.attr('text-anchor', 'start')
|
||
.style('alignment-baseline', 'middle')
|
||
.style('fill', 'var(--text)');
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|