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)}
); })}
{/* Grid lines */} {xTicks.map((tick) => ( ))} {yTicks.map((tick) => ( ))} {/* Ground line (y=0) */} {/* Benchmark vertical lines */} {benchmarkTrajectories.map((benchmark, index) => { const x = xScale(benchmark.range); return ( ); })} {/* 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 ( ); })} {/* 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 ( ); })} {/* X axis labels */} {xTicks.map((tick) => ( {tick} ))} {/* Y axis labels */} {yTicks.map((tick) => ( {tick} ))} {/* Axis labels */} z y
); }