420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
'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;
|
||
start_players: number;
|
||
end_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;
|
||
// Scale is driven by the dumbbell endpoints (start/end concurrent
|
||
// population), not total_players — total_players is cumulative reach
|
||
// across the whole session and would otherwise dwarf the actual
|
||
// population swings the chart exists to show.
|
||
const maxValue = Math.max(1, ...visiblePoints.flatMap((p) => [p.start_players, p.end_players]));
|
||
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
|
||
|
||
const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
|
||
|
||
function barX(i: number) {
|
||
return PAD_LEFT + i * slotWidth + slotWidth / 2;
|
||
}
|
||
function barY(value: number) {
|
||
const h = (value / axisMax) * plotHeight;
|
||
return PAD_TOP + (plotHeight - h);
|
||
}
|
||
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>
|
||
<p className="text-xs text-ink-faint mt-1">
|
||
Each line shows a map's population at the moment it started (grey dot) versus when it ended
|
||
(green = grew, red = shrank) — so a decline shows up on the map where it actually happened,
|
||
not the one that started low right after.
|
||
</p>
|
||
</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 line 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]);
|
||
}
|
||
}}
|
||
>
|
||
{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) => {
|
||
const x = barX(i);
|
||
const yStart = barY(p.start_players);
|
||
const yEnd = barY(p.end_players);
|
||
const grew = p.end_players > p.start_players;
|
||
const shrank = p.end_players < p.start_players;
|
||
const endColor = grew ? '#3FD37A' : shrank ? '#E0665C' : '#8A9691';
|
||
const active = hoverIndex === i;
|
||
return (
|
||
<g key={p.map_id} opacity={active ? 1 : 0.85}>
|
||
<line
|
||
x1={x}
|
||
y1={yStart}
|
||
x2={x}
|
||
y2={yEnd}
|
||
stroke={endColor}
|
||
strokeWidth={active ? 2.5 : 1.75}
|
||
strokeLinecap="round"
|
||
style={active ? { filter: 'drop-shadow(0 0 5px rgba(63, 211, 122, 0.45))' } : undefined}
|
||
/>
|
||
<circle cx={x} cy={yStart} r={active ? 4 : 3} fill="#8A9691" />
|
||
<circle cx={x} cy={yEnd} r={active ? 5 : 3.5} fill={endColor} />
|
||
</g>
|
||
);
|
||
})}
|
||
|
||
<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 - 210, Math.max(0, barX(hoverIndex) - 60)),
|
||
top:
|
||
Math.min(
|
||
barY(visiblePoints[hoverIndex].start_players),
|
||
barY(visiblePoints[hoverIndex].end_players),
|
||
) - 92,
|
||
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: '#E4EBE7', marginTop: 2 }}>
|
||
{visiblePoints[hoverIndex].start_players} → {visiblePoints[hoverIndex].end_players} players
|
||
{(() => {
|
||
const delta = visiblePoints[hoverIndex].end_players - visiblePoints[hoverIndex].start_players;
|
||
const color = delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691';
|
||
return (
|
||
<span style={{ color, marginLeft: 6 }}>
|
||
({delta > 0 ? '+' : ''}
|
||
{delta})
|
||
</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div style={{ color: '#8A9691', marginTop: 2 }}>
|
||
{visiblePoints[hoverIndex].total_players} distinct players during this session (reach)
|
||
</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>
|
||
);
|
||
}
|