Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,54 @@
.bars {
display: flex;
flex-direction: column;
gap: var(--s-2);
min-width: min(420px, 80vw);
padding-inline: var(--s-2);
}
.row {
display: flex;
align-items: center;
gap: var(--s-2);
}
.weapon {
display: flex;
align-items: center;
gap: var(--s-1);
width: 130px;
flex-shrink: 0;
}
.name {
font-size: var(--font-2xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.barTrack {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.barFill {
height: 18px;
min-width: 2px;
border-radius: var(--radius-field);
background-color: var(--color-accent);
}
.barFillCurrent {
background-color: var(--color-second);
}
.value {
font-variant-numeric: tabular-nums;
font-weight: var(--weight-semi);
font-size: var(--font-xs);
flex-shrink: 0;
width: 52px;
}

View File

@@ -0,0 +1,66 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { SendouDialog } from "~/components/elements/Dialog";
import * as WeaponParams from "../core/WeaponParams";
import type {
ParamComparisonEntry,
WeaponParamKind,
} from "../weapon-params-types";
import styles from "./ParamComparisonDialog.module.css";
import { WeaponParamImage } from "./WeaponParamsTable";
/**
* Modal with a simple horizontal bar chart comparing one parameter's numeric value across the
* currently visible weapons. Bars are scaled relative to the largest absolute value so weapons can
* be compared at a glance.
*/
export function ParamComparisonDialog({
kind,
label,
entries,
currentWeaponId,
onClose,
}: {
kind: WeaponParamKind;
label: string;
entries: ParamComparisonEntry[];
currentWeaponId: number;
onClose: () => void;
}) {
const { t } = useTranslation(["params"]);
const sortedEntries = R.sortBy(entries, [(entry) => entry.value, "desc"]);
const maxValue = Math.max(...entries.map((entry) => Math.abs(entry.value)));
return (
<SendouDialog
heading={t("params:compare.heading", { parameter: label })}
onClose={onClose}
>
<div className={styles.bars}>
{sortedEntries.map((entry) => (
<div key={entry.weaponId} className={styles.row}>
<div className={styles.weapon}>
<WeaponParamImage kind={kind} id={entry.weaponId} size={28} />
<span className={styles.name}>{entry.name}</span>
</div>
<div className={styles.barTrack}>
<div
className={clsx(styles.barFill, {
[styles.barFillCurrent]: entry.weaponId === currentWeaponId,
})}
style={{
width: `${maxValue === 0 ? 0 : (Math.abs(entry.value) / maxValue) * 100}%`,
}}
/>
</div>
<span className={styles.value}>
{WeaponParams.formatValue(entry.value)}
</span>
</div>
))}
</div>
</SendouDialog>
);
}

View File

@@ -0,0 +1,44 @@
.kits {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-2);
list-style: none;
padding: 0;
margin: 0;
}
.kit {
display: inline-flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
background-color: var(--color-bg-high);
border: var(--border-style);
border-radius: var(--radius-field);
padding: var(--s-1) var(--s-3);
}
.kitName {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kitGear {
display: inline-flex;
align-items: center;
gap: var(--s-1);
margin-inline-start: var(--s-1);
}
.gearLink {
display: inline-flex;
align-items: center;
border-radius: var(--radius-rounded);
&:hover {
filter: brightness(1.2);
}
}

View File

@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { mySlugify, weaponParamsPage } from "~/utils/urls";
import type { WeaponKitInfo } from "../weapon-params-types";
import styles from "./WeaponKits.module.css";
export function WeaponKits({ kits }: { kits: WeaponKitInfo[] }) {
const { t } = useTranslation(["weapons"]);
return (
<ul className={styles.kits}>
{kits.map((kit) => (
<li key={kit.weaponId} className={styles.kit}>
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={28} />
<span className={styles.kitName}>
{t(`weapons:MAIN_${kit.weaponId}`)}
</span>
<span className={styles.kitGear}>
<Link
to={weaponParamsPage(
mySlugify(t(`weapons:SUB_${kit.subWeaponId}`, { lng: "en" })),
)}
className={styles.gearLink}
>
<SubWeaponImage subWeaponId={kit.subWeaponId} size={22} />
</Link>
<Link
to={weaponParamsPage(
mySlugify(
t(`weapons:SPECIAL_${kit.specialWeaponId}`, { lng: "en" }),
),
)}
className={styles.gearLink}
>
<SpecialWeaponImage
specialWeaponId={kit.specialWeaponId}
size={22}
/>
</Link>
</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,278 @@
.container {
margin-top: var(--s-4);
width: 100%;
max-height: 600px;
overflow: auto;
align-self: stretch;
}
.hiddenBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
margin-bottom: var(--s-2);
padding: var(--s-1-5) var(--s-2);
background-color: var(--color-bg-high);
border: 1px solid var(--color-border);
border-radius: var(--radius-box);
}
.hiddenBarLabel {
flex-shrink: 0;
color: var(--color-text-high);
}
.hiddenBadge {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
.hiddenBadgeName {
font-size: var(--font-2xs);
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hideButton {
position: absolute;
top: var(--s-1);
right: var(--s-1);
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--font-xs);
text-align: left;
& tbody {
& tr:hover {
background-color: var(--color-bg-high);
}
& td {
padding: var(--s-2) var(--s-3);
border-bottom: 1px solid var(--color-border);
}
& tr:first-child td {
border-top: 1px solid var(--color-border);
}
}
}
.thead {
position: sticky;
top: 0;
z-index: 1;
& th {
padding: var(--s-2);
background-color: var(--color-bg);
}
}
.paramHeader {
min-width: 180px;
}
.weaponHeader {
position: relative;
min-width: 90px;
padding: var(--s-2);
text-align: center;
}
.weaponHeaderContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.weaponName {
font-size: var(--font-2xs);
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.categoryHeader {
background-color: var(--color-bg-high);
font-weight: var(--weight-bold);
font-size: var(--font-xs);
letter-spacing: 0.5px;
padding: var(--s-1-5) var(--s-2);
}
.paramName {
font-size: var(--font-xs);
min-width: 180px;
vertical-align: top;
}
.paramNameInner {
display: flex;
align-items: center;
gap: var(--s-1);
}
.expandableRow .paramName {
cursor: pointer;
&:hover {
background-color: var(--color-bg-high);
}
}
.paramNameText {
flex: 1;
}
.paramInfo {
display: flex;
align-items: center;
flex-shrink: 0;
}
.historyIndicator {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--color-accent);
}
.paramCell {
font-size: var(--font-xs);
vertical-align: top;
min-width: 90px;
}
.cellContent {
display: flex;
align-items: center;
gap: var(--s-1);
}
.currentValue {
font-variant-numeric: tabular-nums;
font-weight: var(--weight-semi);
}
.specialPointKits {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.specialPointKit {
display: flex;
align-items: center;
gap: var(--s-1);
}
.historyBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding-inline: var(--s-0-5);
border-radius: var(--radius-full);
background-color: var(--color-accent-low);
color: var(--color-accent);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.noValue {
color: var(--color-text-high);
}
.historyList {
margin-top: var(--s-1);
display: flex;
flex-direction: column;
gap: var(--s-0-5);
font-size: var(--font-xs);
border-top: 1px solid var(--color-border);
padding-top: var(--s-1);
}
.historyItem {
display: flex;
align-items: baseline;
gap: var(--s-1-5);
}
.historyVersion {
color: var(--color-text-high);
font-size: var(--font-2xs);
font-style: italic;
white-space: nowrap;
}
.historyValue {
font-variant-numeric: tabular-nums;
color: var(--color-text-high);
min-width: 1.75rem;
}
.specialPointHistory {
margin-top: var(--s-1);
display: flex;
flex-direction: column;
gap: var(--s-1-5);
border-top: 1px solid var(--color-border);
padding-top: var(--s-1);
}
.specialPointHistoryKit {
display: flex;
align-items: flex-start;
gap: var(--s-1);
& > div:first-child {
flex-shrink: 0;
}
}
.specialPointHistoryKitList {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--s-0-5);
font-size: var(--font-xs);
}
.legend {
margin-top: var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-1);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.legendTitle {
font-weight: var(--weight-bold);
color: var(--color-text);
}
.legendItem {
margin: 0;
padding-left: var(--s-3);
position: relative;
&::before {
content: "•";
position: absolute;
left: var(--s-1);
}
}

View File

@@ -0,0 +1,679 @@
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 type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mySlugify, weaponParamsPage } from "~/utils/urls";
import { getParamExplanation } from "../core/param-explanations";
import * as WeaponParams from "../core/WeaponParams";
import { weaponParamsSearchParams } from "../params-search-params";
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 <SubWeaponImage subWeaponId={id as SubWeaponId} size={size} />;
}
if (kind === "special") {
return (
<SpecialWeaponImage specialWeaponId={id as SpecialWeaponId} size={size} />
);
}
return (
<WeaponImage weaponSplId={id as MainWeaponId} variant="badge" size={size} />
);
}
// 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<Set<string>>(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 [hiddenParamIds, setHiddenWeaponIds] = useSearchParam(
weaponParamsSearchParams,
"hidden",
);
const hiddenWeaponIds = hiddenParamIds.filter(
(id) => id !== currentWeaponId && categoryWeaponIds.includes(id),
);
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 (
<>
<div className={styles.container}>
{hiddenWeaponIds.length > 0 ? (
<HiddenWeaponsBar
kind={kind}
hiddenWeaponIds={hiddenWeaponIds}
onRestore={restoreWeapon}
onShowAll={showAllWeapons}
/>
) : null}
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
<th className={styles.paramHeader}>
{t("params:header.parameter")}
</th>
{visibleWeaponIds.map((weaponId) => {
const weaponName = naming.name(weaponId);
const slug = naming.slug(weaponId);
return (
<th key={weaponId} className={clsx(styles.weaponHeader, {})}>
<Link
to={weaponParamsPage(slug)}
className={styles.weaponHeaderContent}
>
<WeaponParamImage kind={kind} id={weaponId} size={32} />
<span className={styles.weaponName}>{weaponName}</span>
</Link>
{weaponId !== currentWeaponId ? (
<SendouButton
variant="minimal-destructive"
size="miniscule"
shape="square"
icon={<X />}
className={styles.hideButton}
onPress={() => hideWeapon(weaponId)}
aria-label={t("common:actions.hide")}
testId={`hide-weapon-${weaponId}`}
/>
) : null}
</th>
);
})}
</tr>
</thead>
<tbody>
{kind === "main" && specialPoints ? (
<SpecialPointsRow
visibleWeaponIds={visibleWeaponIds}
specialPoints={specialPoints}
isExpanded={expandedRows.has(SPECIAL_POINTS_PARAM_KEY)}
onToggle={() => 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 (
<Fragment key={category}>
<tr>
<td
colSpan={visibleWeaponIds.length + 1}
className={styles.categoryHeader}
>
{category}
</td>
</tr>
{filteredParams.map(({ key, fullKey }) => {
const isExpanded = expandedRows.has(fullKey);
const hasHistory = rowHasHistory(category, key);
const explanation = getParamExplanation(category, key);
return (
<tr
key={fullKey}
className={clsx({
[styles.expandableRow]: hasHistory,
})}
>
<td
className={styles.paramName}
onClick={
hasHistory ? () => toggleRow(fullKey) : undefined
}
>
<div className={styles.paramNameInner}>
<span className={styles.paramNameText}>{key}</span>
{hasHistory ? (
<span className={styles.historyIndicator}>
{isExpanded ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
</span>
) : null}
{explanation ? (
// biome-ignore lint/a11y/noStaticElementInteractions: stops the help popover click from toggling the history row
<span
className={styles.paramInfo}
onClick={(e) => e.stopPropagation()}
>
<InfoPopover tiny>{explanation}</InfoPopover>
</span>
) : null}
<ComparisonButton
label={key}
entries={comparisonEntries(
(weaponId) =>
weaponParams[String(weaponId)]?.categories[
category
]?.[key]?.current,
)}
onCompare={openComparison}
/>
</div>
</td>
{visibleWeaponIds.map((weaponId) => (
<ParamCell
key={weaponId}
param={
weaponParams[String(weaponId)]?.categories[
category
]?.[key]
}
isExpanded={isExpanded}
/>
))}
</tr>
);
})}
</Fragment>
);
})}
{damageMultipliers ? (
<DamageRateInfoSection
visibleWeaponIds={visibleWeaponIds}
currentWeaponId={currentWeaponId}
damageMultipliers={damageMultipliers}
expandedRows={expandedRows}
onToggle={toggleRow}
comparisonEntries={comparisonEntries}
onCompare={openComparison}
/>
) : null}
</tbody>
</table>
</div>
<ParamsLegend />
{comparison ? (
<ParamComparisonDialog
kind={kind}
label={comparison.label}
entries={comparison.entries}
currentWeaponId={currentWeaponId}
onClose={() => 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
<span className={styles.paramInfo} onClick={(e) => e.stopPropagation()}>
<SendouButton
variant="minimal"
size="miniscule"
shape="square"
icon={<ChartColumnBig />}
onPress={() => onCompare(label, entries)}
aria-label={t("params:compare.action")}
testId="compare-param"
/>
</span>
);
}
function ParamsLegend() {
const { t } = useTranslation(["params"]);
return (
<dl className={styles.legend}>
<dt className={styles.legendTitle}>{t("params:legend.title")}</dt>
<dd className={styles.legendItem}>{t("params:legend.damage")}</dd>
<dd className={styles.legendItem}>{t("params:legend.frames")}</dd>
<dd className={styles.legendItem}>{t("params:legend.powerUp")}</dd>
</dl>
);
}
function DamageRateInfoSection({
visibleWeaponIds,
currentWeaponId,
damageMultipliers,
expandedRows,
onToggle,
comparisonEntries,
onCompare,
}: {
visibleWeaponIds: number[];
currentWeaponId: number;
damageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
expandedRows: Set<string>;
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 (
<Fragment>
<tr>
<td
colSpan={visibleWeaponIds.length + 1}
className={styles.categoryHeader}
>
{DAMAGE_RATE_INFO_CATEGORY}
</td>
</tr>
{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 (
<tr
key={fullKey}
className={clsx({ [styles.expandableRow]: hasHistory })}
>
<td
className={styles.paramName}
onClick={hasHistory ? () => onToggle(fullKey) : undefined}
>
<div className={styles.paramNameInner}>
<span className={styles.paramNameText}>{targetLabel}</span>
<ComparisonButton
label={targetLabel}
entries={comparisonEntries(
(weaponId) => multiplierFor(weaponId, target)?.current,
)}
onCompare={onCompare}
/>
{hasHistory ? (
<span className={styles.historyIndicator}>
{isExpanded ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
</span>
) : null}
</div>
</td>
{visibleWeaponIds.map((weaponId) => (
<ParamCell
key={weaponId}
param={multiplierFor(weaponId, target)}
isExpanded={isExpanded}
/>
))}
</tr>
);
})}
</Fragment>
);
}
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 (
<div className={styles.hiddenBar}>
<EyeOff
size={16}
className={styles.hiddenBarLabel}
aria-label={t("common:actions.showAll")}
/>
{hiddenWeaponIds.map((weaponId) => (
<SendouButton
key={weaponId}
variant="minimal"
size="miniscule"
className={styles.hiddenBadge}
onPress={() => onRestore(weaponId)}
testId={`restore-weapon-${weaponId}`}
>
<WeaponParamImage kind={kind} id={weaponId} size={20} />
<span className={styles.hiddenBadgeName}>
{naming.name(weaponId)}
</span>
<X size={12} />
</SendouButton>
))}
<SendouButton
variant="minimal"
size="miniscule"
onPress={onShowAll}
testId="show-all-weapons"
>
{t("common:actions.showAll")}
</SendouButton>
</div>
);
}
function SpecialPointsRow({
visibleWeaponIds,
specialPoints,
isExpanded,
onToggle,
}: {
visibleWeaponIds: number[];
specialPoints: Record<string, SpecialPointWithHistory[]>;
isExpanded: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation(["analyzer"]);
const hasHistory = visibleWeaponIds.some((id) =>
specialPoints[String(id)]?.some((kit) => kit.history.length > 0),
);
return (
<tr className={clsx({ [styles.expandableRow]: hasHistory })}>
<td
className={styles.paramName}
onClick={hasHistory ? onToggle : undefined}
>
<div className={styles.paramNameInner}>
<span className={styles.paramNameText}>
{t("analyzer:stat.specialPoints")}
</span>
{hasHistory ? (
<span className={styles.historyIndicator}>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
) : null}
</div>
</td>
{visibleWeaponIds.map((weaponId) => (
<SpecialPointCell
key={weaponId}
kits={specialPoints[String(weaponId)] ?? []}
isExpanded={isExpanded}
/>
))}
</tr>
);
}
function SpecialPointCell({
kits,
isExpanded,
}: {
kits: SpecialPointWithHistory[];
isExpanded: boolean;
}) {
if (kits.length === 0) {
return (
<td className={styles.paramCell}>
<span className={styles.noValue}>—</span>
</td>
);
}
const kitsWithHistory = kits.filter((kit) => kit.history.length > 0);
const showHistory = isExpanded && kitsWithHistory.length > 0;
const multiKit = kits.length > 1;
return (
<td className={styles.paramCell}>
<div className={styles.cellContent}>
<div className={styles.specialPointKits}>
{kits.map((kit) => (
<div key={kit.weaponId} className={styles.specialPointKit}>
{multiKit ? (
<WeaponImage
weaponSplId={kit.weaponId}
variant="badge"
size={18}
/>
) : null}
<span className={styles.currentValue}>{kit.current}</span>
</div>
))}
</div>
{kitsWithHistory.length > 0 && !isExpanded ? (
<span className={styles.historyBadge}>{kitsWithHistory.length}</span>
) : null}
</div>
{showHistory ? (
<div className={styles.specialPointHistory}>
{kitsWithHistory.map((kit) => (
<div key={kit.weaponId} className={styles.specialPointHistoryKit}>
{multiKit ? (
<WeaponImage
weaponSplId={kit.weaponId}
variant="badge"
size={16}
/>
) : null}
<div className={styles.specialPointHistoryKitList}>
{kit.history.toReversed().map(({ version, value }) => (
<div key={version} className={styles.historyItem}>
<span className={styles.historyValue}>{value}</span>
<span className={styles.historyVersion}>{version}</span>
</div>
))}
</div>
</div>
))}
</div>
) : null}
</td>
);
}
function ParamCell({
param,
isExpanded,
}: {
param: ParamValueWithHistory | undefined;
isExpanded: boolean;
}) {
if (!param) {
return (
<td className={styles.paramCell}>
<span className={styles.noValue}>—</span>
</td>
);
}
const showHistory = isExpanded && param.history.length > 0;
return (
<td className={styles.paramCell}>
<div className={styles.cellContent}>
<span className={styles.currentValue}>
{WeaponParams.formatValue(param.current)}
</span>
{param.history.length > 0 && !isExpanded ? (
<span className={styles.historyBadge}>{param.history.length}</span>
) : null}
</div>
{showHistory ? (
<div className={styles.historyList}>
{param.history.toReversed().map(({ version, value }) => (
<div key={version} className={styles.historyItem}>
<span className={styles.historyValue}>
{WeaponParams.formatValue(value)}
</span>
<span className={styles.historyVersion}>{version}</span>
</div>
))}
</div>
) : null}
</td>
);
}

View File

@@ -0,0 +1,16 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
}
.tabs {
width: 100%;
}
.dataCredit {
font-size: var(--font-2xs);
color: var(--color-text-high);
text-decoration: underline;
}

View File

@@ -0,0 +1,108 @@
import { useTranslation } from "react-i18next";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { Main } from "~/components/Main";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { weaponParamsSearchParams } from "../params-search-params";
import type {
DamageMultiplierWithHistory,
KitPatchHistory,
ParsedWeaponParams,
SpecialPointWithHistory,
WeaponKitInfo,
WeaponParamKind,
WeaponPatch,
} from "../weapon-params-types";
import { WeaponKits } from "./WeaponKits";
import { WeaponParamsTable } from "./WeaponParamsTable";
import styles from "./WeaponParamsView.module.css";
import {
WeaponPatchHistory,
WeaponPatchHistoryByKit,
} from "./WeaponPatchHistory";
export function WeaponParamsView({
kind,
weaponId,
categoryWeaponIds,
weaponParams,
specialPoints,
damageMultipliers,
versions,
patchHistory,
kitPatchHistories,
kits,
}: {
kind: WeaponParamKind;
weaponId: number;
categoryWeaponIds: number[];
weaponParams: Record<string, ParsedWeaponParams>;
specialPoints?: Record<string, SpecialPointWithHistory[]>;
damageMultipliers?: Record<string, DamageMultiplierWithHistory[]>;
versions: string[];
patchHistory: WeaponPatch[];
kitPatchHistories?: KitPatchHistory[];
kits?: WeaponKitInfo[];
}) {
const { t } = useTranslation(["params"]);
const [tab, setTab] = useSearchParam(weaponParamsSearchParams, "tab");
const viewedKitPatchCount =
kitPatchHistories?.find((kit) => kit.weaponId === weaponId)?.patches
.length ?? patchHistory.length;
return (
<Main className={styles.container} bigger>
{kits ? <WeaponKits kits={kits} /> : null}
<SendouTabs
selectedKey={tab}
onSelectionChange={(key) =>
setTab(key === "patches" ? "patches" : "params")
}
className={styles.tabs}
>
<SendouTabList>
<SendouTab id="params">{t("params:tab.params")}</SendouTab>
<SendouTab id="patches" number={viewedKitPatchCount}>
{t("params:tab.patches")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="params">
<WeaponParamsTable
kind={kind}
currentWeaponId={weaponId}
categoryWeaponIds={categoryWeaponIds}
weaponParams={weaponParams}
specialPoints={specialPoints}
damageMultipliers={damageMultipliers}
versions={versions}
/>
</SendouTabPanel>
<SendouTabPanel id="patches">
{kitPatchHistories && kitPatchHistories.length > 0 ? (
<WeaponPatchHistoryByKit
kits={kitPatchHistories}
defaultWeaponId={weaponId as MainWeaponId}
/>
) : (
<WeaponPatchHistory patches={patchHistory} />
)}
</SendouTabPanel>
</SendouTabs>
<a
href="https://leanny.github.io/"
target="_blank"
rel="noopener noreferrer"
className={styles.dataCredit}
>
{t("params:dataCredit.lean")}
</a>
</Main>
);
}

View File

@@ -0,0 +1,176 @@
.kitHistory {
display: flex;
flex-direction: column;
gap: var(--s-3);
width: 100%;
align-self: stretch;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2) var(--s-4);
}
.kitChip {
display: flex;
align-items: center;
gap: var(--s-1);
}
.container {
display: flex;
gap: var(--s-3);
width: 100%;
overflow-x: auto;
padding-bottom: var(--s-2);
align-self: stretch;
}
.divider {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
&:not(:first-child) {
margin-block: var(--s-3);
}
}
.dividerLabel {
display: flex;
align-items: center;
gap: var(--s-1);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.column {
display: flex;
flex-direction: column;
gap: var(--s-2);
flex: 0 0 auto;
width: 15rem;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-1);
padding: var(--s-3) var(--s-2);
border: 2px solid var(--color-border-high);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
}
.version {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
line-height: 1;
}
.date {
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.changes {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
.change {
min-height: 2rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--s-1) var(--s-2);
padding: var(--s-1) var(--s-2);
border: 1px solid var(--color-border);
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
font-size: var(--font-2xs);
&.wide,
&.incoming {
flex-direction: column;
align-items: stretch;
}
&.buff {
border-color: var(--color-success);
background-color: var(--color-success-low);
}
&.nerf {
border-color: var(--color-error);
background-color: var(--color-error-low);
}
}
.changeName {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: var(--s-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.change.wide & {
white-space: normal;
}
}
.changeIcon {
flex-shrink: 0;
}
.changeValues {
display: flex;
align-items: center;
gap: var(--s-1);
flex-shrink: 0;
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
.change.wide & {
flex-wrap: wrap;
}
}
.attackers {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1) var(--s-2);
min-width: 0;
}
.attackerIcons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1);
}
.attackerSuffix {
font-weight: var(--weight-semi);
}
.arrow {
color: var(--color-text-high);
}
.empty {
color: var(--color-text-high);
font-size: var(--font-sm);
padding: var(--s-4);
}

View File

@@ -0,0 +1,381 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Divider } from "~/components/Divider";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import {
damageReceiverSuffix,
translateDamageReceiver,
} from "~/features/object-damage-calculator/calculator-constants";
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import * as WeaponParams from "../core/WeaponParams";
import { weaponParamsSearchParams } from "../params-search-params";
import {
DAMAGE_MULTIPLIER_PARAM_KEY,
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
SPECIAL_POINTS_PARAM_KEY,
} from "../weapon-params-constants";
import type {
IncomingDamageAttackers,
KitPatchHistory,
PatchChange,
WeaponPatch,
} from "../weapon-params-types";
import styles from "./WeaponPatchHistory.module.css";
const PATCH_DATE_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
};
export function WeaponPatchHistory({ patches }: { patches: WeaponPatch[] }) {
const { t } = useTranslation(["params"]);
if (patches.length === 0) {
return <div className={styles.empty}>{t("params:noPatches")}</div>;
}
return (
<div className={styles.container}>
{patches.map((patch) => (
<div key={patch.version} className={styles.column}>
<PatchColumnHeader version={patch.version} date={patch.date} />
<div className={styles.changes}>
{patch.changes.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
</div>
</div>
))}
</div>
);
}
/**
* Patch history of a main weapon shown one kit at a time: the selected kit's main weapon, sub
* weapon and special weapon changes are grouped under dividers within the same patch column. Sub
* and special weapon changes can be toggled off.
*/
export function WeaponPatchHistoryByKit({
kits,
defaultWeaponId,
}: {
kits: KitPatchHistory[];
defaultWeaponId: MainWeaponId;
}) {
const { t } = useTranslation(["params"]);
const kitIds = kits.map((kit) => kit.weaponId);
const [kit, setKit] = useSearchParam(weaponParamsSearchParams, "kit");
const selectedWeaponId =
typeof kit === "number" && kitIds.includes(kit) ? kit : defaultWeaponId;
const selectKit = (weaponId: MainWeaponId) => {
setKit(weaponId === defaultWeaponId ? null : weaponId);
};
const [showSubSpecial, setShowSubSpecial] = useSearchParam(
weaponParamsSearchParams,
"kitExtras",
);
const selectedKit =
kits.find((kit) => kit.weaponId === selectedWeaponId) ?? kits[0];
const patches = selectedKit.patches
.map((patch) => ({
...patch,
changes: showSubSpecial
? patch.changes
: patch.changes.filter((change) => change.source === "main"),
}))
.filter((patch) => patch.changes.length > 0);
return (
<div className={styles.kitHistory}>
<div className={styles.controls}>
{kits.length > 1 ? (
<KitFilter
kits={kits}
selectedWeaponId={selectedKit.weaponId}
onSelect={selectKit}
/>
) : null}
<SendouSwitch isSelected={showSubSpecial} onChange={setShowSubSpecial}>
{t("params:patches.showSubSpecial")}
</SendouSwitch>
</div>
{patches.length === 0 ? (
<div className={styles.empty}>{t("params:noPatches")}</div>
) : (
<div className={styles.container}>
{patches.map((patch) => (
<KitPatchColumn
key={patch.version}
patch={patch}
kit={selectedKit}
/>
))}
</div>
)}
</div>
);
}
function KitFilter({
kits,
selectedWeaponId,
onSelect,
}: {
kits: KitPatchHistory[];
selectedWeaponId: MainWeaponId;
onSelect: (weaponId: MainWeaponId) => void;
}) {
const { t } = useTranslation(["weapons"]);
return (
<SendouChipRadioGroup>
{kits.map((kit) => (
<SendouChipRadio
key={kit.weaponId}
name="patch-history-kit"
value={String(kit.weaponId)}
checked={kit.weaponId === selectedWeaponId}
onChange={(value) => onSelect(Number(value) as MainWeaponId)}
>
<span className={styles.kitChip}>
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={20} />
{t(`weapons:MAIN_${kit.weaponId}`)}
</span>
</SendouChipRadio>
))}
</SendouChipRadioGroup>
);
}
function KitPatchColumn({
patch,
kit,
}: {
patch: WeaponPatch;
kit: KitPatchHistory;
}) {
const mainChanges = patch.changes.filter(
(change) => change.source === "main",
);
const subChanges = patch.changes.filter((change) => change.source === "sub");
const specialChanges = patch.changes.filter(
(change) => change.source === "special",
);
return (
<div className={styles.column}>
<PatchColumnHeader version={patch.version} date={patch.date} />
<div className={styles.changes}>
{mainChanges.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
{subChanges.length > 0 ? (
<>
<SubWeaponDivider subWeaponId={kit.subWeaponId} />
{subChanges.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
</>
) : null}
{specialChanges.length > 0 ? (
<>
<SpecialWeaponDivider specialWeaponId={kit.specialWeaponId} />
{specialChanges.map((change, i) => (
<ChangeBadge key={changeKey(change, i)} change={change} />
))}
</>
) : null}
</div>
</div>
);
}
function SubWeaponDivider({ subWeaponId }: { subWeaponId: SubWeaponId }) {
const { t } = useTranslation(["weapons"]);
return (
<Divider smallText className={styles.divider}>
<span className={styles.dividerLabel}>
<SubWeaponImage subWeaponId={subWeaponId} size={18} />
{t(`weapons:SUB_${subWeaponId}`)}
</span>
</Divider>
);
}
function SpecialWeaponDivider({
specialWeaponId,
}: {
specialWeaponId: SpecialWeaponId;
}) {
const { t } = useTranslation(["weapons"]);
return (
<Divider smallText className={styles.divider}>
<span className={styles.dividerLabel}>
<SpecialWeaponImage specialWeaponId={specialWeaponId} size={18} />
{t(`weapons:SPECIAL_${specialWeaponId}`)}
</span>
</Divider>
);
}
function PatchColumnHeader({
version,
date,
}: {
version: string;
date: string | null;
}) {
return (
<div className={styles.header}>
<div className={styles.version}>{version}</div>
{date ? (
<LocaleTime
date={new Date(date)}
options={PATCH_DATE_OPTIONS}
className={styles.date}
/>
) : null}
</div>
);
}
function changeKey(change: PatchChange, index: number) {
return `${change.category}.${change.key}.${change.weaponId ?? ""}.${change.source ?? ""}.${index}`;
}
function ChangeBadge({ change }: { change: PatchChange }) {
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
if (
change.category === INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY &&
change.attackers
) {
return <IncomingChangeBadge change={change} attackers={change.attackers} />;
}
const isSpecialPoints = change.category === SPECIAL_POINTS_PARAM_KEY;
const isDamageMultiplier = change.category === DAMAGE_MULTIPLIER_PARAM_KEY;
// Damage falloff curves serialize to long "damage @ distance" lists that need their own line.
const isWideValue =
typeof change.from === "string" && change.from.includes("@");
const label = isSpecialPoints
? t("analyzer:stat.specialPoints")
: isDamageMultiplier
? translateDamageReceiver(t, change.key as DamageReceiver)
: change.key;
return (
<div
className={clsx(styles.change, {
[styles.buff]: change.kind === "buff",
[styles.nerf]: change.kind === "nerf",
[styles.wide]: isWideValue,
})}
title={
isSpecialPoints || isDamageMultiplier
? undefined
: `${change.category}.${change.key}`
}
>
<span className={styles.changeName}>
{isSpecialPoints && change.weaponId ? (
<WeaponImage
weaponSplId={change.weaponId}
variant="badge"
size={20}
className={styles.changeIcon}
/>
) : null}
{label}
</span>
<span className={styles.changeValues}>
{WeaponParams.formatValue(change.from)}
<span className={styles.arrow}>→</span>
{WeaponParams.formatValue(change.to)}
</span>
</div>
);
}
/**
* An incoming damage multiplier change: a set of attacking weapons whose shared damage rate against
* the page's sub or special weapon changed. Shows the attacking weapons' icons (with a suffix for
* multi-part objects, e.g. a Big Bubbler's shield vs. weak point) and the from→to rate.
*/
function IncomingChangeBadge({
change,
attackers,
}: {
change: PatchChange;
attackers: IncomingDamageAttackers;
}) {
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
const suffix = damageReceiverSuffix(t, change.key as DamageReceiver);
return (
<div
className={clsx(styles.change, styles.incoming, {
[styles.buff]: change.kind === "buff",
[styles.nerf]: change.kind === "nerf",
})}
title={translateDamageReceiver(t, change.key as DamageReceiver)}
>
<div className={styles.attackers}>
<span className={styles.attackerIcons}>
{attackers.mainWeaponIds.map((id) => (
<WeaponImage
key={`m-${id}`}
weaponSplId={id}
variant="badge"
size={20}
/>
))}
{attackers.subWeaponIds.map((id) => (
<SubWeaponImage key={`s-${id}`} subWeaponId={id} size={20} />
))}
{attackers.specialWeaponIds.map((id) => (
<SpecialWeaponImage
key={`x-${id}`}
specialWeaponId={id}
size={20}
/>
))}
</span>
{suffix ? (
<span className={styles.attackerSuffix}>{suffix}</span>
) : null}
</div>
<span className={styles.changeValues}>
{WeaponParams.formatValue(change.from)}
<span className={styles.arrow}>→</span>
{WeaponParams.formatValue(change.to)}
</span>
</div>
);
}