'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; country?: string | null; } 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 zoom in tighter than 5 minutes const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't pan back further than July 16, 2015 const FETCH_DEBOUNCE_MS = 400; 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(); const svgRef = useRef(null); 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([]); const [mapBoundaries, setMapBoundaries] = useState([]); const [loading, setLoading] = useState(false); const [fetchingMore, setFetchingMore] = useState(false); const [error, setError] = useState(null); // Bounds of everything currently loaded (grows as panning/zooming reaches // beyond it and triggers a new fetch — see ensureCoverage below). const [dataMinEpoch, setDataMinEpoch] = useState(0); const [dataMaxEpoch, setDataMaxEpoch] = useState(0); // The "home" range to return to on Reset — whatever was last explicitly // requested via the calendar Start/End + Update, as opposed to wherever // panning/zooming has since wandered off to. const [homeStart, setHomeStart] = useState(0); const [homeEnd, setHomeEnd] = useState(0); // Currently VISIBLE window. 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 fetchInFlightRef = useRef(false); const wheelDebounceRef = useRef | null>(null); const [hoverPoint, setHoverPoint] = useState(null); const [hoverX, setHoverX] = useState(null); // Currently online — authoritative, always-live, independent of the chart's date range. const [currentPlayers, setCurrentPlayers] = useState([]); const [currentCount, setCurrentCount] = useState(null); const [loadingCurrent, setLoadingCurrent] = useState(true); const [currentUpdatedAt, setCurrentUpdatedAt] = useState(null); const [secondsAgo, setSecondsAgo] = useState(0); // A specific HISTORICAL point clicked (or auto-selected as the latest) on the chart. const [selectedPoint, setSelectedPoint] = useState(null); const [selectedPlayers, setSelectedPlayers] = useState([]); const [loadingSelected, setLoadingSelected] = useState(false); 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); } } async function fetchRange( rangeStartMs: number, rangeEndMs: number, opts: { preserveView?: boolean; selectLatest?: boolean } = {}, ) { const { preserveView = false, selectLatest = false } = opts; if (preserveView) setFetchingMore(true); else setLoading(true); setError(null); try { const params = new URLSearchParams({ start: new Date(rangeStartMs).toISOString(), end: new Date(rangeEndMs).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: any) => ({ ...p, tEpoch: new Date(p.t).getTime() })); const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b: any) => ({ ...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); // Deliberately NOT syncing the start/end calendar fields to minE/maxE // here — those are bucket-boundary timestamps (up to one bucket // width earlier than what was actually requested), not the real // request range. Overwriting the inputs with them caused Update to // silently shrink the End field by one bucket width on every click. // The calendar fields should only ever reflect what was typed. if (!preserveView) { setViewStart(minE); setViewEnd(maxE); setHomeStart(minE); setHomeEnd(maxE); } if (selectLatest) loadPlayersAt(pts[pts.length - 1]); } } catch (e: any) { setError(e.message ?? 'something went wrong'); } finally { setLoading(false); setFetchingMore(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(() => { fetchRange(dayAgo.getTime(), now.getTime(), { selectLatest: true }); 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]); // If the visible window extends beyond what's loaded, fetch more — // fires after a drag ends or a short pause after scroll-zooming, not on // every intermediate frame. function ensureCoverage(neededStart: number, neededEnd: number) { if (fetchInFlightRef.current) return; if (neededStart >= dataMinEpoch && neededEnd <= dataMaxEpoch) return; fetchInFlightRef.current = true; const fetchStart = Math.min(neededStart, dataMinEpoch); const fetchEnd = Math.max(neededEnd, dataMaxEpoch); fetchRange(fetchStart, fetchEnd, { preserveView: true }).finally(() => { fetchInFlightRef.current = 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); } 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 baselineY = PAD_TOP + plotHeight; const areaPath = renderPoints.length > 0 ? `${linePath} L ${xForEpoch(renderPoints[renderPoints.length - 1].tEpoch).toFixed(1)} ${baselineY} L ${xForEpoch(renderPoints[0].tEpoch).toFixed(1)} ${baselineY} Z` : ''; 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) { const rect = e.currentTarget.getBoundingClientRect(); dragStartRef.current = { x: e.clientX - rect.left, viewStart, viewEnd }; dragMovedRef.current = false; } function handleMouseMove(e: React.MouseEvent) { 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 < HARD_FLOOR_EPOCH) { newStart = HARD_FLOOR_EPOCH; newEnd = newStart + span; } const rightLimit = Date.now(); if (newEnd > rightLimit) { newEnd = rightLimit; newStart = newEnd - span; } setViewStart(newStart); setViewEnd(newEnd); } } } function handleMouseUp() { if (dragMovedRef.current) { ensureCoverage(viewStart, viewEnd); } dragStartRef.current = null; } function handleMouseLeave() { setHoverPoint(null); setHoverX(null); dragStartRef.current = null; } function handleClick(e: React.MouseEvent) { 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); } // Native (non-passive) wheel listener — React's onWheel is passive by // default, so e.preventDefault() silently does nothing there and the // page scrolls along with the zoom. This is the only way to actually // stop that. useEffect(() => { const svg = svgRef.current; if (!svg) return; function onWheel(e: WheelEvent) { e.preventDefault(); if (plotWidth === 0) return; const rect = svg!.getBoundingClientRect(); const x = e.clientX - rect.left; const anchorEpoch = epochForX(x); const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15; const fullSpan = Date.now() - HARD_FLOOR_EPOCH; 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 < HARD_FLOOR_EPOCH) { newStart = HARD_FLOOR_EPOCH; newEnd = newStart + newSpan; } const rightLimit = Date.now(); if (newEnd > rightLimit) { newEnd = rightLimit; newStart = newEnd - newSpan; } setViewStart(newStart); setViewEnd(newEnd); if (wheelDebounceRef.current) clearTimeout(wheelDebounceRef.current); wheelDebounceRef.current = setTimeout(() => ensureCoverage(newStart, newEnd), FETCH_DEBOUNCE_MS); } svg.addEventListener('wheel', onWheel, { passive: false }); return () => svg.removeEventListener('wheel', onWheel); // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewStart, viewEnd, plotWidth, dataMinEpoch, dataMaxEpoch]); function resetZoom() { setViewStart(homeStart); setViewEnd(homeEnd); } const isZoomed = viewStart > homeStart + 1000 || viewEnd < homeEnd - 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 (
{/* Currently online — always visible, always live, not tied to the date range below */}
Currently online{currentCount != null ? ` — ${currentCount} player${currentCount === 1 ? '' : 's'}` : ''}
{currentUpdatedAt &&
updated {secondsAgo}s ago
}
Concurrent players

Population over time

{ e.preventDefault(); fetchRange(new Date(start).getTime(), new Date(end).getTime(), { selectLatest: true }); }} >
{error &&
{error}
}
Scroll to zoom, drag to pan (all the way back to 2015), click a point for who was online {fetchingMore && — loading more…}
{isZoomed && ( )}
{loading ? (
loading…
) : points.length === 0 ? (
No data in this range.
) : ( {yTicks.map((v) => { const y = yForValue(v); return ( {v} ); })} {visibleBoundaries.map((b) => ( ))} {xTicks.map((epoch, i) => ( {new Date(epoch).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} ))} {hoverPoint && ( )} )} {hoverPoint && hoverX != null && !loading && (
{new Date(hoverPoint.t).toLocaleString()}
{hoverPoint.players} players
{hoverPoint.mapName &&
{hoverPoint.mapName}
}
)}
{selectedPoint && (
Online at {new Date(selectedPoint.t).toLocaleString()} {selectedPoint.mapName && ( <> {' '} — playing {selectedPoint.mapName} )}{' '} ({selectedPoint.players} player{selectedPoint.players === 1 ? '' : 's'} — from historical data, may lag slightly)
)}
); }