Files
smart-rgb/frontend/src/shared/components/Leaderboard.tsx
2025-10-09 22:56:26 -05:00

144 lines
5.6 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import { useGameAPI } from "@/shared/api/GameAPIContext";
import type { LeaderboardSnapshot, UnsubscribeFn } from "@/shared/api/types";
// Smart precision algorithm for percentage display
function calculatePrecision(percentages: number[]): number {
if (percentages.length === 0) return 0;
// Find the minimum non-zero difference between consecutive percentages
const sorted = [...percentages].sort((a, b) => b - a);
let minDiff = Infinity;
for (let i = 0; i < sorted.length - 1; i++) {
const diff = sorted[i] - sorted[i + 1];
if (diff > 0) {
minDiff = Math.min(minDiff, diff);
}
}
// If all percentages are the same, use 0 decimal places
if (minDiff === Infinity) return 0;
// Determine precision based on the minimum difference
if (minDiff >= 0.1) return 0; // 0.1% or more difference -> 0 decimals
if (minDiff >= 0.01) return 1; // 0.01% or more difference -> 1 decimal
return 2; // 0.001% or more difference -> 2 decimals (max precision)
}
export function Leaderboard({
topN = 8,
initialSnapshot,
highlightedNation,
onNationHover,
}: {
topN?: number;
initialSnapshot?: LeaderboardSnapshot | null;
highlightedNation: number | null;
onNationHover: (nationId: number | null) => void;
}) {
const gameAPI = useGameAPI();
const [collapsed, setCollapsed] = useState(false);
const [snapshot, setSnapshot] = useState<LeaderboardSnapshot | null>(initialSnapshot || null);
const [status, setStatus] = useState<"loading" | "waiting" | "ready" | "error">(initialSnapshot ? "ready" : "waiting");
useEffect(() => {
let unsubscribe: UnsubscribeFn = () => {};
// Subscribe to leaderboard snapshots
try {
unsubscribe = gameAPI.onLeaderboardSnapshot((snapshotData) => {
setSnapshot(snapshotData);
setStatus("ready");
});
} catch (error) {
console.warn("Failed to subscribe to leaderboard snapshots:", error);
setStatus("error");
}
return () => {
unsubscribe();
};
}, [gameAPI]);
const rows = useMemo(() => {
if (!snapshot) return [];
const topEntries = snapshot.entries.slice(0, topN);
// Check if local player is in top N
const playerEntry = snapshot.entries.find(e => e.id === snapshot.client_player_id);
const playerInTopN = playerEntry && topEntries.some(e => e.id === playerEntry.id);
// If player exists but not in top N, add them at the end
if (playerEntry && !playerInTopN) {
const playerPosition = snapshot.entries.findIndex(e => e.id === playerEntry.id);
return [...topEntries, { ...playerEntry, position: playerPosition + 1 }];
}
return topEntries;
}, [snapshot, topN]);
const precision = useMemo(() => {
if (!snapshot || rows.length === 0) return 0;
const percentages = rows.map((r) => r.territory_percent);
return calculatePrecision(percentages);
}, [snapshot, rows]);
return (
<div className="leaderboard no-drag block-game-input" onMouseLeave={() => onNationHover(null)}>
<div className={`leaderboard__header ${collapsed ? 'leaderboard__header--collapsed' : ''}`} onClick={() => setCollapsed((c) => !c)}>
<span>Leaderboard</span>
{collapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</div>
{!collapsed && (
<div className="leaderboard__body">
{status === "ready" && snapshot ? (
<table className="leaderboard__table">
<tbody>
{rows.map((r) => {
const position = "position" in r ? r.position : snapshot.entries.findIndex(e => e.id === r.id) + 1;
const isPlayer = r.id === snapshot.client_player_id;
const isEliminated = r.territory_percent === 0;
const isHighlighted = highlightedNation === r.id;
return (
<tr
key={r.id}
className={`leaderboard__row ${isPlayer ? 'leaderboard__row--player' : ''} ${isEliminated ? 'leaderboard__row--eliminated' : ''} ${isHighlighted ? 'leaderboard__row--highlighted' : ''}`}
onClick={() => !isEliminated && console.log("select", r.id)}
onMouseEnter={() => !isEliminated && onNationHover(r.id)}
onMouseLeave={() => !isEliminated && onNationHover(null)}
>
<td className="leaderboard__position">{isEliminated ? '' : position}</td>
<td className="leaderboard__name">
<div className="leaderboard__name-content">
<div
className="leaderboard__color-circle"
style={{
backgroundColor: `#${r.color}`,
}}
/>
<span>{r.name}</span>
</div>
</td>
<td className="leaderboard__percent">{isEliminated ? '—' : `${(r.territory_percent * 100).toFixed(precision)}%`}</td>
<td className="leaderboard__troops">{isEliminated ? '—' : r.troops.toLocaleString()}</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="leaderboard__placeholder">
{status === "loading" && "Loading leaderboard…"}
{status === "waiting" && "Waiting for updates…"}
{status === "error" && "Error loading leaderboard"}
</div>
)}
</div>
)}
</div>
);
}