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 (
{!isCollapsed ? (
{BENCHMARK_WEAPON_IDS.map((weaponId) => (
{t(`weapons:MAIN_${weaponId}`)}
))}
) : null}
);
}
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 (
{weapons.map((weapon, index) => {
const slotIndex = weaponIds.indexOf(weapon.weaponId);
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
return (
{weapon.range.toFixed(1)}
);
})}
);
}