furhter bug fixes and updates

This commit is contained in:
jenz
2026-08-22 17:10:27 +02:00
parent 9e8e8378a2
commit 16bb9e14a5
18 changed files with 1070 additions and 256 deletions
@@ -9,7 +9,8 @@ interface MapPoint {
map_id: number;
map_name: string;
map_start_dt: string;
players_online_at_start: number;
map_end_dt: string | null;
total_players: number;
}
interface PlayerChip {
@@ -22,31 +23,39 @@ 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 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);
// 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 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=${FETCH_LIMIT}`)
fetch(`/api/map-population?limit=${INITIAL_FETCH}`)
.then((res) => res.json())
.then((data) => {
const pts: MapPoint[] = data.points ?? [];
@@ -75,10 +84,42 @@ export default function MapPopulationChart() {
}
}
// 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.players_online_at_start));
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;
@@ -127,6 +168,7 @@ export default function MapPopulationChart() {
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
ensureMoreIfNeeded(newStart);
}
} else {
setHoverIndex(indexForX(x));
@@ -137,34 +179,44 @@ export default function MapPopulationChart() {
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;
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
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));
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 ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5;
let newStart = Math.round(anchorAbsIdx - ratio * newSize);
let newEnd = newStart + newSize;
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));
if (newStart < 0) {
newStart = 0;
newEnd = 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);
}
if (newEnd > points.length) {
newEnd = points.length;
newStart = newEnd - newSize;
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
}
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);
@@ -172,7 +224,7 @@ export default function MapPopulationChart() {
setViewEndIdx(points.length);
}
const isZoomed = viewStartIdx > 0 || viewEndIdx < points.length;
const isZoomed = viewStartIdx > Math.max(0, points.length - DEFAULT_VISIBLE) || viewEndIdx < points.length;
const yTicks = [0, Math.round(axisMax / 2), axisMax];
return (
@@ -180,7 +232,8 @@ export default function MapPopulationChart() {
<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
{visiblePoints.length} of {points.length}
{reachedStart ? '' : '+'} maps
</h2>
</div>
@@ -190,7 +243,8 @@ export default function MapPopulationChart() {
<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
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">
@@ -211,6 +265,7 @@ export default function MapPopulationChart() {
) : (
<>
<svg
ref={svgRef}
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
@@ -221,7 +276,6 @@ export default function MapPopulationChart() {
setHoverIndex(null);
dragStartRef.current = null;
}}
onWheel={handleWheel}
onClick={() => {
if (dragMovedRef.current) {
dragMovedRef.current = false;
@@ -232,6 +286,13 @@ export default function MapPopulationChart() {
}
}}
>
<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 (
@@ -248,33 +309,16 @@ export default function MapPopulationChart() {
<rect
key={p.map_id}
x={barX(i)}
y={barY(p.players_online_at_start)}
y={barY(p.total_players)}
width={barWidth}
height={barHeight(p.players_online_at_start)}
rx={2}
fill="#3FD37A"
fillOpacity={hoverIndex === i ? 1 : 0.85}
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}
/>
))}
{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}
@@ -288,8 +332,8 @@ export default function MapPopulationChart() {
<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,
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,
@@ -303,7 +347,7 @@ export default function MapPopulationChart() {
{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
{visiblePoints[hoverIndex].total_players} total players during this session
</div>
</div>
)}
@@ -323,13 +367,20 @@ export default function MapPopulationChart() {
{selectedMap && (
<div className="mt-4 pt-4 border-t border-base-border">
<div className="stat-label mb-3">
<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>
)}
@@ -2,11 +2,13 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { formatMinutes } from '@/lib/steam';
interface MapSummary {
map_name: string;
times_played: number;
last_played: string;
total_minutes: number;
}
export default function MapSearch() {
@@ -46,12 +48,25 @@ export default function MapSearch() {
<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"
className="group 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 className="flex items-center gap-4">
<div className="flex flex-col items-end text-xs text-ink-faint font-mono gap-0.5">
<span>{m.times_played}× played · {formatMinutes(m.total_minutes)} total</span>
<span>last played {new Date(m.last_played.replace(' ', 'T')).toLocaleString()}</span>
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-ink-faint group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0"
>
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</Link>
))
@@ -29,10 +29,10 @@ export default function PlayerChipList({
<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"
className="flex items-center gap-2 bg-base border border-base-border rounded-full pl-1 pr-3 py-1 shadow-sm hover:border-accent/60 hover:shadow-[0_0_10px_rgba(63,211,122,0.25)] hover:-translate-y-0.5 transition-all duration-150"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover" />
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover ring-1 ring-base-border" />
<span className="text-sm text-ink">{p.name}</span>
</Link>
))}
@@ -0,0 +1,86 @@
'use client';
import Link from 'next/link';
import { formatMinutes } from '@/lib/steam';
interface Segment {
startMin: number;
endMin: number;
}
interface TimelinePlayer {
steamid: string;
name: string;
avatarUrl: string;
totalMinutes: number;
segments: Segment[];
}
export default function PlayerTimelineChart({
players,
windowMinutes,
windowStartLabel,
windowEndLabel,
loading,
}: {
players: TimelinePlayer[];
windowMinutes: number;
windowStartLabel: string;
windowEndLabel: string;
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 during this window.</div>;
}
return (
<div>
{/* time axis */}
<div className="flex justify-between text-xs text-ink-faint font-mono mb-2 pl-[172px]">
<span>{windowStartLabel}</span>
<span>{windowEndLabel}</span>
</div>
<div className="space-y-1.5">
{players.map((p) => (
<div key={p.steamid} className="flex items-center gap-3">
<Link
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center gap-2 w-[150px] shrink-0 hover:text-accent 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 ring-1 ring-base-border shrink-0" />
<span className="text-sm text-ink truncate">{p.name}</span>
</Link>
<div className="flex-1 h-4 bg-base rounded overflow-hidden border border-base-border relative">
{p.segments.map((seg, i) => {
const left = (seg.startMin / windowMinutes) * 100;
const width = Math.max(0.6, ((seg.endMin - seg.startMin) / windowMinutes) * 100);
return (
<div
key={i}
className="absolute top-0 bottom-0 rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
background: 'linear-gradient(180deg, #5EE596, #2A9C5C)',
boxShadow: '0 0 6px rgba(63, 211, 122, 0.4)',
}}
/>
);
})}
</div>
<span className="text-xs text-ink-muted mono w-16 text-right shrink-0">
{formatMinutes(p.totalMinutes)}
</span>
</div>
))}
</div>
</div>
);
}
@@ -29,7 +29,9 @@ 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
const MIN_ZOOM_SPAN_MS = 5 * 60_000; // don't zoom in tighter than 5 minutes
const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't pan back further than July 16, 2015
const FETCH_DEBOUNCE_MS = 400;
function toLocalInputValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
@@ -38,6 +40,7 @@ function toLocalInputValue(d: Date): string {
export default function PopulationChart() {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const svgRef = useRef<SVGSVGElement>(null);
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
@@ -46,19 +49,28 @@ export default function PopulationChart() {
const [points, setPoints] = useState<Point[]>([]);
const [mapBoundaries, setMapBoundaries] = useState<MapBoundary[]>([]);
const [loading, setLoading] = useState(false);
const [fetchingMore, setFetchingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
// Full bounds of whatever's currently fetched (via the calendar Start/End)
// Bounds of everything currently loaded (grows as panning/zooming reaches
// beyond it and triggers a new fetch — see ensureCoverage below).
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.
// The "home" range to return to on Reset — whatever was last explicitly
// requested via the calendar Start/End + Update, as opposed to wherever
// panning/zooming has since wandered off to.
const [homeStart, setHomeStart] = useState(0);
const [homeEnd, setHomeEnd] = useState(0);
// Currently VISIBLE window.
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 fetchInFlightRef = useRef(false);
const wheelDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
const [hoverX, setHoverX] = useState<number | null>(null);
@@ -70,24 +82,44 @@ export default function PopulationChart() {
const [currentUpdatedAt, setCurrentUpdatedAt] = useState<number | null>(null);
const [secondsAgo, setSecondsAgo] = useState(0);
// A specific HISTORICAL point clicked on the chart.
// A specific HISTORICAL point clicked (or auto-selected as the latest) 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);
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);
}
}
async function fetchRange(
rangeStartMs: number,
rangeEndMs: number,
opts: { preserveView?: boolean; selectLatest?: boolean } = {},
) {
const { preserveView = false, selectLatest = false } = opts;
if (preserveView) setFetchingMore(true);
else setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
start: new Date(rangeStartMs).toISOString(),
end: new Date(rangeEndMs).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() }));
const pts: Point[] = (data.points as any[]).map((p: any) => ({ ...p, tEpoch: new Date(p.t).getTime() }));
const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b: any) => ({ ...b, tEpoch: new Date(b.t).getTime() }));
setPoints(pts);
setMapBoundaries(bounds);
if (pts.length > 0) {
@@ -95,13 +127,25 @@ export default function PopulationChart() {
const maxE = pts[pts.length - 1].tEpoch;
setDataMinEpoch(minE);
setDataMaxEpoch(maxE);
setViewStart(minE);
setViewEnd(maxE);
// Deliberately NOT syncing the start/end calendar fields to minE/maxE
// here — those are bucket-boundary timestamps (up to one bucket
// width earlier than what was actually requested), not the real
// request range. Overwriting the inputs with them caused Update to
// silently shrink the End field by one bucket width on every click.
// The calendar fields should only ever reflect what was typed.
if (!preserveView) {
setViewStart(minE);
setViewEnd(maxE);
setHomeStart(minE);
setHomeEnd(maxE);
}
if (selectLatest) loadPlayersAt(pts[pts.length - 1]);
}
} catch (e: any) {
setError(e.message ?? 'something went wrong');
} finally {
setLoading(false);
setFetchingMore(false);
}
}
@@ -122,7 +166,7 @@ export default function PopulationChart() {
}
useEffect(() => {
load();
fetchRange(dayAgo.getTime(), now.getTime(), { selectLatest: true });
loadCurrentPlayers();
const poll = setInterval(loadCurrentPlayers, 30_000);
return () => clearInterval(poll);
@@ -136,18 +180,18 @@ export default function PopulationChart() {
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);
}
// If the visible window extends beyond what's loaded, fetch more —
// fires after a drag ends or a short pause after scroll-zooming, not on
// every intermediate frame.
function ensureCoverage(neededStart: number, neededEnd: number) {
if (fetchInFlightRef.current) return;
if (neededStart >= dataMinEpoch && neededEnd <= dataMaxEpoch) return;
fetchInFlightRef.current = true;
const fetchStart = Math.min(neededStart, dataMinEpoch);
const fetchEnd = Math.max(neededEnd, dataMaxEpoch);
fetchRange(fetchStart, fetchEnd, { preserveView: true }).finally(() => {
fetchInFlightRef.current = false;
});
}
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
@@ -165,8 +209,6 @@ export default function PopulationChart() {
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;
@@ -184,6 +226,12 @@ export default function PopulationChart() {
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xForEpoch(p.tEpoch).toFixed(1)} ${yForValue(p.players).toFixed(1)}`)
.join(' ');
const baselineY = PAD_TOP + plotHeight;
const areaPath =
renderPoints.length > 0
? `${linePath} L ${xForEpoch(renderPoints[renderPoints.length - 1].tEpoch).toFixed(1)} ${baselineY} L ${xForEpoch(renderPoints[0].tEpoch).toFixed(1)} ${baselineY} Z`
: '';
const visibleBoundaries = mapBoundaries.filter((b) => b.tEpoch >= viewStart && b.tEpoch <= viewEnd);
function findNearestPoint(x: number): Point | null {
@@ -221,12 +269,14 @@ export default function PopulationChart() {
const epochDelta = -(dx / plotWidth) * span;
let newStart = dragStartRef.current.viewStart + epochDelta;
let newEnd = dragStartRef.current.viewEnd + epochDelta;
if (newStart < dataMinEpoch) {
newStart = dataMinEpoch;
if (newStart < HARD_FLOOR_EPOCH) {
newStart = HARD_FLOOR_EPOCH;
newEnd = newStart + span;
}
if (newEnd > dataMaxEpoch) {
newEnd = dataMaxEpoch;
const rightLimit = Date.now();
if (newEnd > rightLimit) {
newEnd = rightLimit;
newStart = newEnd - span;
}
setViewStart(newStart);
@@ -236,6 +286,9 @@ export default function PopulationChart() {
}
function handleMouseUp() {
if (dragMovedRef.current) {
ensureCoverage(viewStart, viewEnd);
}
dragStartRef.current = null;
}
@@ -255,41 +308,58 @@ export default function PopulationChart() {
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
// Native (non-passive) wheel listener — React's onWheel is passive by
// default, so e.preventDefault() silently does nothing there and the
// page scrolls along with the zoom. This is the only way to actually
// stop that.
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const fullSpan = dataMaxEpoch - dataMinEpoch || 1;
let newSpan = (viewEnd - viewStart) * zoomFactor;
newSpan = Math.min(newSpan, fullSpan);
newSpan = Math.max(newSpan, MIN_ZOOM_SPAN_MS);
function onWheel(e: WheelEvent) {
e.preventDefault();
if (plotWidth === 0) return;
const rect = svg!.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorEpoch = epochForX(x);
const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
const ratio = (anchorEpoch - viewStart) / ((viewEnd - viewStart) || 1);
let newStart = anchorEpoch - ratio * newSpan;
let newEnd = newStart + newSpan;
const fullSpan = Date.now() - HARD_FLOOR_EPOCH;
let newSpan = (viewEnd - viewStart) * zoomFactor;
newSpan = Math.min(newSpan, fullSpan);
newSpan = Math.max(newSpan, MIN_ZOOM_SPAN_MS);
if (newStart < dataMinEpoch) {
newStart = dataMinEpoch;
newEnd = newStart + newSpan;
const ratio = (anchorEpoch - viewStart) / ((viewEnd - viewStart) || 1);
let newStart = anchorEpoch - ratio * newSpan;
let newEnd = newStart + newSpan;
if (newStart < HARD_FLOOR_EPOCH) {
newStart = HARD_FLOOR_EPOCH;
newEnd = newStart + newSpan;
}
const rightLimit = Date.now();
if (newEnd > rightLimit) {
newEnd = rightLimit;
newStart = newEnd - newSpan;
}
setViewStart(newStart);
setViewEnd(newEnd);
if (wheelDebounceRef.current) clearTimeout(wheelDebounceRef.current);
wheelDebounceRef.current = setTimeout(() => ensureCoverage(newStart, newEnd), FETCH_DEBOUNCE_MS);
}
if (newEnd > dataMaxEpoch) {
newEnd = dataMaxEpoch;
newStart = newEnd - newSpan;
}
setViewStart(newStart);
setViewEnd(newEnd);
}
svg.addEventListener('wheel', onWheel, { passive: false });
return () => svg.removeEventListener('wheel', onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewStart, viewEnd, plotWidth, dataMinEpoch, dataMaxEpoch]);
function resetZoom() {
setViewStart(dataMinEpoch);
setViewEnd(dataMaxEpoch);
setViewStart(homeStart);
setViewEnd(homeEnd);
}
const isZoomed = viewStart > dataMinEpoch + 1000 || viewEnd < dataMaxEpoch - 1000;
const isZoomed = viewStart > homeStart + 1000 || viewEnd < homeEnd - 1000;
const yTicks = [0, 16, 32, 48, 64];
const xTickCount = 5;
const xTicks =
@@ -303,7 +373,10 @@ export default function PopulationChart() {
<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)]" />
<span className="relative flex w-2 h-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-60" />
<span className="relative inline-flex rounded-full w-2 h-2 bg-accent shadow-[0_0_6px_theme(colors.accent.DEFAULT)]" />
</span>
<div className="stat-label">
Currently online{currentCount != null ? `${currentCount} player${currentCount === 1 ? '' : 's'}` : ''}
</div>
@@ -322,7 +395,7 @@ export default function PopulationChart() {
className="flex flex-wrap items-center gap-3"
onSubmit={(e) => {
e.preventDefault();
load();
fetchRange(new Date(start).getTime(), new Date(end).getTime(), { selectLatest: true });
}}
>
<label className="flex flex-col text-xs text-ink-muted gap-1">
@@ -360,7 +433,8 @@ export default function PopulationChart() {
<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
Scroll to zoom, drag to pan (all the way back to 2015), click a point 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">
@@ -376,6 +450,7 @@ export default function PopulationChart() {
<div className="h-full flex items-center justify-center text-ink-muted text-sm">No data in this range.</div>
) : (
<svg
ref={svgRef}
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
@@ -384,8 +459,14 @@ export default function PopulationChart() {
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onWheel={handleWheel}
>
<defs>
<linearGradient id="popAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3FD37A" stopOpacity="0.28" />
<stop offset="100%" stopColor="#3FD37A" stopOpacity="0" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const y = yForValue(v);
return (
@@ -418,17 +499,34 @@ export default function PopulationChart() {
<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} />
<path d={areaPath} fill="url(#popAreaGradient)" stroke="none" />
<path
d={linePath}
fill="none"
stroke="#3FD37A"
strokeWidth={2}
strokeLinejoin="round"
style={{ filter: 'drop-shadow(0 0 5px rgba(63, 211, 122, 0.45))' }}
/>
{hoverPoint && (
<circle
cx={xForEpoch(hoverPoint.tEpoch)}
cy={yForValue(hoverPoint.players)}
r={4}
fill="#3FD37A"
stroke="#0B0F0E"
strokeWidth={1.5}
/>
<g>
<circle
cx={xForEpoch(hoverPoint.tEpoch)}
cy={yForValue(hoverPoint.players)}
r={9}
fill="#3FD37A"
fillOpacity={0.15}
/>
<circle
cx={xForEpoch(hoverPoint.tEpoch)}
cy={yForValue(hoverPoint.players)}
r={4}
fill="#3FD37A"
stroke="#0B0F0E"
strokeWidth={1.5}
/>
</g>
)}
</svg>
)}
@@ -0,0 +1,298 @@
'use client';
import { useState } from 'react';
import PlayerTimelineChart from './PlayerTimelineChart';
interface RecurringPoint {
date: string; // "2026-07-01"
count: number;
}
interface Segment {
startMin: number;
endMin: number;
}
interface TimelinePlayer {
steamid: string;
name: string;
avatarUrl: string;
totalMinutes: number;
segments: Segment[];
}
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const HEIGHT = 220;
const PAD_LEFT = 32;
const PAD_RIGHT = 8;
const PAD_TOP = 8;
const PAD_BOTTOM = 30;
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function daysAgoStr(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export default function RecurringWindowChart() {
const [selectedDays, setSelectedDays] = useState<number[]>([2]); // default: Tuesday
const [startTime, setStartTime] = useState('06:00');
const [endTime, setEndTime] = useState('15:00');
const [rangeStart, setRangeStart] = useState(daysAgoStr(90));
const [rangeEnd, setRangeEnd] = useState(todayStr());
const [points, setPoints] = useState<RecurringPoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [selected, setSelected] = useState<RecurringPoint | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<TimelinePlayer[]>([]);
const [windowMinutes, setWindowMinutes] = useState(0);
const [loadingPlayers, setLoadingPlayers] = useState(false);
function toggleDay(d: number) {
setSelectedDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort()));
}
async function search() {
if (selectedDays.length === 0) {
setError('Pick at least one day of the week.');
return;
}
setLoading(true);
setError(null);
setSelected(null);
setHasSearched(true);
try {
const params = new URLSearchParams({
days: selectedDays.join(','),
startTime,
endTime,
start: rangeStart,
end: rangeEnd,
});
const res = await fetch(`/api/population/recurring?${params.toString()}`);
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
const data = await res.json();
setPoints(data.points ?? []);
setTruncated(!!data.truncated);
} catch (e: any) {
setError(e.message ?? 'something went wrong');
setPoints([]);
} finally {
setLoading(false);
}
}
async function loadPlayersFor(point: RecurringPoint) {
setSelected(point);
setLoadingPlayers(true);
try {
const params = new URLSearchParams({ date: point.date, startTime, endTime });
const res = await fetch(`/api/population/recurring/players?${params.toString()}`);
const data = await res.json();
setSelectedPlayers(data.players ?? []);
setWindowMinutes(data.windowMinutes ?? 0);
} catch {
setSelectedPlayers([]);
setWindowMinutes(0);
} finally {
setLoadingPlayers(false);
}
}
const maxValue = Math.max(1, ...points.map((p) => p.count));
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
const yTicks = [0, Math.round(axisMax / 2), axisMax];
return (
<div className="panel p-6">
<div className="mb-4">
<div className="stat-label mb-1">Recurring window</div>
<h2 className="text-lg text-ink">Population during a repeating time slot</h2>
<p className="text-sm text-ink-muted mt-1">
e.g. "who was online 6:0015:00 every Tuesday" pick the days, the time window, and the date range to search within.
</p>
</div>
<div className="flex flex-wrap items-end gap-4 mb-4">
<div>
<div className="text-xs text-ink-muted mb-1">Days</div>
<div className="flex flex-wrap gap-1">
{[0, 1, 2, 3, 4, 5, 6].map((d) => (
<button
key={d}
type="button"
onClick={() => toggleDay(d)}
className={`px-2.5 py-1.5 rounded text-xs border transition-colors ${
selectedDays.includes(d)
? 'border-accent/50 text-accent bg-accent-dim/20'
: 'border-base-border text-ink-muted hover:text-ink'
}`}
>
{DAY_LABELS[d]}
</button>
))}
</div>
</div>
<label className="flex flex-col text-xs text-ink-muted gap-1">
From
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(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">
Until
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(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">
Since
<input
type="date"
value={rangeStart}
onChange={(e) => setRangeStart(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">
Until
<input
type="date"
value={rangeEnd}
onChange={(e) => setRangeEnd(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<button
onClick={search}
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"
>
Search
</button>
</div>
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
{truncated && (
<div className="text-xs text-ink-faint mb-4">
Showing the first 120 matching dates narrow the date range for a complete picture.
</div>
)}
{!hasSearched ? (
<div className="text-sm text-ink-muted py-8 text-center">Pick your days and time window, then hit Search.</div>
) : loading ? (
<div className="text-sm text-ink-faint font-mono py-8 text-center">loading</div>
) : points.length === 0 ? (
<div className="text-sm text-ink-muted py-8 text-center">No matching dates in that range.</div>
) : (
<>
<div className="flex items-center gap-1.5 text-xs text-accent mb-2">
<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>
Click any bar to see each player's time within that window
</div>
<div className="relative" style={{ height: HEIGHT }}>
<svg width="100%" height={HEIGHT} viewBox={`0 0 800 ${HEIGHT}`} preserveAspectRatio="none">
<defs>
<linearGradient id="recurringBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#5EE596" />
<stop offset="100%" stopColor="#2A9C5C" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const y = PAD_TOP + (1 - v / axisMax) * plotHeight;
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={800 - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{(() => {
const plotWidth = 800 - PAD_LEFT - PAD_RIGHT;
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const slotWidth = plotWidth / points.length;
const barWidth = Math.max(2, slotWidth * 0.6);
return points.map((p, i) => {
const barH = Math.max(p.count > 0 ? 2 : 0, (p.count / axisMax) * plotHeight);
const x = PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2;
const y = PAD_TOP + (plotHeight - barH);
const showEvery = Math.max(1, Math.ceil(points.length / 10));
return (
<g key={p.date}>
<rect
x={x}
y={y}
width={barWidth}
height={barH}
rx={3}
fill="url(#recurringBarGradient)"
fillOpacity={selected?.date === p.date ? 1 : 0.8}
style={{
cursor: 'pointer',
...(selected?.date === p.date
? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' }
: {}),
}}
onClick={() => loadPlayersFor(p)}
/>
{(i === 0 || i === points.length - 1 || i % showEvery === 0) && (
<text x={x + barWidth / 2} y={HEIGHT - PAD_BOTTOM + 14} textAnchor="middle" fontSize={9} fill="#8A9691">
{new Date(`${p.date}T00:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</text>
)}
</g>
);
});
})()}
<line
x1={PAD_LEFT}
y1={PAD_TOP + (HEIGHT - PAD_TOP - PAD_BOTTOM)}
x2={800 - PAD_RIGHT}
y2={PAD_TOP + (HEIGHT - PAD_TOP - PAD_BOTTOM)}
stroke="#4C5652"
/>
</svg>
</div>
</>
)}
{selected && (
<div className="mt-4 pt-4 border-t border-base-border">
<div className="stat-label mb-3">
{new Date(`${selected.date}T00:00:00Z`).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })}, {startTime}{endTime} — {selectedPlayers.length} player
{selectedPlayers.length === 1 ? '' : 's'}, sorted by time spent in the window
</div>
<PlayerTimelineChart
players={selectedPlayers}
windowMinutes={windowMinutes || 1}
windowStartLabel={startTime}
windowEndLabel={endTime}
loading={loadingPlayers}
/>
</div>
)}
</div>
);
}