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

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

View File

@@ -0,0 +1,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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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);
});
});
});

View File

@@ -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%;
}

View File

@@ -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}`}
>
&times;
</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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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);
});
});
});

View File

@@ -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);
}

View File

@@ -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);
}