sendou.ink/app/features/comp-analyzer/components/DamageComboBar.tsx
Kalle 806207d34e Fix SecurityError from comp-analyzer slider replaceState spam
Dragging the enemy SubDef / RES sliders on the comp-analyzer page
fired `useSearchParamState`'s setter on every intermediate value,
which calls `window.history.replaceState`. Browsers cap that at
100 calls per 10 seconds, so any sustained drag tripped:

    SecurityError: Attempt to use history.replaceState()
    more than 100 times per 10 seconds

Split the slider state into two cells: a local `useState` that drives
the slider thumb, label, and damage calculations on every change, and
the URL-backed hook value, which is only written on drag-end events
(`onPointerUp`, `onKeyUp`, `onBlur`). The URL now updates once per
interaction instead of once per pixel.
2026-05-13 21:24:02 +03:00

411 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { useTranslation } from "react-i18next";
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 { 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 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,
);
if (weaponIds.length < 2) {
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.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>
);
}