mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-10 20:08:38 -06:00
144 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|