Files

391 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<HTMLDivElement>();
const svgRef = useRef<SVGSVGElement>(null);
const [points, setPoints] = useState<MapPoint[]>([]);
const [loading, setLoading] = useState(true);
const [fetchingMore, setFetchingMore] = useState(false);
const [reachedStart, setReachedStart] = useState(false);
const [hoverIndex, setHoverIndex] = useState<number | null>(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<MapPoint | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
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<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, startIdx: viewStartIdx, endIdx: viewEndIdx };
dragMovedRef.current = false;
}
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
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 (
<div className="panel p-6">
<div className="mb-6">
<div className="stat-label mb-1">Population by map</div>
<h2 className="text-lg text-ink">
{visiblePoints.length} of {points.length}
{reachedStart ? '' : '+'} maps
</h2>
</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 (all the way back to 2015), click a bar for who was online
{fetchingMore && <span className="text-ink-faint"> loading more</span>}
</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="h-72 w-full relative">
{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 map history recorded yet.
</div>
) : (
<>
<svg
ref={svgRef}
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => {
setHoverIndex(null);
dragStartRef.current = null;
}}
onClick={() => {
if (dragMovedRef.current) {
dragMovedRef.current = false;
return;
}
if (hoverIndex != null && visiblePoints[hoverIndex]) {
loadPlayersFor(visiblePoints[hoverIndex]);
}
}}
>
<defs>
<linearGradient id="mapBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#5EE596" />
<stop offset="100%" stopColor="#2A9C5C" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const y = barY(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>
);
})}
{visiblePoints.map((p, i) => (
<rect
key={p.map_id}
x={barX(i)}
y={barY(p.total_players)}
width={barWidth}
height={barHeight(p.total_players)}
rx={3}
fill="url(#mapBarGradient)"
fillOpacity={hoverIndex === i ? 1 : 0.8}
style={hoverIndex === i ? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' } : undefined}
/>
))}
<line
x1={PAD_LEFT}
y1={PAD_TOP + plotHeight}
x2={width - PAD_RIGHT}
y2={PAD_TOP + plotHeight}
stroke="#4C5652"
/>
</svg>
{hoverIndex != null && visiblePoints[hoverIndex] && (
<div
className="absolute pointer-events-none"
style={{
left: Math.min(width - 200, Math.max(0, barX(hoverIndex) - 60)),
top: barY(visiblePoints[hoverIndex].total_players) - 70,
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#E4EBE7' }}>{visiblePoints[hoverIndex].map_name}</div>
<div style={{ color: '#8A9691' }}>
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
</div>
<div style={{ color: '#3FD37A' }}>
{visiblePoints[hoverIndex].total_players} total players during this session
</div>
</div>
)}
</>
)}
</div>
{points.length > 0 && (
<div className="mt-2 text-xs text-ink-faint">
Visit the{' '}
<Link href="/maps" className="text-accent hover:underline">
maps page
</Link>{' '}
for full detail (votes, prev/next map).
</div>
)}
{selectedMap && (
<div className="mt-4 pt-4 border-t-2 border-base-divider">
<div className="stat-label mb-1">
Players during{' '}
<Link href={`/maps/period/${selectedMap.map_id}`} className="text-accent hover:underline">
{selectedMap.map_name}
</Link>{' '}
({selectedPlayers.length} player{selectedPlayers.length === 1 ? '' : 's'})
</div>
<div className="text-xs text-ink-faint mb-3">
{new Date(selectedMap.map_start_dt.replace(' ', 'T')).toLocaleString()}
{' '}
{selectedMap.map_end_dt
? new Date(selectedMap.map_end_dt.replace(' ', 'T')).toLocaleString()
: 'still running'}
</div>
<PlayerChipList players={selectedPlayers} loading={loadingPlayers} />
</div>
)}
</div>
);
}