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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user