574 lines
21 KiB
TypeScript
574 lines
21 KiB
TypeScript
'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;
|
|
country?: string | null;
|
|
}
|
|
|
|
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 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');
|
|
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 svgRef = useRef<SVGSVGElement>(null);
|
|
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 [fetchingMore, setFetchingMore] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// 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 (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 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(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: 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) {
|
|
const minE = pts[0].tEpoch;
|
|
const maxE = pts[pts.length - 1].tEpoch;
|
|
setDataMinEpoch(minE);
|
|
setDataMaxEpoch(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);
|
|
}
|
|
}
|
|
|
|
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(() => {
|
|
fetchRange(dayAgo.getTime(), now.getTime(), { selectLatest: true });
|
|
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]);
|
|
|
|
// 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);
|
|
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);
|
|
}
|
|
|
|
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 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 {
|
|
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 < HARD_FLOOR_EPOCH) {
|
|
newStart = HARD_FLOOR_EPOCH;
|
|
newEnd = newStart + span;
|
|
}
|
|
const rightLimit = Date.now();
|
|
if (newEnd > rightLimit) {
|
|
newEnd = rightLimit;
|
|
newStart = newEnd - span;
|
|
}
|
|
setViewStart(newStart);
|
|
setViewEnd(newEnd);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleMouseUp() {
|
|
if (dragMovedRef.current) {
|
|
ensureCoverage(viewStart, viewEnd);
|
|
}
|
|
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);
|
|
}
|
|
|
|
// 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;
|
|
|
|
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 fullSpan = Date.now() - HARD_FLOOR_EPOCH;
|
|
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 < 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);
|
|
}
|
|
|
|
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(homeStart);
|
|
setViewEnd(homeEnd);
|
|
}
|
|
|
|
const isZoomed = viewStart > homeStart + 1000 || viewEnd < homeEnd - 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-2 border-base-divider">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
</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();
|
|
fetchRange(new Date(start).getTime(), new Date(end).getTime(), { selectLatest: true });
|
|
}}
|
|
>
|
|
<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 (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">
|
|
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
|
|
ref={svgRef}
|
|
width={width}
|
|
height={HEIGHT}
|
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={handleClick}
|
|
>
|
|
<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 (
|
|
<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={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 && (
|
|
<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>
|
|
)}
|
|
|
|
{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-2 border-base-divider">
|
|
<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>
|
|
);
|
|
}
|