232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
'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<HTMLDivElement>();
|
|
const [points, setPoints] = useState<Point[]>([]);
|
|
const [mapStart, setMapStart] = useState<number | null>(null);
|
|
const [mapEnd, setMapEnd] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hoverX, setHoverX] = useState<number | null>(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 (
|
|
<div className="panel p-6">
|
|
<div className="mb-4">
|
|
<div className="stat-label mb-1">This session, zoomed in</div>
|
|
<h2 className="text-lg text-ink">Population during this map</h2>
|
|
{startPoint && endPoint && (
|
|
<p className="text-sm text-ink-muted mt-1">
|
|
Started with <span className="text-ink">{startPoint.players}</span> players, ended with{' '}
|
|
<span className="text-ink">{endPoint.players}</span>
|
|
<span style={{ color: deltaColor }}>
|
|
{' '}
|
|
({delta != null && delta > 0 ? '+' : ''}
|
|
{delta})
|
|
</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && <div className="text-sm text-red-400 mb-4">{error}</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 for this session.</div>
|
|
) : (
|
|
<svg
|
|
width={width}
|
|
height={HEIGHT}
|
|
style={{ cursor: 'crosshair' }}
|
|
onMouseMove={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setHoverX(e.clientX - rect.left);
|
|
}}
|
|
onMouseLeave={() => setHoverX(null)}
|
|
>
|
|
<defs>
|
|
<linearGradient id="sessionAreaGradient" 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 (
|
|
<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>
|
|
);
|
|
})}
|
|
|
|
{/* map-session boundaries */}
|
|
{mapStart != null && mapStart >= minEpoch && mapStart <= maxEpoch && (
|
|
<line
|
|
x1={xForEpoch(mapStart)}
|
|
y1={PAD_TOP}
|
|
x2={xForEpoch(mapStart)}
|
|
y2={PAD_TOP + plotHeight}
|
|
stroke="#8A9691"
|
|
strokeDasharray="3 3"
|
|
/>
|
|
)}
|
|
{mapEnd != null && mapEnd >= minEpoch && mapEnd <= maxEpoch && (
|
|
<line
|
|
x1={xForEpoch(mapEnd)}
|
|
y1={PAD_TOP}
|
|
x2={xForEpoch(mapEnd)}
|
|
y2={PAD_TOP + plotHeight}
|
|
stroke="#8A9691"
|
|
strokeDasharray="3 3"
|
|
/>
|
|
)}
|
|
|
|
<text x={xForEpoch(minEpoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="start" fontSize={10} fill="#8A9691">
|
|
{new Date(minEpoch).toLocaleTimeString()}
|
|
</text>
|
|
<text x={xForEpoch(maxEpoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="end" fontSize={10} fill="#8A9691">
|
|
{new Date(maxEpoch).toLocaleTimeString()}
|
|
</text>
|
|
|
|
<line x1={PAD_LEFT} y1={PAD_TOP + plotHeight} x2={width - PAD_RIGHT} y2={PAD_TOP + plotHeight} stroke="#4C5652" />
|
|
|
|
<path d={areaPath} fill="url(#sessionAreaGradient)" 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))' }}
|
|
/>
|
|
|
|
{startPoint && (
|
|
<circle cx={xForEpoch(startPoint.tEpoch)} cy={yForValue(startPoint.players)} r={4} fill="#8A9691" stroke="#0B0F0E" strokeWidth={1.5} />
|
|
)}
|
|
{endPoint && (
|
|
<circle cx={xForEpoch(endPoint.tEpoch)} cy={yForValue(endPoint.players)} r={4} fill={deltaColor} stroke="#0B0F0E" strokeWidth={1.5} />
|
|
)}
|
|
|
|
{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) - 170, Math.max(0, hoverX + 12)),
|
|
top: Math.max(0, yForValue(hoverPoint.players) - 56),
|
|
background: '#121715',
|
|
border: '1px solid #1F2723',
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
padding: '8px 10px',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
<div style={{ color: '#8A9691' }}>{new Date(hoverPoint.t).toLocaleTimeString()}</div>
|
|
<div style={{ color: '#3FD37A', marginTop: 2 }}>{hoverPoint.players} players</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|