'use client'; import { useEffect, useState } from 'react'; import { useContainerWidth } from '@/lib/useContainerWidth'; interface Point { t: string; tEpoch: number; players: number; mapName: string | null; } const HEIGHT = 220; const PAD_LEFT = 32; const PAD_RIGHT = 8; const PAD_TOP = 10; const PAD_BOTTOM = 26; export default function MapSessionTimelineChart({ mapId }: { mapId: number }) { const { ref: containerRef, width } = useContainerWidth(); const [points, setPoints] = useState([]); const [mapStart, setMapStart] = useState(null); const [mapEnd, setMapEnd] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hoverX, setHoverX] = useState(null); useEffect(() => { setLoading(true); setError(null); fetch(`/api/map-population/timeline?mapId=${mapId}`) .then(async (res) => { if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load'); return res.json(); }) .then((data) => { const pts: Point[] = (data.points as any[]).map((p: any) => ({ ...p, tEpoch: new Date(p.t).getTime() })); setPoints(pts); setMapStart(data.mapStart ? new Date(data.mapStart).getTime() : null); setMapEnd(data.mapEnd ? new Date(data.mapEnd).getTime() : null); }) .catch((e) => setError(e.message ?? 'something went wrong')) .finally(() => setLoading(false)); }, [mapId]); const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT); const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM; const minEpoch = points[0]?.tEpoch ?? 0; const maxEpoch = points[points.length - 1]?.tEpoch ?? 1; const span = Math.max(1, maxEpoch - minEpoch); const maxValue = Math.max(1, ...points.map((p) => p.players)); const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5; function xForEpoch(epoch: number): number { return PAD_LEFT + ((epoch - minEpoch) / span) * plotWidth; } function yForValue(v: number): number { return PAD_TOP + (1 - v / axisMax) * plotHeight; } function epochForX(x: number): number { if (plotWidth === 0) return minEpoch; return minEpoch + ((x - PAD_LEFT) / plotWidth) * span; } function findNearestPoint(x: number): Point | null { if (points.length === 0) return null; const target = epochForX(x); let nearest = points[0]; let best = Math.abs(points[0].tEpoch - target); for (const p of points) { const d = Math.abs(p.tEpoch - target); if (d < best) { best = d; nearest = p; } } return nearest; } const linePath = points .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 = points.length > 0 ? `${linePath} L ${xForEpoch(points[points.length - 1].tEpoch).toFixed(1)} ${baselineY} L ${xForEpoch(points[0].tEpoch).toFixed(1)} ${baselineY} Z` : ''; const hoverPoint = hoverX != null ? findNearestPoint(hoverX) : null; // The two endpoints this chart exists to contrast: concurrent population // right at map start vs. right at map end (or "now" if still running) — // same definition as the dumbbell chart, found by nearest bucket to each // boundary rather than a separate query. const startPoint = mapStart != null ? findNearestPoint(xForEpoch(mapStart)) : null; const endPoint = mapEnd != null ? findNearestPoint(xForEpoch(mapEnd)) : points[points.length - 1] ?? null; const delta = startPoint && endPoint ? endPoint.players - startPoint.players : null; const deltaColor = delta == null ? '#8A9691' : delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691'; const yTicks = [0, Math.round(axisMax / 2), axisMax]; return (
This session, zoomed in

Population during this map

{startPoint && endPoint && (

Started with {startPoint.players} players, ended with{' '} {endPoint.players} {' '} ({delta != null && delta > 0 ? '+' : ''} {delta})

)}
{error &&
{error}
}
{loading ? (
loading…
) : points.length === 0 ? (
No data for this session.
) : ( { const rect = e.currentTarget.getBoundingClientRect(); setHoverX(e.clientX - rect.left); }} onMouseLeave={() => setHoverX(null)} > {yTicks.map((v) => { const y = yForValue(v); return ( {v} ); })} {/* map-session boundaries */} {mapStart != null && mapStart >= minEpoch && mapStart <= maxEpoch && ( )} {mapEnd != null && mapEnd >= minEpoch && mapEnd <= maxEpoch && ( )} {new Date(minEpoch).toLocaleTimeString()} {new Date(maxEpoch).toLocaleTimeString()} {startPoint && ( )} {endPoint && ( )} {hoverPoint && ( )} )} {hoverPoint && hoverX != null && !loading && (
{new Date(hoverPoint.t).toLocaleTimeString()}
{hoverPoint.players} players
)}
); }