furhter bug fixes and updates
This commit is contained in:
@@ -13,20 +13,23 @@ Node process; nginx reverse-proxies a subdomain to it.
|
||||
|
||||
## Pages built so far
|
||||
|
||||
- `/` — Overview: concurrent-player population graph (last 24h by default)
|
||||
and a map-to-map population bar chart (last 40 maps). Click any point on
|
||||
either chart to see the actual players (avatar + name) who were online
|
||||
then.
|
||||
- `/players` — search players by current/previous name or SteamID, sortable
|
||||
by recent activity, name, or highest playtime
|
||||
- `/` — Overview: concurrent-player population graph (hand-rolled SVG, not
|
||||
a charting library — scroll to zoom, drag to pan all the way back to
|
||||
2015-07-16, click any point for who was online then), a map-to-map
|
||||
population bar chart (same zoom/pan, pool of 150 maps), and a "recurring
|
||||
window" chart for questions like "who's online 6–15h every Tuesday"
|
||||
(pick days of week + time window + date range)
|
||||
- `/players` — search players by current/previous name or SteamID (shows
|
||||
which old name matched, if any), sortable by recent activity, name, or
|
||||
highest playtime
|
||||
- `/players/[steamid]` — avatar, previous names, SteamID, Steam profile link,
|
||||
RaceTimer rank/level + profile link, total playtime, and a session-by-session
|
||||
history (with the map(s) each session overlapped)
|
||||
- `/maps` — search maps by name
|
||||
- `/maps/[mapname]` — every time period that map was played
|
||||
- `/maps/period/[mapId]` — one specific occurrence: players present during
|
||||
it, votes cast (grouped by vote event, since a map could theoretically see
|
||||
more than one), and prev/next map navigation
|
||||
- `/maps/period/[mapId]` — one specific occurrence: players present at any
|
||||
point during it, votes cast (grouped by vote event, since a map could
|
||||
theoretically see more than one), and prev/next map navigation
|
||||
- `/countries` — every country with at least one player, sorted by player
|
||||
count descending
|
||||
- `/countries/[code]` — players from that country, sortable by highest
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMapPopulationSeries } from '@/lib/queries';
|
||||
import { formatDbDateTime } from '@/lib/timezone';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const limitParam = req.nextUrl.searchParams.get('limit');
|
||||
const beforeParam = req.nextUrl.searchParams.get('before'); // ISO string — page further back for pan-to-load-more
|
||||
const limit = Math.min(Math.max(Number(limitParam) || 40, 1), 200);
|
||||
const points = await getMapPopulationSeries(limit);
|
||||
|
||||
let before: string | undefined;
|
||||
if (beforeParam) {
|
||||
const d = new Date(beforeParam);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return NextResponse.json({ error: 'invalid before param' }, { status: 400 });
|
||||
}
|
||||
before = formatDbDateTime(d);
|
||||
}
|
||||
|
||||
const points = await getMapPopulationSeries(limit, before);
|
||||
return NextResponse.json({ points });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
||||
import { steam2ToSteam64 } from '@/lib/steam';
|
||||
import { parseDbDateTime } from '@/lib/timezone';
|
||||
|
||||
interface SessionRow {
|
||||
steamid: string;
|
||||
current_name: string;
|
||||
session_start_dt: string;
|
||||
session_end_dt: string | null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const date = sp.get('date');
|
||||
const startTime = sp.get('startTime');
|
||||
const endTime = sp.get('endTime');
|
||||
|
||||
if (!date || !startTime || !endTime) {
|
||||
return NextResponse.json({ error: 'date, startTime, endTime are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const windowStartStr = `${date} ${startTime}:00`;
|
||||
const windowEndStr = `${date} ${endTime}:00`;
|
||||
const windowStartMs = parseDbDateTime(windowStartStr).getTime();
|
||||
const windowEndMs = parseDbDateTime(windowEndStr).getTime();
|
||||
const windowMinutes = Math.round((windowEndMs - windowStartMs) / 60000);
|
||||
|
||||
const rows = await query<SessionRow>(
|
||||
`SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name,
|
||||
s.session_start_dt, s.session_end_dt
|
||||
FROM playtime_display_sessions s
|
||||
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
|
||||
WHERE s.session_start_dt < ?
|
||||
AND (s.session_end_dt IS NULL OR s.session_end_dt > ?)`,
|
||||
[windowEndStr, windowStartStr],
|
||||
);
|
||||
|
||||
// Clip each session row to the window and accumulate per-player segments —
|
||||
// a player can have more than one segment if they disconnected and
|
||||
// reconnected within the window.
|
||||
interface Segment {
|
||||
startMin: number;
|
||||
endMin: number;
|
||||
}
|
||||
const byPlayer = new Map<string, { name: string; segments: Segment[]; totalMinutes: number }>();
|
||||
|
||||
for (const r of rows) {
|
||||
const sStart = parseDbDateTime(r.session_start_dt).getTime();
|
||||
const sEnd = r.session_end_dt ? parseDbDateTime(r.session_end_dt).getTime() : Date.now();
|
||||
const clippedStart = Math.max(sStart, windowStartMs);
|
||||
const clippedEnd = Math.min(sEnd, windowEndMs);
|
||||
if (clippedEnd <= clippedStart) continue;
|
||||
|
||||
const startMin = Math.round((clippedStart - windowStartMs) / 60000);
|
||||
const endMin = Math.round((clippedEnd - windowStartMs) / 60000);
|
||||
|
||||
if (!byPlayer.has(r.steamid)) {
|
||||
byPlayer.set(r.steamid, { name: r.current_name, segments: [], totalMinutes: 0 });
|
||||
}
|
||||
const entry = byPlayer.get(r.steamid)!;
|
||||
entry.segments.push({ startMin, endMin });
|
||||
entry.totalMinutes += endMin - startMin;
|
||||
}
|
||||
|
||||
const steam64ByPlayer = new Map<string, string>();
|
||||
for (const steamid of byPlayer.keys()) {
|
||||
const id64 = steam2ToSteam64(steamid);
|
||||
if (id64) steam64ByPlayer.set(steamid, id64);
|
||||
}
|
||||
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
|
||||
|
||||
const players = [...byPlayer.entries()]
|
||||
.map(([steamid, data]) => {
|
||||
const id64 = steam64ByPlayer.get(steamid);
|
||||
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
|
||||
return {
|
||||
steamid,
|
||||
name: data.name,
|
||||
avatarUrl,
|
||||
totalMinutes: data.totalMinutes,
|
||||
segments: data.segments,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalMinutes - a.totalMinutes);
|
||||
|
||||
return NextResponse.json({ windowMinutes, players });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
const MAX_DATES = 120; // safety cap — one query per matching date
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const daysParam = sp.get('days'); // comma list, JS convention: 0=Sun..6=Sat
|
||||
const startTime = sp.get('startTime'); // "06:00"
|
||||
const endTime = sp.get('endTime'); // "15:00"
|
||||
const startDate = sp.get('start'); // "2026-07-01"
|
||||
const endDate = sp.get('end'); // "2026-08-22"
|
||||
|
||||
if (!daysParam || !startTime || !endTime || !startDate || !endDate) {
|
||||
return NextResponse.json({ error: 'days, startTime, endTime, start, end are all required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const days = daysParam.split(',').map(Number).filter((d) => d >= 0 && d <= 6);
|
||||
if (days.length === 0) {
|
||||
return NextResponse.json({ error: 'no valid days selected' }, { status: 400 });
|
||||
}
|
||||
if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime) || startTime >= endTime) {
|
||||
return NextResponse.json({ error: 'startTime must be before endTime, both as HH:MM' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [startY, startM, startD] = startDate.split('-').map(Number);
|
||||
const [endY, endM, endD] = endDate.split('-').map(Number);
|
||||
if (!startY || !startM || !startD || !endY || !endM || !endD) {
|
||||
return NextResponse.json({ error: 'invalid start/end date' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Calendar-date arithmetic anchored to UTC purely as a way to enumerate
|
||||
// plain Y-M-D dates and their day-of-week unambiguously — these Date
|
||||
// objects never represent a real instant, just a calendar day.
|
||||
const cursor = new Date(Date.UTC(startY, startM - 1, startD));
|
||||
const endCursor = new Date(Date.UTC(endY, endM - 1, endD));
|
||||
if (cursor > endCursor) {
|
||||
return NextResponse.json({ error: 'start date must be before end date' }, { status: 400 });
|
||||
}
|
||||
|
||||
const matchingDates: string[] = [];
|
||||
while (cursor <= endCursor && matchingDates.length < MAX_DATES) {
|
||||
if (days.includes(cursor.getUTCDay())) {
|
||||
matchingDates.push(`${cursor.getUTCFullYear()}-${pad(cursor.getUTCMonth() + 1)}-${pad(cursor.getUTCDate())}`);
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
const truncated = matchingDates.length >= MAX_DATES && cursor <= endCursor;
|
||||
|
||||
// Each window boundary is built directly as a "YYYY-MM-DD HH:MM:SS"
|
||||
// string — already in the exact format the DB stores, so no Date
|
||||
// round-trip (and no timezone ambiguity) is needed for these queries.
|
||||
const points = await Promise.all(
|
||||
matchingDates.map(async (dateStr) => {
|
||||
const windowStart = `${dateStr} ${startTime}:00`;
|
||||
const windowEnd = `${dateStr} ${endTime}:00`;
|
||||
const rows = await query<{ steamid: string }>(
|
||||
`SELECT DISTINCT s.steamid
|
||||
FROM playtime_display_sessions s
|
||||
WHERE s.session_start_dt < ?
|
||||
AND (s.session_end_dt IS NULL OR s.session_end_dt > ?)`,
|
||||
[windowEnd, windowStart],
|
||||
);
|
||||
return { date: dateStr, count: rows.length };
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({ points, truncated });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { getCountriesSummary } from '@/lib/queries';
|
||||
import { countryCodeToFlag, countryCodeToName } from '@/lib/steam';
|
||||
import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam';
|
||||
|
||||
// This page queries the DB directly with no dynamic route segment, so
|
||||
// without this Next.js tries to statically pre-render it at BUILD time
|
||||
@@ -23,22 +23,29 @@ export default async function CountriesPage() {
|
||||
<div className="panel p-6 text-sm text-ink-muted">No countries recorded yet.</div>
|
||||
) : (
|
||||
<div className="panel divide-y divide-base-border">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-6 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
|
||||
<div>Country</div>
|
||||
<div className="text-right w-24">Total playtime</div>
|
||||
<div className="text-right w-24">Avg per player</div>
|
||||
</div>
|
||||
{countries.map((c) => (
|
||||
<Link
|
||||
key={c.current_country}
|
||||
href={`/countries/${encodeURIComponent(c.current_country)}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-6 items-center px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg" aria-hidden>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-lg shrink-0" aria-hidden>
|
||||
{countryCodeToFlag(c.current_country)}
|
||||
</span>
|
||||
<span className="text-ink text-sm">{countryCodeToName(c.current_country)}</span>
|
||||
<span className="text-ink-faint text-xs mono">{c.current_country}</span>
|
||||
</div>
|
||||
<div className="text-sm text-ink-muted mono">
|
||||
<span className="text-ink text-sm truncate">{countryCodeToName(c.current_country)}</span>
|
||||
<span className="text-ink-faint text-xs mono shrink-0">{c.current_country}</span>
|
||||
<span className="text-ink-muted text-xs mono shrink-0">
|
||||
{c.player_count} player{c.player_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-ink mono text-right w-24">{formatMinutes(c.total_minutes)}</div>
|
||||
<div className="text-sm text-ink-muted mono text-right w-24">{formatMinutes(Math.round(c.avg_minutes))}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-base text-ink font-sans antialiased;
|
||||
background-image: radial-gradient(ellipse 70% 45% at 50% -10%, rgba(63, 211, 122, 0.07), transparent 70%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -20,7 +22,11 @@
|
||||
|
||||
@layer components {
|
||||
.panel {
|
||||
@apply bg-base-panel border border-base-border rounded-lg;
|
||||
@apply bg-base-panel border border-base-border rounded-xl;
|
||||
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0) 40%);
|
||||
box-shadow:
|
||||
0 1px 0 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 12px 32px -16px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@@ -30,4 +36,13 @@
|
||||
.mono {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
|
||||
/* Subtle lift + accent-tinted background on interactive rows, used
|
||||
across search results, tables, and lists so it's consistent everywhere. */
|
||||
.row-interactive {
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
.row-interactive:hover {
|
||||
background-color: rgba(63, 211, 122, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-base-border">
|
||||
<header className="border-b border-base-border sticky top-0 z-10 bg-base/80 backdrop-blur-md">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent shadow-[0_0_8px_theme(colors.accent.DEFAULT)]" />
|
||||
@@ -32,9 +32,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm text-ink-muted hover:text-accent transition-colors"
|
||||
className="relative text-sm text-ink-muted hover:text-accent transition-colors group py-1"
|
||||
>
|
||||
{item.label}
|
||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-200 group-hover:w-full" />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -17,6 +17,11 @@ export default async function MapPeriodsPage({
|
||||
notFound();
|
||||
}
|
||||
|
||||
const totalMinutes = periods.reduce((sum, p) => {
|
||||
const end = p.map_end_dt ?? new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
return sum + (diffMinutes(p.map_start_dt, end) ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/maps" className="text-sm text-ink-muted hover:text-accent transition-colors">
|
||||
@@ -26,24 +31,49 @@ export default async function MapPeriodsPage({
|
||||
<div>
|
||||
<h1 className="text-2xl text-ink mb-1">{mapName}</h1>
|
||||
<p className="text-sm text-ink-muted">
|
||||
Played {periods.length} time{periods.length === 1 ? '' : 's'}.
|
||||
Played {periods.length} time{periods.length === 1 ? '' : 's'} · {formatMinutes(totalMinutes)} total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="panel divide-y divide-base-border">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
|
||||
<div>Session</div>
|
||||
<div className="text-right w-16">Players</div>
|
||||
<div className="text-right w-20">Duration</div>
|
||||
</div>
|
||||
{periods.map((p) => (
|
||||
<Link
|
||||
key={p.map_id}
|
||||
href={`/maps/period/${p.map_id}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||
className="group grid grid-cols-[1fr_auto_auto] gap-4 items-center px-4 py-3 hover:bg-base transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div>
|
||||
<div className="text-ink text-sm">
|
||||
{new Date(p.map_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||
{p.map_end_dt && (
|
||||
<span className="text-ink-faint">
|
||||
{' – '}
|
||||
{new Date(p.map_end_dt.replace(' ', 'T')).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!p.map_end_dt && <div className="text-xs text-accent">currently running</div>}
|
||||
</div>
|
||||
<div className="mono text-ink-faint text-sm">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
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 ml-auto"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mono text-ink-muted text-sm text-right w-16">{p.player_count}</div>
|
||||
<div className="mono text-ink-faint text-sm text-right w-20">
|
||||
{p.map_end_dt ? formatMinutes(diffMinutes(p.map_start_dt, p.map_end_dt)) : '—'}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -98,7 +98,8 @@ export default async function MapPeriodDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Players present */}
|
||||
{/* Players present + Map votes, side by side */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="panel p-6">
|
||||
<div className="stat-label mb-3">
|
||||
Players present ({players.length})
|
||||
@@ -130,7 +131,6 @@ export default async function MapPeriodDetailPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Votes */}
|
||||
<div className="panel p-6">
|
||||
<div className="stat-label mb-3">Map votes</div>
|
||||
{voteEvents.size === 0 ? (
|
||||
@@ -168,5 +168,6 @@ export default async function MapPeriodDetailPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PopulationChart from '@/components/PopulationChart';
|
||||
import MapPopulationChart from '@/components/MapPopulationChart';
|
||||
import RecurringWindowChart from '@/components/RecurringWindowChart';
|
||||
|
||||
export default function OverviewPage() {
|
||||
return (
|
||||
@@ -10,6 +11,7 @@ export default function OverviewPage() {
|
||||
</div>
|
||||
<PopulationChart />
|
||||
<MapPopulationChart />
|
||||
<RecurringWindowChart />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,16 +179,20 @@ export default function MapPopulationChart() {
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
|
||||
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
if (points.length === 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rect = svg!.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)
|
||||
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));
|
||||
|
||||
@@ -164,15 +210,21 @@ export default function MapPopulationChart() {
|
||||
}
|
||||
setViewStartIdx(newStart);
|
||||
setViewEndIdx(newEnd);
|
||||
ensureMoreIfNeeded(newStart);
|
||||
}
|
||||
|
||||
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);
|
||||
setViewStartIdx(startIdx);
|
||||
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);
|
||||
// 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,15 +308,23 @@ export default function PopulationChart() {
|
||||
if (p) loadPlayersAt(p);
|
||||
}
|
||||
|
||||
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
// 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 = e.currentTarget.getBoundingClientRect();
|
||||
const rect = svg!.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
|
||||
const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
|
||||
|
||||
const fullSpan = dataMaxEpoch - dataMinEpoch || 1;
|
||||
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);
|
||||
@@ -272,24 +333,33 @@ export default function PopulationChart() {
|
||||
let newStart = anchorEpoch - ratio * newSpan;
|
||||
let newEnd = newStart + newSpan;
|
||||
|
||||
if (newStart < dataMinEpoch) {
|
||||
newStart = dataMinEpoch;
|
||||
if (newStart < HARD_FLOOR_EPOCH) {
|
||||
newStart = HARD_FLOOR_EPOCH;
|
||||
newEnd = newStart + newSpan;
|
||||
}
|
||||
if (newEnd > dataMaxEpoch) {
|
||||
newEnd = dataMaxEpoch;
|
||||
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(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,9 +499,25 @@ 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 && (
|
||||
<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)}
|
||||
@@ -429,6 +526,7 @@ export default function PopulationChart() {
|
||||
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:00–15: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>
|
||||
);
|
||||
}
|
||||
@@ -92,25 +92,29 @@ export interface MapPopulationPoint {
|
||||
map_id: number;
|
||||
map_name: string;
|
||||
map_start_dt: string;
|
||||
players_online_at_start: number;
|
||||
map_end_dt: string | null;
|
||||
total_players: number;
|
||||
}
|
||||
|
||||
export async function getMapPopulationSeries(limit = 40): Promise<MapPopulationPoint[]> {
|
||||
// Player count at the moment each map period started — one data point per
|
||||
// map, letting the frontend chart whether population trends up or down
|
||||
// across the rotation. Fetched most-recent-first (for the LIMIT), then
|
||||
// the caller should reverse it back to chronological order for display.
|
||||
export async function getMapPopulationSeries(limit = 40, before?: string): Promise<MapPopulationPoint[]> {
|
||||
// Total DISTINCT players who were connected at any point during each map's
|
||||
// full session (not just at its start) — one data point per map. Fetched
|
||||
// most-recent-first (for the LIMIT), then the caller should reverse it
|
||||
// back to chronological order for display.
|
||||
// `before` (a DB-formatted datetime string) lets the caller page further
|
||||
// back into history than the initial batch, for pan-to-load-more charts.
|
||||
const rows = await query<MapPopulationPoint>(
|
||||
`SELECT m.map_id, m.map_name, m.map_start_dt,
|
||||
(SELECT COUNT(*) FROM playtime_display_sessions s
|
||||
WHERE s.session_start_dt < m.map_start_dt
|
||||
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt,
|
||||
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
|
||||
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
|
||||
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
|
||||
) AS players_online_at_start
|
||||
) AS total_players
|
||||
FROM playtime_display_map_history m
|
||||
WHERE ${REAL_MAP_PERIOD}
|
||||
${before ? 'AND m.map_start_dt < ?' : ''}
|
||||
ORDER BY m.map_start_dt DESC
|
||||
LIMIT ?`,
|
||||
[limit],
|
||||
before ? [before, limit] : [limit],
|
||||
);
|
||||
return rows.reverse();
|
||||
}
|
||||
@@ -118,17 +122,32 @@ export async function getMapPopulationSeries(limit = 40): Promise<MapPopulationP
|
||||
export interface CountrySummary {
|
||||
current_country: string;
|
||||
player_count: number;
|
||||
total_minutes: number;
|
||||
avg_minutes: number;
|
||||
}
|
||||
|
||||
export async function getCountriesSummary(): Promise<CountrySummary[]> {
|
||||
// Players with no resolved country are excluded — there's no meaningful
|
||||
// "unknown" country page to select.
|
||||
// "unknown" country page to select. Each player's playtime is the same
|
||||
// "latest session counter snapshot" logic used everywhere else, wrapped
|
||||
// in a derived table so it can be aggregated per country.
|
||||
return query<CountrySummary>(
|
||||
`SELECT current_country, COUNT(*) AS player_count
|
||||
FROM playtime_display_players
|
||||
WHERE current_country IS NOT NULL
|
||||
GROUP BY current_country
|
||||
ORDER BY player_count DESC, current_country ASC`,
|
||||
`SELECT p.current_country, COUNT(*) AS player_count,
|
||||
SUM(pt.total_minutes) AS total_minutes,
|
||||
AVG(pt.total_minutes) AS avg_minutes
|
||||
FROM playtime_display_players p
|
||||
JOIN (
|
||||
SELECT p2.steamid,
|
||||
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
|
||||
FROM playtime_display_sessions s
|
||||
WHERE s.steamid = p2.steamid
|
||||
ORDER BY s.session_id DESC
|
||||
LIMIT 1) AS total_minutes
|
||||
FROM playtime_display_players p2
|
||||
) pt ON pt.steamid = p.steamid
|
||||
WHERE p.current_country IS NOT NULL
|
||||
GROUP BY p.current_country
|
||||
ORDER BY player_count DESC, p.current_country ASC`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +183,7 @@ export interface MapSummary {
|
||||
map_name: string;
|
||||
times_played: number;
|
||||
last_played: string;
|
||||
total_minutes: number;
|
||||
}
|
||||
|
||||
export interface MapPeriod {
|
||||
@@ -171,6 +191,7 @@ export interface MapPeriod {
|
||||
map_name: string;
|
||||
map_start_dt: string;
|
||||
map_end_dt: string | null;
|
||||
player_count: number;
|
||||
}
|
||||
|
||||
export interface MapPresentPlayer {
|
||||
@@ -194,7 +215,8 @@ export interface MapVoteRow {
|
||||
export async function searchMapNames(q: string, limit = 50): Promise<MapSummary[]> {
|
||||
const like = `%${q}%`;
|
||||
return query<MapSummary>(
|
||||
`SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played
|
||||
`SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played,
|
||||
SUM(TIMESTAMPDIFF(MINUTE, m.map_start_dt, COALESCE(${NEXT_MAP_START('m')}, NOW()))) AS total_minutes
|
||||
FROM playtime_display_map_history m
|
||||
WHERE m.map_name LIKE ?
|
||||
AND ${REAL_MAP_PERIOD}
|
||||
@@ -207,7 +229,11 @@ export async function searchMapNames(q: string, limit = 50): Promise<MapSummary[
|
||||
|
||||
export async function getMapPeriods(mapName: string): Promise<MapPeriod[]> {
|
||||
return query<MapPeriod>(
|
||||
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
|
||||
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt,
|
||||
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
|
||||
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
|
||||
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
|
||||
) AS player_count
|
||||
FROM playtime_display_map_history m
|
||||
WHERE m.map_name = ?
|
||||
AND ${REAL_MAP_PERIOD}
|
||||
@@ -218,7 +244,11 @@ export async function getMapPeriods(mapName: string): Promise<MapPeriod[]> {
|
||||
|
||||
export async function getMapPeriod(mapId: number): Promise<MapPeriod | null> {
|
||||
const rows = await query<MapPeriod>(
|
||||
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
|
||||
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt,
|
||||
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
|
||||
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
|
||||
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
|
||||
) AS player_count
|
||||
FROM playtime_display_map_history m
|
||||
WHERE m.map_id = ?`,
|
||||
[mapId],
|
||||
|
||||
@@ -15,9 +15,12 @@ export function steamProfileUrl(steamId2: string): string | null {
|
||||
return id64 ? `https://steamcommunity.com/profiles/${id64}` : null;
|
||||
}
|
||||
|
||||
// Formats total minutes as "123h 45m" for display.
|
||||
// Formats total minutes as "123h 45m" for display. Negative values (which
|
||||
// shouldn't be possible, but have shown up from anomalous underlying data —
|
||||
// e.g. a player_time counter that decreased between two session snapshots)
|
||||
// are shown as "—" rather than a nonsensical negative duration.
|
||||
export function formatMinutes(totalMinutes: number | null): string {
|
||||
if (totalMinutes == null || Number.isNaN(totalMinutes)) return '—';
|
||||
if (totalMinutes == null || Number.isNaN(totalMinutes) || totalMinutes < 0) return '—';
|
||||
const h = Math.floor(totalMinutes / 60);
|
||||
const m = totalMinutes % 60;
|
||||
return `${h}h ${m}m`;
|
||||
|
||||
Reference in New Issue
Block a user