feat: migrate performance

This commit is contained in:
TrèsAbhi committed 2024-10-07 17:31:13 -05:00
1 parent b31c7a6738
commit 8d51ce2bea
20 files changed
+122 -149

No files matched your search

@@ -0,0 +1,13 @@
import { Table } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { RowLoader } from './RowLoader';
export function BodyLoader() {
return (
<Table.Body>
{times(10, (index) => (
<RowLoader key={index} />
))}
</Table.Body>
);
}
@@ -0,0 +1,11 @@
import { FilterControl } from '../TankSearch/components/FilterControl';
import { PlayerCountControl } from './PlayerCountControl';
export function Controls() {
return (
<>
<FilterControl />
<PlayerCountControl />
</>
);
}
@@ -0,0 +1,63 @@
import {
CaretDownIcon,
CaretSortIcon,
CaretUpIcon,
} from '@radix-ui/react-icons';
import { Flex, IconButton, Table } from '@radix-ui/themes';
import { TankPerformanceSort } from '../../stores/tankPerformanceSort';
import {
TankPerformanceSortTypeNames,
TankPerformanceSortTypeNamesArray,
} from '../../stores/tankPerformanceSort/constants';
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell';
export function Header() {
const sort = TankPerformanceSort.use();
const store = TankPerformanceSort.useStore();
return (
<Table.Header>
<Table.Row align="center">
<StickyColumnHeaderCell>Tank</StickyColumnHeaderCell>
{TankPerformanceSortTypeNamesArray.map((type) => {
const isSelected = sort.type === type;
return (
<StickyColumnHeaderCell
px="2"
key={type}
width="0"
justify="center"
>
<Flex align="center" gap="1">
{TankPerformanceSortTypeNames[type]}
<IconButton
size={isSelected ? '1' : '2'}
variant={isSelected ? 'soft' : 'ghost'}
color={isSelected ? undefined : 'gray'}
highContrast={!isSelected}
onClick={() => {
if (isSelected) {
store.setState({ direction: -sort.direction as 1 | -1 });
} else {
store.setState({ type, direction: -1 });
}
}}
>
{!isSelected && <CaretSortIcon />}
{isSelected && (
<>
{sort.direction === 1 && <CaretUpIcon />}
{sort.direction === -1 && <CaretDownIcon />}
</>
)}
</IconButton>
</Flex>
</StickyColumnHeaderCell>
);
})}
</Table.Row>
</Table.Header>
);
}
@@ -0,0 +1,33 @@
import {
fetchAverageDefinitions,
fetchDiscoveredIdsDefinitions,
formatCompact,
} from '@blitzkit/core';
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { Callout, Flex } from '@radix-ui/themes';
const discoveredIdsDefinitions = await fetchDiscoveredIdsDefinitions();
const averageDefinitions = await fetchAverageDefinitions();
export function PerformanceInfo() {
const samples = discoveredIdsDefinitions.count;
const minutesAgo = Math.floor(
(Date.now() - averageDefinitions.time) / (1000 * 60),
);
const hoursAgo = Math.floor(minutesAgo / 60);
return (
<Flex justify="center">
<Callout.Root style={{ width: 'fit-content' }}>
<Callout.Icon>
<InfoCircledIcon />
</Callout.Icon>
<Callout.Text>
Career stats based on {formatCompact(Math.round(samples))} players.
Updated {hoursAgo === 0 ? minutesAgo : hoursAgo}{' '}
{hoursAgo === 0 ? 'minutes' : 'hours'} ago.
</Callout.Text>
</Callout.Root>
</Flex>
);
}
@@ -0,0 +1,46 @@
import { Flex, Select, Text } from '@radix-ui/themes';
import { TankPerformancePersistent } from '../../stores/tankPerformancePersistent';
import { PlayerCountPeriod } from '../../stores/tankPerformancePersistent/constants';
export function PlayerCountControl() {
const playerCountPeriod = TankPerformancePersistent.use(
(state) => state.playerCountPeriod,
);
const mutateTankPerformanceSort = TankPerformancePersistent.useMutation();
return (
<Flex justify="center" align="center" gap="2">
<Text>Player count</Text>
<Select.Root
value={playerCountPeriod}
onValueChange={(period) => {
mutateTankPerformanceSort((draft) => {
draft.playerCountPeriod = period as PlayerCountPeriod;
});
}}
>
<Select.Trigger />
<Select.Content>
<Select.Item value={PlayerCountPeriod.Past120Days}>
Past 120 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.Past90Days}>
Past 90 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.Past60Days}>
Past 60 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.ThisMonth}>
This month
</Select.Item>
<Select.Item value={PlayerCountPeriod.ThisWeek}>
This week
</Select.Item>
<Select.Item value={PlayerCountPeriod.Yesterday}>
Yesterday
</Select.Item>
</Select.Content>
</Select.Root>
</Flex>
);
}
@@ -0,0 +1,31 @@
import { Skeleton, Table } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { memo, useRef } from 'react';
import { useIntersection } from '../../hooks/useIntersection';
import { TankPerformanceSortTypeNamesArray } from '../../stores/tankPerformanceSort/constants';
interface RowLoaderProps {
onIntersection?: () => void;
}
export const RowLoader = memo<RowLoaderProps>(({ onIntersection }) => {
const row = useRef<HTMLTableRowElement>(null);
useIntersection(() => onIntersection?.(), row, {
disabled: onIntersection === undefined,
});
return (
<Table.Row ref={row}>
<Table.RowHeaderCell align="center">
<Skeleton height="100%" width="128px" />
</Table.RowHeaderCell>
{times(TankPerformanceSortTypeNamesArray.length, (index) => (
<Table.Cell key={index} align="center">
<Skeleton height="1em" width="32px" />
</Table.Cell>
))}
</Table.Row>
);
});
@@ -0,0 +1,23 @@
import { Flex } from '@radix-ui/themes';
import { Suspense } from 'react';
import { StickyTableRoot } from '../StickyTableRoot';
import { BodyLoader } from './BodyLoader';
import { Header } from './Header';
import { Tanks } from './Tanks';
export function TankTable() {
return (
<Flex justify="center">
<StickyTableRoot
size={{ initial: '1', sm: '2' }}
variant="surface"
style={{ maxWidth: '100%', maxHeight: 'calc(100vh - 256px)' }}
>
<Header />
<Suspense fallback={<BodyLoader />}>
<Tanks />
</Suspense>
</StickyTableRoot>
</Flex>
);
}
@@ -0,0 +1,82 @@
import {
TankDefinition,
fetchAverageDefinitions,
formatCompact,
} from '@blitzkit/core';
import { Table } from '@radix-ui/themes';
import { memo } from 'react';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { TankPerformancePersistent } from '../../stores/tankPerformancePersistent';
import { TankRowHeaderCell } from '../TankRowHeaderCell';
interface TankRowProps {
tank: TankDefinition;
}
const averageDefinitions = await fetchAverageDefinitions();
export const TankRow = memo<TankRowProps>(
({ tank }) => {
const averages = averageDefinitions.averages[tank.id];
const ratio = useAveragesExclusionRatio();
const playerCountPeriod = TankPerformancePersistent.use(
(state) => state.playerCountPeriod,
);
return (
<Table.Row>
<TankRowHeaderCell tank={tank} />
<Table.Cell align="center">
{((averages.mu.wins / averages.mu.battles) * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell align="center">
{formatCompact(
Math.round(ratio * averages.samples[playerCountPeriod]),
)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
averages.mu.damage_dealt / averages.mu.battles,
).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
(averages.mu.survived_battles / averages.mu.battles) * 100,
)}
%
</Table.Cell>
<Table.Cell align="center">
{Math.round(averages.mu.xp / averages.mu.battles).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.frags / averages.mu.battles).toFixed(2)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.spotted / averages.mu.battles).toFixed(2)}
</Table.Cell>
<Table.Cell align="center">
{Math.round((averages.mu.hits / averages.mu.shots) * 100)}%
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.shots / averages.mu.battles).toFixed(1)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.hits / averages.mu.battles).toFixed(1)}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.damage_dealt / averages.mu.damage_received).toFixed(2)}
</Table.Cell>
<Table.Cell align="center">
{Math.round(
averages.mu.damage_received / averages.mu.battles,
).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{(averages.mu.capture_points / averages.mu.battles).toFixed(2)}
</Table.Cell>
</Table.Row>
);
},
(prev, next) => prev.tank.id === next.tank.id,
);
@@ -0,0 +1,156 @@
import { fetchAverageDefinitions, fetchTankDefinitions } from '@blitzkit/core';
import { useStore } from '@nanostores/react';
import { Table } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { filterTank } from '../../core/blitzkit/filterTank';
import { $tankFilters } from '../../stores/tankFilters';
import { TankPerformancePersistent } from '../../stores/tankPerformancePersistent';
import { TankPerformanceSort } from '../../stores/tankPerformanceSort';
import { RowLoader } from './RowLoader';
import { TankRow } from './TankRow';
import { Total } from './Total';
const PREVIEW_COUNT = 10;
const DEFAULT_LOADED_ROWS = 25;
const tankDefinitions = await fetchTankDefinitions();
const averageDefinitionsArray = await fetchAverageDefinitions().then(
({ averages }) =>
Object.entries(averages).map(([id, average]) => ({
id: Number(id),
...average,
})),
);
export function Tanks() {
const sort = TankPerformanceSort.use();
const tankPerformancePersistentStore = TankPerformancePersistent.useStore();
const filters = useStore($tankFilters);
const tanksSorted = useMemo(() => {
const { playerCountPeriod } = tankPerformancePersistentStore.getState();
switch (sort.type) {
case 'accuracy':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots),
);
case 'capturePoints':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.capture_points / a.mu.battles -
b.mu.capture_points / b.mu.battles),
);
case 'damage':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.damage_dealt / a.mu.battles -
b.mu.damage_dealt / b.mu.battles),
);
case 'damageRatio':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.damage_dealt / a.mu.damage_received -
b.mu.damage_dealt / b.mu.damage_received),
);
case 'damageTaken':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.damage_received / a.mu.battles -
b.mu.damage_received / b.mu.battles),
);
case 'hits':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles),
);
case 'kills':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles),
);
case 'shots':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles),
);
case 'players':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.samples[playerCountPeriod] - b.samples[playerCountPeriod]),
);
case 'spots':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles),
);
case 'survival':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.survived_battles / a.mu.battles -
b.mu.survived_battles / b.mu.battles),
);
case 'winrate':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction *
(a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles),
);
case 'xp':
return averageDefinitionsArray.sort(
(a, b) =>
sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles),
);
}
}, [sort]);
const tanks = useMemo(() => {
return tanksSorted.filter((tank) =>
filterTank(filters, tankDefinitions.tanks[tank.id]),
);
}, [filters, sort]);
const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS);
useEffect(() => {
setLoadedRows(DEFAULT_LOADED_ROWS);
}, [filters, sort]);
return (
<Table.Body>
<Total tanks={tanks} />
{tanks.slice(0, loadedRows).map((averages) => {
const tank = tankDefinitions.tanks[averages.id];
if (tank === undefined) return null;
return (
<Suspense key={tank.id} fallback={<RowLoader />}>
<TankRow tank={tank} />
</Suspense>
);
})}
{times(Math.min(PREVIEW_COUNT, tanks.length - loadedRows), (index) => {
return (
<RowLoader
key={index}
onIntersection={() => {
setLoadedRows((state) => Math.min(state + 2, tanks.length));
}}
/>
);
})}
</Table.Body>
);
}
@@ -0,0 +1,86 @@
import {
AverageDefinitionsEntry,
fetchAverageDefinitions,
formatCompact,
type BlitzkitStats,
} from '@blitzkit/core';
import { Table } from '@radix-ui/themes';
import { memo, useCallback } from 'react';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { TankPerformancePersistent } from '../../stores/tankPerformancePersistent';
import { StickyRowHeaderCell } from '../StickyRowHeaderCell';
interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry {
id: number;
}
interface TotalProps {
tanks: AverageDefinitionsEntryWithId[];
}
const averageDefinitions = await fetchAverageDefinitions();
export const Total = memo<TotalProps>(
({ tanks }) => {
const playerCountPeriod = TankPerformancePersistent.use(
(state) => state.playerCountPeriod,
);
const ratio = useAveragesExclusionRatio();
const sum = useCallback(
(slice: (tank: BlitzkitStats) => number) => {
return tanks.reduce(
(acc, tank) => acc + tank.samples.total * slice(tank.mu),
0,
);
},
[tanks],
);
const battles = sum(({ battles }) => battles);
const winrate = sum(({ wins }) => wins) / battles;
const damage = sum(({ damage_dealt }) => damage_dealt) / battles;
const survival = sum(({ survived_battles }) => survived_battles) / battles;
const xp = sum(({ xp }) => xp) / battles;
const kills = sum(({ frags }) => frags) / battles;
const spots = sum(({ spotted }) => spotted) / battles;
const hits = sum(({ hits }) => hits) / battles;
const shots = sum(({ shots }) => shots) / battles;
const accuracy = hits / shots;
const damageReceived =
sum(({ damage_received }) => damage_received) / battles;
const damageRatio = damage / damageReceived;
const capturePoints = sum(({ capture_points }) => capture_points) / battles;
return (
<Table.Row>
<StickyRowHeaderCell style={{ overflow: 'hidden', display: 'flex' }}>
Total
</StickyRowHeaderCell>
<Table.Cell align="center">{(winrate * 100).toFixed(1)}%</Table.Cell>
<Table.Cell align="center">
{formatCompact(ratio * averageDefinitions.samples[playerCountPeriod])}
</Table.Cell>
<Table.Cell align="center">
{Math.round(damage).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">
{Math.round(survival * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell align="center">
{Math.round(xp).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">{kills.toFixed(2)}</Table.Cell>
<Table.Cell align="center">{spots.toFixed(2)}</Table.Cell>
<Table.Cell align="center">{(accuracy * 100).toFixed(0)}%</Table.Cell>
<Table.Cell align="center">{shots.toFixed(1)}</Table.Cell>
<Table.Cell align="center">{hits.toFixed(1)}</Table.Cell>
<Table.Cell align="center">{damageRatio.toFixed(2)}</Table.Cell>
<Table.Cell align="center">
{Math.round(damageReceived).toLocaleString()}
</Table.Cell>
<Table.Cell align="center">{capturePoints.toFixed(2)}</Table.Cell>
</Table.Row>
);
},
(prev, next) => prev.tanks === next.tanks,
);
@@ -0,0 +1,8 @@
---
import { Page } from '.';
import ToolLayout from '../../../layouts/ToolLayout.astro';
---
<ToolLayout>
<Page client:load />
</ToolLayout>
@@ -0,0 +1,26 @@
import { PageWrapper } from '../../../components/PageWrapper';
import { Controls } from '../../../components/Performance/Controls';
import { PerformanceInfo } from '../../../components/Performance/PerformanceInfo';
import { TankTable } from '../../../components/Performance/Table';
import { TankPerformancePersistent } from '../../../stores/tankPerformancePersistent';
import { TankPerformanceSort } from '../../../stores/tankPerformanceSort';
export function Page() {
return (
<TankPerformanceSort.Provider>
<TankPerformancePersistent.Provider>
<Content />
</TankPerformancePersistent.Provider>
</TankPerformanceSort.Provider>
);
}
function Content() {
return (
<PageWrapper color="jade" noMaxWidth>
<PerformanceInfo />
<Controls />
<TankTable />
</PageWrapper>
);
}
@@ -8,14 +8,13 @@ export interface TankPerformancePersistent {
playerCountPeriod: PlayerCountPeriod;
}
export const { Provider, use, useMutation, useStore } = createContextualStore(
() =>
create<TankPerformancePersistent>()(
persist(
subscribeWithSelector<TankPerformancePersistent>(() => ({
playerCountPeriod: PlayerCountPeriod.ThisMonth,
})),
{ name: 'tank-performance', merge: (a, b) => lodash.merge(b, a) },
),
export const TankPerformancePersistent = createContextualStore(() =>
create<TankPerformancePersistent>()(
persist(
subscribeWithSelector<TankPerformancePersistent>(() => ({
playerCountPeriod: PlayerCountPeriod.ThisMonth,
})),
{ name: 'tank-performance', merge: (a, b) => lodash.merge(b, a) },
),
),
);
@@ -1,4 +1,4 @@
import { TankPerformanceSortType } from '.';
import type { TankPerformanceSortType } from '.';
export const TankPerformanceSortTypeNames = {
winrate: 'Winrate',
@@ -9,10 +9,9 @@ export interface TankPerformanceSort {
direction: -1 | 1;
}
export const { Provider, use, useMutation, useStore } = createContextualStore(
() =>
create<TankPerformanceSort>()(() => ({
type: 'winrate',
direction: -1,
})),
export const TankPerformanceSort = createContextualStore(() =>
create<TankPerformanceSort>()(() => ({
type: 'winrate',
direction: -1,
})),
);