From ae2839cb8399e990209d1889eecb7a137421f544 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:44:42 -0500 Subject: [PATCH] ranks --- packages/i18n/strings/en.json | 10 +- .../SegmentedControl/index.module.css | 54 +++++ .../src/components/SegmentedControl/index.tsx | 51 +++++ .../components/CompareOptions.tsx | 60 ------ .../components/InfoWithDelta.tsx | 185 ------------------ .../index.module.css | 13 +- .../TankopediaCharacteristics/index.tsx | 85 +++++++- .../website/src/config/characteristics.ts | 7 - .../src/config/characteristicsGroups.ts | 95 +++++++-- .../src/hooks/useComparisonCharacteristics.ts | 74 +++++++ packages/website/src/hooks/useRankings.ts | 21 ++ packages/website/src/stores/tankopedia.ts | 8 +- .../src/tankopedia/calculateRankings.ts | 61 ++++++ packages/website/src/types/characteristics.ts | 9 +- 14 files changed, 452 insertions(+), 281 deletions(-) create mode 100644 packages/website/src/components/SegmentedControl/index.module.css create mode 100644 packages/website/src/components/SegmentedControl/index.tsx delete mode 100644 packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx delete mode 100644 packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/InfoWithDelta.tsx create mode 100644 packages/website/src/hooks/useComparisonCharacteristics.ts create mode 100644 packages/website/src/hooks/useRankings.ts create mode 100644 packages/website/src/tankopedia/calculateRankings.ts diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index ad5127710..f7346cdfe 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -15,11 +15,19 @@ "bhp_t": "{value}bhp/t", "kN": "{value}kN", "hp_s": "{value}hp/s", - "plus": "+{value}" + "plus": "+{value}", + "fraction": "{numerator} / {denominator}" }, "tanks": { "characteristics": { + "compare": { + "label": "Compare against", + "class": "Tier {tier} {class}", + "tier": "All tier {tier}", + "all": "All tanks" + }, + "titles": { "gun": "Gun", "clip": "Clip", diff --git a/packages/website/src/components/SegmentedControl/index.module.css b/packages/website/src/components/SegmentedControl/index.module.css new file mode 100644 index 000000000..c1d493d42 --- /dev/null +++ b/packages/website/src/components/SegmentedControl/index.module.css @@ -0,0 +1,54 @@ +.options { + position: relative; + display: grid; + + border-radius: var(--radius-max); + + background-color: var(--gray-3); + + .indicator { + position: absolute; + top: 0; + height: 100%; + + background-color: var(--accent-3); + outline: var(--outline-focus); + border-radius: var(--radius-max); + + transition: var(--duration-brief); + } + + .option { + position: relative; + + display: flex; + justify-content: center; + + padding: var(--space-3) var(--space-4); + + color: var(--gray-11); + cursor: pointer; + + input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + } + + .label { + white-space: nowrap; + } + + &:has(input:checked) { + color: var(--gray-12); + } + + &:has(input:focus-visible) { + outline: 2px solid var(--accent-8); + outline-offset: 2px; + border-radius: var(--radius-max); + } + } +} diff --git a/packages/website/src/components/SegmentedControl/index.tsx b/packages/website/src/components/SegmentedControl/index.tsx new file mode 100644 index 000000000..357a5133e --- /dev/null +++ b/packages/website/src/components/SegmentedControl/index.tsx @@ -0,0 +1,51 @@ +import { type ReactNode } from "react"; +import styles from "./index.module.css"; + +export interface SegmentedControlOption { + value: Value; + label: ReactNode; +} + +export interface SegmentedControlProps { + options: readonly SegmentedControlOption[]; + value: Value; + onValueChange(value: Value): void; +} + +export function SegmentedControl({ + options, + value, + onValueChange, +}: SegmentedControlProps) { + const selectedIndex = options.findIndex((option) => option.value === value); + const segmentWidth = 100 / options.length; + + return ( +
+ + ); +} diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx deleted file mode 100644 index 283884b7f..000000000 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { TIER_ROMAN_NUMERALS } from "@blitzkit/core"; -import { literals } from "@blitzkit/i18n/src/literals"; -import { Flex, SegmentedControl, Text, type FlexProps } from "@radix-ui/themes"; -import { useLocale } from "../../../../../../hooks/useLocale"; -import { Duel } from "../../../../../../stores/duel"; -import { - Tankopedia, - TankopediaRelativeAgainst, -} from "../../../../../../stores/tankopedia"; - -export function CompareOptions(props: FlexProps) { - const relativeAgainst = Tankopedia.use((state) => state.relativeAgainst); - const tank = Duel.use((state) => state.protagonist.tank); - const { strings } = useLocale(); - - return ( - - - {strings.website.tools.tankopedia.characteristics.compare.label} - - - { - Tankopedia.mutate((draft) => { - draft.relativeAgainst = Number(value) as TankopediaRelativeAgainst; - }); - }} - > - - {literals( - strings.website.tools.tankopedia.characteristics.compare.class, - { - tier: TIER_ROMAN_NUMERALS[tank.tier], - class: - strings.common.tank_class_short_plural_lowercase[tank.class], - } - )} - - - {literals( - strings.website.tools.tankopedia.characteristics.compare.tier, - { tier: TIER_ROMAN_NUMERALS[tank.tier] } - )} - - - {strings.website.tools.tankopedia.characteristics.compare.all} - - - - ); -} diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/InfoWithDelta.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/InfoWithDelta.tsx deleted file mode 100644 index 05d23a7f8..000000000 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/InfoWithDelta.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { createDefaultSkills } from "@blitzkit/core"; -import { Flex, Progress, Text } from "@radix-ui/themes"; -import { clamp } from "lodash-es"; -import { memo, useMemo, type ComponentProps, type ReactNode } from "react"; -import { api } from "../../../../../../api/dynamic"; -import { - tankCharacteristics, - type TankCharacteristics, -} from "../../../../../../core/blitzkit/tankCharacteristics"; -import { tankToDuelMember } from "../../../../../../core/blitzkit/tankToDuelMember"; -import { useDelta } from "../../../../../../hooks/useDelta"; -import { useLocale } from "../../../../../../hooks/useLocale"; -import { Duel } from "../../../../../../stores/duel"; -import { - Tankopedia, - TankopediaRelativeAgainst, -} from "../../../../../../stores/tankopedia"; -import { Info, type InfoProps } from "./Info"; - -type InfoWithDeltaProps = Omit & { - stats: TankCharacteristics; - noRanking?: boolean; -} & ( - | { - value: keyof TankCharacteristics; - } - | { - value: (tank: TankCharacteristics) => number | undefined; - name: ReactNode; - } - ); - -const [ - tankDefinitions, - provisionDefinitions, - equipmentDefinitions, - modelDefinitions, - skillDefinitions, -] = await Promise.all([ - api.tanks(), - api.provisions(), - api.equipment(), - api.models(), - api.skills(), -]); - -export const InfoWithDelta = memo( - ({ stats, indent, noRanking, deltaType, ...props }) => { - const { strings } = useLocale(); - const relativeAgainst = Tankopedia.use((state) => state.relativeAgainst); - const uhWhatDoICallThisVariable = - typeof props.value === "function" - ? props.value(stats)! - : (stats[props.value] as number); - const delta = useDelta(uhWhatDoICallThisVariable); - const protagonistTank = Duel.use((state) => state.protagonist.tank); - const shellIndex = Duel.use((state) => - state.protagonist.gun.shells.indexOf(state.protagonist.shell), - ); - const equipmentMatrix = Duel.use( - (state) => state.protagonist.equipmentMatrix, - ); - const equalize = Duel.use((state) => state.equalize); - const others = useMemo(() => { - const defaultSkills = createDefaultSkills(skillDefinitions); - - return Object.values(tankDefinitions.tanks) - .filter( - (tank) => - (relativeAgainst === TankopediaRelativeAgainst.Class && - tank.tier === protagonistTank.tier && - tank.class === protagonistTank.class) || - (relativeAgainst === TankopediaRelativeAgainst.Tier && - tank.tier === protagonistTank.tier) || - relativeAgainst === TankopediaRelativeAgainst.All, - ) - .map((tank) => { - const member = tankToDuelMember(tank, provisionDefinitions); - - if (member.gun.shells[shellIndex] === undefined) { - return undefined; - } - - return tankCharacteristics( - { - applyDynamicArmor: false, - applyReactiveArmor: false, - applySpallLiner: false, - camouflage: member.camouflage, - consumables: member.consumables, - crewSkills: defaultSkills, - provisions: member.provisions, - engine: member.engine, - gun: member.gun, - equipmentMatrix: equipmentMatrix, - shell: member.gun.shells[shellIndex], - stockEngine: tank.engines[0], - stockGun: tank.turrets[0].guns[0], - stockTrack: tank.tracks[0], - stockTurret: tank.turrets[0], - tank, - track: member.track, - turret: member.turret, - assaultDistance: member.assaultDistance, - equalize, - }, - { - equipmentDefinitions, - provisionDefinitions, - tankModelDefinition: modelDefinitions.models[tank.id], - }, - ); - }) - .filter((tank) => { - if (tank === undefined) return false; - - const othersValue = - typeof props.value === "function" - ? props.value(tank)! - : (tank[props.value] as number); - - return othersValue !== undefined; - }) as TankCharacteristics[]; - }, [relativeAgainst, shellIndex, equipmentMatrix, equalize]); - const betterTanks = others.filter((tank) => { - const othersValue = - typeof props.value === "function" - ? props.value(tank)! - : (tank[props.value] as number); - - if (othersValue === undefined) return false; - return deltaType === "lowerIsBetter" - ? othersValue < uhWhatDoICallThisVariable - : othersValue > uhWhatDoICallThisVariable; - }); - const goodness = (others.length - betterTanks.length) / others.length; - let color: ComponentProps["color"]; - - if (goodness <= 0.25) color = "red"; - else if (goodness <= 0.5) color = "orange"; - else if (goodness <= 0.75) color = "yellow"; - else color = "green"; - - return ( - - - {uhWhatDoICallThisVariable} - - - {!noRanking && ( - - - - - {clamp(betterTanks.length + 1, 1, others.length)} /{" "} - {others.length} - - - )} - - ); - }, - - (a, b) => - (typeof a.value === "function" ? a.value(a.stats) : a.stats[a.value]) === - (typeof b.value === "function" ? b.value(b.stats) : b.stats[b.value]), -); diff --git a/packages/website/src/components/TankopediaCharacteristics/index.module.css b/packages/website/src/components/TankopediaCharacteristics/index.module.css index 81de52b3f..3a25ee318 100644 --- a/packages/website/src/components/TankopediaCharacteristics/index.module.css +++ b/packages/website/src/components/TankopediaCharacteristics/index.module.css @@ -1,16 +1,27 @@ .characteristics-wrapper { + display: flex; + flex-direction: column; + + gap: var(--space-4); margin: var(--space-4); --base-cell-width: 20rem; --group-span: 7; - container-type: inline-size; + .compare-options { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-4); + } .characteristics { --N: round(down, 100cqw / var(--base-cell-width)); --cell-width: calc(100cqw / var(--N)); --cell-height: calc(var(--cell-width) / var(--group-span)); + margin-bottom: calc(-1 * var(--cell-height)); + display: grid; grid-template-columns: repeat( auto-fill, diff --git a/packages/website/src/components/TankopediaCharacteristics/index.tsx b/packages/website/src/components/TankopediaCharacteristics/index.tsx index f0bdd8692..8d0147d5c 100644 --- a/packages/website/src/components/TankopediaCharacteristics/index.tsx +++ b/packages/website/src/components/TankopediaCharacteristics/index.tsx @@ -1,13 +1,22 @@ +import { TIER_ROMAN_NUMERALS } from "@blitzkit/core"; +import { literals } from "@blitzkit/i18n/src/literals"; +import { useRef } from "react"; import { characteristicsGroups } from "../../config/characteristicsGroups"; import { useCharacteristicRenderer } from "../../hooks/useCharacteristicRenderer"; +import { DuelModule, useDuel } from "../../hooks/useDuel"; +import { useRankings } from "../../hooks/useRankings"; import { useStrings } from "../../hooks/useStrings"; +import { Tankopedia, TankopediaRelativeAgainst } from "../../stores/tankopedia"; +import type { CharacteristicRank } from "../../tankopedia/calculateRankings"; import type { + CharacteristicName, CharacteristicRenderConfig, CharacteristicReturnType, CharacteristicsGroup, ComputedCharacteristics, } from "../../types/characteristics"; import { Heading } from "../Heading"; +import { SegmentedControl } from "../SegmentedControl"; import { Text } from "../Text"; import styles from "./index.module.css"; @@ -18,8 +27,12 @@ interface TankopediaCharacteristicsProps { export function TankopediaCharacteristics({ characteristics, }: TankopediaCharacteristicsProps) { + const rankings = useRankings(characteristics); + return (
+ +
{characteristicsGroups.map((group) => { const items = group.order.filter( @@ -34,6 +47,7 @@ export function TankopediaCharacteristics({ key={group.name} group={group} characteristics={characteristics} + rankings={rankings} /> ); })} @@ -45,9 +59,10 @@ export function TankopediaCharacteristics({ interface GroupProps { group: CharacteristicsGroup; characteristics: ComputedCharacteristics; + rankings: Map; } -function Group({ group, characteristics }: GroupProps) { +function Group({ group, characteristics, rankings }: GroupProps) { const strings = useStrings(); const toys = group.order.filter((item) => "toy" in item).length; const characteristicsCount = group.order.filter( @@ -95,7 +110,11 @@ function Group({ group, characteristics }: GroupProps) { {"toy" in item && } {"label" in item &&
-
- + {ranking && ( +
+ - - 1 / 132 - -
+ + {literals(strings.units.fraction, { + numerator: ranking.rank, + denominator: ranking.total, + })} + +
+ )} ); } +function CompareOptions() { + const tank = useDuel("protagonist", DuelModule.Tank); + const relativeAgainst = Tankopedia.use((state) => state.relative_against); + + const strings = useStrings(); + + const options = useRef([ + { + value: TankopediaRelativeAgainst.ClassAndTier, + label: literals(strings.tanks.characteristics.compare.class, { + tier: TIER_ROMAN_NUMERALS[tank.tier], + class: strings.common.tank_class_short_plural_lowercase[tank.class], + }), + }, + { + value: TankopediaRelativeAgainst.Tier, + label: literals(strings.tanks.characteristics.compare.tier, { + tier: TIER_ROMAN_NUMERALS[tank.tier], + }), + }, + { + value: TankopediaRelativeAgainst.All, + label: strings.tanks.characteristics.compare.all, + }, + ]); + + return ( +
+ {strings.tanks.characteristics.compare.label} + { + Tankopedia.mutate((draft) => { + draft.relative_against = value; + }); + }} + /> +
+ ); +} + interface BarProps { value: number; } diff --git a/packages/website/src/config/characteristics.ts b/packages/website/src/config/characteristics.ts index 05d1bde34..e8b28f067 100644 --- a/packages/website/src/config/characteristics.ts +++ b/packages/website/src/config/characteristics.ts @@ -661,13 +661,6 @@ export const characteristics = { }, }, - hull_traverse_speed: { - type: CharacteristicType.Number, - compute({ track, progressive }) { - return track.traverse_speed * progressive("driver"); - }, - }, - hull_traverse_speed_hard: { type: CharacteristicType.Number, compute(context): number { diff --git a/packages/website/src/config/characteristicsGroups.ts b/packages/website/src/config/characteristicsGroups.ts index ad104a333..f7fd37c1f 100644 --- a/packages/website/src/config/characteristicsGroups.ts +++ b/packages/website/src/config/characteristicsGroups.ts @@ -1,5 +1,6 @@ import type { CaseType } from "@blitzkit/core"; import type { GunDefinition } from "@blitzkit/protos"; +import { CharacteristicRanking } from "../types/characteristics"; import type { CharacteristicsGroup } from "../types/characteristics"; export const characteristicsGroups: CharacteristicsGroup[] = [ @@ -80,7 +81,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ { name: "damage_range", decimals: 0, units: "hp", localize: true }, { name: "module_damage", decimals: 0, units: "hp", localize: true }, { name: "module_damage_range", decimals: 0, units: "hp", localize: true }, - { name: "reload", decimals: 1, units: "s" }, + { + name: "reload", + decimals: 1, + units: "s", + ranking: CharacteristicRanking.LowerIsBetter, + }, { name: "reloads", decimals: 1, units: "s" }, { toy: "reload" }, @@ -94,7 +100,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ { name: "clipping_potential", localize: true, units: "hp" }, { name: "clip_size" }, { name: "optimal_shell" }, - { name: "intra_clip", decimals: 2, units: "s" }, + { + name: "intra_clip", + decimals: 2, + units: "s", + ranking: CharacteristicRanking.LowerIsBetter, + }, { name: "burst_size" }, { name: "intra_burst", decimals: 2, units: "s" }, @@ -114,18 +125,28 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ name: "penetration_loss_by_distance", units: "percentage", indent: true, + ranking: CharacteristicRanking.LowerIsBetter, }, { name: "penetration_loss_after_ricochet", units: "percentage", indent: true, + ranking: CharacteristicRanking.LowerIsBetter, }, { name: "shell_velocity", units: "m_s" }, { name: "shell_range", units: "m" }, { name: "shell_capacity" }, { name: "caliber", units: "mm" }, - { name: "ricochet", units: "deg" }, - { name: "normalization", units: "deg" }, + { + name: "ricochet", + units: "deg", + ranking: CharacteristicRanking.None, + }, + { + name: "normalization", + units: "deg", + ranking: CharacteristicRanking.None, + }, { toy: "ricochet" }, ], @@ -135,8 +156,18 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ name: "accuracy", // render: CharacteristicsGroupRender.Statistical, order: [ - { name: "aim_time", decimals: 2, units: "s" }, - { name: "dispersion", decimals: 3, units: "m" }, + { + name: "aim_time", + decimals: 2, + units: "s", + ranking: CharacteristicRanking.LowerIsBetter, + }, + { + name: "dispersion", + decimals: 3, + units: "m", + ranking: CharacteristicRanking.LowerIsBetter, + }, { name: "dispersion_angle", decimals: 3, units: "deg" }, { toy: "aim_time" }, ], @@ -167,7 +198,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ { name: "speed_forward", units: "km_h", indent: true }, { name: "speed_backward", units: "km_h", indent: true }, - { name: "weight", decimals: 1, units: "t" }, + { + name: "weight", + decimals: 1, + units: "t", + ranking: CharacteristicRanking.LowerIsBetter, + }, { name: "engine_power", units: "bhp" }, { name: "power_to_weight", decimals: 1, units: "bhp_t" }, // { name: "brake_force", decimals: 1, units: "kN" }, @@ -185,9 +221,24 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ { name: "soft_terrain_coefficient", decimals: 0, indent: true }, { label: "raw_terrain_coefficient" }, - { name: "hard_terrain_coefficient_raw", decimals: 2, indent: true }, - { name: "medium_terrain_coefficient_raw", decimals: 2, indent: true }, - { name: "soft_terrain_coefficient_raw", decimals: 2, indent: true }, + { + name: "hard_terrain_coefficient_raw", + decimals: 2, + indent: true, + ranking: CharacteristicRanking.LowerIsBetter, + }, + { + name: "medium_terrain_coefficient_raw", + decimals: 2, + indent: true, + ranking: CharacteristicRanking.LowerIsBetter, + }, + { + name: "soft_terrain_coefficient_raw", + decimals: 2, + indent: true, + ranking: CharacteristicRanking.LowerIsBetter, + }, { toy: "traverse" }, @@ -202,7 +253,11 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ // render: CharacteristicsGroupRender.Statistical, order: [ { name: "health", units: "hp" }, - { name: "fire_chance", units: "percentage" }, + { + name: "fire_chance", + units: "percentage", + ranking: CharacteristicRanking.LowerIsBetter, + }, // { name: "fire_rate", units: "hp_s" }, // { name: "ramming_resistance", units: "percentage" }, @@ -221,9 +276,21 @@ export const characteristicsGroups: CharacteristicsGroup[] = [ { toy: "view_range" }, - { name: "width", units: "m" }, - { name: "height", units: "m" }, - { name: "length", units: "m" }, + { + name: "width", + units: "m", + ranking: CharacteristicRanking.LowerIsBetter, + }, + { + name: "height", + units: "m", + ranking: CharacteristicRanking.LowerIsBetter, + }, + { + name: "length", + units: "m", + ranking: CharacteristicRanking.LowerIsBetter, + }, ], }, ]; diff --git a/packages/website/src/hooks/useComparisonCharacteristics.ts b/packages/website/src/hooks/useComparisonCharacteristics.ts new file mode 100644 index 000000000..9581565e6 --- /dev/null +++ b/packages/website/src/hooks/useComparisonCharacteristics.ts @@ -0,0 +1,74 @@ +import { useMemo } from "react"; +import { api } from "../api/dynamic"; +import { Tankopedia, TankopediaRelativeAgainst } from "../stores/tankopedia"; +import { createTankState } from "../tankopedia/createTankState"; +import { computeCharacteristics } from "./useCharacteristics"; +import { DuelModule, useDuel } from "./useDuel"; + +const tankDefinitions = await api.tanks(); + +const candidateStates = new Map( + Object.values(tankDefinitions.tanks).map((candidate) => { + const state = createTankState(candidate); + const turret = candidate.turrets.find((t) => t.id === state.turret)!; + const gun = turret.guns.find((g) => g.id === state.gun)!; + + return [ + candidate.id, + { + state, + shells: gun.shells.map((shell) => shell.id), + }, + ]; + }), +); + +export function useComparisonCharacteristics() { + const environment = Tankopedia.use((state) => state.environment); + + const tank = useDuel("protagonist", DuelModule.Tank); + const gun = useDuel("protagonist", DuelModule.Gun); + const shell = useDuel("protagonist", DuelModule.Shell); + const protagonistShellIndex = gun.shells.findIndex((s) => s.id === shell.id); + + const relativeAgainst = Tankopedia.use((state) => state.relative_against); + + const candidates = useMemo(() => { + return Object.values(tankDefinitions.tanks).filter((candidate) => { + switch (relativeAgainst) { + case TankopediaRelativeAgainst.ClassAndTier: + return candidate.tier === tank.tier && candidate.class === tank.class; + + case TankopediaRelativeAgainst.Tier: + return candidate.tier === tank.tier; + + case TankopediaRelativeAgainst.All: + return true; + } + }); + }, [tank, relativeAgainst]); + + const characteristics = useMemo(() => { + return candidates.flatMap((candidate) => { + const { state: candidateState, shells } = candidateStates.get( + candidate.id, + )!; + const maxStateShellIndex = shells.length - 1; + const stateShellIndex = Math.min( + maxStateShellIndex, + protagonistShellIndex, + ); + + const state = { ...candidateState, shell: shells[stateShellIndex] }; + + return [ + computeCharacteristics( + { protagonist: state, antagonist: state }, + environment, + ), + ]; + }); + }, [candidates, environment, protagonistShellIndex]); + + return characteristics; +} diff --git a/packages/website/src/hooks/useRankings.ts b/packages/website/src/hooks/useRankings.ts new file mode 100644 index 000000000..67f65cab1 --- /dev/null +++ b/packages/website/src/hooks/useRankings.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import { + calculateRankings, + type CharacteristicRank, +} from "../tankopedia/calculateRankings"; +import type { + CharacteristicName, + ComputedCharacteristics, +} from "../types/characteristics"; +import { useComparisonCharacteristics } from "./useComparisonCharacteristics"; + +export function useRankings( + characteristics: ComputedCharacteristics, +): Map { + const comparisonCharacteristics = useComparisonCharacteristics(); + + return useMemo( + () => calculateRankings(characteristics, comparisonCharacteristics), + [characteristics, comparisonCharacteristics], + ); +} diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts index f255c7712..b62261d05 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -71,7 +71,7 @@ export type ArmorPiercingLayer = }; export enum TankopediaRelativeAgainst { - Class, + ClassAndTier, Tier, All, } @@ -90,9 +90,10 @@ interface Tankopedia { antagonist: TankState; environment: TankEnvironment; + relative_against: TankopediaRelativeAgainst; + modelRequested: boolean; shot?: Shot; - relativeAgainst: TankopediaRelativeAgainst; editStatic: boolean; highlightArmor?: { editingPlate: boolean; @@ -133,9 +134,10 @@ export const Tankopedia = new Soapstone( skills: createDefaultSkills(skills), }, + relative_against: TankopediaRelativeAgainst.ClassAndTier, + modelRequested: false, - relativeAgainst: TankopediaRelativeAgainst.Class, editStatic: false, xpMultiplier: 1, requestedDisplay: TankopediaDisplay.Model, diff --git a/packages/website/src/tankopedia/calculateRankings.ts b/packages/website/src/tankopedia/calculateRankings.ts new file mode 100644 index 000000000..c2fdd6817 --- /dev/null +++ b/packages/website/src/tankopedia/calculateRankings.ts @@ -0,0 +1,61 @@ +import { characteristicsGroups } from "../config/characteristicsGroups"; +import { + CharacteristicRanking as CharacteristicRankingDirection, + type CharacteristicName, + type CharacteristicRenderConfig, + type ComputedCharacteristics, +} from "../types/characteristics"; + +const characteristicConfigs = new Map< + CharacteristicName, + CharacteristicRenderConfig +>(); + +for (const { order } of characteristicsGroups) { + for (const item of order) { + if (!("name" in item)) continue; + characteristicConfigs.set(item.name, item); + } +} + +export interface CharacteristicRank { + rank: number; + total: number; + score: number; +} + +export function calculateRankings( + characteristics: ComputedCharacteristics, + comparisons: ComputedCharacteristics[], +) { + const rankings = new Map(); + + for (const [name, value] of characteristics) { + if (typeof value !== "number" || name[0] !== "_") continue; + + const config = characteristicConfigs.get(name)!; + + if (config.ranking === CharacteristicRankingDirection.None) continue; + + const lowerIsBetter = + config.ranking === CharacteristicRankingDirection.LowerIsBetter; + + const values = comparisons + .map((comparison) => comparison.get(name)) + .filter((other) => typeof other === "number"); + + if (values.length === 0) continue; + + const betterCount = values.filter((other) => + lowerIsBetter ? other < value : other > value, + ).length; + + rankings.set(name, { + rank: Math.min(betterCount + 1, values.length), + total: values.length, + score: (values.length - betterCount) / values.length, + }); + } + + return rankings; +} diff --git a/packages/website/src/types/characteristics.ts b/packages/website/src/types/characteristics.ts index 945884630..730ccf9a4 100644 --- a/packages/website/src/types/characteristics.ts +++ b/packages/website/src/types/characteristics.ts @@ -10,7 +10,7 @@ import type { TurretDefinition, } from "@blitzkit/protos"; import type { ReactNode } from "react"; -import { characteristics } from "../config/characteristics"; +import type { characteristics } from "../config/characteristics"; import type { DuelSide } from "../hooks/useEquipment"; import type { TankEnvironment } from "../stores/tankopedia"; import type { TankState } from "../tankopedia/tankState"; @@ -64,6 +64,12 @@ export enum CharacteristicType { Number, } +export enum CharacteristicRanking { + HigherIsBetter, + LowerIsBetter, + None, +} + export type CharacteristicName = keyof typeof characteristics; export type CharacteristicReturnType = ReturnType; @@ -79,6 +85,7 @@ export interface CharacteristicRenderConfig { units?: keyof Strings["units"]; localize?: boolean; indent?: boolean; + ranking?: CharacteristicRanking; strings?: string; render?: (data: {