import clsx from "clsx"; import { ChartColumnBig, ChevronDown, ChevronUp, EyeOff, X, } from "lucide-react"; import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import * as R from "remeda"; import { SendouButton } from "~/components/elements/Button"; import { SpecialWeaponImage, SubWeaponImage, WeaponImage, } from "~/components/Image"; import { InfoPopover } from "~/components/InfoPopover"; import { translateDamageReceiver } from "~/features/object-damage-calculator/calculator-constants"; import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types"; import { useSearchParamStateEncoder } from "~/hooks/useSearchParamState"; import type { MainWeaponId, SpecialWeaponId, SubWeaponId, } from "~/modules/in-game-lists/types"; import { mySlugify, weaponParamsPage } from "~/utils/urls"; import { getParamExplanation } from "../core/param-explanations"; import * as WeaponParams from "../core/WeaponParams"; import { SPECIAL_POINTS_PARAM_KEY } from "../weapon-params-constants"; import type { DamageMultiplierWithHistory, ParamComparisonEntry, ParamValueWithHistory, SpecialPointWithHistory, WeaponParamKind, WeaponParamsTableProps, } from "../weapon-params-types"; import { weaponTranslationKey } from "../weapon-params-types"; import { ParamComparisonDialog } from "./ParamComparisonDialog"; import styles from "./WeaponParamsTable.module.css"; const DAMAGE_RATE_INFO_CATEGORY = "DamageRateInfo"; export function WeaponParamImage({ kind, id, size, }: { kind: WeaponParamKind; id: number; size: number; }) { if (kind === "sub") { return ; } if (kind === "special") { return ( ); } return ( ); } // The display name and (English) url slug of a weapon, resolved from the right `weapons` // translation key for the table's kind. function useWeaponParamNaming(kind: WeaponParamKind) { const { t } = useTranslation(["weapons"]); const name = (id: number): string => t(weaponTranslationKey(kind, id) as never); const slug = (id: number) => mySlugify(t(weaponTranslationKey(kind, id) as never, { lng: "en" })); return { name, slug }; } export function WeaponParamsTable({ kind, currentWeaponId, categoryWeaponIds, weaponParams, specialPoints, damageMultipliers, }: WeaponParamsTableProps) { const { t } = useTranslation(["weapons", "common", "analyzer", "params"]); const naming = useWeaponParamNaming(kind); const [expandedRows, setExpandedRows] = useState>(new Set()); const [comparison, setComparison] = useState<{ label: string; entries: ParamComparisonEntry[]; } | null>(null); const paramDefinitions = WeaponParams.allParamKeys(weaponParams); const paramsByCategory = R.groupBy(paramDefinitions, (def) => def.category); const toggleRow = (fullKey: string) => { setExpandedRows((prev) => { const next = new Set(prev); if (next.has(fullKey)) { next.delete(fullKey); } else { next.add(fullKey); } return next; }); }; const sortedWeaponIds = [ currentWeaponId, ...categoryWeaponIds.filter((id) => id !== currentWeaponId), ]; const [hiddenWeaponIds, setHiddenWeaponIds] = useSearchParamStateEncoder< number[] >({ name: "hidden", defaultValue: [], revive: (value) => value .split(",") .map(Number) .filter( (id) => !Number.isNaN(id) && id !== currentWeaponId && categoryWeaponIds.includes(id), ), encode: (ids) => ids.join(","), }); const hiddenSet = new Set(hiddenWeaponIds); const visibleWeaponIds = sortedWeaponIds.filter((id) => !hiddenSet.has(id)); const hideWeapon = (weaponId: number) => setHiddenWeaponIds([...hiddenWeaponIds, weaponId]); const restoreWeapon = (weaponId: number) => setHiddenWeaponIds(hiddenWeaponIds.filter((id) => id !== weaponId)); const showAllWeapons = () => setHiddenWeaponIds([]); const currentWeaponHasParam = (category: string, key: string) => { return Boolean( weaponParams[String(currentWeaponId)]?.categories[category]?.[key], ); }; const rowHasHistory = (category: string, key: string) => { return visibleWeaponIds.some((id) => { const param = weaponParams[String(id)]?.categories[category]?.[key]; return param && WeaponParams.hasHistory(param); }); }; // Bars are only drawn for plain numbers, so string-valued (or array/object) params and hidden // weapons are skipped here. The compare button shows up only when at least two weapons remain. const comparisonEntries = ( getValue: (weaponId: number) => number | string | undefined, ) => visibleWeaponIds .map((weaponId) => { const value = getValue(weaponId); return typeof value === "number" ? { weaponId, value, name: naming.name(weaponId) } : null; }) .filter((entry): entry is ParamComparisonEntry => entry !== null); const openComparison = (label: string, entries: ParamComparisonEntry[]) => setComparison({ label, entries }); return ( <>
{hiddenWeaponIds.length > 0 ? ( ) : null} {visibleWeaponIds.map((weaponId) => { const weaponName = naming.name(weaponId); const slug = naming.slug(weaponId); return ( ); })} {kind === "main" && specialPoints ? ( toggleRow(SPECIAL_POINTS_PARAM_KEY)} /> ) : null} {Object.entries(paramsByCategory).map(([category, params]) => { const filteredParams = params.filter(({ key }) => currentWeaponHasParam(category, key), ); if (filteredParams.length === 0) { return null; } return ( {filteredParams.map(({ key, fullKey }) => { const isExpanded = expandedRows.has(fullKey); const hasHistory = rowHasHistory(category, key); const explanation = getParamExplanation(category, key); return ( {visibleWeaponIds.map((weaponId) => ( ))} ); })} ); })} {damageMultipliers ? ( ) : null}
{t("params:header.parameter")} {weaponName} {weaponId !== currentWeaponId ? ( } className={styles.hideButton} onPress={() => hideWeapon(weaponId)} aria-label={t("common:actions.hide")} testId={`hide-weapon-${weaponId}`} /> ) : null}
{category}
toggleRow(fullKey) : undefined } >
{key} {hasHistory ? ( {isExpanded ? ( ) : ( )} ) : null} {explanation ? ( // biome-ignore lint/a11y/noStaticElementInteractions: stops the help popover click from toggling the history row e.stopPropagation()} > {explanation} ) : null} weaponParams[String(weaponId)]?.categories[ category ]?.[key]?.current, )} onCompare={openComparison} />
{comparison ? ( setComparison(null)} /> ) : null} ); } function ComparisonButton({ label, entries, onCompare, }: { label: string; entries: ParamComparisonEntry[]; onCompare: (label: string, entries: ParamComparisonEntry[]) => void; }) { const { t } = useTranslation(["params"]); if (entries.length < 2) { return null; } return ( // biome-ignore lint/a11y/noStaticElementInteractions: stops the compare button click from toggling the history row e.stopPropagation()}> } onPress={() => onCompare(label, entries)} aria-label={t("params:compare.action")} testId="compare-param" /> ); } function ParamsLegend() { const { t } = useTranslation(["params"]); return (
{t("params:legend.title")}
{t("params:legend.damage")}
{t("params:legend.frames")}
{t("params:legend.powerUp")}
); } function DamageRateInfoSection({ visibleWeaponIds, currentWeaponId, damageMultipliers, expandedRows, onToggle, comparisonEntries, onCompare, }: { visibleWeaponIds: number[]; currentWeaponId: number; damageMultipliers: Record; expandedRows: Set; onToggle: (fullKey: string) => void; comparisonEntries: ( getValue: (weaponId: number) => number | string | undefined, ) => ParamComparisonEntry[]; onCompare: (label: string, entries: ParamComparisonEntry[]) => void; }) { const { t } = useTranslation(["weapons", "analyzer", "game-misc"]); const targets = (damageMultipliers[String(currentWeaponId)] ?? []).map( (multiplier) => multiplier.target, ); if (targets.length === 0) { return null; } const multiplierFor = (weaponId: number, target: string) => damageMultipliers[String(weaponId)]?.find((m) => m.target === target); return ( {DAMAGE_RATE_INFO_CATEGORY} {targets.map((target) => { const fullKey = `${DAMAGE_RATE_INFO_CATEGORY}.${target}`; const isExpanded = expandedRows.has(fullKey); const hasHistory = visibleWeaponIds.some( (id) => (multiplierFor(id, target)?.history.length ?? 0) > 0, ); const targetLabel = translateDamageReceiver( t, target as DamageReceiver, ); return ( onToggle(fullKey) : undefined} >
{targetLabel} multiplierFor(weaponId, target)?.current, )} onCompare={onCompare} /> {hasHistory ? ( {isExpanded ? ( ) : ( )} ) : null}
{visibleWeaponIds.map((weaponId) => ( ))} ); })}
); } function HiddenWeaponsBar({ kind, hiddenWeaponIds, onRestore, onShowAll, }: { kind: WeaponParamKind; hiddenWeaponIds: number[]; onRestore: (weaponId: number) => void; onShowAll: () => void; }) { const { t } = useTranslation(["weapons", "common"]); const naming = useWeaponParamNaming(kind); return (
{hiddenWeaponIds.map((weaponId) => ( onRestore(weaponId)} testId={`restore-weapon-${weaponId}`} > {naming.name(weaponId)} ))} {t("common:actions.showAll")}
); } function SpecialPointsRow({ visibleWeaponIds, specialPoints, isExpanded, onToggle, }: { visibleWeaponIds: number[]; specialPoints: Record; isExpanded: boolean; onToggle: () => void; }) { const { t } = useTranslation(["analyzer"]); const hasHistory = visibleWeaponIds.some((id) => specialPoints[String(id)]?.some((kit) => kit.history.length > 0), ); return (
{t("analyzer:stat.specialPoints")} {hasHistory ? ( {isExpanded ? : } ) : null}
{visibleWeaponIds.map((weaponId) => ( ))} ); } function SpecialPointCell({ kits, isExpanded, }: { kits: SpecialPointWithHistory[]; isExpanded: boolean; }) { if (kits.length === 0) { return ( ); } const kitsWithHistory = kits.filter((kit) => kit.history.length > 0); const showHistory = isExpanded && kitsWithHistory.length > 0; const multiKit = kits.length > 1; return (
{kits.map((kit) => (
{multiKit ? ( ) : null} {kit.current}
))}
{kitsWithHistory.length > 0 && !isExpanded ? ( {kitsWithHistory.length} ) : null}
{showHistory ? (
{kitsWithHistory.map((kit) => (
{multiKit ? ( ) : null}
{kit.history.toReversed().map(({ version, value }) => (
{value} {version}
))}
))}
) : null} ); } function ParamCell({ param, isExpanded, }: { param: ParamValueWithHistory | undefined; isExpanded: boolean; }) { if (!param) { return ( ); } const showHistory = isExpanded && param.history.length > 0; return (
{WeaponParams.formatValue(param.current)} {param.history.length > 0 && !isExpanded ? ( {param.history.length} ) : null}
{showHistory ? (
{param.history.toReversed().map(({ version, value }) => (
{WeaponParams.formatValue(value)} {version}
))}
) : null} ); }