mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 00:27:30 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
50
apps/web-react/app/features/params/components/WeaponKits.tsx
Normal file
50
apps/web-react/app/features/params/components/WeaponKits.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user