461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
import { useState, useMemo, useCallback, memo, useRef, useEffect } from "react";
|
|
import {
|
|
Text,
|
|
TextInput,
|
|
Stack,
|
|
Group,
|
|
Box,
|
|
ThemeIcon,
|
|
Container,
|
|
Title,
|
|
Divider,
|
|
UnstyledButton,
|
|
Popover,
|
|
ActionIcon,
|
|
ScrollArea,
|
|
} from "@mantine/core";
|
|
import {
|
|
MagnifyingGlassIcon,
|
|
CaretUpIcon,
|
|
CaretDownIcon,
|
|
ChartBarIcon,
|
|
InfoIcon,
|
|
} from "@phosphor-icons/react";
|
|
import { PlayerStats } from "../types";
|
|
import PlayerAvatar from "@/components/player-avatar";
|
|
import { useNavigate } from "@tanstack/react-router";
|
|
import { useAllPlayerStats } from "../queries";
|
|
|
|
type SortKey = keyof PlayerStats | "mmr";
|
|
type SortDirection = "asc" | "desc";
|
|
|
|
interface SortConfig {
|
|
key: SortKey;
|
|
direction: SortDirection;
|
|
}
|
|
|
|
interface PlayerListItemProps {
|
|
stat: PlayerStats;
|
|
onPlayerClick: (playerId: string) => void;
|
|
mmr: number;
|
|
onRegisterViewport: (viewport: HTMLDivElement) => void;
|
|
onUnregisterViewport: (viewport: HTMLDivElement) => void;
|
|
}
|
|
|
|
interface StatCellProps {
|
|
label: string;
|
|
value: string | number;
|
|
}
|
|
|
|
const StatCell = memo(({ label, value }: StatCellProps) => (
|
|
<Stack justify="center" gap={0} style={{ textAlign: 'center', flexShrink: 0 }}>
|
|
<Text size="xs" c="dimmed" fw={700}>
|
|
{label}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{value}
|
|
</Text>
|
|
</Stack>
|
|
));
|
|
|
|
const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onUnregisterViewport }: PlayerListItemProps) => {
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
|
|
const avg_cups_against = useMemo(() => stat.total_cups_against / stat.matches || 0, [stat.total_cups_against, stat.matches]);
|
|
|
|
useEffect(() => {
|
|
if (viewportRef.current) {
|
|
onRegisterViewport(viewportRef.current);
|
|
return () => {
|
|
if (viewportRef.current) {
|
|
onUnregisterViewport(viewportRef.current);
|
|
}
|
|
};
|
|
}
|
|
}, [onRegisterViewport, onUnregisterViewport]);
|
|
|
|
return (
|
|
<>
|
|
<UnstyledButton
|
|
w="100%"
|
|
p="md"
|
|
onClick={() => onPlayerClick(stat.id)}
|
|
style={{
|
|
borderRadius: 0,
|
|
transition: "background-color 0.15s ease",
|
|
}}
|
|
styles={{
|
|
root: {
|
|
'&:hover': {
|
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<Group p={0} gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
|
<PlayerAvatar name={stat.player_name} size={40} style={{ flexShrink: 0 }} disableFullscreen />
|
|
<Stack gap={2} style={{ flexGrow: 1, overflow: 'hidden', minWidth: 0 }}>
|
|
<Group gap='xs'>
|
|
<Text size="sm" fw={600}>
|
|
{stat.player_name}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" ta="right">
|
|
{stat.matches}
|
|
<Text span fw={800}>M</Text>
|
|
</Text>
|
|
<Text size="xs" c="dimmed" ta="right">
|
|
{stat.tournaments}
|
|
<Text span fw={800}>T</Text>
|
|
</Text>
|
|
</Group>
|
|
|
|
<ScrollArea
|
|
viewportRef={viewportRef}
|
|
type="never"
|
|
styles={{
|
|
viewport: {
|
|
WebkitOverflowScrolling: 'touch',
|
|
scrollBehavior: 'auto',
|
|
willChange: 'scroll-position',
|
|
transform: 'translateZ(0)',
|
|
backfaceVisibility: 'hidden',
|
|
},
|
|
}}
|
|
>
|
|
<Group gap='xs' wrap="nowrap">
|
|
<StatCell label="MMR" value={mmr.toFixed(1)} />
|
|
<StatCell label="W" value={stat.wins} />
|
|
<StatCell label="L" value={stat.losses} />
|
|
<StatCell label="W%" value={`${stat.win_percentage.toFixed(1)}%`} />
|
|
<StatCell label="AWM" value={stat.margin_of_victory?.toFixed(1) || 0} />
|
|
<StatCell label="ALM" value={stat.margin_of_loss?.toFixed(1) || 0} />
|
|
<StatCell label="AC" value={stat.avg_cups_per_match.toFixed(1)} />
|
|
<StatCell label="ACA" value={avg_cups_against?.toFixed(1) || 0} />
|
|
<StatCell label="CF" value={stat.total_cups_made} />
|
|
<StatCell label="CA" value={stat.total_cups_against} />
|
|
</Group>
|
|
</ScrollArea>
|
|
</Stack>
|
|
</Group>
|
|
</UnstyledButton>
|
|
</>
|
|
);
|
|
});
|
|
|
|
interface PlayerStatsTableProps {
|
|
viewType?: 'all' | 'mainline' | 'regional';
|
|
}
|
|
|
|
const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|
const { data: playerStats } = useAllPlayerStats(viewType);
|
|
const navigate = useNavigate();
|
|
const [search, setSearch] = useState("");
|
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
|
key: "mmr" as SortKey,
|
|
direction: "desc",
|
|
});
|
|
|
|
const viewportsRef = useRef<Set<HTMLDivElement>>(new Set());
|
|
const scrollHandlersRef = useRef<Map<HTMLDivElement, (e: Event) => void>>(new Map());
|
|
const scrollLeaderRef = useRef<HTMLDivElement | null>(null);
|
|
const scrollTimeoutRef = useRef<number | null>(null);
|
|
|
|
const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => {
|
|
viewportsRef.current.add(viewport);
|
|
|
|
const handleScrollStart = () => {
|
|
scrollLeaderRef.current = viewport;
|
|
};
|
|
|
|
const handleScroll = (e: Event) => {
|
|
const target = e.target as HTMLDivElement;
|
|
|
|
if (!scrollLeaderRef.current) {
|
|
scrollLeaderRef.current = target;
|
|
}
|
|
|
|
if (scrollLeaderRef.current !== target) {
|
|
return;
|
|
}
|
|
|
|
const scrollLeft = target.scrollLeft;
|
|
|
|
viewportsRef.current.forEach((vp) => {
|
|
if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) {
|
|
vp.scrollLeft = scrollLeft;
|
|
}
|
|
});
|
|
|
|
if (scrollTimeoutRef.current) {
|
|
clearTimeout(scrollTimeoutRef.current);
|
|
}
|
|
scrollTimeoutRef.current = window.setTimeout(() => {
|
|
scrollLeaderRef.current = null;
|
|
}, 150);
|
|
};
|
|
|
|
viewport.addEventListener('touchstart', handleScrollStart, { passive: true });
|
|
viewport.addEventListener('mousedown', handleScrollStart, { passive: true });
|
|
viewport.addEventListener('scroll', handleScroll, { passive: true });
|
|
|
|
scrollHandlersRef.current.set(viewport, handleScroll);
|
|
}, []);
|
|
|
|
const handleUnregisterViewport = useCallback((viewport: HTMLDivElement) => {
|
|
viewportsRef.current.delete(viewport);
|
|
|
|
const handler = scrollHandlersRef.current.get(viewport);
|
|
if (handler) {
|
|
viewport.removeEventListener('scroll', handler);
|
|
viewport.removeEventListener('touchstart', handler);
|
|
viewport.removeEventListener('mousedown', handler);
|
|
scrollHandlersRef.current.delete(viewport);
|
|
}
|
|
}, []);
|
|
|
|
const calculateMMR = (stat: PlayerStats): number => {
|
|
if (stat.matches === 0) return 0;
|
|
|
|
const winScore = stat.win_percentage;
|
|
const matchConfidence = Math.min(stat.matches / 15, 1);
|
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
|
const marginScore = stat.margin_of_victory
|
|
? Math.min(stat.margin_of_victory * 20, 50)
|
|
: 0;
|
|
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
|
|
|
const baseMMR =
|
|
winScore * 0.5 +
|
|
avgCupsScore * 0.25 +
|
|
marginScore * 0.15 +
|
|
volumeBonus * 0.1;
|
|
|
|
const finalMMR = baseMMR * matchConfidence;
|
|
return Math.round(finalMMR * 10) / 10;
|
|
};
|
|
|
|
const statsWithMMR = useMemo(() => {
|
|
return playerStats.map((stat) => ({
|
|
...stat,
|
|
mmr: calculateMMR(stat),
|
|
}));
|
|
}, [playerStats]);
|
|
|
|
const filteredAndSortedStats = useMemo(() => {
|
|
let filtered = statsWithMMR.filter((stat) =>
|
|
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
return filtered.sort((a, b) => {
|
|
let aValue: number | string;
|
|
let bValue: number | string;
|
|
|
|
if (sortConfig.key === "mmr") {
|
|
aValue = a.mmr;
|
|
bValue = b.mmr;
|
|
} else {
|
|
aValue = a[sortConfig.key];
|
|
bValue = b[sortConfig.key];
|
|
}
|
|
|
|
if (typeof aValue === "number" && typeof bValue === "number") {
|
|
return sortConfig.direction === "desc"
|
|
? bValue - aValue
|
|
: aValue - bValue;
|
|
}
|
|
|
|
if (typeof aValue === "string" && typeof bValue === "string") {
|
|
return sortConfig.direction === "desc"
|
|
? bValue.localeCompare(aValue)
|
|
: aValue.localeCompare(bValue);
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
}, [statsWithMMR, search, sortConfig]);
|
|
|
|
const handlePlayerClick = useCallback((playerId: string) => {
|
|
navigate({ to: `/profile/${playerId}` });
|
|
}, [navigate]);
|
|
|
|
const handleSort = (key: SortKey) => {
|
|
setSortConfig((prev) => ({
|
|
key,
|
|
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
|
}));
|
|
};
|
|
|
|
const getSortIcon = (key: SortKey) => {
|
|
if (sortConfig.key !== key) return null;
|
|
return sortConfig.direction === "desc" ? (
|
|
<CaretDownIcon size={14} />
|
|
) : (
|
|
<CaretUpIcon size={14} />
|
|
);
|
|
};
|
|
|
|
if (playerStats.length === 0) {
|
|
return (
|
|
<Stack align="center" gap="md" py="xl">
|
|
<ThemeIcon size="xl" variant="light" radius="md">
|
|
<ChartBarIcon size={32} />
|
|
</ThemeIcon>
|
|
<Title order={3} c="dimmed">
|
|
No Stats Available
|
|
</Title>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Container size="100%" px={0}>
|
|
<Stack gap="xs">
|
|
<Text px="md" size="10px" lh={0} c="dimmed">
|
|
Showing {filteredAndSortedStats.length} of {playerStats.length} players
|
|
</Text>
|
|
<TextInput
|
|
placeholder="Search players"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
|
size="md"
|
|
px="md"
|
|
/>
|
|
|
|
<Group px="md" justify="space-between" align="center">
|
|
<Group gap="xs" w="100%">
|
|
<div></div>
|
|
<Text ml='auto' size="xs" c="dimmed">Sort:</Text>
|
|
<UnstyledButton
|
|
onClick={() => handleSort("mmr")}
|
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
>
|
|
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "dark" : "dimmed"}>
|
|
MMR
|
|
</Text>
|
|
{getSortIcon("mmr")}
|
|
</UnstyledButton>
|
|
<Text size="xs" c="dimmed">•</Text>
|
|
<UnstyledButton
|
|
onClick={() => handleSort("wins")}
|
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
>
|
|
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "dark" : "dimmed"}>
|
|
Wins
|
|
</Text>
|
|
{getSortIcon("wins")}
|
|
</UnstyledButton>
|
|
<Text size="xs" c="dimmed">•</Text>
|
|
<UnstyledButton
|
|
onClick={() => handleSort("matches")}
|
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
>
|
|
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "dark" : "dimmed"}>
|
|
Matches
|
|
</Text>
|
|
{getSortIcon("matches")}
|
|
</UnstyledButton>
|
|
<Popover position="bottom-end" withArrow shadow="md">
|
|
<Popover.Target>
|
|
<ActionIcon variant="subtle" size="sm">
|
|
<InfoIcon size={14} />
|
|
</ActionIcon>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<Box maw={280}>
|
|
<Text size="sm" fw={500} mb="xs">
|
|
Stat Abbreviations:
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>M:</strong> Matches
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>T:</strong> Tournaments
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>MMR:</strong> Matchmaking Rating
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>W:</strong> Wins
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>L:</strong> Losses
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>W%:</strong> Win Percentage
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>AWM:</strong> Average Win Margin
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>ALM:</strong> Average Loss Margin
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>AC:</strong> Average Cups Per Match
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>ACA:</strong> Average Cups Against
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>CF:</strong> Cups For
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• <strong>CA:</strong> Cups Against
|
|
</Text>
|
|
|
|
<Divider my="sm" />
|
|
|
|
<Text size="sm" fw={500} mb="xs">
|
|
MMR Calculation:
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Win Rate (50%)
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Average Cups/Match (25%)
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Average Win Margin (15%)
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Match Volume Bonus (10%)
|
|
</Text>
|
|
<Text size="xs" mt="xs" c="dimmed">
|
|
* Confidence penalty applied for players with <15 matches
|
|
</Text>
|
|
<Text size="xs" mt="xs" c="dimmed">
|
|
** Not an official rating
|
|
</Text>
|
|
</Box>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Stack gap={0}>
|
|
{filteredAndSortedStats.map((stat, index) => (
|
|
<Box key={stat.id}>
|
|
<PlayerListItem
|
|
stat={stat}
|
|
onPlayerClick={handlePlayerClick}
|
|
mmr={stat.mmr}
|
|
onRegisterViewport={handleRegisterViewport}
|
|
onUnregisterViewport={handleUnregisterViewport}
|
|
/>
|
|
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
|
|
{filteredAndSortedStats.length === 0 && search && (
|
|
<Text ta="center" c="dimmed" py="xl">
|
|
No players found matching "{search}"
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default PlayerStatsTable;
|