furhter bug fixes and updates

This commit is contained in:
jenz
2026-08-22 17:10:27 +02:00
parent 9e8e8378a2
commit 16bb9e14a5
18 changed files with 1070 additions and 256 deletions
@@ -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,34 +179,44 @@ export default function MapPopulationChart() {
dragStartRef.current = null;
}
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
e.preventDefault();
if (points.length === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorIdxInView = indexForX(x);
const anchorAbsIdx = viewStartIdx + anchorIdxInView;
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
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)
let newSize = Math.round(currentSize * zoomFactor);
newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize));
function onWheel(e: WheelEvent) {
e.preventDefault();
if (points.length === 0) return;
const rect = svg!.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorIdxInView = indexForX(x);
const anchorAbsIdx = viewStartIdx + anchorIdxInView;
const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5;
let newStart = Math.round(anchorAbsIdx - ratio * newSize);
let newEnd = newStart + newSize;
const currentSize = viewEndIdx - viewStartIdx;
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));
if (newStart < 0) {
newStart = 0;
newEnd = newSize;
const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5;
let newStart = Math.round(anchorAbsIdx - ratio * newSize);
let newEnd = newStart + newSize;
if (newStart < 0) {
newStart = 0;
newEnd = newSize;
}
if (newEnd > points.length) {
newEnd = points.length;
newStart = newEnd - newSize;
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
ensureMoreIfNeeded(newStart);
}
if (newEnd > points.length) {
newEnd = points.length;
newStart = newEnd - newSize;
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
}
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);
@@ -172,7 +224,7 @@ export default function MapPopulationChart() {
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>
)}