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

420 lines
16 KiB
TypeScript
Raw 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;
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&apos;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>
);
}