Files
projects-jenz/discord_verificiation/playtime-frontend/components/PopulationChart.tsx
T

475 lines
17 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { useContainerWidth } from '@/lib/useContainerWidth';
import PlayerChipList from './PlayerChipList';
interface Point {
t: string;
tEpoch: number;
players: number;
mapName: string | null;
}
interface MapBoundary {
mapId: number;
mapName: string;
tEpoch: number;
}
interface PlayerChip {
steamid: string;
name: string;
avatarUrl: string;
}
const HEIGHT = 320;
const PAD_LEFT = 36;
const PAD_RIGHT = 8;
const PAD_TOP = 8;
const PAD_BOTTOM = 30;
const Y_MAX = 64; // game's hard player cap
const MIN_ZOOM_SPAN_MS = 5 * 60_000; // don't let scroll-zoom go tighter than 5 minutes
function toLocalInputValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function PopulationChart() {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const [start, setStart] = useState(toLocalInputValue(dayAgo));
const [end, setEnd] = useState(toLocalInputValue(now));
const [points, setPoints] = useState<Point[]>([]);
const [mapBoundaries, setMapBoundaries] = useState<MapBoundary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Full bounds of whatever's currently fetched (via the calendar Start/End)
const [dataMinEpoch, setDataMinEpoch] = useState(0);
const [dataMaxEpoch, setDataMaxEpoch] = useState(0);
// Currently VISIBLE window — what scroll-to-zoom / drag-to-pan adjusts.
// Starts equal to the full fetched range and narrows/shifts from there.
const [viewStart, setViewStart] = useState(0);
const [viewEnd, setViewEnd] = useState(0);
const dragStartRef = useRef<{ x: number; viewStart: number; viewEnd: number } | null>(null);
const dragMovedRef = useRef(false);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
const [hoverX, setHoverX] = useState<number | null>(null);
// Currently online — authoritative, always-live, independent of the chart's date range.
const [currentPlayers, setCurrentPlayers] = useState<PlayerChip[]>([]);
const [currentCount, setCurrentCount] = useState<number | null>(null);
const [loadingCurrent, setLoadingCurrent] = useState(true);
const [currentUpdatedAt, setCurrentUpdatedAt] = useState<number | null>(null);
const [secondsAgo, setSecondsAgo] = useState(0);
// A specific HISTORICAL point clicked on the chart.
const [selectedPoint, setSelectedPoint] = useState<Point | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
const [loadingSelected, setLoadingSelected] = useState(false);
async function load() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
});
const res = await fetch(`/api/population?${params.toString()}`);
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
const data = await res.json();
const pts: Point[] = (data.points as any[]).map((p) => ({ ...p, tEpoch: new Date(p.t).getTime() }));
const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b) => ({ ...b, tEpoch: new Date(b.t).getTime() }));
setPoints(pts);
setMapBoundaries(bounds);
if (pts.length > 0) {
const minE = pts[0].tEpoch;
const maxE = pts[pts.length - 1].tEpoch;
setDataMinEpoch(minE);
setDataMaxEpoch(maxE);
setViewStart(minE);
setViewEnd(maxE);
}
} catch (e: any) {
setError(e.message ?? 'something went wrong');
} finally {
setLoading(false);
}
}
async function loadCurrentPlayers() {
setLoadingCurrent(true);
try {
const res = await fetch('/api/population/current');
const data = await res.json();
setCurrentPlayers(data.players ?? []);
setCurrentCount(data.count ?? 0);
setCurrentUpdatedAt(Date.now());
} catch {
setCurrentPlayers([]);
setCurrentCount(null);
} finally {
setLoadingCurrent(false);
}
}
useEffect(() => {
load();
loadCurrentPlayers();
const poll = setInterval(loadCurrentPlayers, 30_000);
return () => clearInterval(poll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const tick = setInterval(() => {
if (currentUpdatedAt) setSecondsAgo(Math.round((Date.now() - currentUpdatedAt) / 1000));
}, 1000);
return () => clearInterval(tick);
}, [currentUpdatedAt]);
async function loadPlayersAt(point: Point) {
setSelectedPoint(point);
setLoadingSelected(true);
try {
const res = await fetch(`/api/population/players?at=${encodeURIComponent(point.t)}`);
const data = await res.json();
setSelectedPlayers(data.players ?? []);
} catch {
setSelectedPlayers([]);
} finally {
setLoadingSelected(false);
}
}
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
function xForEpoch(epoch: number): number {
if (viewEnd === viewStart) return PAD_LEFT;
return PAD_LEFT + ((epoch - viewStart) / (viewEnd - viewStart)) * plotWidth;
}
function yForValue(v: number): number {
return PAD_TOP + (1 - Math.min(v, Y_MAX) / Y_MAX) * plotHeight;
}
function epochForX(x: number): number {
if (plotWidth === 0) return viewStart;
return viewStart + ((x - PAD_LEFT) / plotWidth) * (viewEnd - viewStart);
}
// Render a couple of points beyond each edge of the visible window too,
// so the line doesn't visibly truncate right at the viewport boundary.
let firstIdx = points.findIndex((p) => p.tEpoch >= viewStart);
if (firstIdx === -1) firstIdx = points.length - 1;
let lastIdx = -1;
for (let i = points.length - 1; i >= 0; i--) {
if (points[i].tEpoch <= viewEnd) {
lastIdx = i;
break;
}
}
const renderStart = Math.max(0, firstIdx - 1);
const renderEnd = Math.min(points.length - 1, Math.max(lastIdx, firstIdx) + 1);
const renderPoints = points.length > 0 ? points.slice(renderStart, renderEnd + 1) : [];
const linePath = renderPoints
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xForEpoch(p.tEpoch).toFixed(1)} ${yForValue(p.players).toFixed(1)}`)
.join(' ');
const visibleBoundaries = mapBoundaries.filter((b) => b.tEpoch >= viewStart && b.tEpoch <= viewEnd);
function findNearestPoint(x: number): Point | null {
if (renderPoints.length === 0) return null;
const targetEpoch = epochForX(x);
let nearest = renderPoints[0];
let bestDist = Math.abs(renderPoints[0].tEpoch - targetEpoch);
for (const p of renderPoints) {
const d = Math.abs(p.tEpoch - targetEpoch);
if (d < bestDist) {
bestDist = d;
nearest = p;
}
}
return nearest;
}
function handleMouseDown(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, viewStart, viewEnd };
dragMovedRef.current = false;
}
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
setHoverX(x);
setHoverPoint(findNearestPoint(x));
if (dragStartRef.current) {
const dx = x - dragStartRef.current.x;
if (Math.abs(dx) > 3) dragMovedRef.current = true;
if (dragMovedRef.current && plotWidth > 0) {
const span = dragStartRef.current.viewEnd - dragStartRef.current.viewStart;
const epochDelta = -(dx / plotWidth) * span;
let newStart = dragStartRef.current.viewStart + epochDelta;
let newEnd = dragStartRef.current.viewEnd + epochDelta;
if (newStart < dataMinEpoch) {
newStart = dataMinEpoch;
newEnd = newStart + span;
}
if (newEnd > dataMaxEpoch) {
newEnd = dataMaxEpoch;
newStart = newEnd - span;
}
setViewStart(newStart);
setViewEnd(newEnd);
}
}
}
function handleMouseUp() {
dragStartRef.current = null;
}
function handleMouseLeave() {
setHoverPoint(null);
setHoverX(null);
dragStartRef.current = null;
}
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (dragMovedRef.current) {
dragMovedRef.current = false;
return; // was a drag, not a click-to-select
}
const rect = e.currentTarget.getBoundingClientRect();
const p = findNearestPoint(e.clientX - rect.left);
if (p) loadPlayersAt(p);
}
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
e.preventDefault();
if (plotWidth === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorEpoch = epochForX(x);
const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15; // scroll down = zoom out, up = zoom in
const fullSpan = dataMaxEpoch - dataMinEpoch || 1;
let newSpan = (viewEnd - viewStart) * zoomFactor;
newSpan = Math.min(newSpan, fullSpan);
newSpan = Math.max(newSpan, MIN_ZOOM_SPAN_MS);
const ratio = (anchorEpoch - viewStart) / ((viewEnd - viewStart) || 1);
let newStart = anchorEpoch - ratio * newSpan;
let newEnd = newStart + newSpan;
if (newStart < dataMinEpoch) {
newStart = dataMinEpoch;
newEnd = newStart + newSpan;
}
if (newEnd > dataMaxEpoch) {
newEnd = dataMaxEpoch;
newStart = newEnd - newSpan;
}
setViewStart(newStart);
setViewEnd(newEnd);
}
function resetZoom() {
setViewStart(dataMinEpoch);
setViewEnd(dataMaxEpoch);
}
const isZoomed = viewStart > dataMinEpoch + 1000 || viewEnd < dataMaxEpoch - 1000;
const yTicks = [0, 16, 32, 48, 64];
const xTickCount = 5;
const xTicks =
viewEnd > viewStart
? Array.from({ length: xTickCount }, (_, i) => viewStart + (i / (xTickCount - 1)) * (viewEnd - viewStart))
: [];
return (
<div className="panel p-6">
{/* Currently online — always visible, always live, not tied to the date range below */}
<div className="mb-6 pb-6 border-b border-base-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_theme(colors.accent.DEFAULT)]" />
<div className="stat-label">
Currently online{currentCount != null ? ` — ${currentCount} player${currentCount === 1 ? '' : 's'}` : ''}
</div>
</div>
{currentUpdatedAt && <div className="text-xs text-ink-faint font-mono">updated {secondsAgo}s ago</div>}
</div>
<PlayerChipList players={currentPlayers} loading={loadingCurrent} />
</div>
<div className="flex flex-wrap items-end justify-between gap-4 mb-4">
<div>
<div className="stat-label mb-1">Concurrent players</div>
<h2 className="text-lg text-ink">Population over time</h2>
</div>
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(e) => {
e.preventDefault();
load();
}}
>
<label className="flex flex-col text-xs text-ink-muted gap-1">
Start
<input
type="datetime-local"
value={start}
onChange={(e) => setStart(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<label className="flex flex-col text-xs text-ink-muted gap-1">
End
<input
type="datetime-local"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<button
type="submit"
className="bg-accent-dim hover:bg-accent hover:text-base text-accent border border-accent/40 rounded px-4 py-2 text-sm font-medium transition-colors self-end"
>
Update
</button>
</form>
</div>
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
<div className="flex items-center justify-between gap-4 mb-2">
<div className="flex items-center gap-1.5 text-xs text-accent">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
Scroll to zoom, drag to pan, click a point for who was online
</div>
{isZoomed && (
<button onClick={resetZoom} className="text-xs text-ink-muted hover:text-accent transition-colors underline">
Reset zoom
</button>
)}
</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 in this range.</div>
) : (
<svg
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onWheel={handleWheel}
>
{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>
);
})}
{visibleBoundaries.map((b) => (
<line
key={b.mapId}
x1={xForEpoch(b.tEpoch)}
y1={PAD_TOP}
x2={xForEpoch(b.tEpoch)}
y2={PAD_TOP + plotHeight}
stroke="#4C5652"
strokeDasharray="3 3"
/>
))}
{xTicks.map((epoch, i) => (
<text key={i} x={xForEpoch(epoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="middle" fontSize={10} fill="#8A9691">
{new Date(epoch).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</text>
))}
<line x1={PAD_LEFT} y1={PAD_TOP + plotHeight} x2={width - PAD_RIGHT} y2={PAD_TOP + plotHeight} stroke="#4C5652" />
<path d={linePath} fill="none" stroke="#3FD37A" strokeWidth={2} />
{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) - 190, Math.max(0, hoverX + 12)),
top: Math.max(0, yForValue(hoverPoint.players) - 70),
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#8A9691' }}>{new Date(hoverPoint.t).toLocaleString()}</div>
<div style={{ color: '#3FD37A', marginTop: 2 }}>{hoverPoint.players} players</div>
{hoverPoint.mapName && <div style={{ color: '#E4EBE7', marginTop: 2 }}>{hoverPoint.mapName}</div>}
</div>
)}
</div>
{selectedPoint && (
<div className="mt-4 pt-4 border-t border-base-border">
<div className="stat-label mb-3">
Online at {new Date(selectedPoint.t).toLocaleString()}
{selectedPoint.mapName && (
<>
{' '}
playing <span className="text-ink">{selectedPoint.mapName}</span>
</>
)}{' '}
({selectedPoint.players} player{selectedPoint.players === 1 ? '' : 's'} from historical data, may lag slightly)
</div>
<PlayerChipList players={selectedPlayers} loading={loadingSelected} />
</div>
)}
</div>
);
}