Files
flxn-app/src/features/players/components/player-stats-table.tsx
2026-02-09 14:31:55 -06:00

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 &lt;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;