updating the playtime frontend with claude only code to replace the one graph with a dumb-bell graph, having each individual map show its players at beginning versus end and also the overall impact of maps + their popularity
This commit is contained in:
@@ -16,9 +16,11 @@ Node process; nginx reverse-proxies a subdomain to it.
|
|||||||
- `/` — Overview: concurrent-player population graph (hand-rolled SVG, not
|
- `/` — Overview: concurrent-player population graph (hand-rolled SVG, not
|
||||||
a charting library — scroll to zoom, drag to pan all the way back to
|
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
|
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
|
population chart (dumbbell-style: grey dot = population when the map
|
||||||
window" chart for questions like "who's online 6–15h every Tuesday"
|
started, colored dot = population when it ended, green/red depending on
|
||||||
(pick days of week + time window + date range)
|
whether it grew or shrank — 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
|
- `/players` — search players by current/previous name or SteamID (shows
|
||||||
which old name matched, if any), sortable by recent activity, name, or
|
which old name matched, if any), sortable by recent activity, name, or
|
||||||
highest playtime
|
highest playtime
|
||||||
@@ -27,9 +29,16 @@ Node process; nginx reverse-proxies a subdomain to it.
|
|||||||
history (with the map(s) each session overlapped)
|
history (with the map(s) each session overlapped)
|
||||||
- `/maps` — search maps by name
|
- `/maps` — search maps by name
|
||||||
- `/maps/[mapname]` — every time period that map was played
|
- `/maps/[mapname]` — every time period that map was played
|
||||||
- `/maps/period/[mapId]` — one specific occurrence: players present at any
|
- `/maps/impact` — leaderboard ranking maps by how they tend to affect
|
||||||
point during it, votes cast (grouped by vote event, since a map could
|
population (average start→end player-count change per occurrence,
|
||||||
theoretically see more than one), and prev/next map navigation
|
average retention %, best/worst single session), toggleable between
|
||||||
|
impact and raw popularity (times played), rather than the plain
|
||||||
|
played-in-order list on `/maps`
|
||||||
|
- `/maps/period/[mapId]` — one specific occurrence: a zoomed-in population
|
||||||
|
timeline for just that session's window (padded a little on each side so
|
||||||
|
the transition in/out is visible), 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
|
- `/countries` — every country with at least one player, sorted by player
|
||||||
count descending
|
count descending
|
||||||
- `/countries/[code]` — players from that country, sortable by highest
|
- `/countries/[code]` — players from that country, sortable by highest
|
||||||
@@ -37,7 +46,9 @@ Node process; nginx reverse-proxies a subdomain to it.
|
|||||||
|
|
||||||
A `favicon.ico` in `app/` is picked up automatically by Next — no config needed.
|
A `favicon.ico` in `app/` is picked up automatically by Next — no config needed.
|
||||||
|
|
||||||
Every feature from the original planning conversation is now built.
|
Every feature from the original planning conversation is now built, including
|
||||||
|
the map-population-impact features (dumbbell chart, impact leaderboard,
|
||||||
|
per-session zoomed timeline) discussed most recently.
|
||||||
|
|
||||||
## Deploying on the Hetzner box
|
## Deploying on the Hetzner box
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMapPeriod } from '@/lib/queries';
|
||||||
|
import { parseDbDate } from '@/lib/dates';
|
||||||
|
import { computePopulationSeries } from '@/lib/populationSeries';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const mapIdParam = req.nextUrl.searchParams.get('mapId');
|
||||||
|
const mapId = Number(mapIdParam);
|
||||||
|
if (!Number.isInteger(mapId)) {
|
||||||
|
return NextResponse.json({ error: 'mapId query param is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = await getMapPeriod(mapId);
|
||||||
|
if (!period) {
|
||||||
|
return NextResponse.json({ error: 'map session not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStart = parseDbDate(period.map_start_dt);
|
||||||
|
const mapEnd = period.map_end_dt ? parseDbDate(period.map_end_dt) : new Date();
|
||||||
|
|
||||||
|
// Pad a bit on either side of the session's own window so the chart
|
||||||
|
// shows the transition into and out of the map, not just a flat line
|
||||||
|
// bounded exactly at its edges. Capped relative to the session's own
|
||||||
|
// duration so a 10-minute map doesn't get a 10-minute pad that's as long
|
||||||
|
// as the map itself.
|
||||||
|
const durationMs = Math.max(1, mapEnd.getTime() - mapStart.getTime());
|
||||||
|
const padMs = Math.min(10 * 60_000, Math.max(1 * 60_000, durationMs * 0.25));
|
||||||
|
|
||||||
|
const rangeStart = new Date(mapStart.getTime() - padMs);
|
||||||
|
const now = new Date();
|
||||||
|
const rangeEnd = period.map_end_dt ? new Date(Math.min(mapEnd.getTime() + padMs, now.getTime())) : now;
|
||||||
|
|
||||||
|
// Smaller target than the general population chart (150) — map sessions
|
||||||
|
// are usually tens of minutes to a couple hours, and a coarser bucket
|
||||||
|
// would flatten exactly the kind of mid-session swing this view exists
|
||||||
|
// to surface.
|
||||||
|
const { bucketMinutes, points, mapBoundaries } = await computePopulationSeries(rangeStart, rangeEnd, {
|
||||||
|
targetPoints: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
bucketMinutes,
|
||||||
|
points,
|
||||||
|
mapBoundaries,
|
||||||
|
mapStart: mapStart.toISOString(),
|
||||||
|
mapEnd: period.map_end_dt ? mapEnd.toISOString() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMapImpactLeaderboard } from '@/lib/queries';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sortByParam = req.nextUrl.searchParams.get('sortBy');
|
||||||
|
const directionParam = req.nextUrl.searchParams.get('direction');
|
||||||
|
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
|
||||||
|
|
||||||
|
const sortBy = sortByParam === 'popularity' ? 'popularity' : 'impact';
|
||||||
|
// Impact defaults to ascending (worst/most population-harming first, the
|
||||||
|
// whole point of the feature); popularity defaults to descending (most
|
||||||
|
// played first). Either can be flipped explicitly via `direction`.
|
||||||
|
const direction = directionParam === 'asc' || directionParam === 'desc'
|
||||||
|
? directionParam
|
||||||
|
: sortBy === 'popularity' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
const maps = await getMapImpactLeaderboard(sortBy, direction, PAGE_SIZE, offset);
|
||||||
|
const hasMore = maps.length === PAGE_SIZE;
|
||||||
|
|
||||||
|
return NextResponse.json({ maps, hasMore });
|
||||||
|
}
|
||||||
@@ -1,27 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { computePopulationSeries } from '@/lib/populationSeries';
|
||||||
import { parseDbDate } from '@/lib/dates';
|
|
||||||
import { formatDbDateTime } from '@/lib/timezone';
|
|
||||||
|
|
||||||
interface SessionRow {
|
|
||||||
session_start_dt: string;
|
|
||||||
session_end_dt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MapRow {
|
|
||||||
map_id: number;
|
|
||||||
map_name: string;
|
|
||||||
map_start_dt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Picks a bucket size that keeps the number of points in a sane range
|
|
||||||
// (roughly 60-300 buckets) regardless of how wide the requested range is.
|
|
||||||
function pickBucketMinutes(rangeMinutes: number): number {
|
|
||||||
const target = 150;
|
|
||||||
const raw = Math.ceil(rangeMinutes / target);
|
|
||||||
const steps = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]; // minutes
|
|
||||||
return steps.find((s) => s >= raw) ?? steps[steps.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const searchParams = req.nextUrl.searchParams;
|
const searchParams = req.nextUrl.searchParams;
|
||||||
@@ -39,95 +17,6 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'invalid or inverted start/end range' }, { status: 400 });
|
return NextResponse.json({ error: 'invalid or inverted start/end range' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to MySQL-native "YYYY-MM-DD HH:MM:SS" strings (in the DB's own
|
const { bucketMinutes, points, mapBoundaries } = await computePopulationSeries(rangeStart, rangeEnd);
|
||||||
// timezone) rather than passing raw ISO-Z strings as query parameters —
|
|
||||||
// MySQL DATETIME columns don't reliably parse the T/Z/milliseconds ISO
|
|
||||||
// format, which was silently dropping/mismatching rows in this fetch.
|
|
||||||
const formattedEnd = formatDbDateTime(rangeEnd);
|
|
||||||
const formattedStart = formatDbDateTime(rangeStart);
|
|
||||||
// Generous safety margin so the map fetch below doesn't scan the entire
|
|
||||||
// history table as it grows over months/years — 24h is far longer than
|
|
||||||
// any realistic single map duration, so this can never miss the map that
|
|
||||||
// was actually active at rangeStart.
|
|
||||||
const formattedLookback = formatDbDateTime(new Date(rangeStart.getTime() - 24 * 60 * 60 * 1000));
|
|
||||||
|
|
||||||
const [sessionRows, mapRows] = await Promise.all([
|
|
||||||
query<SessionRow>(
|
|
||||||
`SELECT session_start_dt, session_end_dt
|
|
||||||
FROM playtime_display_sessions
|
|
||||||
WHERE session_start_dt < ?
|
|
||||||
AND (session_end_dt IS NULL OR session_end_dt > ?)`,
|
|
||||||
[formattedEnd, formattedStart],
|
|
||||||
),
|
|
||||||
// map_end_dt was dropped from the schema (redundant with, and
|
|
||||||
// occasionally out of sync with, the next row's map_start_dt). Filter
|
|
||||||
// tickrate-restart artifacts (<2min) using the gap to the next map;
|
|
||||||
// the currently-running map (no next row yet) always passes.
|
|
||||||
query<MapRow>(
|
|
||||||
`SELECT m.map_id, m.map_name, m.map_start_dt
|
|
||||||
FROM playtime_display_map_history m
|
|
||||||
WHERE m.map_start_dt < ?
|
|
||||||
AND m.map_start_dt > ?
|
|
||||||
AND (
|
|
||||||
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt) IS NULL
|
|
||||||
OR TIMESTAMPDIFF(
|
|
||||||
MINUTE, m.map_start_dt,
|
|
||||||
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt)
|
|
||||||
) >= 2
|
|
||||||
)
|
|
||||||
ORDER BY m.map_start_dt`,
|
|
||||||
[formattedEnd, formattedLookback],
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sessions = sessionRows.map((r) => ({
|
|
||||||
start: parseDbDate(r.session_start_dt),
|
|
||||||
end: r.session_end_dt ? parseDbDate(r.session_end_dt) : new Date(), // still-open session heartbeat, see plugin notes
|
|
||||||
}));
|
|
||||||
|
|
||||||
const maps = mapRows.map((r) => ({
|
|
||||||
mapId: r.map_id,
|
|
||||||
mapName: r.map_name,
|
|
||||||
start: parseDbDate(r.map_start_dt),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// `maps` is sorted ascending by start_dt (from the ORDER BY in the query
|
|
||||||
// above). Deliberately does NOT check each row's own end time — a map
|
|
||||||
// whose map_end_dt never got closed (e.g. OnMapEnd not firing for a
|
|
||||||
// specific transition, due to a crash/restart/admin changelevel) would
|
|
||||||
// otherwise look "still running" forever and permanently shadow every
|
|
||||||
// real map that came after it. Instead: whichever map most recently
|
|
||||||
// started by time t is what was playing then, regardless of whether its
|
|
||||||
// own end timestamp was ever reliably recorded.
|
|
||||||
function mapNameAt(t: Date): string | null {
|
|
||||||
let result: string | null = null;
|
|
||||||
for (const m of maps) {
|
|
||||||
if (m.start <= t) {
|
|
||||||
result = m.mapName;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rangeMinutes = (rangeEnd.getTime() - rangeStart.getTime()) / 60000;
|
|
||||||
const bucketMinutes = pickBucketMinutes(rangeMinutes);
|
|
||||||
|
|
||||||
const points: { t: string; players: number; mapName: string | null }[] = [];
|
|
||||||
for (let t = rangeStart.getTime(); t < rangeEnd.getTime(); t += bucketMinutes * 60000) {
|
|
||||||
const bucketStart = new Date(t);
|
|
||||||
const bucketEnd = new Date(t + bucketMinutes * 60000);
|
|
||||||
const count = sessions.filter((s) => s.start < bucketEnd && s.end > bucketStart).length;
|
|
||||||
points.push({ t: bucketStart.toISOString(), players: count, mapName: mapNameAt(bucketStart) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map-change boundaries within the visible range, for drawing vertical
|
|
||||||
// separator lines on the chart — only the ones whose start actually
|
|
||||||
// falls inside [rangeStart, rangeEnd).
|
|
||||||
const mapBoundaries = maps
|
|
||||||
.filter((m) => m.start >= rangeStart && m.start < rangeEnd)
|
|
||||||
.map((m) => ({ mapId: m.mapId, mapName: m.mapName, t: m.start.toISOString() }));
|
|
||||||
|
|
||||||
return NextResponse.json({ bucketMinutes, points, mapBoundaries });
|
return NextResponse.json({ bucketMinutes, points, mapBoundaries });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import MapImpactLeaderboard from '@/components/MapImpactLeaderboard';
|
||||||
|
|
||||||
|
export default function MapsImpactPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link href="/maps" className="text-sm text-ink-muted hover:text-accent transition-colors">
|
||||||
|
← Back to maps
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl text-ink mb-1">Map impact</h1>
|
||||||
|
<p className="text-sm text-ink-muted">
|
||||||
|
How each map tends to affect server population, not just how often it's played. Avg Δ is the
|
||||||
|
average change in concurrent players from when a map started to when it ended, across every time
|
||||||
|
it's been played — negative means the map tends to lose players, positive means it tends to
|
||||||
|
gain them. Retention is the average end-vs-start population as a percentage. Best/worst Δ are the
|
||||||
|
single best and single worst swings recorded for that map.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapImpactLeaderboard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import MapSearch from '@/components/MapSearch';
|
import MapSearch from '@/components/MapSearch';
|
||||||
|
|
||||||
export default function MapsPage() {
|
export default function MapsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h1 className="text-2xl text-ink mb-1">Maps</h1>
|
<div>
|
||||||
<p className="text-sm text-ink-muted">Search for a map to see when it was played.</p>
|
<h1 className="text-2xl text-ink mb-1">Maps</h1>
|
||||||
|
<p className="text-sm text-ink-muted">Search for a map to see when it was played.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/maps/impact"
|
||||||
|
className="text-sm text-accent hover:underline flex items-center gap-1 shrink-0"
|
||||||
|
>
|
||||||
|
View maps by population impact
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<MapSearch />
|
<MapSearch />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { attachAvatars } from '@/lib/steamApi';
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
import { diffMinutes } from '@/lib/dates';
|
import { diffMinutes } from '@/lib/dates';
|
||||||
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
||||||
|
import MapSessionTimelineChart from '@/components/MapSessionTimelineChart';
|
||||||
|
|
||||||
export default async function MapPeriodDetailPage({
|
export default async function MapPeriodDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -135,6 +136,8 @@ export default async function MapPeriodDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MapSessionTimelineChart mapId={mapId} />
|
||||||
|
|
||||||
{/* Players present + Map votes, side by side */}
|
{/* Players present + Map votes, side by side */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { formatMinutes } from '@/lib/steam';
|
||||||
|
import { useInfiniteScroll } from '@/lib/useInfiniteScroll';
|
||||||
|
|
||||||
|
interface MapImpactRow {
|
||||||
|
map_name: string;
|
||||||
|
times_played: number;
|
||||||
|
total_minutes: number;
|
||||||
|
last_played: string;
|
||||||
|
avg_net_change: number | null;
|
||||||
|
avg_retention_pct: number | null;
|
||||||
|
best_session_change: number | null;
|
||||||
|
worst_session_change: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortBy = 'impact' | 'popularity';
|
||||||
|
type Direction = 'asc' | 'desc';
|
||||||
|
|
||||||
|
function ChangeBadge({ value }: { value: number | null }) {
|
||||||
|
if (value == null) return <span className="text-ink-faint">—</span>;
|
||||||
|
const rounded = Math.round(value * 10) / 10;
|
||||||
|
const color = rounded > 0 ? '#3FD37A' : rounded < 0 ? '#E0665C' : '#8A9691';
|
||||||
|
return (
|
||||||
|
<span className="mono" style={{ color }}>
|
||||||
|
{rounded > 0 ? '+' : ''}
|
||||||
|
{rounded}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapImpactLeaderboard() {
|
||||||
|
const [sortBy, setSortBy] = useState<SortBy>('impact');
|
||||||
|
const [direction, setDirection] = useState<Direction>('asc'); // impact default: worst (most population-harming) first
|
||||||
|
const [maps, setMaps] = useState<MapImpactRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
|
||||||
|
async function fetchPage(sBy: SortBy, dir: Direction, offset: number) {
|
||||||
|
const params = new URLSearchParams({ sortBy: sBy, direction: dir, offset: String(offset) });
|
||||||
|
const res = await fetch(`/api/maps/impact?${params.toString()}`);
|
||||||
|
return res.json() as Promise<{ maps: MapImpactRow[]; hasMore: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
offsetRef.current = 0;
|
||||||
|
fetchPage(sortBy, direction, 0).then((data) => {
|
||||||
|
setMaps(data.maps);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current = data.maps.length;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [sortBy, direction]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMoreRef.current || !hasMore || loading) return;
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
setLoadingMore(true);
|
||||||
|
const data = await fetchPage(sortBy, direction, offsetRef.current);
|
||||||
|
setMaps((prev) => [...prev, ...data.maps]);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current += data.maps.length;
|
||||||
|
setLoadingMore(false);
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll(loadMore, !loading && hasMore);
|
||||||
|
|
||||||
|
function switchSort(next: SortBy) {
|
||||||
|
if (next === sortBy) return;
|
||||||
|
setSortBy(next);
|
||||||
|
// Each metric has its own sensible default direction.
|
||||||
|
setDirection(next === 'popularity' ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex rounded-lg border border-base-border overflow-hidden text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => switchSort('impact')}
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
sortBy === 'impact' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Sort by impact
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchSort('popularity')}
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-base-border ${
|
||||||
|
sortBy === 'popularity' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Sort by popularity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||||
|
className="text-xs text-ink-muted hover:text-accent transition-colors underline"
|
||||||
|
>
|
||||||
|
{sortBy === 'impact'
|
||||||
|
? direction === 'asc'
|
||||||
|
? 'Showing worst impact first'
|
||||||
|
: 'Showing best impact first'
|
||||||
|
: direction === 'desc'
|
||||||
|
? 'Showing most played first'
|
||||||
|
: 'Showing least played first'}
|
||||||
|
{' '}— flip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel row-divide">
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
|
||||||
|
<div>Map</div>
|
||||||
|
<div className="text-right w-16">Played</div>
|
||||||
|
<div className="text-right w-24">Avg Δ</div>
|
||||||
|
<div className="text-right w-20">Retention</div>
|
||||||
|
<div className="text-right w-28">Best / worst Δ</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
|
||||||
|
) : maps.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-ink-muted">No completed map sessions recorded yet.</div>
|
||||||
|
) : (
|
||||||
|
maps.map((m, i) => (
|
||||||
|
<Link
|
||||||
|
key={m.map_name}
|
||||||
|
href={`/maps/${encodeURIComponent(m.map_name)}`}
|
||||||
|
className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 items-center px-4 py-3 hover:bg-base transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-ink-faint text-xs w-6 text-right shrink-0">{i + 1}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-ink truncate">{m.map_name}</div>
|
||||||
|
<div className="text-xs text-ink-faint">{formatMinutes(m.total_minutes)} total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mono text-ink-muted text-right w-16">{m.times_played}×</div>
|
||||||
|
<div className="text-right w-24">
|
||||||
|
<ChangeBadge value={m.avg_net_change} />
|
||||||
|
</div>
|
||||||
|
<div className="mono text-ink-muted text-right w-20">
|
||||||
|
{m.avg_retention_pct == null ? '—' : `${Math.round(m.avg_retention_pct)}%`}
|
||||||
|
</div>
|
||||||
|
<div className="text-right w-28 text-xs">
|
||||||
|
<ChangeBadge value={m.best_session_change} /> <span className="text-ink-faint">/</span>{' '}
|
||||||
|
<ChangeBadge value={m.worst_session_change} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && maps.length > 0 && (
|
||||||
|
<div ref={sentinelRef} className="p-4 text-center">
|
||||||
|
{loadingMore ? (
|
||||||
|
<span className="text-xs text-ink-faint font-mono">loading more…</span>
|
||||||
|
) : !hasMore ? (
|
||||||
|
<span className="text-xs text-ink-faint">— end of list —</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ interface MapPoint {
|
|||||||
map_start_dt: string;
|
map_start_dt: string;
|
||||||
map_end_dt: string | null;
|
map_end_dt: string | null;
|
||||||
total_players: number;
|
total_players: number;
|
||||||
|
start_players: number;
|
||||||
|
end_players: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayerChip {
|
interface PlayerChip {
|
||||||
@@ -120,22 +122,22 @@ export default function MapPopulationChart() {
|
|||||||
const visiblePoints = points.slice(viewStartIdx, viewEndIdx);
|
const visiblePoints = points.slice(viewStartIdx, viewEndIdx);
|
||||||
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
|
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
|
||||||
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
|
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
|
||||||
const maxValue = Math.max(1, ...visiblePoints.map((p) => p.total_players));
|
// Scale is driven by the dumbbell endpoints (start/end concurrent
|
||||||
|
// population), not total_players — total_players is cumulative reach
|
||||||
|
// across the whole session and would otherwise dwarf the actual
|
||||||
|
// population swings the chart exists to show.
|
||||||
|
const maxValue = Math.max(1, ...visiblePoints.flatMap((p) => [p.start_players, p.end_players]));
|
||||||
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
|
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
|
||||||
|
|
||||||
const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
|
const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
|
||||||
const barWidth = Math.max(2, slotWidth * 0.6);
|
|
||||||
|
|
||||||
function barX(i: number) {
|
function barX(i: number) {
|
||||||
return PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2;
|
return PAD_LEFT + i * slotWidth + slotWidth / 2;
|
||||||
}
|
}
|
||||||
function barY(value: number) {
|
function barY(value: number) {
|
||||||
const h = (value / axisMax) * plotHeight;
|
const h = (value / axisMax) * plotHeight;
|
||||||
return PAD_TOP + (plotHeight - h);
|
return PAD_TOP + (plotHeight - h);
|
||||||
}
|
}
|
||||||
function barHeight(value: number) {
|
|
||||||
return Math.max(value > 0 ? 2 : 0, (value / axisMax) * plotHeight);
|
|
||||||
}
|
|
||||||
function indexForX(x: number): number {
|
function indexForX(x: number): number {
|
||||||
if (slotWidth === 0) return 0;
|
if (slotWidth === 0) return 0;
|
||||||
return Math.max(0, Math.min(visiblePoints.length - 1, Math.floor((x - PAD_LEFT) / slotWidth)));
|
return Math.max(0, Math.min(visiblePoints.length - 1, Math.floor((x - PAD_LEFT) / slotWidth)));
|
||||||
@@ -236,6 +238,11 @@ export default function MapPopulationChart() {
|
|||||||
{visiblePoints.length} of {points.length}
|
{visiblePoints.length} of {points.length}
|
||||||
{reachedStart ? '' : '+'} maps
|
{reachedStart ? '' : '+'} maps
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-xs text-ink-faint mt-1">
|
||||||
|
Each line shows a map's population at the moment it started (grey dot) versus when it ended
|
||||||
|
(green = grew, red = shrank) — so a decline shows up on the map where it actually happened,
|
||||||
|
not the one that started low right after.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 mb-2">
|
<div className="flex items-center justify-between gap-4 mb-2">
|
||||||
@@ -244,7 +251,7 @@ export default function MapPopulationChart() {
|
|||||||
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
|
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
</svg>
|
</svg>
|
||||||
Scroll to zoom, drag to pan (all the way back to 2015), click a bar for who was online
|
Scroll to zoom, drag to pan (all the way back to 2015), click a line for who was online
|
||||||
{fetchingMore && <span className="text-ink-faint">— loading more…</span>}
|
{fetchingMore && <span className="text-ink-faint">— loading more…</span>}
|
||||||
</div>
|
</div>
|
||||||
{isZoomed && (
|
{isZoomed && (
|
||||||
@@ -287,13 +294,6 @@ 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) => {
|
{yTicks.map((v) => {
|
||||||
const y = barY(v);
|
const y = barY(v);
|
||||||
return (
|
return (
|
||||||
@@ -306,19 +306,31 @@ export default function MapPopulationChart() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{visiblePoints.map((p, i) => (
|
{visiblePoints.map((p, i) => {
|
||||||
<rect
|
const x = barX(i);
|
||||||
key={p.map_id}
|
const yStart = barY(p.start_players);
|
||||||
x={barX(i)}
|
const yEnd = barY(p.end_players);
|
||||||
y={barY(p.total_players)}
|
const grew = p.end_players > p.start_players;
|
||||||
width={barWidth}
|
const shrank = p.end_players < p.start_players;
|
||||||
height={barHeight(p.total_players)}
|
const endColor = grew ? '#3FD37A' : shrank ? '#E0665C' : '#8A9691';
|
||||||
rx={3}
|
const active = hoverIndex === i;
|
||||||
fill="url(#mapBarGradient)"
|
return (
|
||||||
fillOpacity={hoverIndex === i ? 1 : 0.8}
|
<g key={p.map_id} opacity={active ? 1 : 0.85}>
|
||||||
style={hoverIndex === i ? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' } : undefined}
|
<line
|
||||||
/>
|
x1={x}
|
||||||
))}
|
y1={yStart}
|
||||||
|
x2={x}
|
||||||
|
y2={yEnd}
|
||||||
|
stroke={endColor}
|
||||||
|
strokeWidth={active ? 2.5 : 1.75}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={active ? { filter: 'drop-shadow(0 0 5px rgba(63, 211, 122, 0.45))' } : undefined}
|
||||||
|
/>
|
||||||
|
<circle cx={x} cy={yStart} r={active ? 4 : 3} fill="#8A9691" />
|
||||||
|
<circle cx={x} cy={yEnd} r={active ? 5 : 3.5} fill={endColor} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<line
|
<line
|
||||||
x1={PAD_LEFT}
|
x1={PAD_LEFT}
|
||||||
@@ -333,8 +345,12 @@ export default function MapPopulationChart() {
|
|||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none"
|
className="absolute pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: Math.min(width - 200, Math.max(0, barX(hoverIndex) - 60)),
|
left: Math.min(width - 210, Math.max(0, barX(hoverIndex) - 60)),
|
||||||
top: barY(visiblePoints[hoverIndex].total_players) - 70,
|
top:
|
||||||
|
Math.min(
|
||||||
|
barY(visiblePoints[hoverIndex].start_players),
|
||||||
|
barY(visiblePoints[hoverIndex].end_players),
|
||||||
|
) - 92,
|
||||||
background: '#121715',
|
background: '#121715',
|
||||||
border: '1px solid #1F2723',
|
border: '1px solid #1F2723',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
@@ -347,8 +363,21 @@ export default function MapPopulationChart() {
|
|||||||
<div style={{ color: '#8A9691' }}>
|
<div style={{ color: '#8A9691' }}>
|
||||||
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
|
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#3FD37A' }}>
|
<div style={{ color: '#E4EBE7', marginTop: 2 }}>
|
||||||
{visiblePoints[hoverIndex].total_players} total players during this session
|
{visiblePoints[hoverIndex].start_players} → {visiblePoints[hoverIndex].end_players} players
|
||||||
|
{(() => {
|
||||||
|
const delta = visiblePoints[hoverIndex].end_players - visiblePoints[hoverIndex].start_players;
|
||||||
|
const color = delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691';
|
||||||
|
return (
|
||||||
|
<span style={{ color, marginLeft: 6 }}>
|
||||||
|
({delta > 0 ? '+' : ''}
|
||||||
|
{delta})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#8A9691', marginTop: 2 }}>
|
||||||
|
{visiblePoints[hoverIndex].total_players} distinct players during this session (reach)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { query } from '@/lib/db';
|
||||||
|
import { parseDbDate } from '@/lib/dates';
|
||||||
|
|
||||||
|
// --- Why this exists ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// The original implementation of this leaderboard did everything in SQL:
|
||||||
|
// for every map_history row it ran correlated subqueries to (a) find the
|
||||||
|
// next map's start time (a MIN(...) scan over the whole table) and (b)
|
||||||
|
// count DISTINCT steamids with a session overlapping a specific instant (a
|
||||||
|
// scan over the whole sessions table) — twice per row (once for the
|
||||||
|
// occurrence's start, once for its end). That's roughly
|
||||||
|
// O(maps_rows * (maps_rows + sessions_rows)) of work done by MySQL on every
|
||||||
|
// request, which is what made /maps/impact extremely slow once both tables
|
||||||
|
// grew to real production size.
|
||||||
|
//
|
||||||
|
// This version fetches the two tables' raw columns once (cheap — no joins,
|
||||||
|
// no correlated subqueries) and does the equivalent computation in Node
|
||||||
|
// with a sweep-line: sort all session start/end instants once, then
|
||||||
|
// answering "how many sessions were active at instant t" is a binary
|
||||||
|
// search instead of a full table scan. That turns the whole computation
|
||||||
|
// into roughly O((maps + sessions) * log(sessions)), done once, and it's
|
||||||
|
// then cached (see getMapImpactLeaderboard below) so most requests don't
|
||||||
|
// even pay that.
|
||||||
|
|
||||||
|
interface RawMapRow {
|
||||||
|
map_id: number;
|
||||||
|
map_name: string;
|
||||||
|
map_start_dt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawSessionRow {
|
||||||
|
session_start_dt: string;
|
||||||
|
session_end_dt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapImpactSummary {
|
||||||
|
map_name: string;
|
||||||
|
times_played: number;
|
||||||
|
total_minutes: number;
|
||||||
|
last_played: string;
|
||||||
|
avg_net_change: number | null;
|
||||||
|
avg_retention_pct: number | null;
|
||||||
|
best_session_change: number | null;
|
||||||
|
worst_session_change: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search: count of values in a sorted ascending array that are <= target.
|
||||||
|
function countLessOrEqual(sorted: number[], target: number): number {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = sorted.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (sorted[mid] <= target) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeMapImpactStats(): Promise<MapImpactSummary[]> {
|
||||||
|
const [mapRows, sessionRows] = await Promise.all([
|
||||||
|
query<RawMapRow>(
|
||||||
|
`SELECT map_id, map_name, map_start_dt
|
||||||
|
FROM playtime_display_map_history
|
||||||
|
ORDER BY map_start_dt ASC`,
|
||||||
|
),
|
||||||
|
query<RawSessionRow>(
|
||||||
|
`SELECT session_start_dt, session_end_dt
|
||||||
|
FROM playtime_display_sessions`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sweep-line population lookup: a session is "active at instant t" iff
|
||||||
|
// session_start_dt <= t AND (session_end_dt IS NULL OR session_end_dt > t)
|
||||||
|
// — same semantics the old SQL used. count(starts <= t) counts every
|
||||||
|
// session that had begun by t; subtracting count(ends <= t) removes
|
||||||
|
// exactly those that have also already ended by t, leaving the active
|
||||||
|
// count. Sessions with a NULL end never appear in `ends`, so they count
|
||||||
|
// as active forever once started — matching "IS NULL" in the original.
|
||||||
|
const starts: number[] = [];
|
||||||
|
const ends: number[] = [];
|
||||||
|
for (const s of sessionRows) {
|
||||||
|
starts.push(parseDbDate(s.session_start_dt).getTime());
|
||||||
|
if (s.session_end_dt) ends.push(parseDbDate(s.session_end_dt).getTime());
|
||||||
|
}
|
||||||
|
starts.sort((a, b) => a - b);
|
||||||
|
ends.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
function populationAt(t: number): number {
|
||||||
|
return countLessOrEqual(starts, t) - countLessOrEqual(ends, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tickrate changes sometimes require a very quick map restart, producing
|
||||||
|
// a second map_history row for the same map seconds later — treat
|
||||||
|
// anything under 2 minutes as that artifact rather than a real play
|
||||||
|
// (same threshold/semantics as REAL_MAP_PERIOD elsewhere in lib/queries.ts).
|
||||||
|
const MIN_REAL_PERIOD_MS = 2 * 60_000;
|
||||||
|
|
||||||
|
const perMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
times_played: number;
|
||||||
|
total_minutes: number;
|
||||||
|
last_played: number;
|
||||||
|
netChangeSum: number;
|
||||||
|
netChangeCount: number;
|
||||||
|
retentionSum: number;
|
||||||
|
retentionCount: number;
|
||||||
|
best: number;
|
||||||
|
worst: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (let i = 0; i < mapRows.length; i++) {
|
||||||
|
const m = mapRows[i];
|
||||||
|
const next = mapRows[i + 1]; // next row chronologically, unfiltered — matches NEXT_MAP_START's definition
|
||||||
|
if (!next) continue; // currently-running map: no known end yet, excluded from impact calc
|
||||||
|
|
||||||
|
const startMs = parseDbDate(m.map_start_dt).getTime();
|
||||||
|
const nextStartMs = parseDbDate(next.map_start_dt).getTime();
|
||||||
|
const durationMs = nextStartMs - startMs;
|
||||||
|
if (durationMs < MIN_REAL_PERIOD_MS) continue; // tickrate-restart artifact, not a real occurrence
|
||||||
|
|
||||||
|
const startPlayers = populationAt(startMs);
|
||||||
|
const endPlayers = populationAt(nextStartMs);
|
||||||
|
const netChange = endPlayers - startPlayers;
|
||||||
|
const retentionPct = startPlayers > 0 ? (endPlayers / startPlayers) * 100 : null;
|
||||||
|
|
||||||
|
let agg = perMap.get(m.map_name);
|
||||||
|
if (!agg) {
|
||||||
|
agg = {
|
||||||
|
times_played: 0,
|
||||||
|
total_minutes: 0,
|
||||||
|
last_played: 0,
|
||||||
|
netChangeSum: 0,
|
||||||
|
netChangeCount: 0,
|
||||||
|
retentionSum: 0,
|
||||||
|
retentionCount: 0,
|
||||||
|
best: -Infinity,
|
||||||
|
worst: Infinity,
|
||||||
|
};
|
||||||
|
perMap.set(m.map_name, agg);
|
||||||
|
}
|
||||||
|
agg.times_played += 1;
|
||||||
|
agg.total_minutes += durationMs / 60000;
|
||||||
|
if (startMs > agg.last_played) agg.last_played = startMs;
|
||||||
|
agg.netChangeSum += netChange;
|
||||||
|
agg.netChangeCount += 1;
|
||||||
|
if (retentionPct != null) {
|
||||||
|
agg.retentionSum += retentionPct;
|
||||||
|
agg.retentionCount += 1;
|
||||||
|
}
|
||||||
|
if (netChange > agg.best) agg.best = netChange;
|
||||||
|
if (netChange < agg.worst) agg.worst = netChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: MapImpactSummary[] = [];
|
||||||
|
for (const [map_name, agg] of perMap) {
|
||||||
|
result.push({
|
||||||
|
map_name,
|
||||||
|
times_played: agg.times_played,
|
||||||
|
total_minutes: Math.round(agg.total_minutes),
|
||||||
|
last_played: new Date(agg.last_played).toISOString(),
|
||||||
|
avg_net_change: agg.netChangeCount > 0 ? agg.netChangeSum / agg.netChangeCount : null,
|
||||||
|
avg_retention_pct: agg.retentionCount > 0 ? agg.retentionSum / agg.retentionCount : null,
|
||||||
|
best_session_change: agg.netChangeCount > 0 ? agg.best : null,
|
||||||
|
worst_session_change: agg.netChangeCount > 0 ? agg.worst : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cache ---------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Recomputing is a couple of DB round trips plus an O(n log n) sweep — fast
|
||||||
|
// compared to the old query, but there's no reason to redo it on every
|
||||||
|
// single page view. Kept alive for 30 minutes, with stale-while-revalidate:
|
||||||
|
// a stale cache is served immediately (never emptied) while a background
|
||||||
|
// refresh runs, and only swapped in once the new data is actually ready.
|
||||||
|
// A single in-flight recompute is shared by any requests that arrive while
|
||||||
|
// it's running, rather than each kicking off its own.
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
let cachedData: MapImpactSummary[] | null = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
let pending: Promise<MapImpactSummary[]> | null = null;
|
||||||
|
|
||||||
|
function refresh(): Promise<MapImpactSummary[]> {
|
||||||
|
if (!pending) {
|
||||||
|
pending = computeMapImpactStats()
|
||||||
|
.then((data) => {
|
||||||
|
cachedData = data;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMapImpactStatsCached(): Promise<MapImpactSummary[]> {
|
||||||
|
const isStale = !cachedData || Date.now() - cachedAt > CACHE_TTL_MS;
|
||||||
|
if (!cachedData) {
|
||||||
|
// Nothing to serve yet (first request since process start) — must wait.
|
||||||
|
return refresh();
|
||||||
|
}
|
||||||
|
if (isStale) {
|
||||||
|
// Serve what we have right now; let the refresh happen in the
|
||||||
|
// background so this (and any concurrent) request doesn't have to
|
||||||
|
// wait on it. The cache is never cleared before the new data exists.
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranks maps by how they tend to affect server population, rather than
|
||||||
|
* just how often they're played. For every completed occurrence of a map
|
||||||
|
* (excluding the one currently running, since its "end" isn't known yet),
|
||||||
|
* net_change = population-at-map-end minus population-at-map-start, and
|
||||||
|
* retention_pct = end/start as a percentage (null when the map started
|
||||||
|
* with 0 players). Those per-occurrence numbers are averaged (and
|
||||||
|
* min/max'd) per map name — see computeMapImpactStats above.
|
||||||
|
*
|
||||||
|
* `sortBy` picks which story the leaderboard tells: 'impact' (average net
|
||||||
|
* change — the maps that tend to grow or shrink the population) or
|
||||||
|
* 'popularity' (times played — pure frequency, unrelated to impact). The
|
||||||
|
* underlying per-map stats are cached (see above); this just sorts/slices
|
||||||
|
* them per request, which is cheap even for hundreds of maps.
|
||||||
|
*/
|
||||||
|
export async function getMapImpactLeaderboard(
|
||||||
|
sortBy: 'impact' | 'popularity',
|
||||||
|
direction: 'asc' | 'desc',
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<MapImpactSummary[]> {
|
||||||
|
const all = await getMapImpactStatsCached();
|
||||||
|
const sign = direction === 'asc' ? 1 : -1;
|
||||||
|
const sorted = [...all].sort((a, b) => {
|
||||||
|
const av = sortBy === 'popularity' ? a.times_played : a.avg_net_change ?? 0;
|
||||||
|
const bv = sortBy === 'popularity' ? b.times_played : b.avg_net_change ?? 0;
|
||||||
|
if (av !== bv) return sign * (av - bv);
|
||||||
|
return b.times_played - a.times_played; // stable tiebreaker, matches old ORDER BY ..., times_played DESC
|
||||||
|
});
|
||||||
|
return sorted.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { query } from '@/lib/db';
|
||||||
|
import { parseDbDate } from '@/lib/dates';
|
||||||
|
import { formatDbDateTime } from '@/lib/timezone';
|
||||||
|
|
||||||
|
interface SessionRow {
|
||||||
|
session_start_dt: string;
|
||||||
|
session_end_dt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapRow {
|
||||||
|
map_id: number;
|
||||||
|
map_name: string;
|
||||||
|
map_start_dt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopulationSeriesPoint {
|
||||||
|
t: string;
|
||||||
|
players: number;
|
||||||
|
mapName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopulationMapBoundary {
|
||||||
|
mapId: number;
|
||||||
|
mapName: string;
|
||||||
|
t: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopulationSeriesResult {
|
||||||
|
bucketMinutes: number;
|
||||||
|
points: PopulationSeriesPoint[];
|
||||||
|
mapBoundaries: PopulationMapBoundary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks a bucket size that keeps the number of points in a sane range
|
||||||
|
// (roughly 60-300 buckets by default) regardless of how wide the requested
|
||||||
|
// range is. `targetPoints` can be lowered for a zoomed-in view that wants
|
||||||
|
// finer resolution over a short span (e.g. a single map's session).
|
||||||
|
export function pickBucketMinutes(rangeMinutes: number, targetPoints = 150): number {
|
||||||
|
const raw = Math.ceil(rangeMinutes / targetPoints);
|
||||||
|
const steps = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]; // minutes
|
||||||
|
return steps.find((s) => s >= raw) ?? steps[steps.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a bucketed concurrent-player time series between two instants,
|
||||||
|
* plus the map-change boundaries that fall inside that window. Shared by
|
||||||
|
* the general population-over-time chart and the per-session zoomed
|
||||||
|
* timeline (which calls this with a narrow range and a smaller
|
||||||
|
* targetPoints so a ~30-90 minute map session still gets fine buckets).
|
||||||
|
*/
|
||||||
|
export async function computePopulationSeries(
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
opts: { bucketMinutes?: number; targetPoints?: number } = {},
|
||||||
|
): Promise<PopulationSeriesResult> {
|
||||||
|
// Convert to MySQL-native "YYYY-MM-DD HH:MM:SS" strings (in the DB's own
|
||||||
|
// timezone) rather than passing raw ISO-Z strings as query parameters —
|
||||||
|
// MySQL DATETIME columns don't reliably parse the T/Z/milliseconds ISO
|
||||||
|
// format, which was silently dropping/mismatching rows in this fetch.
|
||||||
|
const formattedEnd = formatDbDateTime(rangeEnd);
|
||||||
|
const formattedStart = formatDbDateTime(rangeStart);
|
||||||
|
// Generous safety margin so the map fetch below doesn't scan the entire
|
||||||
|
// history table as it grows over months/years — 24h is far longer than
|
||||||
|
// any realistic single map duration, so this can never miss the map that
|
||||||
|
// was actually active at rangeStart.
|
||||||
|
const formattedLookback = formatDbDateTime(new Date(rangeStart.getTime() - 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
const [sessionRows, mapRows] = await Promise.all([
|
||||||
|
query<SessionRow>(
|
||||||
|
`SELECT session_start_dt, session_end_dt
|
||||||
|
FROM playtime_display_sessions
|
||||||
|
WHERE session_start_dt < ?
|
||||||
|
AND (session_end_dt IS NULL OR session_end_dt > ?)`,
|
||||||
|
[formattedEnd, formattedStart],
|
||||||
|
),
|
||||||
|
// map_end_dt was dropped from the schema (redundant with, and
|
||||||
|
// occasionally out of sync with, the next row's map_start_dt). Filter
|
||||||
|
// tickrate-restart artifacts (<2min) using the gap to the next map;
|
||||||
|
// the currently-running map (no next row yet) always passes.
|
||||||
|
query<MapRow>(
|
||||||
|
`SELECT m.map_id, m.map_name, m.map_start_dt
|
||||||
|
FROM playtime_display_map_history m
|
||||||
|
WHERE m.map_start_dt < ?
|
||||||
|
AND m.map_start_dt > ?
|
||||||
|
AND (
|
||||||
|
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt) IS NULL
|
||||||
|
OR TIMESTAMPDIFF(
|
||||||
|
MINUTE, m.map_start_dt,
|
||||||
|
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt)
|
||||||
|
) >= 2
|
||||||
|
)
|
||||||
|
ORDER BY m.map_start_dt`,
|
||||||
|
[formattedEnd, formattedLookback],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sessions = sessionRows.map((r) => ({
|
||||||
|
start: parseDbDate(r.session_start_dt),
|
||||||
|
end: r.session_end_dt ? parseDbDate(r.session_end_dt) : new Date(), // still-open session heartbeat, see plugin notes
|
||||||
|
}));
|
||||||
|
|
||||||
|
const maps = mapRows.map((r) => ({
|
||||||
|
mapId: r.map_id,
|
||||||
|
mapName: r.map_name,
|
||||||
|
start: parseDbDate(r.map_start_dt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// `maps` is sorted ascending by start_dt (from the ORDER BY in the query
|
||||||
|
// above). Deliberately does NOT check each row's own end time — a map
|
||||||
|
// whose map_end_dt never got closed (e.g. OnMapEnd not firing for a
|
||||||
|
// specific transition, due to a crash/restart/admin changelevel) would
|
||||||
|
// otherwise look "still running" forever and permanently shadow every
|
||||||
|
// real map that came after it. Instead: whichever map most recently
|
||||||
|
// started by time t is what was playing then, regardless of whether its
|
||||||
|
// own end timestamp was ever reliably recorded.
|
||||||
|
function mapNameAt(t: Date): string | null {
|
||||||
|
let result: string | null = null;
|
||||||
|
for (const m of maps) {
|
||||||
|
if (m.start <= t) {
|
||||||
|
result = m.mapName;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeMinutes = (rangeEnd.getTime() - rangeStart.getTime()) / 60000;
|
||||||
|
const bucketMinutes = opts.bucketMinutes ?? pickBucketMinutes(rangeMinutes, opts.targetPoints);
|
||||||
|
|
||||||
|
const points: PopulationSeriesPoint[] = [];
|
||||||
|
for (let t = rangeStart.getTime(); t < rangeEnd.getTime(); t += bucketMinutes * 60000) {
|
||||||
|
const bucketStart = new Date(t);
|
||||||
|
const bucketEnd = new Date(t + bucketMinutes * 60000);
|
||||||
|
const count = sessions.filter((s) => s.start < bucketEnd && s.end > bucketStart).length;
|
||||||
|
points.push({ t: bucketStart.toISOString(), players: count, mapName: mapNameAt(bucketStart) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map-change boundaries within the visible range, for drawing vertical
|
||||||
|
// separator lines on the chart — only the ones whose start actually
|
||||||
|
// falls inside [rangeStart, rangeEnd).
|
||||||
|
const mapBoundaries = maps
|
||||||
|
.filter((m) => m.start >= rangeStart && m.start < rangeEnd)
|
||||||
|
.map((m) => ({ mapId: m.mapId, mapName: m.mapName, t: m.start.toISOString() }));
|
||||||
|
|
||||||
|
return { bucketMinutes, points, mapBoundaries };
|
||||||
|
}
|
||||||
@@ -101,13 +101,19 @@ export interface MapPopulationPoint {
|
|||||||
map_start_dt: string;
|
map_start_dt: string;
|
||||||
map_end_dt: string | null;
|
map_end_dt: string | null;
|
||||||
total_players: number;
|
total_players: number;
|
||||||
|
// Population at the instant the map started, and at the instant it ended
|
||||||
|
// (or "now" for the still-running map) — the two endpoints of the
|
||||||
|
// dumbbell. Kept separate from total_players (which is reach — everyone
|
||||||
|
// who was ever connected at some point during the map, regardless of
|
||||||
|
// when) so the chart can show *when* a decline actually happened instead
|
||||||
|
// of implying it was caused by whichever map came right after.
|
||||||
|
start_players: number;
|
||||||
|
end_players: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMapPopulationSeries(limit = 40, before?: string): Promise<MapPopulationPoint[]> {
|
export async function getMapPopulationSeries(limit = 40, before?: string): Promise<MapPopulationPoint[]> {
|
||||||
// Total DISTINCT players who were connected at any point during each map's
|
// One data point per map, fetched most-recent-first (for the LIMIT), then
|
||||||
// full session (not just at its start) — one data point per map. Fetched
|
// the caller should reverse it back to chronological order for display.
|
||||||
// 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
|
// `before` (a DB-formatted datetime string) lets the caller page further
|
||||||
// back into history than the initial batch, for pan-to-load-more charts.
|
// back into history than the initial batch, for pan-to-load-more charts.
|
||||||
const rows = await query<MapPopulationPoint>(
|
const rows = await query<MapPopulationPoint>(
|
||||||
@@ -115,7 +121,15 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi
|
|||||||
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
|
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
|
||||||
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
|
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)
|
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
|
||||||
) AS total_players
|
) AS total_players,
|
||||||
|
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
|
||||||
|
WHERE s.session_start_dt <= m.map_start_dt
|
||||||
|
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
|
||||||
|
) AS start_players,
|
||||||
|
(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 > COALESCE(${NEXT_MAP_START('m')}, NOW()))
|
||||||
|
) AS end_players
|
||||||
FROM playtime_display_map_history m
|
FROM playtime_display_map_history m
|
||||||
WHERE ${REAL_MAP_PERIOD}
|
WHERE ${REAL_MAP_PERIOD}
|
||||||
${before ? 'AND m.map_start_dt < ?' : ''}
|
${before ? 'AND m.map_start_dt < ?' : ''}
|
||||||
@@ -126,6 +140,14 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi
|
|||||||
return rows.reverse();
|
return rows.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getMapImpactLeaderboard moved to lib/mapImpact.ts — the SQL version here
|
||||||
|
// used per-row correlated subqueries (effectively O(map_rows * sessions))
|
||||||
|
// which was fine on a small dataset but became extremely slow at real
|
||||||
|
// production size. Re-exported from here so existing imports don't need to
|
||||||
|
// change. See lib/mapImpact.ts for the full explanation.
|
||||||
|
export { getMapImpactLeaderboard } from '@/lib/mapImpact';
|
||||||
|
export type { MapImpactSummary } from '@/lib/mapImpact';
|
||||||
|
|
||||||
export interface CountrySummary {
|
export interface CountrySummary {
|
||||||
current_country: string | null; // null represents the "Unknown" bucket
|
current_country: string | null; // null represents the "Unknown" bucket
|
||||||
player_count: number;
|
player_count: number;
|
||||||
|
|||||||
+2279
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user