initial commit of the paytime session frontend
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { formatMinutes } from '@/lib/steam';
|
||||
|
||||
interface CountryPlayer {
|
||||
steamid: string;
|
||||
current_name: string;
|
||||
last_seen: string;
|
||||
total_minutes: number | null;
|
||||
}
|
||||
|
||||
export default function CountryPlayerList({ code }: { code: string }) {
|
||||
const [sort, setSort] = useState<'playtime' | 'recent'>('playtime');
|
||||
const [players, setPlayers] = useState<CountryPlayer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/countries/${code}?sort=${sort}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setPlayers(data.players))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code, sort]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||||
className="bg-base-panel border border-base-border rounded-lg px-3 py-2 text-sm text-ink-muted focus:border-accent/50 transition-colors"
|
||||
>
|
||||
<option value="playtime">Highest playtime</option>
|
||||
<option value="recent">Most recently played</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="panel divide-y divide-base-border">
|
||||
{loading ? (
|
||||
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
|
||||
) : players.length === 0 ? (
|
||||
<div className="p-4 text-sm text-ink-muted">No players found.</div>
|
||||
) : (
|
||||
players.map((p) => (
|
||||
<Link
|
||||
key={p.steamid}
|
||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="text-ink text-sm">{p.current_name}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-ink-faint">
|
||||
<span className="mono">{formatMinutes(p.total_minutes)}</span>
|
||||
<span>{new Date(p.last_seen.replace(' ', 'T')).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
'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;
|
||||
players_online_at_start: number;
|
||||
}
|
||||
|
||||
interface PlayerChip {
|
||||
steamid: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
const HEIGHT = 288;
|
||||
const PAD_LEFT = 32;
|
||||
const PAD_RIGHT = 8;
|
||||
const PAD_TOP = 8;
|
||||
const PAD_BOTTOM = 36;
|
||||
const FETCH_LIMIT = 150; // pool of maps available to pan/zoom through
|
||||
const DEFAULT_VISIBLE = 40;
|
||||
const MIN_VISIBLE = 5;
|
||||
|
||||
export default function MapPopulationChart() {
|
||||
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
|
||||
const [points, setPoints] = useState<MapPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
// Visible window (index range into `points`) — what scroll-to-zoom /
|
||||
// drag-to-pan adjusts. Defaults to the most recent DEFAULT_VISIBLE maps.
|
||||
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 [selectedMap, setSelectedMap] = useState<MapPoint | null>(null);
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
|
||||
const [loadingPlayers, setLoadingPlayers] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/map-population?limit=${FETCH_LIMIT}`)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
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.players_online_at_start));
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
setHoverIndex(indexForX(x));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
|
||||
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
e.preventDefault();
|
||||
if (points.length === 0) return;
|
||||
const rect = e.currentTarget.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; // scroll down = zoom out (more bars), up = zoom in (fewer bars)
|
||||
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);
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
const startIdx = Math.max(0, points.length - DEFAULT_VISIBLE);
|
||||
setViewStartIdx(startIdx);
|
||||
setViewEndIdx(points.length);
|
||||
}
|
||||
|
||||
const isZoomed = viewStartIdx > 0 || 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} 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, click a bar for who was online
|
||||
</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
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={() => {
|
||||
setHoverIndex(null);
|
||||
dragStartRef.current = null;
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
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) => (
|
||||
<rect
|
||||
key={p.map_id}
|
||||
x={barX(i)}
|
||||
y={barY(p.players_online_at_start)}
|
||||
width={barWidth}
|
||||
height={barHeight(p.players_online_at_start)}
|
||||
rx={2}
|
||||
fill="#3FD37A"
|
||||
fillOpacity={hoverIndex === i ? 1 : 0.85}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visiblePoints.map((p, i) => {
|
||||
const showEvery = Math.max(1, Math.ceil((visiblePoints.length * 70) / plotWidth));
|
||||
if (i !== 0 && i !== visiblePoints.length - 1 && i % showEvery !== 0) return null;
|
||||
const label = p.map_name.length > 12 ? `${p.map_name.slice(0, 11)}…` : p.map_name;
|
||||
return (
|
||||
<text
|
||||
key={p.map_id}
|
||||
x={barX(i) + barWidth / 2}
|
||||
y={HEIGHT - PAD_BOTTOM + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fill="#8A9691"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
<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 - 180, Math.max(0, barX(hoverIndex) - 60)),
|
||||
top: barY(visiblePoints[hoverIndex].players_online_at_start) - 60,
|
||||
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].players_online_at_start} players at map start
|
||||
</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 border-base-border">
|
||||
<div className="stat-label mb-3">
|
||||
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>
|
||||
<PlayerChipList players={selectedPlayers} loading={loadingPlayers} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface MapSummary {
|
||||
map_name: string;
|
||||
times_played: number;
|
||||
last_played: string;
|
||||
}
|
||||
|
||||
export default function MapSearch() {
|
||||
const [q, setQ] = useState('');
|
||||
const [maps, setMaps] = useState<MapSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/maps?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
setMaps(data.maps);
|
||||
setLoading(false);
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(handle);
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by map name…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="w-full bg-base-panel border border-base-border rounded-lg px-4 py-3 text-ink placeholder:text-ink-faint focus:border-accent/50 transition-colors"
|
||||
/>
|
||||
|
||||
<div className="panel divide-y divide-base-border">
|
||||
{loading ? (
|
||||
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
|
||||
) : maps.length === 0 ? (
|
||||
<div className="p-4 text-sm text-ink-muted">No maps found.</div>
|
||||
) : (
|
||||
maps.map((m) => (
|
||||
<Link
|
||||
key={m.map_name}
|
||||
href={`/maps/${encodeURIComponent(m.map_name)}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="text-ink text-sm">{m.map_name}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-ink-faint font-mono">
|
||||
<span>{m.times_played}× played</span>
|
||||
<span>last {new Date(m.last_played.replace(' ', 'T')).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
interface PlayerChip {
|
||||
steamid: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export default function PlayerChipList({
|
||||
players,
|
||||
loading,
|
||||
}: {
|
||||
players: PlayerChip[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="text-sm text-ink-faint font-mono">loading players…</div>;
|
||||
}
|
||||
|
||||
if (players.length === 0) {
|
||||
return <div className="text-sm text-ink-muted">No players recorded at this point.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{players.map((p) => (
|
||||
<Link
|
||||
key={p.steamid}
|
||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||
className="flex items-center gap-2 bg-base border border-base-border rounded-full pl-1 pr-3 py-1 hover:border-accent/50 transition-colors"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover" />
|
||||
<span className="text-sm text-ink">{p.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
|
||||
|
||||
interface Player {
|
||||
steamid: string;
|
||||
current_name: string;
|
||||
current_country: string | null;
|
||||
matched_name: string | null;
|
||||
total_minutes: number | null;
|
||||
}
|
||||
|
||||
export default function PlayerSearch() {
|
||||
const [q, setQ] = useState('');
|
||||
const [sort, setSort] = useState<'recent' | 'name' | 'playtime'>('recent');
|
||||
const [players, setPlayers] = useState<Player[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ q, sort });
|
||||
const res = await fetch(`/api/players?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
setPlayers(data.players);
|
||||
setLoading(false);
|
||||
}, 250); // debounce so we're not hitting the DB on every keystroke
|
||||
|
||||
return () => clearTimeout(handle);
|
||||
}, [q, sort]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or SteamID…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="flex-1 bg-base-panel border border-base-border rounded-lg px-4 py-3 text-ink placeholder:text-ink-faint focus:border-accent/50 transition-colors"
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||||
className="bg-base-panel border border-base-border rounded-lg px-3 text-sm text-ink-muted focus:border-accent/50 transition-colors"
|
||||
>
|
||||
<option value="recent">Recently active</option>
|
||||
<option value="name">Name (A–Z)</option>
|
||||
<option value="playtime">Highest playtime</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="panel divide-y divide-base-border">
|
||||
{loading ? (
|
||||
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
|
||||
) : players.length === 0 ? (
|
||||
<div className="p-4 text-sm text-ink-muted">No players found.</div>
|
||||
) : (
|
||||
players.map((p) => (
|
||||
<Link
|
||||
key={p.steamid}
|
||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg" aria-hidden>
|
||||
{countryCodeToFlag(p.current_country)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-ink text-sm">{p.current_name}</div>
|
||||
{p.matched_name && p.matched_name !== p.current_name && (
|
||||
<div className="text-xs text-ink-faint">previously: {p.matched_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="mono text-ink-faint text-xs">{formatMinutes(p.total_minutes)}</span>
|
||||
<span className="mono text-ink-faint">{p.steamid}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
'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;
|
||||
}
|
||||
|
||||
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 let scroll-zoom go tighter than 5 minutes
|
||||
|
||||
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<HTMLDivElement>();
|
||||
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<Point[]>([]);
|
||||
const [mapBoundaries, setMapBoundaries] = useState<MapBoundary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Full bounds of whatever's currently fetched (via the calendar Start/End)
|
||||
const [dataMinEpoch, setDataMinEpoch] = useState(0);
|
||||
const [dataMaxEpoch, setDataMaxEpoch] = useState(0);
|
||||
|
||||
// Currently VISIBLE window — what scroll-to-zoom / drag-to-pan adjusts.
|
||||
// Starts equal to the full fetched range and narrows/shifts from there.
|
||||
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 [hoverPoint, setHoverPoint] = useState<Point | null>(null);
|
||||
const [hoverX, setHoverX] = useState<number | null>(null);
|
||||
|
||||
// Currently online — authoritative, always-live, independent of the chart's date range.
|
||||
const [currentPlayers, setCurrentPlayers] = useState<PlayerChip[]>([]);
|
||||
const [currentCount, setCurrentCount] = useState<number | null>(null);
|
||||
const [loadingCurrent, setLoadingCurrent] = useState(true);
|
||||
const [currentUpdatedAt, setCurrentUpdatedAt] = useState<number | null>(null);
|
||||
const [secondsAgo, setSecondsAgo] = useState(0);
|
||||
|
||||
// A specific HISTORICAL point clicked on the chart.
|
||||
const [selectedPoint, setSelectedPoint] = useState<Point | null>(null);
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
|
||||
const [loadingSelected, setLoadingSelected] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).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) => ({ ...p, tEpoch: new Date(p.t).getTime() }));
|
||||
const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b) => ({ ...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);
|
||||
setViewStart(minE);
|
||||
setViewEnd(maxE);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'something went wrong');
|
||||
} finally {
|
||||
setLoading(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(() => {
|
||||
load();
|
||||
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]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Render a couple of points beyond each edge of the visible window too,
|
||||
// so the line doesn't visibly truncate right at the viewport boundary.
|
||||
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 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<SVGSVGElement>) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
dragStartRef.current = { x: e.clientX - rect.left, viewStart, viewEnd };
|
||||
dragMovedRef.current = false;
|
||||
}
|
||||
|
||||
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
||||
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 < dataMinEpoch) {
|
||||
newStart = dataMinEpoch;
|
||||
newEnd = newStart + span;
|
||||
}
|
||||
if (newEnd > dataMaxEpoch) {
|
||||
newEnd = dataMaxEpoch;
|
||||
newStart = newEnd - span;
|
||||
}
|
||||
setViewStart(newStart);
|
||||
setViewEnd(newEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
setHoverPoint(null);
|
||||
setHoverX(null);
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
|
||||
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||
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);
|
||||
}
|
||||
|
||||
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
e.preventDefault();
|
||||
if (plotWidth === 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const anchorEpoch = epochForX(x);
|
||||
const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15; // scroll down = zoom out, up = zoom in
|
||||
|
||||
const fullSpan = dataMaxEpoch - dataMinEpoch || 1;
|
||||
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 < dataMinEpoch) {
|
||||
newStart = dataMinEpoch;
|
||||
newEnd = newStart + newSpan;
|
||||
}
|
||||
if (newEnd > dataMaxEpoch) {
|
||||
newEnd = dataMaxEpoch;
|
||||
newStart = newEnd - newSpan;
|
||||
}
|
||||
setViewStart(newStart);
|
||||
setViewEnd(newEnd);
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
setViewStart(dataMinEpoch);
|
||||
setViewEnd(dataMaxEpoch);
|
||||
}
|
||||
|
||||
const isZoomed = viewStart > dataMinEpoch + 1000 || viewEnd < dataMaxEpoch - 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 (
|
||||
<div className="panel p-6">
|
||||
{/* Currently online — always visible, always live, not tied to the date range below */}
|
||||
<div className="mb-6 pb-6 border-b border-base-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_theme(colors.accent.DEFAULT)]" />
|
||||
<div className="stat-label">
|
||||
Currently online{currentCount != null ? ` — ${currentCount} player${currentCount === 1 ? '' : 's'}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{currentUpdatedAt && <div className="text-xs text-ink-faint font-mono">updated {secondsAgo}s ago</div>}
|
||||
</div>
|
||||
<PlayerChipList players={currentPlayers} loading={loadingCurrent} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="stat-label mb-1">Concurrent players</div>
|
||||
<h2 className="text-lg text-ink">Population over time</h2>
|
||||
</div>
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
load();
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col text-xs text-ink-muted gap-1">
|
||||
Start
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col text-xs text-ink-muted gap-1">
|
||||
End
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-dim hover:bg-accent hover:text-base text-accent border border-accent/40 rounded px-4 py-2 text-sm font-medium transition-colors self-end"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-400 mb-4">{error}</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, click a point for who was online
|
||||
</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="w-full relative" style={{ height: HEIGHT }}>
|
||||
{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 data in this range.</div>
|
||||
) : (
|
||||
<svg
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{yTicks.map((v) => {
|
||||
const y = yForValue(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>
|
||||
);
|
||||
})}
|
||||
|
||||
{visibleBoundaries.map((b) => (
|
||||
<line
|
||||
key={b.mapId}
|
||||
x1={xForEpoch(b.tEpoch)}
|
||||
y1={PAD_TOP}
|
||||
x2={xForEpoch(b.tEpoch)}
|
||||
y2={PAD_TOP + plotHeight}
|
||||
stroke="#4C5652"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
))}
|
||||
|
||||
{xTicks.map((epoch, i) => (
|
||||
<text key={i} x={xForEpoch(epoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="middle" fontSize={10} fill="#8A9691">
|
||||
{new Date(epoch).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<line x1={PAD_LEFT} y1={PAD_TOP + plotHeight} x2={width - PAD_RIGHT} y2={PAD_TOP + plotHeight} stroke="#4C5652" />
|
||||
|
||||
<path d={linePath} fill="none" stroke="#3FD37A" strokeWidth={2} />
|
||||
|
||||
{hoverPoint && (
|
||||
<circle
|
||||
cx={xForEpoch(hoverPoint.tEpoch)}
|
||||
cy={yForValue(hoverPoint.players)}
|
||||
r={4}
|
||||
fill="#3FD37A"
|
||||
stroke="#0B0F0E"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{hoverPoint && hoverX != null && !loading && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: Math.min(Math.max(width, 200) - 190, Math.max(0, hoverX + 12)),
|
||||
top: Math.max(0, yForValue(hoverPoint.players) - 70),
|
||||
background: '#121715',
|
||||
border: '1px solid #1F2723',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
padding: '8px 10px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#8A9691' }}>{new Date(hoverPoint.t).toLocaleString()}</div>
|
||||
<div style={{ color: '#3FD37A', marginTop: 2 }}>{hoverPoint.players} players</div>
|
||||
{hoverPoint.mapName && <div style={{ color: '#E4EBE7', marginTop: 2 }}>{hoverPoint.mapName}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPoint && (
|
||||
<div className="mt-4 pt-4 border-t border-base-border">
|
||||
<div className="stat-label mb-3">
|
||||
Online at {new Date(selectedPoint.t).toLocaleString()}
|
||||
{selectedPoint.mapName && (
|
||||
<>
|
||||
{' '}
|
||||
— playing <span className="text-ink">{selectedPoint.mapName}</span>
|
||||
</>
|
||||
)}{' '}
|
||||
({selectedPoint.players} player{selectedPoint.players === 1 ? '' : 's'} — from historical data, may lag slightly)
|
||||
</div>
|
||||
<PlayerChipList players={selectedPlayers} loading={loadingSelected} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user