mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 23:19:39 -05:00
Build stats / popular builds (#1307)
* Add abilityPoints migration * Initial stats * Build stats UI * Add TODO * Update css file name * Popular builds page
This commit is contained in:
parent
3a7549c732
commit
cfa0d4dd0d
27
app/components/icons/ChartBar.tsx
Normal file
27
app/components/icons/ChartBar.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ChartBarIcon({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
32
app/components/icons/Fire.tsx
Normal file
32
app/components/icons/Fire.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { CSSProperties } from "react";
|
||||
|
||||
export function FireIcon({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -69,3 +69,5 @@ export const ADMIN_ID = process.env.NODE_ENV === "production" ? 274 : 1;
|
|||
export const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
|
||||
|
||||
export const CUSTOMIZED_CSS_VARS_NAME = "css";
|
||||
|
||||
export const MAX_AP = 57;
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export interface BuildAbility {
|
|||
gearType: "HEAD" | "CLOTHES" | "SHOES";
|
||||
ability: Ability;
|
||||
slotIndex: 0 | 1 | 2 | 3;
|
||||
abilityPoints: number; // 3 or 10
|
||||
}
|
||||
|
||||
export interface MapPoolMap {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { DamageType } from "./analyzer-types";
|
|||
import { type MainWeaponId } from "~/modules/in-game-lists";
|
||||
|
||||
export const MAX_LDE_INTENSITY = 21;
|
||||
export const MAX_AP = 57;
|
||||
|
||||
export const DAMAGE_TYPE = [
|
||||
"NORMAL_MIN",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { MAX_AP } from "../analyzer-constants";
|
||||
import { MAX_AP } from "~/constants";
|
||||
import type { AbilityPoints } from "../analyzer-types";
|
||||
|
||||
export const SPECIAL_EFFECTS = [
|
||||
|
|
|
|||
150
app/features/build-stats/build-stats-utils.test.ts
Normal file
150
app/features/build-stats/build-stats-utils.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { suite } from "uvu";
|
||||
import * as assert from "uvu/assert";
|
||||
import {
|
||||
abilityPointCountsToAverages,
|
||||
popularBuilds,
|
||||
} from "./build-stats-utils";
|
||||
|
||||
const AbilityPointCountsToAverages = suite("abilityPointCountsToAverages()");
|
||||
const PopularBuilds = suite("popularBuilds()");
|
||||
|
||||
const commonAbilities = [
|
||||
{
|
||||
ability: "QR" as const,
|
||||
abilityPointsSum: 57,
|
||||
},
|
||||
{
|
||||
ability: "SJ" as const,
|
||||
abilityPointsSum: 10,
|
||||
},
|
||||
{
|
||||
ability: "CB" as const,
|
||||
abilityPointsSum: 10,
|
||||
},
|
||||
{
|
||||
ability: "T" as const,
|
||||
abilityPointsSum: 10,
|
||||
},
|
||||
{
|
||||
ability: "SS" as const,
|
||||
abilityPointsSum: 27,
|
||||
},
|
||||
];
|
||||
|
||||
const allAbilities = [
|
||||
...commonAbilities,
|
||||
{ ability: "BRU" as const, abilityPointsSum: 57 },
|
||||
];
|
||||
|
||||
AbilityPointCountsToAverages("calculates build count", () => {
|
||||
const { weaponBuildsCount } = abilityPointCountsToAverages({
|
||||
allAbilities,
|
||||
weaponAbilities: commonAbilities,
|
||||
});
|
||||
|
||||
assert.is(weaponBuildsCount, 2);
|
||||
});
|
||||
|
||||
AbilityPointCountsToAverages("calculates average ap (main only)", () => {
|
||||
const { mainOnlyAbilities } = abilityPointCountsToAverages({
|
||||
allAbilities,
|
||||
weaponAbilities: commonAbilities,
|
||||
});
|
||||
|
||||
assert.is(
|
||||
mainOnlyAbilities.find((a) => a.name === "T")?.percentage.weapon,
|
||||
50
|
||||
);
|
||||
});
|
||||
|
||||
AbilityPointCountsToAverages("calculates average ap (stackable)", () => {
|
||||
const { stackableAbilities } = abilityPointCountsToAverages({
|
||||
allAbilities,
|
||||
weaponAbilities: commonAbilities,
|
||||
});
|
||||
|
||||
assert.is(
|
||||
stackableAbilities.find((a) => a.name === "SS")?.apAverage.weapon,
|
||||
13.5
|
||||
);
|
||||
});
|
||||
|
||||
AbilityPointCountsToAverages("calculates average ap for all builds", () => {
|
||||
const { mainOnlyAbilities } = abilityPointCountsToAverages({
|
||||
allAbilities,
|
||||
weaponAbilities: commonAbilities,
|
||||
});
|
||||
|
||||
assert.is(
|
||||
mainOnlyAbilities.find((a) => a.name === "T")?.percentage.all,
|
||||
33.33
|
||||
);
|
||||
});
|
||||
|
||||
PopularBuilds("calculates popular build", () => {
|
||||
const builds = popularBuilds([
|
||||
...new Array(10).fill(null).map(() => ({
|
||||
abilities: [{ ability: "QR" as const, abilityPoints: 57 }],
|
||||
})),
|
||||
{
|
||||
abilities: [{ ability: "BRU" as const, abilityPoints: 57 }],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.is(builds.length, 1);
|
||||
assert.is(builds[0]!.count, 10);
|
||||
assert.is(builds[0]!.abilities[0]!.ability, "QR");
|
||||
});
|
||||
|
||||
PopularBuilds("calculates second most popular build (sorted by count)", () => {
|
||||
const builds = popularBuilds([
|
||||
...new Array(10).fill(null).map(() => ({
|
||||
abilities: [{ ability: "QR" as const, abilityPoints: 57 }],
|
||||
})),
|
||||
...new Array(3).fill(null).map(() => ({
|
||||
abilities: [{ ability: "SS" as const, abilityPoints: 57 }],
|
||||
})),
|
||||
...new Array(5).fill(null).map(() => ({
|
||||
abilities: [{ ability: "SSU" as const, abilityPoints: 57 }],
|
||||
})),
|
||||
]);
|
||||
|
||||
assert.is(builds.length, 3);
|
||||
assert.is(builds[1]!.abilities[0]!.ability, "SSU");
|
||||
});
|
||||
|
||||
PopularBuilds("sums up abilities", () => {
|
||||
const builds = popularBuilds([
|
||||
{ abilities: [{ ability: "QR" as const, abilityPoints: 57 }] },
|
||||
{
|
||||
abilities: [
|
||||
{ ability: "QR" as const, abilityPoints: 10 },
|
||||
{ ability: "QR" as const, abilityPoints: 47 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.is(builds.length, 1);
|
||||
});
|
||||
|
||||
PopularBuilds("sorts abilities", () => {
|
||||
const builds = popularBuilds([
|
||||
{
|
||||
abilities: [
|
||||
{ ability: "QR" as const, abilityPoints: 10 },
|
||||
{ ability: "SS" as const, abilityPoints: 47 },
|
||||
],
|
||||
},
|
||||
{
|
||||
abilities: [
|
||||
{ ability: "QR" as const, abilityPoints: 10 },
|
||||
{ ability: "SS" as const, abilityPoints: 47 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.is(builds[0]!.abilities[1]!.ability, "QR");
|
||||
});
|
||||
|
||||
AbilityPointCountsToAverages.run();
|
||||
PopularBuilds.run();
|
||||
161
app/features/build-stats/build-stats-utils.ts
Normal file
161
app/features/build-stats/build-stats-utils.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import invariant from "tiny-invariant";
|
||||
import { MAX_AP } from "~/constants";
|
||||
import { abilities, type Ability } from "~/modules/in-game-lists";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { isStackableAbility } from "../build-analyzer/core/utils";
|
||||
import type { AbilitiesByWeapon } from "./queries/abilitiesByWeaponId.server";
|
||||
import type { AverageAbilityPointsResult } from "./queries/averageAbilityPoints.server";
|
||||
|
||||
const toBuildsCount = (counts: AverageAbilityPointsResult[]) =>
|
||||
counts.reduce((acc, cur) => acc + cur.abilityPointsSum, 0) / MAX_AP;
|
||||
|
||||
const toAbilityPoints = (
|
||||
abilities: AverageAbilityPointsResult[],
|
||||
ability: Ability
|
||||
) =>
|
||||
abilities.find((current) => current.ability === ability)?.abilityPointsSum ??
|
||||
0;
|
||||
|
||||
export function abilityPointCountsToAverages({
|
||||
allAbilities,
|
||||
weaponAbilities,
|
||||
}: {
|
||||
allAbilities: AverageAbilityPointsResult[];
|
||||
weaponAbilities: AverageAbilityPointsResult[];
|
||||
}) {
|
||||
const allBuildsCount = toBuildsCount(allAbilities);
|
||||
const weaponBuildsCount = toBuildsCount(weaponAbilities);
|
||||
invariant(
|
||||
allBuildsCount > weaponBuildsCount,
|
||||
"allBuildsCount < weaponBuildsCount"
|
||||
);
|
||||
|
||||
const mainOnlyAbilities = abilities
|
||||
.filter((ability) => ability.type !== "STACKABLE")
|
||||
.map((ability) => {
|
||||
const allBuildsAbilityPoints = toAbilityPoints(
|
||||
allAbilities,
|
||||
ability.name
|
||||
);
|
||||
const weaponBuildsAbilityPoints = toAbilityPoints(
|
||||
weaponAbilities,
|
||||
ability.name
|
||||
);
|
||||
|
||||
// main only abilities always take place of 10AP
|
||||
const allBuildsWithTheAbility = allBuildsAbilityPoints / 10;
|
||||
const weaponBuildsWithTheAbility = weaponBuildsAbilityPoints / 10;
|
||||
|
||||
return {
|
||||
name: ability.name,
|
||||
percentage: {
|
||||
all: roundToNDecimalPlaces(
|
||||
(allBuildsWithTheAbility / allBuildsCount) * 100
|
||||
),
|
||||
weapon: roundToNDecimalPlaces(
|
||||
(weaponBuildsWithTheAbility / weaponBuildsCount) * 100
|
||||
),
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.percentage.weapon - a.percentage.weapon);
|
||||
|
||||
const stackableAbilities = abilities
|
||||
.filter((ability) => ability.type === "STACKABLE")
|
||||
.map((ability) => {
|
||||
const allBuildsAbilityPoints = toAbilityPoints(
|
||||
allAbilities,
|
||||
ability.name
|
||||
);
|
||||
const weaponBuildsAbilityPoints = toAbilityPoints(
|
||||
weaponAbilities,
|
||||
ability.name
|
||||
);
|
||||
|
||||
return {
|
||||
name: ability.name,
|
||||
apAverage: {
|
||||
all: roundToNDecimalPlaces(allBuildsAbilityPoints / allBuildsCount),
|
||||
weapon: roundToNDecimalPlaces(
|
||||
weaponBuildsAbilityPoints / weaponBuildsCount
|
||||
),
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.apAverage.weapon - a.apAverage.weapon);
|
||||
|
||||
return {
|
||||
mainOnlyAbilities,
|
||||
stackableAbilities,
|
||||
weaponBuildsCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
type AbilityCountsMap = Map<Ability, number>;
|
||||
|
||||
const POPULAR_BUILDS_TO_SHOW = 25;
|
||||
|
||||
export function popularBuilds(builds: Array<AbilitiesByWeapon>) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const build of builds) {
|
||||
const summedUpAbilities = sumUpAbilities(build);
|
||||
const serializedAbilities = serializeAbilityCountsMap(summedUpAbilities);
|
||||
|
||||
counts.set(serializedAbilities, (counts.get(serializedAbilities) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const serializedToShow = Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.filter(([, count]) => count > 1)
|
||||
.slice(0, POPULAR_BUILDS_TO_SHOW);
|
||||
|
||||
return serializedToShowToResultType(serializedToShow);
|
||||
}
|
||||
|
||||
function sumUpAbilities(build: AbilitiesByWeapon) {
|
||||
const result: AbilityCountsMap = new Map();
|
||||
for (const { ability, abilityPoints } of build.abilities) {
|
||||
result.set(ability, (result.get(ability) ?? 0) + abilityPoints);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeAbilityCountsMap(abilityCountsMap: AbilityCountsMap) {
|
||||
return Array.from(abilityCountsMap.entries())
|
||||
.sort((a, b) => {
|
||||
if (a[1] === b[1]) {
|
||||
return a[0].localeCompare(b[0]);
|
||||
}
|
||||
|
||||
return b[1] - a[1];
|
||||
})
|
||||
.map(([ability, count]) => `${ability}_${count}`)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function serializedToShowToResultType(serializedToShow: [string, number][]) {
|
||||
let previousCount: number;
|
||||
return serializedToShow.map(([serialized, count]) => {
|
||||
const abilities = serialized.split(",").map((serializedAbility) => {
|
||||
const [ability, count] = serializedAbility.split("_");
|
||||
invariant(ability, "ability is not defined");
|
||||
invariant(count, "count is not defined");
|
||||
return {
|
||||
ability: ability as Ability,
|
||||
count: isStackableAbility(ability as Ability)
|
||||
? Number(count)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (previousCount === count) {
|
||||
return { abilities, count: null, id: serialized };
|
||||
}
|
||||
|
||||
previousCount = count;
|
||||
return { abilities, count, id: serialized };
|
||||
});
|
||||
}
|
||||
24
app/features/build-stats/build-stats.css
Normal file
24
app/features/build-stats/build-stats.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
.build-stats__ability-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.build-stats__bars {
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--semi-bold);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: max-content 65px 1fr;
|
||||
column-gap: var(--s-2);
|
||||
row-gap: var(--s-1);
|
||||
}
|
||||
|
||||
.build-stats__bar {
|
||||
background-color: var(--theme);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.build-stats__value-text {
|
||||
width: 100px;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Ability, MainWeaponId } from "~/modules/in-game-lists";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
json_group_array(
|
||||
json_object(
|
||||
'ability',
|
||||
"BuildAbility"."ability",
|
||||
'abilityPoints',
|
||||
"BuildAbility"."abilityPoints"
|
||||
)
|
||||
) as "abilities"
|
||||
from "BuildAbility"
|
||||
left join "BuildWeapon" on "BuildWeapon"."buildId" = "BuildAbility"."buildId"
|
||||
where "BuildWeapon"."weaponSplId" = @weaponSplId
|
||||
group by "BuildAbility"."buildId"
|
||||
`);
|
||||
|
||||
export interface AbilitiesByWeapon {
|
||||
abilities: Array<{
|
||||
ability: Ability;
|
||||
abilityPoints: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function abilitiesByWeaponId(
|
||||
weaponSplId: MainWeaponId
|
||||
): Array<AbilitiesByWeapon> {
|
||||
return stm
|
||||
.all({ weaponSplId })
|
||||
.map((row) => ({ abilities: JSON.parse(row.abilities) }));
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Ability, MainWeaponId } from "~/modules/in-game-lists";
|
||||
|
||||
const query = (includeWeaponId: boolean) => /* sql */ `
|
||||
select "BuildAbility"."ability", sum("BuildAbility"."abilityPoints") as "abilityPointsSum"
|
||||
from "BuildAbility"
|
||||
left join "BuildWeapon" on "BuildAbility"."buildId" = "BuildWeapon"."buildId"
|
||||
${
|
||||
includeWeaponId
|
||||
? /* sql */ `where "BuildWeapon"."weaponSplId" = @weaponSplId`
|
||||
: ""
|
||||
}
|
||||
group by "BuildAbility"."ability"
|
||||
`;
|
||||
|
||||
const findByWeaponIdStm = sql.prepare(query(true));
|
||||
const findAllStm = sql.prepare(query(false));
|
||||
|
||||
export interface AverageAbilityPointsResult {
|
||||
ability: Ability;
|
||||
abilityPointsSum: number;
|
||||
}
|
||||
|
||||
export function averageAbilityPoints(
|
||||
weaponSplId?: MainWeaponId | null
|
||||
): Array<AverageAbilityPointsResult> {
|
||||
const stm = typeof weaponSplId === "number" ? findByWeaponIdStm : findAllStm;
|
||||
|
||||
return stm.all({ weaponSplId });
|
||||
}
|
||||
125
app/features/build-stats/routes/builds.$slug.popular.tsx
Normal file
125
app/features/build-stats/routes/builds.$slug.popular.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { LoaderArgs, MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { i18next } from "~/modules/i18n";
|
||||
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
||||
import {
|
||||
BUILDS_PAGE,
|
||||
navIconUrl,
|
||||
outlinedMainWeaponImageUrl,
|
||||
weaponBuildPage,
|
||||
} from "~/utils/urls";
|
||||
import { popularBuilds } from "../build-stats-utils";
|
||||
import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
||||
if (!data) return {};
|
||||
|
||||
return {
|
||||
title: data.meta.title,
|
||||
};
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["analyzer", "builds"],
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.data as SerializeFrom<typeof loader> | undefined;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
imgPath: navIconUrl("builds"),
|
||||
href: BUILDS_PAGE,
|
||||
type: "IMAGE",
|
||||
},
|
||||
{
|
||||
imgPath: outlinedMainWeaponImageUrl(data.meta.weaponId),
|
||||
href: weaponBuildPage(data.meta.slug),
|
||||
type: "IMAGE",
|
||||
},
|
||||
{
|
||||
href: "/",
|
||||
text: data.meta.breadcrumbText,
|
||||
type: "TEXT",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["builds", "weapons", "common"]);
|
||||
const slug = params["slug"];
|
||||
const weaponId = notFoundIfFalsy(weaponNameSlugToId(slug));
|
||||
const abilities = abilitiesByWeaponId(weaponId);
|
||||
|
||||
const weaponName = t(`weapons:MAIN_${weaponId}`);
|
||||
|
||||
return {
|
||||
popularBuilds: popularBuilds(abilities),
|
||||
meta: {
|
||||
weaponId,
|
||||
slug: slug!,
|
||||
title: makeTitle([
|
||||
t("builds:linkButton.popularBuilds"),
|
||||
weaponName,
|
||||
t("common:pages.builds"),
|
||||
]),
|
||||
breadcrumbText: t("builds:linkButton.popularBuilds"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function PopularBuildsPage() {
|
||||
const { t } = useTranslation(["analyzer", "builds"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
{data.popularBuilds.length === 0 && (
|
||||
<div className="text-lg text-lighter text-center">
|
||||
{t("builds:noPopularBuilds")}
|
||||
</div>
|
||||
)}
|
||||
{data.popularBuilds.map((build, i) => {
|
||||
return (
|
||||
<div key={build.id} className="stack horizontal lg items-center">
|
||||
<div
|
||||
className={clsx("stack items-center", {
|
||||
invisible: !build.count,
|
||||
})}
|
||||
>
|
||||
<div className="text-lg text-lighter font-bold">#{i + 1}</div>
|
||||
<div className="text-sm font-semi-bold text-theme">
|
||||
×{build.count}
|
||||
</div>
|
||||
</div>{" "}
|
||||
<div className="stack horizontal md flex-wrap">
|
||||
{build.abilities.map(({ ability, count }) => {
|
||||
return (
|
||||
<div
|
||||
key={ability}
|
||||
className="text-sm font-semi-bold stack xs items-center"
|
||||
>
|
||||
<Ability ability={ability} size="SUB" />{" "}
|
||||
<div className={clsx({ invisible: !count })}>
|
||||
{count}
|
||||
{t("analyzer:abilityPoints.short")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
192
app/features/build-stats/routes/builds.$slug.stats.tsx
Normal file
192
app/features/build-stats/routes/builds.$slug.stats.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import type {
|
||||
LinksFunction,
|
||||
LoaderArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Main } from "~/components/Main";
|
||||
import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
||||
import { averageAbilityPoints } from "../queries/averageAbilityPoints.server";
|
||||
import { abilityPointCountsToAverages } from "../build-stats-utils";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import styles from "../build-stats.css";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { notFoundIfFalsy } from "~/utils/remix";
|
||||
import { MAX_AP } from "~/constants";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import {
|
||||
BUILDS_PAGE,
|
||||
navIconUrl,
|
||||
outlinedMainWeaponImageUrl,
|
||||
weaponBuildPage,
|
||||
} from "~/utils/urls";
|
||||
import { i18next } from "~/modules/i18n";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
||||
if (!data) return {};
|
||||
|
||||
return {
|
||||
title: data.meta.title,
|
||||
};
|
||||
};
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["weapons", "builds", "analyzer"],
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.data as SerializeFrom<typeof loader> | undefined;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
imgPath: navIconUrl("builds"),
|
||||
href: BUILDS_PAGE,
|
||||
type: "IMAGE",
|
||||
},
|
||||
{
|
||||
imgPath: outlinedMainWeaponImageUrl(data.weaponId),
|
||||
href: weaponBuildPage(data.meta.slug),
|
||||
type: "IMAGE",
|
||||
},
|
||||
{
|
||||
href: "/",
|
||||
text: data.meta.breadcrumbText,
|
||||
type: "TEXT",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["builds", "weapons", "common"]);
|
||||
const weaponId = notFoundIfFalsy(weaponNameSlugToId(params["slug"]));
|
||||
|
||||
const weaponName = t(`weapons:MAIN_${weaponId}`);
|
||||
|
||||
return {
|
||||
stats: abilityPointCountsToAverages({
|
||||
allAbilities: averageAbilityPoints(),
|
||||
weaponAbilities: averageAbilityPoints(weaponId),
|
||||
}),
|
||||
weaponId,
|
||||
meta: {
|
||||
slug: params["slug"]!,
|
||||
title: makeTitle([
|
||||
t("builds:linkButton.abilityStats"),
|
||||
weaponName,
|
||||
t("common:pages.builds"),
|
||||
]),
|
||||
breadcrumbText: t("builds:linkButton.abilityStats"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function BuildStatsPage() {
|
||||
const { t } = useTranslation(["weapons", "builds", "analyzer"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main halfWidth className="stack lg">
|
||||
<div className="text-xs text-lighter font-bold">
|
||||
{t("builds:stats.count.title", {
|
||||
count: data.stats.weaponBuildsCount,
|
||||
weapon: t(`weapons:MAIN_${data.weaponId}`),
|
||||
})}
|
||||
</div>
|
||||
<div className="stack md">
|
||||
<h2 className="text-lg">{t("builds:stats.ap.title")}</h2>
|
||||
<div className="stack md">
|
||||
{data.stats.stackableAbilities.map((stats) => {
|
||||
const apToPx = (ap: number) =>
|
||||
Math.floor(
|
||||
(ap / data.stats.stackableAbilities[0]!.apAverage.weapon) * 200
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={stats.name} className="build-stats__ability-row">
|
||||
<Ability ability={stats.name} size="SUB" />
|
||||
<div className="build-stats__bars">
|
||||
<div>
|
||||
<WeaponImage
|
||||
variant="badge"
|
||||
weaponSplId={data.weaponId}
|
||||
width={22}
|
||||
/>{" "}
|
||||
</div>
|
||||
<div>
|
||||
{stats.apAverage.weapon} {t("analyzer:abilityPoints.short")}
|
||||
</div>{" "}
|
||||
<div
|
||||
className="build-stats__bar"
|
||||
style={{ width: `${apToPx(stats.apAverage.weapon)}px` }}
|
||||
/>
|
||||
<div className="text-xs text-lighter font-bold justify-self-center">
|
||||
{t("builds:stats.all")}
|
||||
</div>
|
||||
<div>
|
||||
{stats.apAverage.all} {t("analyzer:abilityPoints.short")}
|
||||
</div>{" "}
|
||||
<div
|
||||
className="build-stats__bar"
|
||||
style={{ width: `${apToPx(stats.apAverage.all)}px` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stack md">
|
||||
<h2 className="text-lg">{t("builds:stats.percentage.title")}</h2>
|
||||
<div className="stack md">
|
||||
{data.stats.mainOnlyAbilities.map((stats) => {
|
||||
const percentageToPx = (ap: number) =>
|
||||
Math.floor((ap / MAX_AP) * 125);
|
||||
|
||||
return (
|
||||
<div key={stats.name} className="build-stats__ability-row">
|
||||
<Ability ability={stats.name} size="SUB" />
|
||||
<div className="build-stats__bars">
|
||||
<div>
|
||||
<WeaponImage
|
||||
variant="badge"
|
||||
weaponSplId={data.weaponId}
|
||||
width={22}
|
||||
/>{" "}
|
||||
</div>
|
||||
<div>{stats.percentage.weapon}%</div>{" "}
|
||||
<div
|
||||
className="build-stats__bar"
|
||||
style={{
|
||||
width: `${percentageToPx(stats.percentage.weapon)}px`,
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-lighter font-bold justify-self-center">
|
||||
{t("builds:stats.all")}
|
||||
</div>
|
||||
<div>{stats.percentage.all}%</div>{" "}
|
||||
<div
|
||||
className="build-stats__bar"
|
||||
style={{
|
||||
width: `${percentageToPx(stats.percentage.all)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
|
@ -73,3 +73,11 @@ export const abilities = [
|
|||
] as const;
|
||||
|
||||
export const abilitiesShort = abilities.map((ability) => ability.name);
|
||||
|
||||
export const stackableAbilitiesShort = abilities
|
||||
.filter((ability) => ability.type === "STACKABLE")
|
||||
.map((ability) => ability.name);
|
||||
|
||||
export const mainOnlyAbilitiesShort = abilities
|
||||
.filter((ability) => ability.type !== "STACKABLE")
|
||||
.map((ability) => ability.name);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
weaponBuildPage,
|
||||
} from "~/utils/urls";
|
||||
import { Main } from "~/components/Main";
|
||||
import { ChartBarIcon } from "~/components/icons/ChartBar";
|
||||
import { FireIcon } from "~/components/icons/Fire";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
|
@ -90,10 +92,28 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
|||
|
||||
export default function WeaponsBuildsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { t } = useTranslation(["common", "builds"]);
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<div className="builds-buttons">
|
||||
<LinkButton
|
||||
to="stats"
|
||||
variant="outlined"
|
||||
icon={<ChartBarIcon />}
|
||||
size="tiny"
|
||||
>
|
||||
{t("builds:linkButton.abilityStats")}
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to="popular"
|
||||
variant="outlined"
|
||||
icon={<FireIcon />}
|
||||
size="tiny"
|
||||
>
|
||||
{t("builds:linkButton.popularBuilds")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="builds-container">
|
||||
{data.builds.map((build) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -824,6 +824,12 @@ dialog::backdrop {
|
|||
row-gap: var(--s-4);
|
||||
}
|
||||
|
||||
.builds-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.build {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@
|
|||
color: var(--theme-warning);
|
||||
}
|
||||
|
||||
.text-theme {
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.fill-success {
|
||||
fill: var(--theme-success);
|
||||
}
|
||||
|
|
@ -206,6 +210,10 @@
|
|||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.justify-self-center {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.self-center {
|
||||
align-self: center;
|
||||
}
|
||||
|
|
|
|||
5
migrations/021-abilities.js
Normal file
5
migrations/021-abilities.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module.exports.up = function (db) {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "BuildAbility" add "abilityPoints" integer generated always as (case when "slotIndex" = 0 then 10 else 3 end) virtual`
|
||||
).run();
|
||||
};
|
||||
|
|
@ -11,5 +11,14 @@
|
|||
"forms.gear.CLOTHES": "Gear (clothes)",
|
||||
"forms.gear.SHOES": "Gear (shoes)",
|
||||
|
||||
"deleteConfirm": "Delete build '{{title}}'?"
|
||||
"deleteConfirm": "Delete build '{{title}}'?",
|
||||
|
||||
"stats.count.title": "Stats from {{count}} {{weapon}} builds",
|
||||
"stats.ap.title": "Ability point averages",
|
||||
"stats.percentage.title": "Primary-only ability appearance",
|
||||
"stats.all": "All",
|
||||
|
||||
"linkButton.abilityStats": "Ability stats",
|
||||
"linkButton.popularBuilds": "Popular builds",
|
||||
"noPopularBuilds": "It seems there is no popular builds for this weapon at this moment."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ module.exports = {
|
|||
route("/vods", "features/vods/routes/vods.tsx");
|
||||
route("/vods/new", "features/vods/routes/vods.new.tsx");
|
||||
route("/vods/:id", "features/vods/routes/vods.$id.tsx");
|
||||
|
||||
route(
|
||||
"/builds/:slug/stats",
|
||||
"features/build-stats/routes/builds.$slug.stats.tsx"
|
||||
);
|
||||
route(
|
||||
"/builds/:slug/popular",
|
||||
"features/build-stats/routes/builds.$slug.popular.tsx"
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user