Files
blitzkit/packages/website/src/components/TankopediaCharacteristics/index.tsx
T

283 lines
7.6 KiB
TypeScript
Raw Normal View History

2026-10-11 15:44:42 -05:00
import { TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n/src/literals";
import { useRef } from "react";
2026-09-30 18:22:18 -05:00
import { characteristicsGroups } from "../../config/characteristicsGroups";
2026-10-11 16:27:53 -05:00
import { toys } from "../../config/toys";
2026-09-30 18:22:18 -05:00
import { useCharacteristicRenderer } from "../../hooks/useCharacteristicRenderer";
2026-10-11 15:44:42 -05:00
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useRankings } from "../../hooks/useRankings";
2026-09-30 18:22:18 -05:00
import { useStrings } from "../../hooks/useStrings";
2026-10-11 15:44:42 -05:00
import { Tankopedia, TankopediaRelativeAgainst } from "../../stores/tankopedia";
import type { CharacteristicRank } from "../../tankopedia/calculateRankings";
2026-09-30 18:22:18 -05:00
import type {
2026-10-11 15:44:42 -05:00
CharacteristicName,
2026-09-30 18:22:18 -05:00
CharacteristicRenderConfig,
CharacteristicReturnType,
CharacteristicsGroup,
ComputedCharacteristics,
2026-10-11 16:27:53 -05:00
ToyName,
2026-09-30 18:22:18 -05:00
} from "../../types/characteristics";
import { Heading } from "../Heading";
2026-10-11 15:44:42 -05:00
import { SegmentedControl } from "../SegmentedControl";
2026-09-30 18:22:18 -05:00
import { Text } from "../Text";
import styles from "./index.module.css";
interface TankopediaCharacteristicsProps {
characteristics: ComputedCharacteristics;
}
export function TankopediaCharacteristics({
characteristics,
}: TankopediaCharacteristicsProps) {
2026-10-11 15:44:42 -05:00
const rankings = useRankings(characteristics);
2026-09-30 18:22:18 -05:00
return (
<div className={styles["characteristics-wrapper"]}>
2026-10-11 15:44:42 -05:00
<CompareOptions />
2026-09-30 18:22:18 -05:00
<div className={styles.characteristics}>
{characteristicsGroups.map((group) => {
const items = group.order.filter(
2026-10-01 15:52:14 -05:00
(item) => "name" in item && characteristics.has(item.name),
2026-09-30 18:22:18 -05:00
).length;
const shouldRender = items > 0;
if (!shouldRender) return null;
return (
<Group
key={group.name}
group={group}
characteristics={characteristics}
2026-10-11 15:44:42 -05:00
rankings={rankings}
2026-09-30 18:22:18 -05:00
/>
);
})}
</div>
</div>
);
}
interface GroupProps {
group: CharacteristicsGroup;
characteristics: ComputedCharacteristics;
2026-10-11 15:44:42 -05:00
rankings: Map<CharacteristicName, CharacteristicRank>;
2026-09-30 18:22:18 -05:00
}
2026-10-11 15:44:42 -05:00
function Group({ group, characteristics, rankings }: GroupProps) {
2026-09-30 18:22:18 -05:00
const strings = useStrings();
const toys = group.order.filter((item) => "toy" in item).length;
2026-10-11 01:14:08 -05:00
const characteristicsCount = group.order.filter(
2026-10-01 15:52:14 -05:00
(item) => "name" in item && characteristics.has(item.name),
2026-09-30 18:22:18 -05:00
).length;
2026-10-11 01:14:08 -05:00
const labelsCount =
characteristicsCount > 0
? group.order.filter((item) => "label" in item).length
: 0;
const items = characteristicsCount + labelsCount;
2026-09-30 18:22:18 -05:00
const groupSpan = `calc(${toys} * var(--group-span) + ${items} + 2)`;
const contentSpan = `calc(${toys} * var(--group-span) + ${items})`;
return (
<div
className={styles.group}
style={{
gridRow: `span ${groupSpan}`,
gridTemplateRows: `repeat(${groupSpan}, 1fr)`,
}}
>
<div className={styles.title}>
<div className={styles.center}>
<Heading size="3" className={styles.text}>
<div className={styles.align}>
{
(
strings.tanks.characteristics.titles as Record<string, string>
)[group.name]
}
<img src={`/media/groups/${group.name}.png`} />
</div>
</Heading>
</div>
</div>
<div
className={styles.content}
style={{
gridRow: `span ${contentSpan}`,
}}
>
{group.order.map((item) => (
<>
2026-10-11 16:27:53 -05:00
{"toy" in item && (
<ToyAssigner characteristics={characteristics} {...item} />
)}
2026-10-11 01:14:08 -05:00
{"label" in item && <Label {...item} />}
2026-10-11 00:47:00 -05:00
{"name" in item && characteristics.has(item.name) && (
2026-10-11 15:44:42 -05:00
<Item
config={item}
value={characteristics.get(item.name)!}
ranking={rankings.get(item.name)}
/>
2026-09-30 18:22:18 -05:00
)}
</>
))}
<div className={styles.dead} />
</div>
</div>
);
}
2026-10-11 01:14:08 -05:00
interface LabelProps {
label: string;
}
function Label({ label }: LabelProps) {
const strings = useStrings();
return (
<div className={styles.label}>
<Text lowContrast>
{(strings.tanks.characteristics.items as Record<string, string>)[label]}
</Text>
</div>
);
}
2026-09-30 18:22:18 -05:00
interface ItemProps {
config: CharacteristicRenderConfig;
value: CharacteristicReturnType;
2026-10-11 15:44:42 -05:00
ranking?: CharacteristicRank;
2026-09-30 18:22:18 -05:00
}
2026-10-11 15:44:42 -05:00
function Item({ config, value, ranking }: ItemProps) {
2026-09-30 18:22:18 -05:00
const strings = useStrings();
const renderCharacteristic = useCharacteristicRenderer();
const hasUnits = config.units !== undefined;
return (
2026-10-11 01:14:08 -05:00
<span className={styles.item} data-indent={config.indent}>
2026-09-30 18:22:18 -05:00
<div className={styles.value}>
<Text lowContrast>
{
(strings.tanks.characteristics.items as Record<string, string>)[
config.name
]
}
</Text>
<Text lowContrast={hasUnits} size={hasUnits ? "minor" : undefined}>
<div className={styles.units}>
{renderCharacteristic(value, config)}
</div>
</Text>
</div>
2026-10-11 15:44:42 -05:00
{ranking && (
<div className={styles.ranking}>
<Bar value={ranking.score} />
2026-09-30 18:22:18 -05:00
2026-10-11 15:44:42 -05:00
<Text lowContrast size="minor" className={styles.number}>
{literals(strings.units.fraction, {
numerator: ranking.rank,
denominator: ranking.total,
})}
</Text>
</div>
)}
2026-09-30 18:22:18 -05:00
</span>
);
}
2026-10-11 15:44:42 -05:00
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>
);
}
2026-09-30 18:22:18 -05:00
interface BarProps {
value: number;
}
const barColors = ["red", "orange", "amber", "jade"];
function Bar({ value }: BarProps) {
const index = (barColors.length - 1) * value;
const iA = Math.floor(index);
const iB = Math.ceil(index);
const A = barColors[iA];
const B = barColors[iB];
const x = index % 1;
const p = (1 - x) * 100;
const v = 7;
return (
<div className={styles["bar-wrapper"]}>
<div
className={styles.bar}
style={{
backgroundColor: `color-mix(in srgb, var(--${A}-${v}) ${p}%, var(--${B}-${v}))`,
width: `${value * 100}%`,
}}
/>
</div>
);
}
2026-10-11 16:27:53 -05:00
interface ToyAssignerProps {
toy: ToyName;
characteristics: ComputedCharacteristics;
2026-09-30 18:22:18 -05:00
}
2026-10-11 16:27:53 -05:00
function ToyAssigner({ toy, characteristics }: ToyAssignerProps) {
const Toy = toys[toy];
2026-09-30 18:22:18 -05:00
return (
<div className={styles.toy}>
<div className={styles.sandbox}>
2026-10-11 16:27:53 -05:00
{Toy ? (
<Toy characteristics={characteristics} />
) : (
<Text lowContrast>{toy}</Text>
)}
2026-09-30 18:22:18 -05:00
</div>
</div>
);
}