SendouQ top 10 special display (#1483)

* Component initial

* TopTeanPlayer component

* Add to seasons page

* Finish
This commit is contained in:
Kalle
2023-09-05 21:03:36 +03:00
committed by GitHub
parent b15a4adf35
commit bfa9371786
32 changed files with 352 additions and 45 deletions

View File

@@ -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`}

View File

@@ -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);

View 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>
);
}

View File

@@ -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);

View 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;
}

View File

@@ -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>
);
}

View 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
}
}
]
]

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -222,6 +222,10 @@
margin-inline-end: auto;
}
.mr-1 {
margin-inline-end: var(--s-1);
}
.mx-auto {
margin: 0 auto;
}

View File

@@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB