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:
@@ -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_end_dt: string | null;
|
||||
total_players: number;
|
||||
start_players: number;
|
||||
end_players: number;
|
||||
}
|
||||
|
||||
interface PlayerChip {
|
||||
@@ -120,22 +122,22 @@ export default function MapPopulationChart() {
|
||||
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.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 slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
|
||||
const barWidth = Math.max(2, slotWidth * 0.6);
|
||||
|
||||
function barX(i: number) {
|
||||
return PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2;
|
||||
return PAD_LEFT + i * slotWidth + slotWidth / 2;
|
||||
}
|
||||
function barY(value: number) {
|
||||
const h = (value / axisMax) * plotHeight;
|
||||
return PAD_TOP + (plotHeight - h);
|
||||
}
|
||||
function barHeight(value: number) {
|
||||
return Math.max(value > 0 ? 2 : 0, (value / axisMax) * plotHeight);
|
||||
}
|
||||
function indexForX(x: number): number {
|
||||
if (slotWidth === 0) return 0;
|
||||
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}
|
||||
{reachedStart ? '' : '+'} maps
|
||||
</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 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" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</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>}
|
||||
</div>
|
||||
{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) => {
|
||||
const y = barY(v);
|
||||
return (
|
||||
@@ -306,19 +306,31 @@ export default function MapPopulationChart() {
|
||||
);
|
||||
})}
|
||||
|
||||
{visiblePoints.map((p, i) => (
|
||||
<rect
|
||||
key={p.map_id}
|
||||
x={barX(i)}
|
||||
y={barY(p.total_players)}
|
||||
width={barWidth}
|
||||
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 x = barX(i);
|
||||
const yStart = barY(p.start_players);
|
||||
const yEnd = barY(p.end_players);
|
||||
const grew = p.end_players > p.start_players;
|
||||
const shrank = p.end_players < p.start_players;
|
||||
const endColor = grew ? '#3FD37A' : shrank ? '#E0665C' : '#8A9691';
|
||||
const active = hoverIndex === i;
|
||||
return (
|
||||
<g key={p.map_id} opacity={active ? 1 : 0.85}>
|
||||
<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
|
||||
x1={PAD_LEFT}
|
||||
@@ -333,8 +345,12 @@ export default function MapPopulationChart() {
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: Math.min(width - 200, Math.max(0, barX(hoverIndex) - 60)),
|
||||
top: barY(visiblePoints[hoverIndex].total_players) - 70,
|
||||
left: Math.min(width - 210, Math.max(0, barX(hoverIndex) - 60)),
|
||||
top:
|
||||
Math.min(
|
||||
barY(visiblePoints[hoverIndex].start_players),
|
||||
barY(visiblePoints[hoverIndex].end_players),
|
||||
) - 92,
|
||||
background: '#121715',
|
||||
border: '1px solid #1F2723',
|
||||
borderRadius: 6,
|
||||
@@ -347,8 +363,21 @@ export default function MapPopulationChart() {
|
||||
<div style={{ color: '#8A9691' }}>
|
||||
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ color: '#3FD37A' }}>
|
||||
{visiblePoints[hoverIndex].total_players} total players during this session
|
||||
<div style={{ color: '#E4EBE7', marginTop: 2 }}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user