updating the playtime frontend with claude only code to replace the one graph with a dumb-bell graph, having each individual map show its players at beginning versus end and also the overall impact of maps + their popularity

This commit is contained in:
jenz
2026-10-02 14:43:45 +02:00
parent 6019e6854d
commit 8a76454784
14 changed files with 3300 additions and 159 deletions
@@ -0,0 +1,231 @@
'use client';
import { useEffect, useState } from 'react';
import { useContainerWidth } from '@/lib/useContainerWidth';
interface Point {
t: string;
tEpoch: number;
players: number;
mapName: string | null;
}
const HEIGHT = 220;
const PAD_LEFT = 32;
const PAD_RIGHT = 8;
const PAD_TOP = 10;
const PAD_BOTTOM = 26;
export default function MapSessionTimelineChart({ mapId }: { mapId: number }) {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const [points, setPoints] = useState<Point[]>([]);
const [mapStart, setMapStart] = useState<number | null>(null);
const [mapEnd, setMapEnd] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hoverX, setHoverX] = useState<number | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/map-population/timeline?mapId=${mapId}`)
.then(async (res) => {
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
return res.json();
})
.then((data) => {
const pts: Point[] = (data.points as any[]).map((p: any) => ({ ...p, tEpoch: new Date(p.t).getTime() }));
setPoints(pts);
setMapStart(data.mapStart ? new Date(data.mapStart).getTime() : null);
setMapEnd(data.mapEnd ? new Date(data.mapEnd).getTime() : null);
})
.catch((e) => setError(e.message ?? 'something went wrong'))
.finally(() => setLoading(false));
}, [mapId]);
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const minEpoch = points[0]?.tEpoch ?? 0;
const maxEpoch = points[points.length - 1]?.tEpoch ?? 1;
const span = Math.max(1, maxEpoch - minEpoch);
const maxValue = Math.max(1, ...points.map((p) => p.players));
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
function xForEpoch(epoch: number): number {
return PAD_LEFT + ((epoch - minEpoch) / span) * plotWidth;
}
function yForValue(v: number): number {
return PAD_TOP + (1 - v / axisMax) * plotHeight;
}
function epochForX(x: number): number {
if (plotWidth === 0) return minEpoch;
return minEpoch + ((x - PAD_LEFT) / plotWidth) * span;
}
function findNearestPoint(x: number): Point | null {
if (points.length === 0) return null;
const target = epochForX(x);
let nearest = points[0];
let best = Math.abs(points[0].tEpoch - target);
for (const p of points) {
const d = Math.abs(p.tEpoch - target);
if (d < best) {
best = d;
nearest = p;
}
}
return nearest;
}
const linePath = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xForEpoch(p.tEpoch).toFixed(1)} ${yForValue(p.players).toFixed(1)}`)
.join(' ');
const baselineY = PAD_TOP + plotHeight;
const areaPath =
points.length > 0
? `${linePath} L ${xForEpoch(points[points.length - 1].tEpoch).toFixed(1)} ${baselineY} L ${xForEpoch(points[0].tEpoch).toFixed(1)} ${baselineY} Z`
: '';
const hoverPoint = hoverX != null ? findNearestPoint(hoverX) : null;
// The two endpoints this chart exists to contrast: concurrent population
// right at map start vs. right at map end (or "now" if still running) —
// same definition as the dumbbell chart, found by nearest bucket to each
// boundary rather than a separate query.
const startPoint = mapStart != null ? findNearestPoint(xForEpoch(mapStart)) : null;
const endPoint = mapEnd != null ? findNearestPoint(xForEpoch(mapEnd)) : points[points.length - 1] ?? null;
const delta = startPoint && endPoint ? endPoint.players - startPoint.players : null;
const deltaColor = delta == null ? '#8A9691' : delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691';
const yTicks = [0, Math.round(axisMax / 2), axisMax];
return (
<div className="panel p-6">
<div className="mb-4">
<div className="stat-label mb-1">This session, zoomed in</div>
<h2 className="text-lg text-ink">Population during this map</h2>
{startPoint && endPoint && (
<p className="text-sm text-ink-muted mt-1">
Started with <span className="text-ink">{startPoint.players}</span> players, ended with{' '}
<span className="text-ink">{endPoint.players}</span>
<span style={{ color: deltaColor }}>
{' '}
({delta != null && delta > 0 ? '+' : ''}
{delta})
</span>
</p>
)}
</div>
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
<div ref={containerRef} className="w-full relative" style={{ height: HEIGHT }}>
{loading ? (
<div className="h-full flex items-center justify-center text-ink-faint text-sm font-mono">loading…</div>
) : points.length === 0 ? (
<div className="h-full flex items-center justify-center text-ink-muted text-sm">No data for this session.</div>
) : (
<svg
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair' }}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setHoverX(e.clientX - rect.left);
}}
onMouseLeave={() => setHoverX(null)}
>
<defs>
<linearGradient id="sessionAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3FD37A" stopOpacity="0.28" />
<stop offset="100%" stopColor="#3FD37A" stopOpacity="0" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const y = yForValue(v);
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={width - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{/* map-session boundaries */}
{mapStart != null && mapStart >= minEpoch && mapStart <= maxEpoch && (
<line
x1={xForEpoch(mapStart)}
y1={PAD_TOP}
x2={xForEpoch(mapStart)}
y2={PAD_TOP + plotHeight}
stroke="#8A9691"
strokeDasharray="3 3"
/>
)}
{mapEnd != null && mapEnd >= minEpoch && mapEnd <= maxEpoch && (
<line
x1={xForEpoch(mapEnd)}
y1={PAD_TOP}
x2={xForEpoch(mapEnd)}
y2={PAD_TOP + plotHeight}
stroke="#8A9691"
strokeDasharray="3 3"
/>
)}
<text x={xForEpoch(minEpoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="start" fontSize={10} fill="#8A9691">
{new Date(minEpoch).toLocaleTimeString()}
</text>
<text x={xForEpoch(maxEpoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="end" fontSize={10} fill="#8A9691">
{new Date(maxEpoch).toLocaleTimeString()}
</text>
<line x1={PAD_LEFT} y1={PAD_TOP + plotHeight} x2={width - PAD_RIGHT} y2={PAD_TOP + plotHeight} stroke="#4C5652" />
<path d={areaPath} fill="url(#sessionAreaGradient)" stroke="none" />
<path
d={linePath}
fill="none"
stroke="#3FD37A"
strokeWidth={2}
strokeLinejoin="round"
style={{ filter: 'drop-shadow(0 0 5px rgba(63, 211, 122, 0.45))' }}
/>
{startPoint && (
<circle cx={xForEpoch(startPoint.tEpoch)} cy={yForValue(startPoint.players)} r={4} fill="#8A9691" stroke="#0B0F0E" strokeWidth={1.5} />
)}
{endPoint && (
<circle cx={xForEpoch(endPoint.tEpoch)} cy={yForValue(endPoint.players)} r={4} fill={deltaColor} stroke="#0B0F0E" strokeWidth={1.5} />
)}
{hoverPoint && (
<circle cx={xForEpoch(hoverPoint.tEpoch)} cy={yForValue(hoverPoint.players)} r={4} fill="#3FD37A" stroke="#0B0F0E" strokeWidth={1.5} />
)}
</svg>
)}
{hoverPoint && hoverX != null && !loading && (
<div
className="absolute pointer-events-none"
style={{
left: Math.min(Math.max(width, 200) - 170, Math.max(0, hoverX + 12)),
top: Math.max(0, yForValue(hoverPoint.players) - 56),
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#8A9691' }}>{new Date(hoverPoint.t).toLocaleTimeString()}</div>
<div style={{ color: '#3FD37A', marginTop: 2 }}>{hoverPoint.players} players</div>
</div>
)}
</div>
</div>
);
}