mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 03:59:50 +02:00
ranks
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
-60
@@ -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>
|
||||
);
|
||||
}
|
||||
-185
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user