initial commit of the paytime session frontend

This commit is contained in:
jenz
2026-08-22 14:53:27 +02:00
parent 4ad037bd13
commit 9e8e8378a2
43 changed files with 2936 additions and 0 deletions
@@ -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>
);
}