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,339 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-field);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
background: var(--color-bg-higher);
|
||||
border: none;
|
||||
border-radius: var(--radius-field);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text-high);
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3);
|
||||
}
|
||||
|
||||
.comboRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.barSection {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-selector);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segmentWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1);
|
||||
height: 36px;
|
||||
padding: 0 var(--s-2);
|
||||
color: var(--color-text-inverse);
|
||||
font-weight: var(--weight-semi);
|
||||
|
||||
&[data-slot-color="yellow"] {
|
||||
background-color: #f5d742;
|
||||
}
|
||||
|
||||
&[data-slot-color="pink"] {
|
||||
background-color: #f5b8d0;
|
||||
}
|
||||
|
||||
&[data-slot-color="green"] {
|
||||
background-color: #90e8a8;
|
||||
}
|
||||
|
||||
&[data-slot-color="blue"] {
|
||||
background-color: #8cd4f5;
|
||||
}
|
||||
}
|
||||
|
||||
.damageTypeLabel {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--s-0-5) var(--s-0-5);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-selector);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.damageValue {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.hitMultiplier {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
opacity: 0.8;
|
||||
margin-left: var(--s-0-5);
|
||||
}
|
||||
|
||||
.weaponIcon {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.subSpecialWeaponIcon {
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.thresholdLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgb(80 80 100) 0,
|
||||
rgb(80 80 100) 5px,
|
||||
transparent 5px,
|
||||
transparent 10px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.totalSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.totalDamage {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.hitCount {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.singleWeaponToggleRow {
|
||||
display: flex;
|
||||
padding-block-end: var(--s-3);
|
||||
border-block-end: 1px solid var(--color-border);
|
||||
margin-block-end: var(--s-1);
|
||||
}
|
||||
|
||||
.filterControlsRow {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
padding-block-end: var(--s-2);
|
||||
border-block-end: 1px solid var(--color-border);
|
||||
margin-block-end: var(--s-1);
|
||||
}
|
||||
|
||||
.filterControlButton {
|
||||
padding: var(--s-1) var(--s-2);
|
||||
background: var(--color-bg-higher);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-bg-high);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.filteredItemsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
padding-bottom: var(--s-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--s-1);
|
||||
}
|
||||
|
||||
.filteredItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
background: var(--color-bg-higher);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
.slidersContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
gap: var(--s-2) var(--s-2);
|
||||
align-items: center;
|
||||
padding-bottom: var(--s-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--s-1);
|
||||
}
|
||||
|
||||
@container (width < 640px) {
|
||||
.slidersContainer {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
/* Row 1: icon 1 + label 1 */
|
||||
.slidersContainer > :nth-child(1) {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.slidersContainer > :nth-child(2) {
|
||||
grid-row: 1;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
/* Row 2: slider 1 + value 1 */
|
||||
.slidersContainer > :nth-child(3) {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.slidersContainer > :nth-child(4) {
|
||||
grid-row: 2;
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
/* Row 3: icon 2 + label 2 */
|
||||
.slidersContainer > :nth-child(5) {
|
||||
grid-row: 3;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.slidersContainer > :nth-child(6) {
|
||||
grid-row: 3;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
/* Row 4: slider 2 + value 2 */
|
||||
.slidersContainer > :nth-child(7) {
|
||||
grid-row: 4;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.slidersContainer > :nth-child(8) {
|
||||
grid-row: 4;
|
||||
grid-column: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.resSliderLabel {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resSlider {
|
||||
min-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resSliderValue {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
.inkTimeSegment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1);
|
||||
height: 36px;
|
||||
padding: 0 var(--s-2);
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--color-bg-higher) 0,
|
||||
var(--color-bg-higher) 4px,
|
||||
var(--color-bg-high) 4px,
|
||||
var(--color-bg-high) 8px
|
||||
);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--weight-semi);
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.inkTimeDamage {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.inkTimeLabel {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-second);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
padding: var(--s-0-5) var(--s-0-5);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
abilityImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { LETHAL_DAMAGE } from "../comp-analyzer-constants";
|
||||
import {
|
||||
useSingleWeaponCombos,
|
||||
useTargetResAp,
|
||||
useTargetSubDefenseAp,
|
||||
} from "../comp-analyzer-hooks";
|
||||
import type { DamageCombo, DamageSegment } from "../comp-analyzer-types";
|
||||
import {
|
||||
calculateDamageCombos,
|
||||
calculateInkTimeToKill,
|
||||
type ExcludedDamageKey,
|
||||
getAllDamageKeys,
|
||||
} from "../core/damage-combinations";
|
||||
import styles from "./DamageComboBar.module.css";
|
||||
|
||||
function filterKeyToString(key: ExcludedDamageKey): string {
|
||||
return `${key.weaponId}-${key.weaponType}-${key.damageType}`;
|
||||
}
|
||||
|
||||
function weaponTypeFromSegment(
|
||||
segment: DamageSegment,
|
||||
): "main" | "sub" | "special" {
|
||||
if (segment.isSubWeapon) return "sub";
|
||||
if (segment.isSpecialWeapon) return "special";
|
||||
return "main";
|
||||
}
|
||||
|
||||
const SLOT_COLORS = ["yellow", "pink", "green", "blue"] as const;
|
||||
|
||||
interface DamageComboBarProps {
|
||||
combo: DamageCombo;
|
||||
inkTimeFrames: number | null;
|
||||
onToggleFilter: (key: ExcludedDamageKey) => void;
|
||||
}
|
||||
|
||||
function DamageComboBar({
|
||||
combo,
|
||||
inkTimeFrames,
|
||||
onToggleFilter,
|
||||
}: DamageComboBarProps) {
|
||||
const { t } = useTranslation(["analyzer", "weapons"]);
|
||||
|
||||
const thresholdPosition = (LETHAL_DAMAGE / combo.totalDamage) * 100;
|
||||
const inkDamage = inkTimeFrames ? LETHAL_DAMAGE - combo.totalDamage : 0;
|
||||
const totalWithInk = inkTimeFrames
|
||||
? LETHAL_DAMAGE
|
||||
: Math.max(combo.totalDamage, LETHAL_DAMAGE);
|
||||
|
||||
return (
|
||||
<div className={styles.comboRow}>
|
||||
<div className={styles.barSection}>
|
||||
{inkTimeFrames ? (
|
||||
<InkTimeSegment
|
||||
inkDamage={inkDamage}
|
||||
totalDamage={totalWithInk}
|
||||
inkTimeFrames={inkTimeFrames}
|
||||
/>
|
||||
) : null}
|
||||
{combo.segments.map((segment, index) => (
|
||||
<SegmentBar
|
||||
key={index}
|
||||
segment={segment}
|
||||
totalDamage={totalWithInk}
|
||||
damageTypeLabel={t(`analyzer:damage.${segment.damageType}` as any)}
|
||||
onToggleFilter={onToggleFilter}
|
||||
/>
|
||||
))}
|
||||
{thresholdPosition < 100 && !inkTimeFrames ? (
|
||||
<div
|
||||
className={styles.thresholdLine}
|
||||
style={{ left: `${thresholdPosition}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.totalSection}>
|
||||
<span className={styles.totalDamage}>
|
||||
{combo.totalDamage.toFixed(1)}
|
||||
</span>
|
||||
<span className={styles.hitCount}>
|
||||
{t("analyzer:comp.hits", { count: combo.hitCount })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentBarProps {
|
||||
segment: DamageSegment;
|
||||
totalDamage: number;
|
||||
damageTypeLabel: string;
|
||||
onToggleFilter: (key: ExcludedDamageKey) => void;
|
||||
}
|
||||
|
||||
function SegmentBar({
|
||||
segment,
|
||||
totalDamage,
|
||||
damageTypeLabel,
|
||||
onToggleFilter,
|
||||
}: SegmentBarProps) {
|
||||
const segmentDamage = segment.damageValue * segment.count;
|
||||
const widthPercent = (segmentDamage / totalDamage) * 100;
|
||||
const slotColor = SLOT_COLORS[segment.weaponSlot] ?? "yellow";
|
||||
const params = mainWeaponParams(segment.weaponId);
|
||||
|
||||
const handleFilterClick = () => {
|
||||
onToggleFilter({
|
||||
weaponId: segment.weaponId,
|
||||
weaponType: weaponTypeFromSegment(segment),
|
||||
damageType: segment.damageType,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.segmentWrapper}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
>
|
||||
<div className={styles.segment} data-slot-color={slotColor}>
|
||||
<WeaponIcon
|
||||
weaponId={segment.weaponId}
|
||||
isSubWeapon={segment.isSubWeapon}
|
||||
isSpecialWeapon={segment.isSpecialWeapon}
|
||||
subWeaponId={params.subWeaponId}
|
||||
specialWeaponId={params.specialWeaponId}
|
||||
/>
|
||||
<span className={styles.damageValue}>
|
||||
{segment.damageValue}
|
||||
{segment.count > 1 ? (
|
||||
<span className={styles.hitMultiplier}>×{segment.count}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.damageTypeLabel}
|
||||
onClick={handleFilterClick}
|
||||
>
|
||||
{damageTypeLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkTimeSegmentProps {
|
||||
inkDamage: number;
|
||||
totalDamage: number;
|
||||
inkTimeFrames: number;
|
||||
}
|
||||
|
||||
function InkTimeSegment({
|
||||
inkDamage,
|
||||
totalDamage,
|
||||
inkTimeFrames,
|
||||
}: InkTimeSegmentProps) {
|
||||
const widthPercent = (inkDamage / totalDamage) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.segmentWrapper}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
>
|
||||
<div className={styles.inkTimeSegment}>
|
||||
<Image path={abilityImageUrl("RES")} alt="" size={18} />
|
||||
<span className={styles.inkTimeDamage}>{inkDamage.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className={styles.inkTimeLabel}>{inkTimeFrames}f</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WeaponIconProps {
|
||||
weaponId: MainWeaponId;
|
||||
isSubWeapon: boolean;
|
||||
isSpecialWeapon: boolean;
|
||||
subWeaponId: SubWeaponId;
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
}
|
||||
|
||||
function WeaponIcon({
|
||||
weaponId,
|
||||
isSubWeapon,
|
||||
isSpecialWeapon,
|
||||
subWeaponId,
|
||||
specialWeaponId,
|
||||
}: WeaponIconProps) {
|
||||
if (isSubWeapon) {
|
||||
return (
|
||||
<Image
|
||||
path={subWeaponImageUrl(subWeaponId)}
|
||||
alt=""
|
||||
size={18}
|
||||
className={styles.subSpecialWeaponIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSpecialWeapon) {
|
||||
return (
|
||||
<Image
|
||||
path={specialWeaponImageUrl(specialWeaponId)}
|
||||
alt=""
|
||||
size={18}
|
||||
className={styles.subSpecialWeaponIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WeaponImage
|
||||
weaponSplId={weaponId}
|
||||
variant="build"
|
||||
size={24}
|
||||
className={styles.weaponIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilteredItemProps {
|
||||
filterKey: ExcludedDamageKey;
|
||||
onRestore: (key: ExcludedDamageKey) => void;
|
||||
}
|
||||
|
||||
function FilteredItem({ filterKey, onRestore }: FilteredItemProps) {
|
||||
const { t } = useTranslation(["analyzer", "weapons"]);
|
||||
const params = mainWeaponParams(filterKey.weaponId);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filteredItem}
|
||||
onClick={() => onRestore(filterKey)}
|
||||
>
|
||||
<WeaponIcon
|
||||
weaponId={filterKey.weaponId}
|
||||
isSubWeapon={filterKey.weaponType === "sub"}
|
||||
isSpecialWeapon={filterKey.weaponType === "special"}
|
||||
subWeaponId={params.subWeaponId}
|
||||
specialWeaponId={params.specialWeaponId}
|
||||
/>
|
||||
<span>{t(`analyzer:damage.${filterKey.damageType}` as any)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DamageComboListProps {
|
||||
weaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
export function DamageComboList({ weaponIds }: DamageComboListProps) {
|
||||
const { t } = useTranslation(["analyzer"]);
|
||||
const [excludedKeys, setExcludedKeys] = useState<ExcludedDamageKey[]>([]);
|
||||
const [initialTargetResAp, commitTargetResAp] = useTargetResAp();
|
||||
const [initialTargetSubDefenseAp, commitTargetSubDefenseAp] =
|
||||
useTargetSubDefenseAp();
|
||||
const [targetResAp, setTargetResAp] = useState(initialTargetResAp);
|
||||
const [targetSubDefenseAp, setTargetSubDefenseAp] = useState(
|
||||
initialTargetSubDefenseAp,
|
||||
);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [singleWeaponCombos, setSingleWeaponCombos] = useSingleWeaponCombos();
|
||||
|
||||
const singleWeaponOnly = weaponIds.length === 1;
|
||||
const includeSingleWeaponCombos = singleWeaponOnly || singleWeaponCombos;
|
||||
|
||||
const allDamageKeys = getAllDamageKeys(weaponIds, targetSubDefenseAp);
|
||||
const allDamageKeyStrings = new Set(allDamageKeys.map(filterKeyToString));
|
||||
const validExcludedKeys = excludedKeys.filter((key) =>
|
||||
allDamageKeyStrings.has(filterKeyToString(key)),
|
||||
);
|
||||
|
||||
const combos = calculateDamageCombos(
|
||||
weaponIds,
|
||||
validExcludedKeys,
|
||||
targetSubDefenseAp,
|
||||
undefined,
|
||||
includeSingleWeaponCombos,
|
||||
);
|
||||
|
||||
if (weaponIds.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleToggleFilter = (key: ExcludedDamageKey) => {
|
||||
const keyString = filterKeyToString(key);
|
||||
const exists = excludedKeys.some((k) => filterKeyToString(k) === keyString);
|
||||
|
||||
if (exists) {
|
||||
setExcludedKeys(
|
||||
excludedKeys.filter((k) => filterKeyToString(k) !== keyString),
|
||||
);
|
||||
} else {
|
||||
setExcludedKeys([...excludedKeys, key]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAll = () => {
|
||||
setExcludedKeys(allDamageKeys);
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setExcludedKeys([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="damage-combo-list">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
data-testid="damage-combo-toggle"
|
||||
>
|
||||
<span className={styles.headerTitle}>
|
||||
{t("analyzer:comp.damageCombos")}
|
||||
</span>
|
||||
<span className={styles.collapseIcon}>{isCollapsed ? "+" : "-"}</span>
|
||||
</button>
|
||||
{isCollapsed ? null : (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.slidersContainer}>
|
||||
<Image path={abilityImageUrl("SRU")} alt="" size={24} />
|
||||
<label className={styles.resSliderLabel}>
|
||||
{t("analyzer:comp.enemySubDef")}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX_AP}
|
||||
value={targetSubDefenseAp}
|
||||
onChange={(e) => setTargetSubDefenseAp(Number(e.target.value))}
|
||||
onPointerUp={() => commitTargetSubDefenseAp(targetSubDefenseAp)}
|
||||
onKeyUp={() => commitTargetSubDefenseAp(targetSubDefenseAp)}
|
||||
onBlur={() => commitTargetSubDefenseAp(targetSubDefenseAp)}
|
||||
className={styles.resSlider}
|
||||
/>
|
||||
<span className={styles.resSliderValue}>
|
||||
{targetSubDefenseAp} AP
|
||||
</span>
|
||||
<Image path={abilityImageUrl("RES")} alt="" size={24} />
|
||||
<label className={styles.resSliderLabel}>
|
||||
{t("analyzer:comp.enemyRes")}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX_AP}
|
||||
value={targetResAp}
|
||||
onChange={(e) => setTargetResAp(Number(e.target.value))}
|
||||
onPointerUp={() => commitTargetResAp(targetResAp)}
|
||||
onKeyUp={() => commitTargetResAp(targetResAp)}
|
||||
onBlur={() => commitTargetResAp(targetResAp)}
|
||||
className={styles.resSlider}
|
||||
/>
|
||||
<span className={styles.resSliderValue}>{targetResAp} AP</span>
|
||||
</div>
|
||||
<div className={styles.singleWeaponToggleRow}>
|
||||
<SendouSwitch
|
||||
isSelected={includeSingleWeaponCombos}
|
||||
onChange={setSingleWeaponCombos}
|
||||
isDisabled={singleWeaponOnly}
|
||||
>
|
||||
{t("analyzer:comp.singleWeaponCombos")}
|
||||
</SendouSwitch>
|
||||
</div>
|
||||
<div className={styles.filterControlsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterControlButton}
|
||||
onClick={handleRemoveAll}
|
||||
disabled={validExcludedKeys.length === allDamageKeys.length}
|
||||
>
|
||||
{t("analyzer:comp.removeAll")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterControlButton}
|
||||
onClick={handleClearAll}
|
||||
disabled={validExcludedKeys.length === 0}
|
||||
>
|
||||
{t("analyzer:comp.addAll")}
|
||||
</button>
|
||||
</div>
|
||||
{validExcludedKeys.length > 0 ? (
|
||||
<div className={styles.filteredItemsRow}>
|
||||
{validExcludedKeys.map((key) => (
|
||||
<FilteredItem
|
||||
key={filterKeyToString(key)}
|
||||
filterKey={key}
|
||||
onRestore={handleToggleFilter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{combos.map((combo, index) => {
|
||||
const inkTimeFrames = calculateInkTimeToKill(
|
||||
combo.totalDamage,
|
||||
targetResAp,
|
||||
);
|
||||
if (combo.totalDamage < 100 && inkTimeFrames === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DamageComboBar
|
||||
key={index}
|
||||
combo={combo}
|
||||
inkTimeFrames={inkTimeFrames}
|
||||
onToggleFilter={handleToggleFilter}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-field);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
background: var(--color-bg-higher);
|
||||
border: none;
|
||||
border-radius: var(--radius-field);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text-high);
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--s-3);
|
||||
}
|
||||
|
||||
.benchmarkLegend {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-3);
|
||||
padding-bottom: var(--s-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.legendLine {
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background-image: repeating-linear-gradient(
|
||||
to right,
|
||||
rgb(100 100 120) 0,
|
||||
rgb(100 100 120) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
|
||||
.legendLabel {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.weaponLegend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.weaponLegendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.weaponLegendColor {
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.weaponLegendRange {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: 150px;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
BENCHMARK_WEAPON_IDS,
|
||||
getBenchmarkTrajectories,
|
||||
getWeaponsWithRange,
|
||||
type TrajectoryPoint,
|
||||
type WeaponWithRange,
|
||||
} from "../core/weapon-range";
|
||||
import styles from "./RangeVisualization.module.css";
|
||||
|
||||
const SLOT_COLORS = ["#f5d742", "#f5b8d0", "#90e8a8", "#8cd4f5"] as const;
|
||||
|
||||
const BENCHMARK_COLORS = [
|
||||
"rgba(100, 100, 120, 0.5)",
|
||||
"rgba(100, 100, 120, 0.5)",
|
||||
] as const;
|
||||
|
||||
interface RangeVisualizationProps {
|
||||
weaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
export function RangeVisualization({ weaponIds }: RangeVisualizationProps) {
|
||||
const { t } = useTranslation(["analyzer", "weapons"]);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const weaponsWithRange = getWeaponsWithRange(weaponIds);
|
||||
const benchmarkTrajectories = getBenchmarkTrajectories();
|
||||
|
||||
if (weaponsWithRange.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxRange = 32;
|
||||
|
||||
const allYValues: number[] = [];
|
||||
for (const weapon of weaponsWithRange) {
|
||||
if (weapon.trajectory) {
|
||||
for (const point of weapon.trajectory) {
|
||||
if (point.y >= 0) {
|
||||
allYValues.push(point.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minY = 0;
|
||||
const maxY = Math.max(...allYValues, 2);
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="range-visualization">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
data-testid="range-toggle"
|
||||
>
|
||||
<span className={styles.headerTitle}>
|
||||
{t("analyzer:comp.weaponRanges")}
|
||||
</span>
|
||||
<span className={styles.collapseIcon}>{isCollapsed ? "+" : "-"}</span>
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.benchmarkLegend}>
|
||||
{BENCHMARK_WEAPON_IDS.map((weaponId) => (
|
||||
<div key={weaponId} className={styles.legendItem}>
|
||||
<span className={styles.legendLine} />
|
||||
<span className={styles.legendLabel}>
|
||||
{t(`weapons:MAIN_${weaponId}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<TrajectoryChart
|
||||
weapons={weaponsWithRange}
|
||||
benchmarkTrajectories={benchmarkTrajectories}
|
||||
maxRange={maxRange}
|
||||
minY={minY}
|
||||
maxY={maxY}
|
||||
weaponIds={weaponIds}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrajectoryChartProps {
|
||||
weapons: WeaponWithRange[];
|
||||
benchmarkTrajectories: Array<{
|
||||
id: MainWeaponId;
|
||||
range: number;
|
||||
trajectory?: TrajectoryPoint[];
|
||||
}>;
|
||||
maxRange: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
weaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
function TrajectoryChart({
|
||||
weapons,
|
||||
benchmarkTrajectories,
|
||||
maxRange,
|
||||
minY,
|
||||
maxY,
|
||||
weaponIds,
|
||||
}: TrajectoryChartProps) {
|
||||
const chartWidth = 600;
|
||||
const chartHeight = 200;
|
||||
const padding = { top: 20, right: 60, bottom: 30, left: 50 };
|
||||
const innerWidth = chartWidth - padding.left - padding.right;
|
||||
const innerHeight = chartHeight - padding.top - padding.bottom;
|
||||
|
||||
const xScale = (z: number) => (z / maxRange) * innerWidth;
|
||||
const yScale = (y: number) =>
|
||||
innerHeight - ((y - minY) / (maxY - minY)) * innerHeight;
|
||||
|
||||
// Returns the point where trajectory hits y=0 (ground level).
|
||||
// This differs from lastPoint which may be below ground or mid-air.
|
||||
// Used for blast radius positioning since explosions happen at ground impact.
|
||||
const getGroundIntersection = (
|
||||
trajectory: TrajectoryPoint[],
|
||||
): TrajectoryPoint | null => {
|
||||
for (let i = 0; i < trajectory.length; i++) {
|
||||
const point = trajectory[i];
|
||||
if (point.y < 0) {
|
||||
const prevPoint = trajectory[i - 1];
|
||||
if (prevPoint && prevPoint.y >= 0) {
|
||||
const t = prevPoint.y / (prevPoint.y - point.y);
|
||||
const groundZ = prevPoint.z + t * (point.z - prevPoint.z);
|
||||
return { z: groundZ, y: 0 };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
const lastPoint = trajectory[trajectory.length - 1];
|
||||
return lastPoint && lastPoint.y >= 0 ? lastPoint : null;
|
||||
};
|
||||
|
||||
const trajectoryToPath = (trajectory: TrajectoryPoint[]): string => {
|
||||
if (trajectory.length === 0) return "";
|
||||
|
||||
const clampedPoints: TrajectoryPoint[] = [];
|
||||
for (const point of trajectory) {
|
||||
if (point.y >= 0) {
|
||||
clampedPoints.push(point);
|
||||
} else {
|
||||
const prevPoint = clampedPoints[clampedPoints.length - 1];
|
||||
if (prevPoint && prevPoint.y > 0) {
|
||||
const t = prevPoint.y / (prevPoint.y - point.y);
|
||||
const groundZ = prevPoint.z + t * (point.z - prevPoint.z);
|
||||
clampedPoints.push({ z: groundZ, y: 0 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (clampedPoints.length === 0) return "";
|
||||
|
||||
const points = clampedPoints.map((p) => `${xScale(p.z)},${yScale(p.y)}`);
|
||||
return `M ${points.join(" L ")}`;
|
||||
};
|
||||
|
||||
const groundY = yScale(0);
|
||||
|
||||
const xTicks = [];
|
||||
const xStep = Math.ceil(maxRange / 5);
|
||||
for (let x = 0; x <= maxRange; x += xStep) {
|
||||
xTicks.push(x);
|
||||
}
|
||||
|
||||
const yTicks = [];
|
||||
const yStep = Math.ceil((maxY - minY) / 4);
|
||||
for (let y = Math.ceil(minY); y <= maxY; y += yStep) {
|
||||
yTicks.push(y);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chartContainer}>
|
||||
<div className={styles.weaponLegend}>
|
||||
{weapons.map((weapon, index) => {
|
||||
const slotIndex = weaponIds.indexOf(weapon.weaponId);
|
||||
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
|
||||
return (
|
||||
<div
|
||||
key={`${weapon.weaponId}-${index}`}
|
||||
className={styles.weaponLegendItem}
|
||||
>
|
||||
<WeaponImage
|
||||
weaponSplId={weapon.weaponId}
|
||||
variant="build"
|
||||
size={24}
|
||||
/>
|
||||
<span
|
||||
className={styles.weaponLegendColor}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className={styles.weaponLegendRange}>
|
||||
{weapon.range.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<svg
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className={styles.chart}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* Grid lines */}
|
||||
{xTicks.map((tick) => (
|
||||
<line
|
||||
key={`x-grid-${tick}`}
|
||||
x1={xScale(tick)}
|
||||
y1={0}
|
||||
x2={xScale(tick)}
|
||||
y2={innerHeight}
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
))}
|
||||
{yTicks.map((tick) => (
|
||||
<line
|
||||
key={`y-grid-${tick}`}
|
||||
x1={0}
|
||||
y1={yScale(tick)}
|
||||
x2={innerWidth}
|
||||
y2={yScale(tick)}
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ground line (y=0) */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={groundY}
|
||||
x2={innerWidth}
|
||||
y2={groundY}
|
||||
stroke="var(--color-text-high)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Benchmark vertical lines */}
|
||||
{benchmarkTrajectories.map((benchmark, index) => {
|
||||
const x = xScale(benchmark.range);
|
||||
return (
|
||||
<line
|
||||
key={benchmark.id}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={innerHeight}
|
||||
stroke={BENCHMARK_COLORS[index % BENCHMARK_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Weapon trajectories */}
|
||||
{weapons.map((weapon, index) => {
|
||||
if (!weapon.trajectory) return null;
|
||||
const slotIndex = weaponIds.indexOf(weapon.weaponId);
|
||||
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
|
||||
return (
|
||||
<path
|
||||
key={`${weapon.weaponId}-${index}`}
|
||||
d={trajectoryToPath(weapon.trajectory)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Blast radius circles */}
|
||||
{weapons.map((weapon, index) => {
|
||||
if (!weapon.blastRadius || !weapon.trajectory) return null;
|
||||
const groundPoint = getGroundIntersection(weapon.trajectory);
|
||||
if (!groundPoint) return null;
|
||||
const slotIndex = weaponIds.indexOf(weapon.weaponId);
|
||||
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
|
||||
const radiusPixels = xScale(weapon.blastRadius);
|
||||
return (
|
||||
<circle
|
||||
key={`blast-${weapon.weaponId}-${index}`}
|
||||
cx={xScale(groundPoint.z)}
|
||||
cy={yScale(0)}
|
||||
r={radiusPixels}
|
||||
fill={`${color}33`}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X axis labels */}
|
||||
{xTicks.map((tick) => (
|
||||
<text
|
||||
key={`x-label-${tick}`}
|
||||
x={xScale(tick)}
|
||||
y={innerHeight + 15}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill="var(--color-text-high)"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Y axis labels */}
|
||||
{yTicks.map((tick) => (
|
||||
<text
|
||||
key={`y-label-${tick}`}
|
||||
x={-8}
|
||||
y={yScale(tick) + 3}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
fill="var(--color-text-high)"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Axis labels */}
|
||||
<text
|
||||
x={innerWidth / 2}
|
||||
y={innerHeight + 25}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fill="var(--color-text-high)"
|
||||
>
|
||||
z
|
||||
</text>
|
||||
<text
|
||||
x={-25}
|
||||
y={innerHeight / 2}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fill="var(--color-text-high)"
|
||||
transform={`rotate(-90, -25, ${innerHeight / 2})`}
|
||||
>
|
||||
y
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { MAX_WEAPONS } from "../comp-analyzer-constants";
|
||||
import { SelectedWeapons } from "./SelectedWeapons";
|
||||
|
||||
const defaultProps: ComponentProps<typeof SelectedWeapons> = {
|
||||
selectedWeaponIds: [],
|
||||
onRemove: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
};
|
||||
|
||||
function renderSelectedWeapons(
|
||||
props: Partial<ComponentProps<typeof SelectedWeapons>> = {},
|
||||
) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: <SelectedWeapons {...defaultProps} {...props} />,
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
describe("SelectedWeapons", () => {
|
||||
describe("empty state", () => {
|
||||
test("renders empty slots when no weapons selected", async () => {
|
||||
const screen = await renderSelectedWeapons({ selectedWeaponIds: [] });
|
||||
|
||||
const emptySlots = screen.container.querySelectorAll(
|
||||
'[class*="weaponNameEmpty"]',
|
||||
);
|
||||
expect(emptySlots.length).toBe(MAX_WEAPONS);
|
||||
});
|
||||
|
||||
test("renders correct number of slots", async () => {
|
||||
const screen = await renderSelectedWeapons();
|
||||
|
||||
const rows = screen.container.querySelectorAll(
|
||||
'[class*="selectedWeaponRow"]',
|
||||
);
|
||||
expect(rows.length).toBe(MAX_WEAPONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selected weapons", () => {
|
||||
test("renders selected weapon with name", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0],
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("Sploosh-o-matic")).toBeVisible();
|
||||
});
|
||||
|
||||
test("renders multiple selected weapons", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0, 10],
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("Sploosh-o-matic")).toBeVisible();
|
||||
await expect.element(screen.getByText("Splattershot Jr.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("renders sub and special icons for selected weapon", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0],
|
||||
});
|
||||
|
||||
const subSpecialContainers = screen.container.querySelectorAll(
|
||||
'[class*="subSpecialContainer"]',
|
||||
);
|
||||
const filledContainers = Array.from(subSpecialContainers).filter(
|
||||
(container) => !container.className.includes("Spacer"),
|
||||
);
|
||||
expect(filledContainers.length).toBeGreaterThan(0);
|
||||
|
||||
const kitIcons = screen.container.querySelectorAll('[class*="kitIcon"]');
|
||||
expect(kitIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("shows empty slots for remaining positions", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0],
|
||||
});
|
||||
|
||||
const emptySlots = screen.container.querySelectorAll(
|
||||
'[class*="weaponNameEmpty"]',
|
||||
);
|
||||
expect(emptySlots.length).toBe(MAX_WEAPONS - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove functionality", () => {
|
||||
test("renders remove button for selected weapons", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0],
|
||||
});
|
||||
|
||||
const removeButton = screen.getByTestId("remove-weapon-0");
|
||||
await expect.element(removeButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("calls onRemove with correct index when remove button clicked", async () => {
|
||||
const onRemove = vi.fn();
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0, 10],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
const removeButtons = screen.container.querySelectorAll(
|
||||
'[class*="removeButton"]',
|
||||
);
|
||||
(removeButtons[0] as HTMLElement).click();
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
test("calls onRemove with second index when second remove button clicked", async () => {
|
||||
const onRemove = vi.fn();
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0, 10],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
const removeButtons = screen.container.querySelectorAll(
|
||||
'[class*="removeButton"]',
|
||||
);
|
||||
(removeButtons[1] as HTMLElement).click();
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
test("does not render remove button for empty slots", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [],
|
||||
});
|
||||
|
||||
const removeButtons = screen.container.querySelectorAll(
|
||||
'[class*="removeButton"]',
|
||||
);
|
||||
expect(removeButtons.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
.selectedWeapons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.selectedWeaponRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.isDragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.weaponImageContainer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background-color: var(--color-bg-high);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weaponImageContainerEmpty {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background-color: var(--color-bg-higher);
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.weaponNamePill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
padding-left: var(--s-5);
|
||||
background-color: var(--color-bg-high);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.weaponName {
|
||||
flex: 1;
|
||||
font-weight: var(--weight-semi);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.weaponNamePillEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
padding-left: var(--s-5);
|
||||
background-color: var(--color-bg-higher);
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.weaponNameEmpty {
|
||||
flex: 1;
|
||||
font-weight: var(--weight-semi);
|
||||
font-style: italic;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
color: var(--color-text-high);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--font-md);
|
||||
line-height: 1;
|
||||
color: var(--color-text-high);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.subSpecialContainer {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subSpecialContainerSpacer {
|
||||
width: 89px;
|
||||
}
|
||||
|
||||
.kitIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: var(--color-bg-high);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
abilityImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { MAX_WEAPONS } from "../comp-analyzer-constants";
|
||||
import styles from "./SelectedWeapons.module.css";
|
||||
|
||||
interface SelectedWeaponsProps {
|
||||
selectedWeaponIds: MainWeaponId[];
|
||||
onRemove: (index: number) => void;
|
||||
onReorder: (newIds: MainWeaponId[]) => void;
|
||||
}
|
||||
|
||||
export function SelectedWeapons({
|
||||
selectedWeaponIds,
|
||||
onRemove,
|
||||
onReorder,
|
||||
}: SelectedWeaponsProps) {
|
||||
const { t } = useTranslation(["weapons", "analyzer"]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedWeaponIds.indexOf(active.id as MainWeaponId);
|
||||
const newIndex = selectedWeaponIds.indexOf(over.id as MainWeaponId);
|
||||
|
||||
const newIds = [...selectedWeaponIds];
|
||||
const [removed] = newIds.splice(oldIndex, 1);
|
||||
newIds.splice(newIndex, 0, removed);
|
||||
|
||||
onReorder(newIds);
|
||||
}
|
||||
};
|
||||
|
||||
const emptySlotCount = MAX_WEAPONS - selectedWeaponIds.length;
|
||||
const showDragHandle = selectedWeaponIds.length > 1;
|
||||
|
||||
return (
|
||||
<div className={styles.selectedWeapons} data-testid="selected-weapons">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedWeaponIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{selectedWeaponIds.map((weaponId, index) => (
|
||||
<SortableWeaponRow
|
||||
key={weaponId}
|
||||
weaponId={weaponId}
|
||||
index={index}
|
||||
onRemove={onRemove}
|
||||
showDragHandle={showDragHandle}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{Array.from({ length: emptySlotCount }, (_, i) => (
|
||||
<div key={`empty-${i}`} className={styles.selectedWeaponRow}>
|
||||
<div className={styles.weaponImageContainerEmpty}>
|
||||
<Image path={abilityImageUrl("UNKNOWN")} alt="" size={48} />
|
||||
</div>
|
||||
<div className={styles.weaponNamePillEmpty}>
|
||||
<span className={styles.weaponNameEmpty}>
|
||||
{t("analyzer:comp.pickWeapon")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainerSpacer} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableWeaponRowProps {
|
||||
weaponId: MainWeaponId;
|
||||
index: number;
|
||||
onRemove: (index: number) => void;
|
||||
showDragHandle: boolean;
|
||||
}
|
||||
|
||||
function SortableWeaponRow({
|
||||
weaponId,
|
||||
index,
|
||||
onRemove,
|
||||
showDragHandle,
|
||||
}: SortableWeaponRowProps) {
|
||||
const { t } = useTranslation(["weapons", "analyzer"]);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: weaponId });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const params = mainWeaponParams(weaponId);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={clsx(styles.selectedWeaponRow, {
|
||||
[styles.isDragging]: isDragging,
|
||||
})}
|
||||
data-testid={`selected-weapon-${index}`}
|
||||
{...attributes}
|
||||
>
|
||||
<div className={styles.weaponImageContainer}>
|
||||
<WeaponImage weaponSplId={weaponId} variant="build" size={48} />
|
||||
</div>
|
||||
<div className={styles.weaponNamePill}>
|
||||
<span className={styles.weaponName}>
|
||||
{t(`weapons:MAIN_${weaponId}`)}
|
||||
</span>
|
||||
{showDragHandle ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dragHandle}
|
||||
aria-label={t("analyzer:comp.reorderWeapon")}
|
||||
{...listeners}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeButton}
|
||||
onClick={() => onRemove(index)}
|
||||
aria-label={t("analyzer:comp.removeWeapon")}
|
||||
data-testid={`remove-weapon-${index}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainer}>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={subWeaponImageUrl(params.subWeaponId)}
|
||||
alt={t(`weapons:SUB_${params.subWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(params.specialWeaponId)}
|
||||
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 3rem;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
border-radius: var(--radius-field);
|
||||
background: var(--color-bg-high);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.blast {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.range {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// note: dev only component, not used in production code
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
import { specialWeaponImageUrl } from "~/utils/urls";
|
||||
import {
|
||||
getSpecialsWithRange,
|
||||
type SpecialWeaponWithRange,
|
||||
} from "../core/special-weapon-range";
|
||||
import styles from "./SpecialRangeVisualization.module.css";
|
||||
|
||||
const RANGE_TYPE_COLOR: Record<SpecialWeaponWithRange["rangeType"], string> = {
|
||||
projectile: "#8cd4f5",
|
||||
radius: "#f5b8d0",
|
||||
};
|
||||
|
||||
export function SpecialRangeVisualization() {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
const specials = getSpecialsWithRange();
|
||||
if (specials.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxRange = Math.max(
|
||||
...specials.map((special) => special.range + (special.blastRadius ?? 0)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="special-range-visualization">
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<span
|
||||
className={styles.legendSwatch}
|
||||
style={{ backgroundColor: RANGE_TYPE_COLOR.projectile }}
|
||||
/>
|
||||
projectile
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<span
|
||||
className={styles.legendSwatch}
|
||||
style={{ backgroundColor: RANGE_TYPE_COLOR.radius }}
|
||||
/>
|
||||
radius
|
||||
</div>
|
||||
</div>
|
||||
{specials.map((special) => {
|
||||
const color = RANGE_TYPE_COLOR[special.rangeType];
|
||||
const rangeWidth = (special.range / maxRange) * 100;
|
||||
const blastWidth = special.blastRadius
|
||||
? (special.blastRadius / maxRange) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={special.specialWeaponId} className={styles.row}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(special.specialWeaponId)}
|
||||
width={28}
|
||||
height={28}
|
||||
alt={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
|
||||
title={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
|
||||
/>
|
||||
<div className={styles.track}>
|
||||
{blastWidth > 0 ? (
|
||||
<span
|
||||
className={styles.blast}
|
||||
style={{
|
||||
insetInlineStart: `${rangeWidth}%`,
|
||||
width: `${blastWidth}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={styles.bar}
|
||||
style={{ width: `${rangeWidth}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.range}>{special.range.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-4);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.categorySection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.categoryLabel {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.categoryPill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg-high);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
max-width: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1) var(--s-4);
|
||||
border-left: 1.5px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
|
||||
&[data-first="true"] {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
.categoryName {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls";
|
||||
import {
|
||||
SPECIAL_CATEGORY_ORDER,
|
||||
SPECIAL_WEAPON_CATEGORIES,
|
||||
type SpecialWeaponCategory,
|
||||
SUB_CATEGORY_ORDER,
|
||||
SUB_WEAPON_CATEGORIES,
|
||||
type SubWeaponCategory,
|
||||
} from "../comp-analyzer-constants";
|
||||
import styles from "./WeaponCategories.module.css";
|
||||
|
||||
interface WeaponCategoriesProps {
|
||||
selectedWeaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
interface SubWeaponItem {
|
||||
subId: SubWeaponId;
|
||||
category: SubWeaponCategory;
|
||||
weaponIndex: number;
|
||||
}
|
||||
|
||||
interface SpecialWeaponItem {
|
||||
specialId: SpecialWeaponId;
|
||||
category: SpecialWeaponCategory;
|
||||
weaponIndex: number;
|
||||
}
|
||||
|
||||
export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) {
|
||||
const { t } = useTranslation(["weapons", "analyzer"]);
|
||||
|
||||
if (selectedWeaponIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subItems: SubWeaponItem[] = [];
|
||||
const specialItems: SpecialWeaponItem[] = [];
|
||||
|
||||
for (const [index, weaponId] of selectedWeaponIds.entries()) {
|
||||
const params = mainWeaponParams(weaponId);
|
||||
|
||||
subItems.push({
|
||||
subId: params.subWeaponId,
|
||||
category: SUB_WEAPON_CATEGORIES[params.subWeaponId],
|
||||
weaponIndex: index,
|
||||
});
|
||||
|
||||
specialItems.push({
|
||||
specialId: params.specialWeaponId,
|
||||
category: SPECIAL_WEAPON_CATEGORIES[params.specialWeaponId],
|
||||
weaponIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedSubItems = [...subItems].sort((a, b) => {
|
||||
const aIndex = SUB_CATEGORY_ORDER.indexOf(a.category);
|
||||
const bIndex = SUB_CATEGORY_ORDER.indexOf(b.category);
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
return a.weaponIndex - b.weaponIndex;
|
||||
});
|
||||
|
||||
const sortedSpecialItems = [...specialItems].sort((a, b) => {
|
||||
const aIndex = SPECIAL_CATEGORY_ORDER.indexOf(a.category);
|
||||
const bIndex = SPECIAL_CATEGORY_ORDER.indexOf(b.category);
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
return a.weaponIndex - b.weaponIndex;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.categorySection}>
|
||||
<div className={styles.categoryLabel}>
|
||||
{t("analyzer:comp.subRoles")}
|
||||
</div>
|
||||
<div className={styles.categoryPill}>
|
||||
{sortedSubItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.weaponIndex}-${item.subId}`}
|
||||
className={styles.categoryItem}
|
||||
data-first={index === 0}
|
||||
>
|
||||
<Image
|
||||
path={subWeaponImageUrl(item.subId)}
|
||||
alt={t(`weapons:SUB_${item.subId}`)}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.categoryName}>
|
||||
{t(`analyzer:comp.subCategory.${item.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.categorySection}>
|
||||
<div className={styles.categoryLabel}>
|
||||
{t("analyzer:comp.specialRoles")}
|
||||
</div>
|
||||
<div className={styles.categoryPill}>
|
||||
{sortedSpecialItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.weaponIndex}-${item.specialId}`}
|
||||
className={styles.categoryItem}
|
||||
data-first={index === 0}
|
||||
>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(item.specialId)}
|
||||
alt={t(`weapons:SPECIAL_${item.specialId}`)}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.categoryName}>
|
||||
{t(`analyzer:comp.specialCategory.${item.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { WeaponGrid } from "./WeaponGrid";
|
||||
|
||||
const defaultProps: ComponentProps<typeof WeaponGrid> = {
|
||||
selectedWeaponIds: [],
|
||||
onWeaponClick: vi.fn(),
|
||||
categorization: "category",
|
||||
onCategorizationChange: vi.fn(),
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
};
|
||||
|
||||
function renderWeaponGrid(
|
||||
props: Partial<ComponentProps<typeof WeaponGrid>> = {},
|
||||
) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: <WeaponGrid {...defaultProps} {...props} />,
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
describe("WeaponGrid", () => {
|
||||
describe("collapse toggle", () => {
|
||||
test("renders with collapse toggle visible", async () => {
|
||||
const screen = await renderWeaponGrid();
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Hide weapon selector"))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
test("shows 'Show weapon selector' when collapsed", async () => {
|
||||
const screen = await renderWeaponGrid({ isCollapsed: true });
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Show weapon selector"))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
test("calls onToggleCollapse when toggle button clicked", async () => {
|
||||
const onToggleCollapse = vi.fn();
|
||||
const screen = await renderWeaponGrid({ onToggleCollapse });
|
||||
|
||||
await screen.getByText("Hide weapon selector").click();
|
||||
|
||||
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("hides weapon grid content when collapsed", async () => {
|
||||
const screen = await renderWeaponGrid({ isCollapsed: true });
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Show weapon selector"))
|
||||
.toBeVisible();
|
||||
const groupByLabel = screen.container.querySelector(
|
||||
'[class*="categorizationToggle"]',
|
||||
);
|
||||
expect(groupByLabel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("categorization", () => {
|
||||
test("shows categorization radio buttons when expanded", async () => {
|
||||
const screen = await renderWeaponGrid();
|
||||
|
||||
await expect.element(screen.getByText("Weapon type")).toBeVisible();
|
||||
await expect.element(screen.getByText("Sub weapon")).toBeVisible();
|
||||
await expect.element(screen.getByText("Special weapon")).toBeVisible();
|
||||
});
|
||||
|
||||
test("calls onCategorizationChange when radio button clicked", async () => {
|
||||
const onCategorizationChange = vi.fn();
|
||||
const screen = await renderWeaponGrid({ onCategorizationChange });
|
||||
|
||||
await screen.getByText("Sub weapon").click();
|
||||
|
||||
expect(onCategorizationChange).toHaveBeenCalledWith("sub");
|
||||
});
|
||||
|
||||
test("shows correct radio button as checked", async () => {
|
||||
const screen = await renderWeaponGrid({ categorization: "special" });
|
||||
|
||||
const specialRadio = screen.getByRole("radio", {
|
||||
name: "Special weapon",
|
||||
});
|
||||
await expect.element(specialRadio).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("weapon selection", () => {
|
||||
test("calls onWeaponClick when weapon button clicked", async () => {
|
||||
const onWeaponClick = vi.fn();
|
||||
const screen = await renderWeaponGrid({ onWeaponClick });
|
||||
|
||||
const weaponButtons = screen.container.querySelectorAll("button[title]");
|
||||
const firstWeaponButton = weaponButtons[1] as HTMLElement | undefined;
|
||||
if (firstWeaponButton) {
|
||||
firstWeaponButton.click();
|
||||
expect(onWeaponClick).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("disables unselected weapons when max is reached", async () => {
|
||||
const screen = await renderWeaponGrid({
|
||||
selectedWeaponIds: [0, 10, 20, 30],
|
||||
});
|
||||
|
||||
const disabledButtons = screen.container.querySelectorAll(
|
||||
"button[title][disabled]",
|
||||
);
|
||||
expect(disabledButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("shows selected weapons with selected style", async () => {
|
||||
const screen = await renderWeaponGrid({
|
||||
selectedWeaponIds: [0],
|
||||
});
|
||||
|
||||
const selectedButtons = screen.container.querySelectorAll(
|
||||
'[class*="weaponButtonSelected"]',
|
||||
);
|
||||
expect(selectedButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.collapseToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.collapseArrow {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.collapseArrowCollapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.categorizationToggle {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.weaponGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
@container (width < 640px) {
|
||||
.weaponGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@container (width < 560px) {
|
||||
.weaponGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.categorySection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.categoryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding-bottom: var(--s-1);
|
||||
border-bottom: 3px solid var(--color-border);
|
||||
}
|
||||
|
||||
.categoryName {
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-sm);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.categoryWeapons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.weaponButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s-1);
|
||||
background-color: var(--color-bg-higher);
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-box);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.weaponButtonSelected {
|
||||
background-color: var(--color-bg-high);
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
weaponCategoryUrl,
|
||||
} from "~/utils/urls";
|
||||
import { MAX_WEAPONS } from "../comp-analyzer-constants";
|
||||
import type { CategorizationType } from "../comp-analyzer-types";
|
||||
import styles from "./WeaponGrid.module.css";
|
||||
|
||||
interface WeaponGridProps {
|
||||
selectedWeaponIds: MainWeaponId[];
|
||||
onWeaponClick: (weaponId: MainWeaponId) => void;
|
||||
categorization: CategorizationType;
|
||||
onCategorizationChange: (categorization: CategorizationType) => void;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
export function WeaponGrid({
|
||||
selectedWeaponIds,
|
||||
onWeaponClick,
|
||||
categorization,
|
||||
onCategorizationChange,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
}: WeaponGridProps) {
|
||||
const { t } = useTranslation(["weapons", "analyzer"]);
|
||||
const isMaxSelected = selectedWeaponIds.length >= MAX_WEAPONS;
|
||||
|
||||
const groupedWeapons = groupWeaponsByType(categorization);
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="weapon-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseToggle}
|
||||
onClick={onToggleCollapse}
|
||||
data-testid="weapon-grid-toggle"
|
||||
>
|
||||
<span
|
||||
className={`${styles.collapseArrow} ${isCollapsed ? styles.collapseArrowCollapsed : ""}`}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
<span>
|
||||
{isCollapsed
|
||||
? t("analyzer:comp.showWeaponGrid")
|
||||
: t("analyzer:comp.hideWeaponGrid")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<div>
|
||||
<Label>{t("analyzer:comp.groupBy")}</Label>
|
||||
<div
|
||||
className={styles.categorizationToggle}
|
||||
data-testid="categorization-toggle"
|
||||
>
|
||||
<label className="stack horizontal sm items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="categorization"
|
||||
value="category"
|
||||
checked={categorization === "category"}
|
||||
onChange={() => onCategorizationChange("category")}
|
||||
data-testid="categorization-category"
|
||||
/>
|
||||
<span>{t("analyzer:comp.groupBy.category")}</span>
|
||||
</label>
|
||||
<label className="stack horizontal sm items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="categorization"
|
||||
value="sub"
|
||||
checked={categorization === "sub"}
|
||||
onChange={() => onCategorizationChange("sub")}
|
||||
data-testid="categorization-sub"
|
||||
/>
|
||||
<span>{t("analyzer:comp.groupBy.sub")}</span>
|
||||
</label>
|
||||
<label className="stack horizontal sm items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="categorization"
|
||||
value="special"
|
||||
checked={categorization === "special"}
|
||||
onChange={() => onCategorizationChange("special")}
|
||||
data-testid="categorization-special"
|
||||
/>
|
||||
<span>{t("analyzer:comp.groupBy.special")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.weaponGrid}>
|
||||
{groupedWeapons.map((group) => (
|
||||
<div key={group.key} className={styles.categorySection}>
|
||||
<div className={styles.categoryHeader}>
|
||||
{group.iconPath ? (
|
||||
<Image path={group.iconPath} alt="" size={24} />
|
||||
) : null}
|
||||
<span className={styles.categoryName}>
|
||||
{group.name.startsWith("SUB_") ||
|
||||
group.name.startsWith("SPECIAL_")
|
||||
? t(`weapons:${group.name}` as "SUB_0")
|
||||
: group.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.categoryWeapons}>
|
||||
{group.weaponIds.map((weaponId) => {
|
||||
const isSelected = selectedWeaponIds.includes(weaponId);
|
||||
const isDisabled = !isSelected && isMaxSelected;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={weaponId}
|
||||
type="button"
|
||||
className={`${styles.weaponButton} ${isSelected ? styles.weaponButtonSelected : ""}`}
|
||||
onClick={() => onWeaponClick(weaponId)}
|
||||
disabled={isDisabled}
|
||||
title={t(`weapons:MAIN_${weaponId}`)}
|
||||
data-testid={`weapon-button-${weaponId}`}
|
||||
>
|
||||
<WeaponImage
|
||||
weaponSplId={weaponId}
|
||||
variant="build"
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WeaponGroup {
|
||||
key: string;
|
||||
name: string;
|
||||
iconPath: string | null;
|
||||
weaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] {
|
||||
if (categorization === "category") {
|
||||
return weaponCategories.map((category) => ({
|
||||
key: category.name,
|
||||
name: category.name.toLowerCase(),
|
||||
iconPath: weaponCategoryUrl(category.name),
|
||||
weaponIds: [...category.weaponIds] as MainWeaponId[],
|
||||
}));
|
||||
}
|
||||
|
||||
if (categorization === "sub") {
|
||||
return subWeaponIds
|
||||
.map((subId) => {
|
||||
const weaponsWithSub = mainWeaponIds.filter((weaponId) => {
|
||||
const params = mainWeaponParams(weaponId);
|
||||
return params.subWeaponId === subId;
|
||||
});
|
||||
|
||||
return {
|
||||
key: `sub-${subId}`,
|
||||
name: `SUB_${subId}`,
|
||||
iconPath: subWeaponImageUrl(subId as SubWeaponId),
|
||||
weaponIds: weaponsWithSub,
|
||||
};
|
||||
})
|
||||
.filter((group) => group.weaponIds.length > 0);
|
||||
}
|
||||
|
||||
return specialWeaponIds
|
||||
.map((specialId) => {
|
||||
const weaponsWithSpecial = mainWeaponIds.filter((weaponId) => {
|
||||
const params = mainWeaponParams(weaponId);
|
||||
return params.specialWeaponId === specialId;
|
||||
});
|
||||
|
||||
return {
|
||||
key: `special-${specialId}`,
|
||||
name: `SPECIAL_${specialId}`,
|
||||
iconPath: specialWeaponImageUrl(specialId as SpecialWeaponId),
|
||||
weaponIds: weaponsWithSpecial,
|
||||
};
|
||||
})
|
||||
.filter((group) => group.weaponIds.length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user