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

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