SendouQ top 10 special display (#1483)
* Component initial * TopTeanPlayer component * Add to seasons page * Finish
@@ -21,6 +21,7 @@ interface ImageProps {
|
||||
height?: number;
|
||||
size?: number;
|
||||
style?: React.CSSProperties;
|
||||
containerStyle?: React.CSSProperties;
|
||||
testId?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -36,10 +37,16 @@ export function Image({
|
||||
style,
|
||||
testId,
|
||||
containerClassName,
|
||||
containerStyle,
|
||||
onClick,
|
||||
}: ImageProps) {
|
||||
return (
|
||||
<picture title={title} className={containerClassName} onClick={onClick}>
|
||||
<picture
|
||||
title={title}
|
||||
className={containerClassName}
|
||||
style={containerStyle}
|
||||
onClick={onClick}
|
||||
>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcSet={`${path}.avif`}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type PlacementProps = {
|
||||
textClassName?: string;
|
||||
size?: number;
|
||||
textOnly?: boolean;
|
||||
showAsSuperscript?: boolean;
|
||||
};
|
||||
|
||||
const getSpecialPlacementIconPath = (placement: number): string | null => {
|
||||
@@ -32,6 +33,7 @@ export function Placement({
|
||||
textClassName,
|
||||
size = 20,
|
||||
textOnly = false,
|
||||
showAsSuperscript = true,
|
||||
}: PlacementProps) {
|
||||
const { t } = useTranslation(undefined, {});
|
||||
|
||||
@@ -44,7 +46,7 @@ export function Placement({
|
||||
fallbackLng: [],
|
||||
});
|
||||
|
||||
const isSuperscript = ordinalSuffix.startsWith("^");
|
||||
const isSuperscript = showAsSuperscript && ordinalSuffix.startsWith("^");
|
||||
const ordinalSuffixText = ordinalSuffix.replace(/^\^/, "");
|
||||
|
||||
const iconPath = textOnly ? null : getSpecialPlacementIconPath(placement);
|
||||
|
||||
77
app/features/leaderboards/components/TopTenPlayer.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { winnersImageUrl } from "~/utils/urls";
|
||||
import playerData from "../top-ten.json";
|
||||
import invariant from "tiny-invariant";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function TopTenPlayer({
|
||||
power,
|
||||
placement,
|
||||
season,
|
||||
small = false,
|
||||
}: {
|
||||
power?: number;
|
||||
placement: number;
|
||||
season: number;
|
||||
small?: boolean;
|
||||
}) {
|
||||
const data = playerData[season]?.[placement - 1];
|
||||
invariant(data, `No data for season ${season} and placement ${placement}`);
|
||||
const { name, countryCode, transforms } = data;
|
||||
|
||||
const transformMultiplier = small ? 1 / 3 : 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("stack horizontal items-center text-main-forced", {
|
||||
md: !small,
|
||||
sm: small,
|
||||
"mt-2": small,
|
||||
})}
|
||||
>
|
||||
<div className={clsx("winner__container", { small })}>
|
||||
<Image
|
||||
path={winnersImageUrl({ season, placement })}
|
||||
alt=""
|
||||
containerClassName="winner__img-container"
|
||||
className="winner__img"
|
||||
height={small ? 50 : 150}
|
||||
containerStyle={
|
||||
{
|
||||
"--winner-top": transforms?.top
|
||||
? `${transforms.top * transformMultiplier}px`
|
||||
: undefined,
|
||||
"--winner-left": transforms?.left
|
||||
? `${transforms.left * transformMultiplier}px`
|
||||
: undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="text-xs text-lighter stack horizontal xxs items-center"
|
||||
style={placement > 3 ? { marginBlockEnd: "-4px" } : undefined}
|
||||
>
|
||||
{placement <= 3 ? (
|
||||
<Placement placement={placement} size={15} iconClassName="mr-1" />
|
||||
) : null}{" "}
|
||||
<Placement placement={placement} textOnly showAsSuperscript={false} />{" "}
|
||||
place
|
||||
</div>
|
||||
{!small ? (
|
||||
<>
|
||||
<div className="text-xl font-semi-bold">
|
||||
<Flag tiny countryCode={countryCode} /> {name}
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ lineHeight: "1" }}>
|
||||
{power}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,17 @@ import type { SeasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeap
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import { weaponCategories } from "~/modules/in-game-lists";
|
||||
import type { TeamSPLeaderboardItem } from "../queries/teamSPLeaderboard.server";
|
||||
import { seasonHasTopTen } from "../leaderboards-utils";
|
||||
|
||||
export function addTiers(entries: UserSPLeaderboardItem[], season: number) {
|
||||
const tiers = freshUserSkills(season);
|
||||
|
||||
const encounteredTiers = new Set<string>();
|
||||
return entries.map((entry) => {
|
||||
return entries.map((entry, i) => {
|
||||
if (i < 10 && seasonHasTopTen(season)) {
|
||||
return { ...entry, tier: undefined };
|
||||
}
|
||||
|
||||
const tier = tiers.userSkills[entry.id].tier;
|
||||
const tierKey = `${tier.name}${tier.isPlus ? "+" : ""}`;
|
||||
const tierAlreadyEncountered = encounteredTiers.has(tierKey);
|
||||
|
||||
37
app/features/leaderboards/leaderboards-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import playerData from "./top-ten.json";
|
||||
|
||||
export function seasonHasTopTen(season: number) {
|
||||
return !!playerData[season];
|
||||
}
|
||||
|
||||
export function playerTopTenData({
|
||||
season,
|
||||
userId,
|
||||
}: {
|
||||
season: number;
|
||||
userId: number;
|
||||
}) {
|
||||
for (const player of playerData[season] ?? []) {
|
||||
if (player.id === userId) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function playerTopTenPlacement({
|
||||
season,
|
||||
userId,
|
||||
}: {
|
||||
season: number;
|
||||
userId: number;
|
||||
}) {
|
||||
for (const [i, player] of (playerData[season] ?? []).entries()) {
|
||||
if (player.id === userId) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -53,6 +53,8 @@ import { seasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.se
|
||||
import { cachified } from "cachified";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import { HALF_HOUR_IN_MS } from "~/constants";
|
||||
import { TopTenPlayer } from "../components/TopTenPlayer";
|
||||
import { seasonHasTopTen } from "../leaderboards-utils";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["vods"],
|
||||
@@ -157,6 +159,10 @@ export default function LeaderboardsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const isAllUserLeaderboard =
|
||||
!searchParams.get(TYPE_SEARCH_PARAM_KEY) ||
|
||||
searchParams.get(TYPE_SEARCH_PARAM_KEY) === "USER";
|
||||
|
||||
return (
|
||||
<Main halfWidth className="stack lg">
|
||||
<select
|
||||
@@ -216,13 +222,33 @@ export default function LeaderboardsPage() {
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{/* TODO: dynamic season */}
|
||||
{seasonHasTopTen(0) && isAllUserLeaderboard && data.userLeaderboard ? (
|
||||
<div className="stack lg mx-auto">
|
||||
{data.userLeaderboard
|
||||
.filter((_, i) => i <= 9)
|
||||
.map((entry, i) => {
|
||||
return (
|
||||
// TODO dynamic season
|
||||
<Link
|
||||
key={`${entry.id}-${0}`}
|
||||
to={userSeasonsPage({ user: entry, season: 0 })}
|
||||
>
|
||||
<TopTenPlayer
|
||||
placement={i + 1}
|
||||
power={entry.power}
|
||||
season={0}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.userLeaderboard ? (
|
||||
<PlayersTable
|
||||
entries={data.userLeaderboard}
|
||||
showTiers={
|
||||
!searchParams.get(TYPE_SEARCH_PARAM_KEY) ||
|
||||
searchParams.get(TYPE_SEARCH_PARAM_KEY) === "USER"
|
||||
}
|
||||
showTiers={isAllUserLeaderboard}
|
||||
/>
|
||||
) : null}
|
||||
{data.teamLeaderboard ? (
|
||||
@@ -248,46 +274,48 @@ function PlayersTable({
|
||||
}) {
|
||||
return (
|
||||
<div className="placements__table">
|
||||
{entries.map((entry) => {
|
||||
return (
|
||||
<React.Fragment key={entry.entryId}>
|
||||
{entry.tier && showTiers ? (
|
||||
<div className="placements__tier-header">
|
||||
<TierImage tier={entry.tier} width={32} />
|
||||
{entry.tier.name}
|
||||
{entry.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{/* TODO: dynamic season */}
|
||||
<Link
|
||||
to={userSeasonsPage({ user: entry, season: 0 })}
|
||||
className="placements__table__row"
|
||||
>
|
||||
<div className="placements__table__inner-row">
|
||||
<div className="placements__table__rank">
|
||||
{entry.placementRank}
|
||||
{entries
|
||||
.filter((_, i) => !seasonHasTopTen(0) || i > 9)
|
||||
.map((entry) => {
|
||||
return (
|
||||
<React.Fragment key={entry.entryId}>
|
||||
{entry.tier && showTiers ? (
|
||||
<div className="placements__tier-header">
|
||||
<TierImage tier={entry.tier} width={32} />
|
||||
{entry.tier.name}
|
||||
{entry.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
<div>
|
||||
<Avatar size="xxs" user={entry} />
|
||||
) : null}
|
||||
{/* TODO: dynamic season */}
|
||||
<Link
|
||||
to={userSeasonsPage({ user: entry, season: 0 })}
|
||||
className="placements__table__row"
|
||||
>
|
||||
<div className="placements__table__inner-row">
|
||||
<div className="placements__table__rank">
|
||||
{entry.placementRank}
|
||||
</div>
|
||||
<div>
|
||||
<Avatar size="xxs" user={entry} />
|
||||
</div>
|
||||
{entry.weaponSplId ? (
|
||||
<WeaponImage
|
||||
className="placements__table__weapon"
|
||||
variant="build"
|
||||
weaponSplId={entry.weaponSplId}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
) : null}
|
||||
<div className="placements__table__name">
|
||||
{entry.discordName}
|
||||
</div>
|
||||
<div className="placements__table__power">{entry.power}</div>
|
||||
</div>
|
||||
{entry.weaponSplId ? (
|
||||
<WeaponImage
|
||||
className="placements__table__weapon"
|
||||
variant="build"
|
||||
weaponSplId={entry.weaponSplId}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
) : null}
|
||||
<div className="placements__table__name">
|
||||
{entry.discordName}
|
||||
</div>
|
||||
<div className="placements__table__power">{entry.power}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
94
app/features/leaderboards/top-ten.json
Normal file
@@ -0,0 +1,94 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"name": "Grey",
|
||||
"countryCode": "FR",
|
||||
"id": 123,
|
||||
"transforms": {
|
||||
"left": 25,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Kyo",
|
||||
"id": 101,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 11,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "biscuit!",
|
||||
"id": 25,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 29,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Stalk",
|
||||
"id": 40,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 32,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Xenith",
|
||||
"id": 11517,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 19,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Kiver",
|
||||
"id": 35,
|
||||
"countryCode": "FR",
|
||||
"transforms": {
|
||||
"left": 34,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Oscar",
|
||||
"id": 1343,
|
||||
"countryCode": "MX",
|
||||
"transforms": {
|
||||
"left": 18,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Home",
|
||||
"id": 201,
|
||||
"countryCode": "ID",
|
||||
"transforms": {
|
||||
"left": 34,
|
||||
"top": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Gos",
|
||||
"id": 279,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 30,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Elis",
|
||||
"id": 141,
|
||||
"countryCode": "GB-WLS",
|
||||
"transforms": {
|
||||
"left": 27,
|
||||
"top": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -54,6 +54,8 @@ import { Popover } from "~/components/Popover";
|
||||
import { useWeaponUsage } from "~/hooks/swr";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { Tab, Tabs } from "~/components/Tabs";
|
||||
import { TopTenPlayer } from "~/features/leaderboards/components/TopTenPlayer";
|
||||
import { playerTopTenPlacement } from "~/features/leaderboards/leaderboards-utils";
|
||||
|
||||
export const seasonsSearchParamsSchema = z.object({
|
||||
page: z.coerce.number().default(1),
|
||||
@@ -242,11 +244,20 @@ function Winrates() {
|
||||
|
||||
function Rank({ currentOrdinal }: { currentOrdinal: number }) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const parentRouteData = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
const maxOrdinal = Math.max(...data.skills.map((s) => s.ordinal));
|
||||
|
||||
const peakAndCurrentSame = currentOrdinal === maxOrdinal;
|
||||
|
||||
// TODO: dynamic season
|
||||
const topTenPlacement = playerTopTenPlacement({
|
||||
season: 0,
|
||||
userId: parentRouteData.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="stack horizontal items-center justify-center sm">
|
||||
<TierImage tier={data.tier} />
|
||||
@@ -261,6 +272,10 @@ function Rank({ currentOrdinal }: { currentOrdinal: number }) {
|
||||
Peak {ordinalToSp(maxOrdinal)}SP
|
||||
</div>
|
||||
) : null}
|
||||
{/* TODO: dynamic season */}
|
||||
{topTenPlacement ? (
|
||||
<TopTenPlayer small placement={topTenPlacement} season={0} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1262,6 +1262,31 @@ dialog::backdrop {
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.winner__container {
|
||||
height: 125px;
|
||||
width: 125px;
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lighter);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.winner__container.small {
|
||||
height: 41.6667px;
|
||||
width: 41.6667px;
|
||||
}
|
||||
|
||||
.winner__img-container {
|
||||
position: absolute;
|
||||
top: var(--winner-top, 5px);
|
||||
left: var(--winner-left, 25px);
|
||||
}
|
||||
|
||||
.winner__img {
|
||||
overflow: visible;
|
||||
max-width: initial;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
margin-top: 3rem !important;
|
||||
background: var(--theme) !important;
|
||||
|
||||
@@ -982,3 +982,7 @@
|
||||
.twf-zw {
|
||||
background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/1f1ff-1f1fc.svg");
|
||||
}
|
||||
|
||||
.twf-gb-wls {
|
||||
background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/1f3f4-e0067-e0062-e0077-e006c-e0073-e007f.svg");
|
||||
}
|
||||
|
||||
@@ -222,6 +222,10 @@
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -335,6 +335,15 @@ export const brandImageUrl = (brand: "tentatek" | "takoroka") =>
|
||||
export const tierImageUrl = (tier: TierName) =>
|
||||
`/static-assets/img/tiers/${tier.toLowerCase()}`;
|
||||
export const TIER_PLUS_URL = `/static-assets/img/tiers/plus`;
|
||||
|
||||
export const winnersImageUrl = ({
|
||||
season,
|
||||
placement,
|
||||
}: {
|
||||
season: number;
|
||||
placement: number;
|
||||
}) => `/static-assets/img/winners/${season}/${placement}`;
|
||||
|
||||
export const stageMinimapImageUrlWithEnding = ({
|
||||
stageId,
|
||||
mode,
|
||||
|
||||
BIN
public/static-assets/img/winners/0/1.avif
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/static-assets/img/winners/0/1.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/static-assets/img/winners/0/10.avif
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/static-assets/img/winners/0/10.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/static-assets/img/winners/0/2.avif
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/static-assets/img/winners/0/2.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
public/static-assets/img/winners/0/3.avif
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/static-assets/img/winners/0/3.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/static-assets/img/winners/0/4.avif
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/static-assets/img/winners/0/4.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/static-assets/img/winners/0/5.avif
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/static-assets/img/winners/0/5.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/static-assets/img/winners/0/6.avif
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/static-assets/img/winners/0/6.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/static-assets/img/winners/0/7.avif
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/static-assets/img/winners/0/7.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/static-assets/img/winners/0/8.avif
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/static-assets/img/winners/0/8.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
public/static-assets/img/winners/0/9.avif
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/static-assets/img/winners/0/9.png
Normal file
|
After Width: | Height: | Size: 113 KiB |