'use client'; import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { useContainerWidth } from '@/lib/useContainerWidth'; import PlayerChipList from './PlayerChipList'; interface MapPoint { map_id: number; map_name: string; map_start_dt: string; map_end_dt: string | null; total_players: number; } interface PlayerChip { steamid: string; name: string; avatarUrl: string; country?: string | null; } const HEIGHT = 288; const PAD_LEFT = 32; const PAD_RIGHT = 8; const PAD_TOP = 8; const PAD_BOTTOM = 12; // no map-name labels anymore, so far less bottom padding needed const INITIAL_FETCH = 150; const PAGE_FETCH = 100; // fetched when panning back beyond what's loaded const DEFAULT_VISIBLE = 40; const MIN_VISIBLE = 5; const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't page back further than July 16, 2015 function parseDt(s: string): number { return new Date(s.replace(' ', 'T')).getTime(); } export default function MapPopulationChart() { const { ref: containerRef, width } = useContainerWidth(); const svgRef = useRef(null); const [points, setPoints] = useState([]); const [loading, setLoading] = useState(true); const [fetchingMore, setFetchingMore] = useState(false); const [reachedStart, setReachedStart] = useState(false); const [hoverIndex, setHoverIndex] = useState(null); const [viewStartIdx, setViewStartIdx] = useState(0); const [viewEndIdx, setViewEndIdx] = useState(0); const dragStartRef = useRef<{ x: number; startIdx: number; endIdx: number } | null>(null); const dragMovedRef = useRef(false); const fetchInFlightRef = useRef(false); const [selectedMap, setSelectedMap] = useState(null); const [selectedPlayers, setSelectedPlayers] = useState([]); const [loadingPlayers, setLoadingPlayers] = useState(false); useEffect(() => { fetch(`/api/map-population?limit=${INITIAL_FETCH}`) .then((res) => res.json()) .then((data) => { const pts: MapPoint[] = data.points ?? []; setPoints(pts); const startIdx = Math.max(0, pts.length - DEFAULT_VISIBLE); setViewStartIdx(startIdx); setViewEndIdx(pts.length); const latest = pts[pts.length - 1]; if (latest) loadPlayersFor(latest); }) .finally(() => setLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function loadPlayersFor(point: MapPoint) { setSelectedMap(point); setLoadingPlayers(true); try { const res = await fetch(`/api/map-population/players?mapId=${point.map_id}`); const data = await res.json(); setSelectedPlayers(data.players ?? []); } catch { setSelectedPlayers([]); } finally { setLoadingPlayers(false); } } // If panning/zooming brings the view near index 0, fetch more (older) // maps and prepend them, shifting indices to keep the visual window // stable. Stops once the earliest loaded map is at/before the hard floor. function ensureMoreIfNeeded(nextStartIdx: number) { if (fetchInFlightRef.current || reachedStart) return; if (nextStartIdx > 5) return; // only bother once we're close to the loaded edge const earliest = points[0]; if (!earliest) return; if (parseDt(earliest.map_start_dt) <= HARD_FLOOR_EPOCH) { setReachedStart(true); return; } fetchInFlightRef.current = true; setFetchingMore(true); fetch(`/api/map-population?limit=${PAGE_FETCH}&before=${encodeURIComponent(new Date(parseDt(earliest.map_start_dt)).toISOString())}`) .then((res) => res.json()) .then((data) => { const older: MapPoint[] = data.points ?? []; if (older.length === 0) { setReachedStart(true); return; } setPoints((prev) => [...older, ...prev]); setViewStartIdx((v) => v + older.length); setViewEndIdx((v) => v + older.length); }) .finally(() => { fetchInFlightRef.current = false; setFetchingMore(false); }); } const visiblePoints = points.slice(viewStartIdx, viewEndIdx); const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT); const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM; const maxValue = Math.max(1, ...visiblePoints.map((p) => p.total_players)); const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5; const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0; const barWidth = Math.max(2, slotWidth * 0.6); function barX(i: number) { return PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2; } function barY(value: number) { const h = (value / axisMax) * plotHeight; return PAD_TOP + (plotHeight - h); } function barHeight(value: number) { return Math.max(value > 0 ? 2 : 0, (value / axisMax) * plotHeight); } function indexForX(x: number): number { if (slotWidth === 0) return 0; return Math.max(0, Math.min(visiblePoints.length - 1, Math.floor((x - PAD_LEFT) / slotWidth))); } function handleMouseDown(e: React.MouseEvent) { const rect = e.currentTarget.getBoundingClientRect(); dragStartRef.current = { x: e.clientX - rect.left, startIdx: viewStartIdx, endIdx: viewEndIdx }; dragMovedRef.current = false; } function handleMouseMove(e: React.MouseEvent) { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; if (dragStartRef.current) { const dx = x - dragStartRef.current.x; if (Math.abs(dx) > 3) dragMovedRef.current = true; if (dragMovedRef.current && slotWidth > 0) { const shiftBars = -Math.round(dx / slotWidth); const windowSize = dragStartRef.current.endIdx - dragStartRef.current.startIdx; let newStart = dragStartRef.current.startIdx + shiftBars; let newEnd = dragStartRef.current.endIdx + shiftBars; if (newStart < 0) { newStart = 0; newEnd = windowSize; } if (newEnd > points.length) { newEnd = points.length; newStart = newEnd - windowSize; } setViewStartIdx(newStart); setViewEndIdx(newEnd); ensureMoreIfNeeded(newStart); } } else { setHoverIndex(indexForX(x)); } } function handleMouseUp() { dragStartRef.current = null; } useEffect(() => { const svg = svgRef.current; if (!svg) return; function onWheel(e: WheelEvent) { e.preventDefault(); if (points.length === 0) return; const rect = svg!.getBoundingClientRect(); const x = e.clientX - rect.left; const anchorIdxInView = indexForX(x); const anchorAbsIdx = viewStartIdx + anchorIdxInView; const currentSize = viewEndIdx - viewStartIdx; const zoomFactor = e.deltaY > 0 ? 1.2 : 1 / 1.2; let newSize = Math.round(currentSize * zoomFactor); newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize)); const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5; let newStart = Math.round(anchorAbsIdx - ratio * newSize); let newEnd = newStart + newSize; if (newStart < 0) { newStart = 0; newEnd = newSize; } if (newEnd > points.length) { newEnd = points.length; newStart = newEnd - newSize; } setViewStartIdx(newStart); setViewEndIdx(newEnd); ensureMoreIfNeeded(newStart); } svg.addEventListener('wheel', onWheel, { passive: false }); return () => svg.removeEventListener('wheel', onWheel); // eslint-disable-next-line react-hooks/exhaustive-deps }, [points.length, viewStartIdx, viewEndIdx]); function resetZoom() { const startIdx = Math.max(0, points.length - DEFAULT_VISIBLE); setViewStartIdx(startIdx); setViewEndIdx(points.length); } const isZoomed = viewStartIdx > Math.max(0, points.length - DEFAULT_VISIBLE) || viewEndIdx < points.length; const yTicks = [0, Math.round(axisMax / 2), axisMax]; return (
Population by map

{visiblePoints.length} of {points.length} {reachedStart ? '' : '+'} maps

Scroll to zoom, drag to pan (all the way back to 2015), click a bar for who was online {fetchingMore && — loading more…}
{isZoomed && ( )}
{loading ? (
loading…
) : points.length === 0 ? (
No map history recorded yet.
) : ( <> { setHoverIndex(null); dragStartRef.current = null; }} onClick={() => { if (dragMovedRef.current) { dragMovedRef.current = false; return; } if (hoverIndex != null && visiblePoints[hoverIndex]) { loadPlayersFor(visiblePoints[hoverIndex]); } }} > {yTicks.map((v) => { const y = barY(v); return ( {v} ); })} {visiblePoints.map((p, i) => ( ))} {hoverIndex != null && visiblePoints[hoverIndex] && (
{visiblePoints[hoverIndex].map_name}
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
{visiblePoints[hoverIndex].total_players} total players during this session
)} )}
{points.length > 0 && (
Visit the{' '} maps page {' '} for full detail (votes, prev/next map).
)} {selectedMap && (
Players during{' '} {selectedMap.map_name} {' '} ({selectedPlayers.length} player{selectedPlayers.length === 1 ? '' : 's'})
{new Date(selectedMap.map_start_dt.replace(' ', 'T')).toLocaleString()} {' – '} {selectedMap.map_end_dt ? new Date(selectedMap.map_end_dt.replace(' ', 'T')).toLocaleString() : 'still running'}
)}
); }