This commit is contained in:
tresabhi committed 2026-10-11 15:44:42 -05:00
1 parent eb0c35d6c6
commit ae2839cb83
14 files changed
+452 -281

No files matched your search

+9 -1
View File
@@ -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",
@@ -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);
}
}
}
@@ -0,0 +1,51 @@
import { type ReactNode } from "react";
import styles from "./index.module.css";
export interface SegmentedControlOption<Value extends string | number> {
value: Value;
label: ReactNode;
}
export interface SegmentedControlProps<Value extends string | number> {
options: readonly SegmentedControlOption<Value>[];
value: Value;
onValueChange(value: Value): void;
}
export function SegmentedControl<Value extends string | number>({
options,
value,
onValueChange,
}: SegmentedControlProps<Value>) {
const selectedIndex = options.findIndex((option) => option.value === value);
const segmentWidth = 100 / options.length;
return (
<div
className={styles.options}
role="radiogroup"
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
>
<div
className={styles.indicator}
style={{
left: `${Math.max(selectedIndex, 0) * segmentWidth}%`,
width: `${segmentWidth}%`,
}}
aria-hidden="true"
/>
{options.map((option) => (
<label className={styles.option} key={option.value}>
<input
type="radio"
value={option.value}
checked={option.value === value}
onChange={() => onValueChange(option.value)}
/>
<span className={styles.label}>{option.label}</span>
</label>
))}
</div>
);
}
@@ -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 (
<Flex
align="center"
gap="2"
justify="center"
direction={{ initial: "column", sm: "row" }}
{...props}
>
<Text color="gray">
{strings.website.tools.tankopedia.characteristics.compare.label}
</Text>
<SegmentedControl.Root
variant="classic"
size="1"
value={`${relativeAgainst}`}
onValueChange={(value) => {
Tankopedia.mutate((draft) => {
draft.relativeAgainst = Number(value) as TankopediaRelativeAgainst;
});
}}
>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.Class}`}>
{literals(
strings.website.tools.tankopedia.characteristics.compare.class,
{
tier: TIER_ROMAN_NUMERALS[tank.tier],
class:
strings.common.tank_class_short_plural_lowercase[tank.class],
}
)}
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.Tier}`}>
{literals(
strings.website.tools.tankopedia.characteristics.compare.tier,
{ tier: TIER_ROMAN_NUMERALS[tank.tier] }
)}
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.All}`}>
{strings.website.tools.tankopedia.characteristics.compare.all}
</SegmentedControl.Item>
</SegmentedControl.Root>
</Flex>
);
}
@@ -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<InfoProps, "name"> & {
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<InfoWithDeltaProps>(
({ 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<typeof Progress>["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 (
<Flex direction="column">
<Info
deltaType={deltaType}
indent={indent}
name={
"name" in props
? props.name
: strings.website.tools.tankopedia.characteristics.values[
props.value
]
}
delta={delta}
{...props}
>
{uhWhatDoICallThisVariable}
</Info>
{!noRanking && (
<Flex pl={indent ? "2" : "0"} align="center" gap="2">
<Progress
variant="soft"
size="1"
value={goodness * 100}
color={color}
style={{ height: "0.125rem", opacity: 0.5 }}
/>
<Text color="gray" size="1">
{clamp(betterTanks.length + 1, 1, others.length)} /{" "}
{others.length}
</Text>
</Flex>
)}
</Flex>
);
},
(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]),
);
@@ -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,
@@ -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 (
<div className={styles["characteristics-wrapper"]}>
<CompareOptions />
<div className={styles.characteristics}>
{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<CharacteristicName, CharacteristicRank>;
}
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 && <Toy {...item} />}
{"label" in item && <Label {...item} />}
{"name" in item && characteristics.has(item.name) && (
<Item config={item} value={characteristics.get(item.name)!} />
<Item
config={item}
value={characteristics.get(item.name)!}
ranking={rankings.get(item.name)}
/>
)}
</>
))}
@@ -125,9 +144,10 @@ function Label({ label }: LabelProps) {
interface ItemProps {
config: CharacteristicRenderConfig;
value: CharacteristicReturnType;
ranking?: CharacteristicRank;
}
function Item({ config, value }: ItemProps) {
function Item({ config, value, ranking }: ItemProps) {
const strings = useStrings();
const renderCharacteristic = useCharacteristicRenderer();
const hasUnits = config.units !== undefined;
@@ -150,17 +170,64 @@ function Item({ config, value }: ItemProps) {
</Text>
</div>
<div className={styles.ranking}>
<Bar value={Math.random()} />
{ranking && (
<div className={styles.ranking}>
<Bar value={ranking.score} />
<Text lowContrast size="minor" className={styles.number}>
1 / 132
</Text>
</div>
<Text lowContrast size="minor" className={styles.number}>
{literals(strings.units.fraction, {
numerator: ranking.rank,
denominator: ranking.total,
})}
</Text>
</div>
)}
</span>
);
}
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 (
<div className={styles["compare-options"]}>
<Text lowContrast>{strings.tanks.characteristics.compare.label}</Text>
<SegmentedControl
options={options.current}
value={relativeAgainst}
onValueChange={(value) => {
Tankopedia.mutate((draft) => {
draft.relative_against = value;
});
}}
/>
</div>
);
}
interface BarProps {
value: number;
}
@@ -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 {
@@ -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,
},
],
},
];
@@ -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;
}
+21
View File
@@ -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<CharacteristicName, CharacteristicRank> {
const comparisonCharacteristics = useComparisonCharacteristics();
return useMemo(
() => calculateRankings(characteristics, comparisonCharacteristics),
[characteristics, comparisonCharacteristics],
);
}
+5 -3
View File
@@ -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<Tankopedia, [TankDefinition]>(
skills: createDefaultSkills(skills),
},
relative_against: TankopediaRelativeAgainst.ClassAndTier,
modelRequested: false,
relativeAgainst: TankopediaRelativeAgainst.Class,
editStatic: false,
xpMultiplier: 1,
requestedDisplay: TankopediaDisplay.Model,
@@ -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<CharacteristicName, CharacteristicRank>();
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;
}
@@ -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<Characteristic["compute"]>;
@@ -79,6 +85,7 @@ export interface CharacteristicRenderConfig {
units?: keyof Strings["units"];
localize?: boolean;
indent?: boolean;
ranking?: CharacteristicRanking;
strings?: string;
render?: (data: {