1369 lines
62 KiB
HTML
1369 lines
62 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Defense Simulation</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: 'Courier New', monospace;
|
||
background: #0a0f14;
|
||
color: #d4d4d4;
|
||
padding: 12px;
|
||
}
|
||
|
||
.container { width: 100%; }
|
||
|
||
.main-layout {
|
||
display: grid;
|
||
grid-template-columns: 340px 1fr;
|
||
gap: 12px;
|
||
align-items: start;
|
||
}
|
||
|
||
.controls {
|
||
background: #1a2332;
|
||
padding: 15px;
|
||
border: 1px solid #3a4f6a;
|
||
border-radius: 3px;
|
||
position: sticky;
|
||
top: 12px;
|
||
align-self: start;
|
||
max-height: calc(100vh - 24px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 11px;
|
||
color: #8ab4f8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 10px;
|
||
border-bottom: 1px solid #3a4f6a;
|
||
padding-bottom: 5px;
|
||
}
|
||
|
||
.param-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.param-label { color: #a0b4c8; }
|
||
|
||
.param-input {
|
||
width: 60px;
|
||
padding: 3px 5px;
|
||
background: #0f1923;
|
||
border: 1px solid #3a4f6a;
|
||
color: #d4d4d4;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.btn {
|
||
width: 100%;
|
||
padding: 8px;
|
||
background: #2a5f8a;
|
||
border: 1px solid #3a7fba;
|
||
color: #fff;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.btn:hover { background: #3a7fba; }
|
||
|
||
.map-container {
|
||
background: #1a2332;
|
||
border: 1px solid #3a4f6a;
|
||
border-radius: 3px;
|
||
padding: 10px;
|
||
position: relative;
|
||
}
|
||
|
||
#map {
|
||
width: 100%;
|
||
height: calc(100vh - 200px);
|
||
min-height: 380px;
|
||
background: #0f1923;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.alert-box {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
padding: 8px 15px;
|
||
background: #8b0000;
|
||
border: 2px solid #ff0000;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
display: none;
|
||
}
|
||
|
||
.alert-box.active { display: block; }
|
||
.alert-box.success { background: #006400; border-color: #00ff00; }
|
||
|
||
.radar {
|
||
position: absolute;
|
||
width: 16px;
|
||
height: 16px;
|
||
background: #4caf50;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 10;
|
||
}
|
||
|
||
.radar.active { background: #00e676; box-shadow: 0 0 8px #00e676; }
|
||
.radar.inactive { background: #444; }
|
||
|
||
.radar-range {
|
||
position: absolute;
|
||
border: 1px dashed rgba(76, 175, 80, 0.2);
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 1;
|
||
}
|
||
|
||
.radar-range.active { border-color: rgba(0, 230, 118, 0.4); }
|
||
|
||
.drone {
|
||
position: absolute;
|
||
width: 8px;
|
||
height: 8px;
|
||
background: #2196f3;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 10;
|
||
}
|
||
|
||
.drone.flying { background: #03a9f4; box-shadow: 0 0 5px #03a9f4; }
|
||
.drone.ir-tracking { background: #00bcd4; box-shadow: 0 0 6px #00bcd4; }
|
||
|
||
.target {
|
||
position: absolute;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: #f44336;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 10;
|
||
}
|
||
|
||
.altitude-marker {
|
||
position: absolute;
|
||
font-size: 9px;
|
||
color: #8ab4f8;
|
||
background: rgba(15, 25, 35, 0.9);
|
||
padding: 1px 4px;
|
||
border-radius: 2px;
|
||
white-space: nowrap;
|
||
z-index: 20;
|
||
}
|
||
|
||
.city {
|
||
position: absolute;
|
||
width: 80px;
|
||
height: 80px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border: 1px solid #666;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: #888;
|
||
z-index: 5;
|
||
}
|
||
|
||
.trajectory {
|
||
position: absolute;
|
||
height: 2px;
|
||
background: rgba(244, 67, 54, 0.6);
|
||
transform-origin: left center;
|
||
z-index: 5;
|
||
}
|
||
|
||
.error-band {
|
||
position: absolute;
|
||
background: rgba(244, 67, 54, 0.08);
|
||
border-top: 1px dashed rgba(244, 67, 54, 0.3);
|
||
border-bottom: 1px dashed rgba(244, 67, 54, 0.3);
|
||
transform-origin: left center;
|
||
z-index: 4;
|
||
}
|
||
|
||
.grid-line {
|
||
position: absolute;
|
||
background: rgba(58, 79, 106, 0.15);
|
||
}
|
||
|
||
.data-panel {
|
||
margin-top: 10px;
|
||
padding: 12px;
|
||
background: #1a2332;
|
||
border: 1px solid #3a4f6a;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.data-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr);
|
||
gap: 8px;
|
||
}
|
||
|
||
.data-cell {
|
||
background: #0f1923;
|
||
padding: 6px;
|
||
border: 1px solid #3a4f6a;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.data-cell .lbl { color: #8ab4f8; font-size: 9px; text-transform: uppercase; }
|
||
.data-cell .val { color: #d4d4d4; font-size: 13px; font-weight: bold; }
|
||
|
||
.stats-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 10px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.stats-table th, .stats-table td {
|
||
border: 1px solid #3a4f6a;
|
||
padding: 4px 6px;
|
||
text-align: center;
|
||
background: #0f1923;
|
||
}
|
||
|
||
.stats-table th {
|
||
color: #8ab4f8;
|
||
text-transform: uppercase;
|
||
font-size: 9px;
|
||
}
|
||
|
||
.stats-table td.preset-name {
|
||
text-align: left;
|
||
color: #d4d4d4;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.stats-table tr.highlight td {
|
||
background: #1a3a5a;
|
||
border-color: #4fc3f7;
|
||
}
|
||
|
||
.closeup {
|
||
width: 200px;
|
||
height: 150px;
|
||
background: #0f1923;
|
||
border: 1px solid #3a4f6a;
|
||
border-radius: 2px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.closeup-title {
|
||
position: absolute;
|
||
top: 3px;
|
||
left: 5px;
|
||
font-size: 9px;
|
||
color: #8ab4f8;
|
||
text-transform: uppercase;
|
||
z-index: 2;
|
||
}
|
||
|
||
.closeup-dot {
|
||
position: absolute;
|
||
width: 8px;
|
||
height: 8px;
|
||
background: #f44336;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 2;
|
||
}
|
||
|
||
.closeup-drone {
|
||
position: absolute;
|
||
width: 6px;
|
||
height: 6px;
|
||
background: #03a9f4;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 2;
|
||
}
|
||
|
||
.closeup-band {
|
||
position: absolute;
|
||
background: rgba(244, 67, 54, 0.15);
|
||
border-top: 1px dashed #f44336;
|
||
border-bottom: 1px dashed #f44336;
|
||
z-index: 1;
|
||
}
|
||
|
||
.closeup-traj {
|
||
position: absolute;
|
||
height: 2px;
|
||
background: rgba(244, 67, 54, 0.6);
|
||
transform-origin: left center;
|
||
z-index: 1;
|
||
}
|
||
|
||
.overlay-svg {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
z-index: 6;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.legend {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
background: rgba(15, 25, 35, 0.9);
|
||
border: 1px solid #3a4f6a;
|
||
padding: 6px 10px;
|
||
font-size: 10px;
|
||
z-index: 20;
|
||
}
|
||
.legend .item { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
|
||
.legend .swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
||
|
||
.stats-panel {
|
||
background: #1a2332;
|
||
border: 1px solid #3a4f6a;
|
||
border-radius: 3px;
|
||
padding: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="main-layout">
|
||
<div class="controls">
|
||
<div class="section-title">Attack Parameters</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Projectile Preset</span>
|
||
<select id="presetSelect" class="param-input" style="width: 130px;">
|
||
<option value="custom">Custom</option>
|
||
<option value="shahed-136">Shahed-136 (Geran-2)</option>
|
||
<option value="shahed-238" selected>Shahed-238 (Geran-3)</option>
|
||
<option value="cruise-missile">Cruise Missile</option>
|
||
<option value="ballistic-reentry">Ballistic (Re-entry)</option>
|
||
<option value="prop-drone">Small UAS / Prop Drone</option>
|
||
</select>
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Start Dist (km)</span>
|
||
<input type="number" id="attackStartDist" value="35" min="5" max="200" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Speed (m/s)</span>
|
||
<input type="number" id="attackSpeed" value="150" min="10" max="2000" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Min Cruise Alt (m)</span>
|
||
<input type="number" id="cruiseAltMin" value="1000" min="0" max="80000" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Max Cruise Alt (m)</span>
|
||
<input type="number" id="cruiseAltMax" value="2000" min="0" max="80000" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Descent Start (km)</span>
|
||
<input type="number" id="descentStart" value="8" min="0" max="50" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Terminal Alt (m)</span>
|
||
<input type="number" id="terminalAlt" value="200" min="0" max="80000" class="param-input">
|
||
</div>
|
||
|
||
<div class="section-title">Defense Parameters</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Radars</span>
|
||
<input type="number" id="numRadars" value="8" min="4" max="16" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Drones</span>
|
||
<input type="number" id="numDrones" value="24" min="8" max="48" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Radar Range (km)</span>
|
||
<input type="number" id="radarRange" value="20" min="5" max="50" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Drone Speed (m/s)</span>
|
||
<input type="number" id="droneSpeed" value="100" min="50" max="200" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">IR Range (m)</span>
|
||
<input type="number" id="irRange" value="5000" min="1000" max="10000" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">City Radius (km)</span>
|
||
<input type="number" id="cityRadius" value="10" min="5" max="20" class="param-input">
|
||
</div>
|
||
<div class="param-row">
|
||
<span class="param-label">Drone Line (km)</span>
|
||
<input type="number" id="droneDefenseLine" value="8" min="3" max="15" class="param-input">
|
||
</div>
|
||
|
||
<button class="btn" onclick="runSimulation()">RUN SIMULATION</button>
|
||
<button class="btn" onclick="resetSimulation()">RESET</button>
|
||
</div>
|
||
|
||
<div class="map-container">
|
||
<div id="map">
|
||
<div class="alert-box" id="alertBox">ALERT!</div>
|
||
</div>
|
||
|
||
<div class="data-panel">
|
||
<div class="data-grid">
|
||
<div class="data-cell"><span class="lbl">Status</span><span class="val" id="dataStatus">STANDBY</span></div>
|
||
<div class="data-cell"><span class="lbl">Radars Active</span><span class="val" id="dataRadars">0</span></div>
|
||
<div class="data-cell"><span class="lbl">Data Points</span><span class="val" id="dataPoints">0</span></div>
|
||
<div class="data-cell"><span class="lbl">R² Fit</span><span class="val" id="dataR2">--</span></div>
|
||
<div class="data-cell"><span class="lbl">Speed Est</span><span class="val" id="dataSpeed">-- m/s</span></div>
|
||
<div class="data-cell"><span class="lbl">Altitude</span><span class="val" id="dataAltitude">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">Pos Error σ</span><span class="val" id="dataErrorSigma">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">95% Conf</span><span class="val" id="dataError95">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">Time to Impact</span><span class="val" id="dataTTI">-- s</span></div>
|
||
<div class="data-cell"><span class="lbl">Heading</span><span class="val" id="dataHeading">--°</span></div>
|
||
<div class="data-cell"><span class="lbl">Drone Alt</span><span class="val" id="dataDroneAlt">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">IR Drones</span><span class="val" id="dataIRDrones">0</span></div>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||
<div class="closeup" id="closeupMap">
|
||
<span class="closeup-title">CLOSEUP (500m)</span>
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<div class="data-grid" style="grid-template-columns: repeat(3, 1fr); gap: 6px;">
|
||
<div class="data-cell"><span class="lbl">IR Drone 1 Dist</span><span class="val" id="irDist1">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">IR Drone 2 Dist</span><span class="val" id="irDist2">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">IR Drone 3 Dist</span><span class="val" id="irDist3">-- m</span></div>
|
||
</div>
|
||
<div class="data-grid" style="grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 6px;">
|
||
<div class="data-cell"><span class="lbl">IR Error σ</span><span class="val" id="irErrorSigma">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">IR 95% Conf</span><span class="val" id="irError95">-- m</span></div>
|
||
<div class="data-cell"><span class="lbl">Combined Error</span><span class="val" id="combinedError">-- m</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-panel">
|
||
<div class="section-title">Interception Stats by Projectile (100 runs each)</div>
|
||
<table class="stats-table" id="statsTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Projectile</th>
|
||
<th>Intercept</th>
|
||
<th>Detect</th>
|
||
<th>Collision</th>
|
||
<th>Impact</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="statsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// =====================================================================
|
||
// Constants (matching Python simulation)
|
||
// =====================================================================
|
||
const SCALE = 10; // 1 km = 10 px on main map
|
||
const CLOSEUP_SCALE = 360; // 1 km = 360 px on closeup (500m view)
|
||
const MEASUREMENT_ERROR = 20.0; // meters (1 sigma)
|
||
const IR_ERROR = 3.0; // meters (1 sigma)
|
||
|
||
// Projectile presets (prefill attack parameters)
|
||
const PRESETS = {
|
||
custom: { startDist: 35, speed: 150, cruiseMin: 1000, cruiseMax: 2000, descentStart: 8, terminalAlt: 200 },
|
||
'shahed-136': { startDist: 30, speed: 50, cruiseMin: 100, cruiseMax: 300, descentStart: 5, terminalAlt: 0 },
|
||
'shahed-238': { startDist: 35, speed: 150, cruiseMin: 1000, cruiseMax: 2000, descentStart: 8, terminalAlt: 200 },
|
||
'cruise-missile':{ startDist: 50, speed: 300, cruiseMin: 50, cruiseMax: 150, descentStart: 5, terminalAlt: 0 },
|
||
'ballistic-reentry':{ startDist: 100, speed: 800, cruiseMin: 60000, cruiseMax: 80000, descentStart: 40, terminalAlt: 0 },
|
||
'prop-drone': { startDist: 15, speed: 30, cruiseMin: 100, cruiseMax: 300, descentStart: 3, terminalAlt: 0 }
|
||
};
|
||
|
||
function applyPreset(name) {
|
||
const p = PRESETS[name];
|
||
if (!p) return;
|
||
document.getElementById('attackStartDist').value = p.startDist;
|
||
document.getElementById('attackSpeed').value = p.speed;
|
||
document.getElementById('cruiseAltMin').value = p.cruiseMin;
|
||
document.getElementById('cruiseAltMax').value = p.cruiseMax;
|
||
document.getElementById('descentStart').value = p.descentStart;
|
||
document.getElementById('terminalAlt').value = p.terminalAlt;
|
||
}
|
||
|
||
let animationId = null;
|
||
|
||
// =====================================================================
|
||
// Utility functions
|
||
// =====================================================================
|
||
function getMapCenter() {
|
||
const map = document.getElementById('map');
|
||
return { x: map.offsetWidth / 2, y: map.offsetHeight / 2 };
|
||
}
|
||
|
||
function kmToPixels(km) { return km * SCALE; }
|
||
function kmToCloseup(km) { return km * CLOSEUP_SCALE; }
|
||
|
||
function clearMap() {
|
||
const map = document.getElementById('map');
|
||
const alert = document.getElementById('alertBox');
|
||
map.innerHTML = '';
|
||
map.appendChild(alert);
|
||
// SVG overlay for detected data points + projected best-fit trajectory
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.id = 'overlay';
|
||
svg.setAttribute('class', 'overlay-svg');
|
||
map.appendChild(svg);
|
||
// Legend (top-left, opposite the alert box)
|
||
const legend = document.createElement('div');
|
||
legend.className = 'legend';
|
||
legend.innerHTML = `
|
||
<div class="item"><span class="swatch" style="background:#f44336;"></span>Target</div>
|
||
<div class="item"><span class="swatch" style="background:#00e5ff;"></span>Detected points</div>
|
||
<div class="item"><span class="swatch" style="background:#ffeb3b;"></span>Best-fit projection</div>
|
||
<div class="item"><span class="swatch" style="background:#03a9f4;"></span>Interceptor drones</div>
|
||
`;
|
||
map.appendChild(legend);
|
||
}
|
||
|
||
// =====================================================================
|
||
// Target trajectory generation (matches Python)
|
||
// =====================================================================
|
||
function targetTrajectory(startPos, targetPos, cruiseAlt, speed, descentStart, terminalAlt) {
|
||
// start, target: [x, y] in km; cruiseAlt: m; speed: m/s; descentStart: km; terminalAlt: m
|
||
const dx = targetPos[0] - startPos[0];
|
||
const dy = targetPos[1] - startPos[1];
|
||
const horizDist = Math.sqrt(dx*dx + dy*dy); // km
|
||
if (horizDist < 1e-6) return [];
|
||
|
||
const dirX = dx / horizDist;
|
||
const dirY = dy / horizDist;
|
||
|
||
const descentStartDist = Math.max(0, horizDist - descentStart); // km from start
|
||
const descentDist = descentStart; // km
|
||
const descentDuration = (descentDist * 1000) / speed; // s
|
||
const totalDuration = (horizDist * 1000) / speed; // s
|
||
|
||
const traj = [];
|
||
let t = 0;
|
||
const dt = 0.5; // s (matching Python)
|
||
|
||
while (t <= totalDuration) {
|
||
const distTraveled = (speed * t) / 1000; // km
|
||
const distToTarget = horizDist - distTraveled;
|
||
|
||
let z; // meters
|
||
if (descentDist <= 0 || distTraveled < descentStartDist) {
|
||
z = cruiseAlt;
|
||
} else {
|
||
const descentProgress = Math.min(1.0, (distTraveled - descentStartDist) / descentDist);
|
||
z = cruiseAlt + (terminalAlt - cruiseAlt) * descentProgress;
|
||
}
|
||
|
||
const x = startPos[0] + dirX * distTraveled;
|
||
const y = startPos[1] + dirY * distTraveled;
|
||
|
||
traj.push({ x, y, z, t });
|
||
t += dt;
|
||
}
|
||
|
||
return traj;
|
||
}
|
||
|
||
// =====================================================================
|
||
// Radar detection and position estimation (matches Python)
|
||
// =====================================================================
|
||
function detectFromRadars(pos, radarPosList, radarRange, noiseFunc) {
|
||
const detections = [];
|
||
for (let i = 0; i < radarPosList.length; i++) {
|
||
const rpos = radarPosList[i];
|
||
const dist = Math.sqrt((pos[0]-rpos[0])**2 + (pos[1]-rpos[1])**2 + (pos[2]-rpos[2])**2);
|
||
if (dist < radarRange) {
|
||
const rangeMeas = dist + noiseFunc(MEASUREMENT_ERROR);
|
||
const azTrue = Math.atan2(pos[1]-rpos[1], pos[0]-rpos[0]);
|
||
const elTrue = Math.atan2(pos[2]-rpos[2], Math.sqrt((pos[0]-rpos[0])**2 + (pos[1]-rpos[1])**2));
|
||
const azMeas = azTrue + noiseFunc(MEASUREMENT_ERROR / Math.max(100, dist));
|
||
const elMeas = elTrue + noiseFunc(MEASUREMENT_ERROR / Math.max(100, dist));
|
||
detections.push({ range: rangeMeas, azimuth: azMeas, elevation: elMeas, radarIndex: i });
|
||
}
|
||
}
|
||
return detections;
|
||
}
|
||
|
||
function estimatePosition3D(detections, radarPosList) {
|
||
if (detections.length < 2) return null;
|
||
|
||
const estimates = [];
|
||
for (const det of detections) {
|
||
const ri = radarPosList[det.radarIndex];
|
||
const az = det.azimuth;
|
||
const el = det.elevation;
|
||
const r = det.range;
|
||
const dx = r * Math.cos(el) * Math.cos(az);
|
||
const dy = r * Math.cos(el) * Math.sin(az);
|
||
const dz = r * Math.sin(el);
|
||
estimates.push([ri[0]+dx, ri[1]+dy, ri[2]+dz]);
|
||
}
|
||
|
||
// Mean of estimates
|
||
const p = [
|
||
estimates.reduce((s, e) => s + e[0], 0) / estimates.length,
|
||
estimates.reduce((s, e) => s + e[1], 0) / estimates.length,
|
||
estimates.reduce((s, e) => s + e[2], 0) / estimates.length
|
||
];
|
||
return p;
|
||
}
|
||
|
||
// =====================================================================
|
||
// Trajectory estimation (linear regression, matches Python)
|
||
// =====================================================================
|
||
function estimateTrajectory(observations) {
|
||
// observations: array of {x, y, z, t}
|
||
if (observations.length < 2) return null;
|
||
|
||
const times = observations.map(o => o.t);
|
||
const xs = observations.map(o => o.x);
|
||
const ys = observations.map(o => o.y);
|
||
const zs = observations.map(o => o.z);
|
||
const n = observations.length;
|
||
|
||
const vel = [0, 0, 0];
|
||
|
||
for (let axis = 0; axis < 3; axis++) {
|
||
const pos = axis === 0 ? xs : (axis === 1 ? ys : zs);
|
||
// Least squares: pos = pos0 + vel*t
|
||
let sumT = 0, sumP = 0, sumT2 = 0, sumTP = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
sumT += times[i];
|
||
sumP += pos[i];
|
||
sumT2 += times[i]*times[i];
|
||
sumTP += times[i]*pos[i];
|
||
}
|
||
const denom = n * sumT2 - sumT * sumT;
|
||
if (Math.abs(denom) < 1e-10) continue;
|
||
vel[axis] = (n * sumTP - sumT * sumP) / denom;
|
||
}
|
||
|
||
// R² calculation (use x-axis as representative)
|
||
const meanX = xs.reduce((s, v) => s + v, 0) / n;
|
||
const pos0 = xs[0] - vel[0] * times[0];
|
||
let ssTot = 0, ssRes = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
ssTot += (xs[i] - meanX) ** 2;
|
||
const pred = pos0 + vel[0] * times[i];
|
||
ssRes += (xs[i] - pred) ** 2;
|
||
}
|
||
const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 1;
|
||
|
||
return { vel, r2 };
|
||
}
|
||
|
||
// =====================================================================
|
||
// Intercept calculation (matches Python quadratic solver)
|
||
// =====================================================================
|
||
function computeInterceptPoint(dronePos, targetPos, targetVel, droneSpeed) {
|
||
const relPos = [targetPos[0]-dronePos[0], targetPos[1]-dronePos[1], targetPos[2]-dronePos[2]];
|
||
const a = targetVel[0]**2 + targetVel[1]**2 + targetVel[2]**2 - droneSpeed**2;
|
||
const b = 2 * (relPos[0]*targetVel[0] + relPos[1]*targetVel[1] + relPos[2]*targetVel[2]);
|
||
const c = relPos[0]**2 + relPos[1]**2 + relPos[2]**2;
|
||
|
||
let t;
|
||
if (Math.abs(a) < 1e-10) {
|
||
// Linear
|
||
if (Math.abs(b) < 1e-10) {
|
||
t = Math.sqrt(c) / droneSpeed;
|
||
} else {
|
||
t = -c / b;
|
||
}
|
||
} else {
|
||
const disc = b*b - 4*a*c;
|
||
if (disc < 0) {
|
||
t = Math.sqrt(c) / droneSpeed;
|
||
} else {
|
||
const sqrtDisc = Math.sqrt(disc);
|
||
const t1 = (-b + sqrtDisc) / (2*a);
|
||
const t2 = (-b - sqrtDisc) / (2*a);
|
||
const pos = [t1, t2].filter(x => x > 0);
|
||
t = pos.length > 0 ? Math.min(...pos) : Math.sqrt(c) / droneSpeed;
|
||
}
|
||
}
|
||
|
||
if (t <= 0) t = Math.sqrt(c) / droneSpeed;
|
||
|
||
const targetFuture = [
|
||
targetPos[0] + targetVel[0]*t,
|
||
targetPos[1] + targetVel[1]*t,
|
||
targetPos[2] + targetVel[2]*t
|
||
];
|
||
return { intercept: targetFuture, t };
|
||
}
|
||
|
||
// =====================================================================
|
||
// Geometry + overlay helpers
|
||
// =====================================================================
|
||
// Perpendicular distance (km) from point D to the line through P with direction V
|
||
// (P in km, V in km/s, D in km)
|
||
function perpDistToLine(P, V, D) {
|
||
const rel = [D[0]-P[0], D[1]-P[1], D[2]-P[2]];
|
||
const cross = [
|
||
rel[1]*V[2] - rel[2]*V[1],
|
||
rel[2]*V[0] - rel[0]*V[2],
|
||
rel[0]*V[1] - rel[1]*V[0]
|
||
];
|
||
const c = Math.sqrt(cross[0]*cross[0] + cross[1]*cross[1] + cross[2]*cross[2]);
|
||
const v = Math.sqrt(V[0]*V[0] + V[1]*V[1] + V[2]*V[2]);
|
||
return v > 1e-9 ? c / v : Math.sqrt(c);
|
||
}
|
||
|
||
// Draw detected data points + best-fit projected trajectory on the SVG overlay
|
||
function drawOverlay(observations, pos, velMs, tti, center) {
|
||
const svg = document.getElementById('overlay');
|
||
if (!svg) return;
|
||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||
const toPx = (x, y) => [center.x + kmToPixels(x), center.y - kmToPixels(y)];
|
||
|
||
// Detected data points (estimated positions)
|
||
for (const o of observations) {
|
||
const [px, py] = toPx(o.x, o.y);
|
||
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||
c.setAttribute('cx', px);
|
||
c.setAttribute('cy', py);
|
||
c.setAttribute('r', 3);
|
||
c.setAttribute('fill', '#00e5ff');
|
||
c.setAttribute('fill-opacity', '0.85');
|
||
svg.appendChild(c);
|
||
}
|
||
|
||
// Best-fit projected trajectory: from current position forward to projected impact
|
||
if (velMs && tti > 0) {
|
||
const velKs = [velMs[0]/1000, velMs[1]/1000];
|
||
const end = [pos[0] + velKs[0]*tti, pos[1] + velKs[1]*tti];
|
||
const [x1, y1] = toPx(pos[0], pos[1]);
|
||
const [x2, y2] = toPx(end[0], end[1]);
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||
line.setAttribute('x1', x1);
|
||
line.setAttribute('y1', y1);
|
||
line.setAttribute('x2', x2);
|
||
line.setAttribute('y2', y2);
|
||
line.setAttribute('stroke', '#ffeb3b');
|
||
line.setAttribute('stroke-width', '2');
|
||
line.setAttribute('stroke-dasharray', '6 4');
|
||
svg.appendChild(line);
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// Map drawing
|
||
// =====================================================================
|
||
function drawCity(center, cityRadius) {
|
||
const city = document.createElement('div');
|
||
city.className = 'city';
|
||
city.style.left = center.x + 'px';
|
||
city.style.top = center.y + 'px';
|
||
city.style.width = kmToPixels(cityRadius * 2) + 'px';
|
||
city.style.height = kmToPixels(cityRadius * 2) + 'px';
|
||
city.textContent = 'CITY';
|
||
document.getElementById('map').appendChild(city);
|
||
}
|
||
|
||
function drawGrid(center, width, height) {
|
||
const map = document.getElementById('map');
|
||
for (let km = -20; km <= 20; km += 5) {
|
||
const x = center.x + kmToPixels(km);
|
||
if (x >= 0 && x <= width) {
|
||
const line = document.createElement('div');
|
||
line.className = 'grid-line';
|
||
line.style.left = x + 'px';
|
||
line.style.top = '0';
|
||
line.style.width = '1px';
|
||
line.style.height = height + 'px';
|
||
map.appendChild(line);
|
||
}
|
||
const y = center.y - kmToPixels(km);
|
||
if (y >= 0 && y <= height) {
|
||
const line = document.createElement('div');
|
||
line.className = 'grid-line';
|
||
line.style.top = y + 'px';
|
||
line.style.left = '0';
|
||
line.style.width = width + 'px';
|
||
line.style.height = '1px';
|
||
map.appendChild(line);
|
||
}
|
||
}
|
||
}
|
||
|
||
function setupDefense() {
|
||
const map = document.getElementById('map');
|
||
const center = getMapCenter();
|
||
const numRadars = parseInt(document.getElementById('numRadars').value);
|
||
const numDrones = parseInt(document.getElementById('numDrones').value);
|
||
const radarRange = parseFloat(document.getElementById('radarRange').value);
|
||
const cityRadius = parseFloat(document.getElementById('cityRadius').value);
|
||
const droneDefenseLine = parseFloat(document.getElementById('droneDefenseLine').value);
|
||
|
||
drawGrid(center, map.offsetWidth, map.offsetHeight);
|
||
drawCity(center, cityRadius);
|
||
|
||
const radarPositions = [];
|
||
for (let i = 0; i < numRadars; i++) {
|
||
const angle = 2 * Math.PI * i / numRadars;
|
||
const dist = cityRadius + 2;
|
||
const pos = [dist * Math.cos(angle), dist * Math.sin(angle), 0];
|
||
radarPositions.push(pos);
|
||
|
||
const rangeCircle = document.createElement('div');
|
||
rangeCircle.className = 'radar-range';
|
||
rangeCircle.id = `range-${i}`;
|
||
rangeCircle.style.left = (center.x + kmToPixels(pos[0])) + 'px';
|
||
rangeCircle.style.top = (center.y - kmToPixels(pos[1])) + 'px';
|
||
rangeCircle.style.width = kmToPixels(radarRange * 2) + 'px';
|
||
rangeCircle.style.height = kmToPixels(radarRange * 2) + 'px';
|
||
map.appendChild(rangeCircle);
|
||
|
||
const radar = document.createElement('div');
|
||
radar.className = 'radar inactive';
|
||
radar.id = `radar-${i}`;
|
||
radar.style.left = (center.x + kmToPixels(pos[0])) + 'px';
|
||
radar.style.top = (center.y - kmToPixels(pos[1])) + 'px';
|
||
map.appendChild(radar);
|
||
}
|
||
|
||
const dronePositions = [];
|
||
for (let i = 0; i < numDrones; i++) {
|
||
const angle = 2 * Math.PI * i / numDrones;
|
||
const dist = cityRadius + droneDefenseLine;
|
||
const pos = {
|
||
x: dist * Math.cos(angle),
|
||
y: dist * Math.sin(angle),
|
||
z: 0,
|
||
index: i,
|
||
state: 'waiting',
|
||
intercept: null
|
||
};
|
||
dronePositions.push(pos);
|
||
|
||
const drone = document.createElement('div');
|
||
drone.className = 'drone';
|
||
drone.id = `drone-${i}`;
|
||
drone.style.left = (center.x + kmToPixels(pos.x)) + 'px';
|
||
drone.style.top = (center.y - kmToPixels(pos.y)) + 'px';
|
||
map.appendChild(drone);
|
||
}
|
||
|
||
return { radarPositions, dronePositions };
|
||
}
|
||
|
||
// =====================================================================
|
||
// Closeup view (rotated to face trajectory direction)
|
||
// =====================================================================
|
||
function drawCloseup(targetPos, trajDir, errorBandMeters, dronePositions, launchedDrones) {
|
||
const closeup = document.getElementById('closeupMap');
|
||
closeup.innerHTML = '<span class="closeup-title">CLOSEUP (500m)</span>';
|
||
|
||
const cx = 100; // center of 200px box
|
||
const cy = 75; // center of 150px box
|
||
|
||
// Rotation angle to face trajectory direction
|
||
const angle = Math.atan2(trajDir[1], trajDir[0]);
|
||
|
||
// Error band (perpendicular to trajectory)
|
||
const bandHalf = kmToCloseup(errorBandMeters / 1000);
|
||
const bandHeight = Math.max(4, bandHalf * 2);
|
||
const band = document.createElement('div');
|
||
band.className = 'closeup-band';
|
||
band.style.left = (cx - 60) + 'px';
|
||
band.style.top = (cy - bandHeight / 2) + 'px';
|
||
band.style.width = 120 + 'px';
|
||
band.style.height = bandHeight + 'px';
|
||
band.style.transform = `rotate(${-angle}rad)`;
|
||
band.style.transformOrigin = 'center';
|
||
closeup.appendChild(band);
|
||
|
||
// Trajectory line
|
||
const traj = document.createElement('div');
|
||
traj.className = 'closeup-traj';
|
||
traj.style.left = (cx - 60) + 'px';
|
||
traj.style.top = cy + 'px';
|
||
traj.style.width = 120 + 'px';
|
||
traj.style.transform = `rotate(${-angle}rad)`;
|
||
traj.style.transformOrigin = 'left center';
|
||
closeup.appendChild(traj);
|
||
|
||
// Target dot
|
||
const dot = document.createElement('div');
|
||
dot.className = 'closeup-dot';
|
||
dot.style.left = cx + 'px';
|
||
dot.style.top = cy + 'px';
|
||
closeup.appendChild(dot);
|
||
|
||
// Drones within 250m
|
||
launchedDrones.forEach(i => {
|
||
const drone = dronePositions[i];
|
||
const dx = drone.x - targetPos[0]; // km
|
||
const dy = drone.y - targetPos[1]; // km
|
||
if (Math.abs(dx * 1000) <= 250 && Math.abs(dy * 1000) <= 250) {
|
||
const droneDot = document.createElement('div');
|
||
droneDot.className = 'closeup-drone';
|
||
droneDot.style.background = drone.state === 'ir-tracking' ? '#00bcd4' : '#03a9f4';
|
||
// Rotate coordinates to align with trajectory
|
||
const rx = dx * Math.cos(angle) + dy * Math.sin(angle);
|
||
const ry = -dx * Math.sin(angle) + dy * Math.cos(angle);
|
||
droneDot.style.left = (cx + kmToCloseup(rx)) + 'px';
|
||
droneDot.style.top = (cy - kmToCloseup(ry)) + 'px';
|
||
closeup.appendChild(droneDot);
|
||
}
|
||
});
|
||
}
|
||
|
||
// =====================================================================
|
||
// Data display
|
||
// =====================================================================
|
||
function updateData(status, radars, points, r2, speed, alt, errSigma, err95, tti, heading, droneAlt, irDrones) {
|
||
document.getElementById('dataStatus').textContent = status;
|
||
document.getElementById('dataRadars').textContent = radars;
|
||
document.getElementById('dataPoints').textContent = points;
|
||
document.getElementById('dataR2').textContent = r2 !== null ? r2.toFixed(4) : '--';
|
||
document.getElementById('dataSpeed').textContent = speed !== null ? `${speed.toFixed(0)} m/s` : '-- m/s';
|
||
document.getElementById('dataAltitude').textContent = alt !== null ? `${alt.toFixed(0)} m` : '-- m';
|
||
document.getElementById('dataErrorSigma').textContent = errSigma !== null ? `${errSigma.toFixed(1)} m` : '-- m';
|
||
document.getElementById('dataError95').textContent = err95 !== null ? `${err95.toFixed(1)} m` : '-- m';
|
||
document.getElementById('dataTTI').textContent = tti !== null ? `${tti.toFixed(1)} s` : '-- s';
|
||
document.getElementById('dataHeading').textContent = heading !== null ? `${heading.toFixed(1)}°` : '--°';
|
||
document.getElementById('dataDroneAlt').textContent = droneAlt !== null ? `${droneAlt.toFixed(0)} m` : '-- m';
|
||
document.getElementById('dataIRDrones').textContent = irDrones;
|
||
}
|
||
|
||
function updateIRData(dists, irSigma, ir95, combined) {
|
||
document.getElementById('irDist1').textContent = dists[0] !== null ? `${dists[0].toFixed(0)} m` : '-- m';
|
||
document.getElementById('irDist2').textContent = dists[1] !== null ? `${dists[1].toFixed(0)} m` : '-- m';
|
||
document.getElementById('irDist3').textContent = dists[2] !== null ? `${dists[2].toFixed(0)} m` : '-- m';
|
||
document.getElementById('irErrorSigma').textContent = irSigma !== null ? `${irSigma.toFixed(1)} m` : '-- m';
|
||
document.getElementById('irError95').textContent = ir95 !== null ? `${ir95.toFixed(1)} m` : '-- m';
|
||
document.getElementById('combinedError').textContent = combined !== null ? `${combined.toFixed(1)} m` : '-- m';
|
||
}
|
||
|
||
// =====================================================================
|
||
// Noise function (Gaussian)
|
||
// =====================================================================
|
||
function gaussianNoise(sigma) {
|
||
// Box-Muller transform
|
||
const u1 = Math.random();
|
||
const u2 = Math.random();
|
||
return sigma * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
||
}
|
||
|
||
// =====================================================================
|
||
// Simulation
|
||
// =====================================================================
|
||
function runSimulation() {
|
||
if (animationId) cancelAnimationFrame(animationId);
|
||
const { radarPositions, dronePositions } = resetSimulation();
|
||
|
||
const center = getMapCenter();
|
||
|
||
const attackStartDist = parseFloat(document.getElementById('attackStartDist').value);
|
||
const targetSpeed = parseFloat(document.getElementById('attackSpeed').value);
|
||
const cruiseAltMin = parseFloat(document.getElementById('cruiseAltMin').value);
|
||
const cruiseAltMax = parseFloat(document.getElementById('cruiseAltMax').value);
|
||
const descentStart = parseFloat(document.getElementById('descentStart').value);
|
||
const terminalAlt = parseFloat(document.getElementById('terminalAlt').value);
|
||
const radarRange = parseFloat(document.getElementById('radarRange').value);
|
||
const droneSpeed = parseFloat(document.getElementById('droneSpeed').value);
|
||
const irRange = parseFloat(document.getElementById('irRange').value) / 1000;
|
||
const cityRadius = parseFloat(document.getElementById('cityRadius').value);
|
||
|
||
// Meter-based radar positions for the detection/estimation pipeline
|
||
// (the radar math must run in meters, matching the Python reference)
|
||
const radarPositionsM = radarPositions.map(r => [r[0]*1000, r[1]*1000, r[2]*1000]);
|
||
|
||
// Random start
|
||
const startAngle = Math.random() * 2 * Math.PI;
|
||
const startPos = [
|
||
attackStartDist * Math.cos(startAngle),
|
||
attackStartDist * Math.sin(startAngle)
|
||
];
|
||
|
||
// Random target
|
||
const targetAngle = Math.random() * 2 * Math.PI;
|
||
const targetDist = Math.random() * cityRadius * 0.8;
|
||
const targetPos = [
|
||
targetDist * Math.cos(targetAngle),
|
||
targetDist * Math.sin(targetAngle)
|
||
];
|
||
|
||
// Random cruise altitude
|
||
const cruiseAlt = cruiseAltMin + Math.random() * (cruiseAltMax - cruiseAltMin);
|
||
|
||
// Generate trajectory
|
||
const trajectory = targetTrajectory(startPos, targetPos, cruiseAlt, targetSpeed, descentStart, terminalAlt);
|
||
if (trajectory.length === 0) return;
|
||
|
||
// Create target element
|
||
const targetEl = document.createElement('div');
|
||
targetEl.className = 'target';
|
||
document.getElementById('map').appendChild(targetEl);
|
||
|
||
// Altitude marker
|
||
const altMarker = document.createElement('div');
|
||
altMarker.className = 'altitude-marker';
|
||
document.getElementById('map').appendChild(altMarker);
|
||
|
||
// State
|
||
let time = 0;
|
||
let detectionTime = null;
|
||
let launchedDrones = [];
|
||
let collision = false;
|
||
let observations = [];
|
||
let latestVelMs = null;
|
||
let latestTti = null;
|
||
|
||
const dt = 0.5; // s
|
||
|
||
// Precompute true position lookup
|
||
function getTruePos(t) {
|
||
// Find closest trajectory point
|
||
let best = trajectory[0];
|
||
let bestDist = Infinity;
|
||
for (const p of trajectory) {
|
||
const d = Math.abs(p.t - t);
|
||
if (d < bestDist) {
|
||
bestDist = d;
|
||
best = p;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function update() {
|
||
time += dt;
|
||
|
||
const truePos = getTruePos(time);
|
||
const pos = [truePos.x, truePos.y, truePos.z / 1000]; // z in km for map
|
||
const posM = [pos[0] * 1000, pos[1] * 1000, pos[2] * 1000]; // meters, for radar math
|
||
|
||
targetEl.style.left = (center.x + kmToPixels(pos[0])) + 'px';
|
||
targetEl.style.top = (center.y - kmToPixels(pos[1])) + 'px';
|
||
|
||
// Altitude marker
|
||
altMarker.textContent = `${truePos.z.toFixed(0)}m`;
|
||
altMarker.style.left = (center.x + kmToPixels(pos[0]) + 15) + 'px';
|
||
altMarker.style.top = (center.y - kmToPixels(pos[1]) - 10) + 'px';
|
||
|
||
// Radar detection (in meters)
|
||
const detections = detectFromRadars(posM, radarPositionsM, radarRange * 1000, gaussianNoise);
|
||
const activeRadars = detections.map(d => d.radarIndex);
|
||
activeRadars.forEach(i => {
|
||
document.getElementById(`radar-${i}`).className = 'radar active';
|
||
document.getElementById(`range-${i}`).className = 'radar-range active';
|
||
});
|
||
|
||
if (detections.length > 0) {
|
||
observations.push({ x: pos[0], y: pos[1], z: pos[2] * 1000, t: time });
|
||
}
|
||
|
||
// First detection
|
||
if (detectionTime === null && observations.length >= 3) {
|
||
detectionTime = time;
|
||
|
||
// Show alert
|
||
const alertBox = document.getElementById('alertBox');
|
||
alertBox.className = 'alert-box active';
|
||
|
||
// Estimate position (meters)
|
||
const posEst = estimatePosition3D(detections, radarPositionsM);
|
||
if (posEst) {
|
||
observations[observations.length - 1] = {
|
||
x: posEst[0] / 1000, y: posEst[1] / 1000, z: posEst[2], t: time
|
||
};
|
||
}
|
||
|
||
// Estimate trajectory
|
||
const traj = estimateTrajectory(observations);
|
||
if (traj) {
|
||
// Velocity in m/s (observations are in km for x,y)
|
||
const velMs = [traj.vel[0] * 1000, traj.vel[1] * 1000, traj.vel[2]];
|
||
const speedEst = Math.sqrt(velMs[0]**2 + velMs[1]**2 + velMs[2]**2);
|
||
|
||
// Time to impact
|
||
const horizDist = Math.sqrt(pos[0]**2 + pos[1]**2);
|
||
const tti = horizDist / (speedEst / 1000);
|
||
latestVelMs = velMs;
|
||
latestTti = tti;
|
||
|
||
// Select drones closest to the projected trajectory line,
|
||
// preferring those with a viable intercept (t < tti).
|
||
// Intercepts are computed in meters (consistent units) and stored back in km.
|
||
const velKs = [velMs[0]/1000, velMs[1]/1000, velMs[2]/1000];
|
||
const posM = [pos[0]*1000, pos[1]*1000, pos[2]*1000];
|
||
const candidates = [];
|
||
for (let i = 0; i < dronePositions.length; i++) {
|
||
const dronePos = dronePositions[i];
|
||
const dronePosM = [dronePos.x*1000, dronePos.y*1000, dronePos.z*1000];
|
||
const result = computeInterceptPoint(dronePosM, posM, velMs, droneSpeed);
|
||
const dist = perpDistToLine(pos, velKs, [dronePos.x, dronePos.y, dronePos.z]);
|
||
const viable = (result.t > 0 && result.t < tti);
|
||
candidates.push({
|
||
i,
|
||
t: result.t,
|
||
intercept: [result.intercept[0]/1000, result.intercept[1]/1000, result.intercept[2]/1000],
|
||
dist,
|
||
viable
|
||
});
|
||
}
|
||
const viable = candidates.filter(c => c.viable).sort((a, b) => a.dist - b.dist);
|
||
const nonViable = candidates.filter(c => !c.viable).sort((a, b) => a.dist - b.dist);
|
||
const chosen = viable.slice(0, 3);
|
||
for (const nv of nonViable) {
|
||
if (chosen.length >= 3) break;
|
||
chosen.push(nv);
|
||
}
|
||
launchedDrones = chosen.map(c => c.i);
|
||
|
||
for (const c of chosen) {
|
||
dronePositions[c.i].intercept = c.intercept;
|
||
dronePositions[c.i].state = 'flying';
|
||
document.getElementById(`drone-${c.i}`).className = 'drone flying';
|
||
}
|
||
|
||
// Update data display
|
||
const n = observations.length;
|
||
const errSigma = MEASUREMENT_ERROR / Math.sqrt(n);
|
||
const err95 = 1.96 * errSigma;
|
||
const heading = (Math.atan2(velMs[1], velMs[0]) * 180 / Math.PI + 360) % 360;
|
||
|
||
updateData('TRACKING', activeRadars.length, n, traj.r2, speedEst, pos[2]*1000, errSigma, err95, tti, heading, 0, 0);
|
||
}
|
||
}
|
||
|
||
// Continuous updates
|
||
if (detectionTime !== null && !collision && observations.length >= 2) {
|
||
const traj = estimateTrajectory(observations);
|
||
if (traj) {
|
||
const velMs = [traj.vel[0] * 1000, traj.vel[1] * 1000, traj.vel[2]];
|
||
const speedEst = Math.sqrt(velMs[0]**2 + velMs[1]**2 + velMs[2]**2);
|
||
const horizDist = Math.sqrt(pos[0]**2 + pos[1]**2);
|
||
const tti = horizDist / (speedEst / 1000);
|
||
latestVelMs = velMs;
|
||
latestTti = tti;
|
||
const n = observations.length;
|
||
const errSigma = MEASUREMENT_ERROR / Math.sqrt(n);
|
||
const err95 = 1.96 * errSigma;
|
||
const heading = (Math.atan2(velMs[1], velMs[0]) * 180 / Math.PI + 360) % 360;
|
||
|
||
// Re-target drones
|
||
if ((time - detectionTime) % 5 < dt) {
|
||
const posM = [pos[0]*1000, pos[1]*1000, pos[2]*1000];
|
||
for (const i of launchedDrones) {
|
||
const drone = dronePositions[i];
|
||
if (drone.state === 'flying') {
|
||
const dronePosM = [drone.x*1000, drone.y*1000, drone.z*1000];
|
||
const result = computeInterceptPoint(dronePosM, posM, velMs, droneSpeed);
|
||
drone.intercept = [result.intercept[0]/1000, result.intercept[1]/1000, result.intercept[2]/1000];
|
||
}
|
||
}
|
||
}
|
||
|
||
updateData('TRACKING', activeRadars.length, n, traj.r2, speedEst, pos[2]*1000, errSigma, err95, tti, heading, 0, 0);
|
||
}
|
||
}
|
||
|
||
// Update drones
|
||
if (launchedDrones.length > 0 && !collision) {
|
||
let irDists = [null, null, null];
|
||
let irCount = 0;
|
||
let maxDroneAlt = 0;
|
||
|
||
launchedDrones.forEach((i, idx) => {
|
||
const drone = dronePositions[i];
|
||
if (drone.state === 'flying' || drone.state === 'ir-tracking') {
|
||
const intercept = drone.intercept;
|
||
if (intercept) {
|
||
const delta = [intercept[0]-drone.x, intercept[1]-drone.y, intercept[2]-drone.z];
|
||
const dist = Math.sqrt(delta[0]**2 + delta[1]**2 + delta[2]**2);
|
||
if (dist > 0.001) {
|
||
const step = droneSpeed * dt / 1000; // km
|
||
drone.x += (delta[0] / dist) * step;
|
||
drone.y += (delta[1] / dist) * step;
|
||
drone.z += (delta[2] / dist) * step;
|
||
}
|
||
}
|
||
|
||
// 3D distance to target
|
||
const dx = pos[0] - drone.x;
|
||
const dy = pos[1] - drone.y;
|
||
const dz = pos[2] - drone.z;
|
||
const dist3D = Math.sqrt(dx*dx + dy*dy + dz*dz); // km
|
||
|
||
if (drone.state === 'flying' && dist3D < irRange) {
|
||
drone.state = 'ir-tracking';
|
||
document.getElementById(`drone-${i}`).className = 'drone ir-tracking';
|
||
}
|
||
|
||
if (drone.state === 'ir-tracking') {
|
||
irDists[idx] = dist3D * 1000; // meters
|
||
irCount++;
|
||
}
|
||
|
||
maxDroneAlt = Math.max(maxDroneAlt, drone.z * 1000);
|
||
|
||
// Update drone position on map
|
||
const droneEl = document.getElementById(`drone-${i}`);
|
||
droneEl.style.left = (center.x + kmToPixels(drone.x)) + 'px';
|
||
droneEl.style.top = (center.y - kmToPixels(drone.y)) + 'px';
|
||
|
||
// Collision check
|
||
if (dist3D * 1000 < 20) { // 20 m
|
||
collision = true;
|
||
const alertBox = document.getElementById('alertBox');
|
||
alertBox.textContent = 'TARGET DESTROYED';
|
||
alertBox.className = 'alert-box active success';
|
||
updateData('DESTROYED', activeRadars.length, observations.length, null, null, null, null, null, null, null, maxDroneAlt, irCount);
|
||
}
|
||
}
|
||
});
|
||
|
||
if (irCount > 0) {
|
||
const irSigma = IR_ERROR / Math.sqrt(irCount);
|
||
const ir95 = 1.96 * irSigma;
|
||
const radarSigma = MEASUREMENT_ERROR / Math.sqrt(observations.length);
|
||
const combined = Math.sqrt(radarSigma**2 + irSigma**2);
|
||
updateIRData(irDists, irSigma, ir95, combined);
|
||
}
|
||
|
||
// Update closeup
|
||
if (detectionTime !== null) {
|
||
const traj = estimateTrajectory(observations);
|
||
if (traj) {
|
||
const velMs = [traj.vel[0] * 1000, traj.vel[1] * 1000, traj.vel[2]];
|
||
const n = observations.length;
|
||
const err95 = 1.96 * MEASUREMENT_ERROR / Math.sqrt(n);
|
||
const trajDir = [velMs[0], velMs[1]];
|
||
drawCloseup(pos, trajDir, err95, dronePositions, launchedDrones);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw detected data points + best-fit projected trajectory
|
||
if (observations.length > 0 && latestVelMs) {
|
||
drawOverlay(observations, pos, latestVelMs, latestTti, center);
|
||
}
|
||
|
||
// Target reached city
|
||
const horizDist = Math.sqrt(pos[0]**2 + pos[1]**2);
|
||
if (horizDist < cityRadius && !collision) {
|
||
const alertBox = document.getElementById('alertBox');
|
||
alertBox.textContent = 'IMPACT';
|
||
alertBox.className = 'alert-box active';
|
||
updateData('IMPACT', activeRadars.length, observations.length, null, null, null, null, null, null, null, 0, 0);
|
||
}
|
||
|
||
if (!collision && horizDist > cityRadius) {
|
||
animationId = requestAnimationFrame(update);
|
||
}
|
||
}
|
||
|
||
document.getElementById('alertBox').className = 'alert-box';
|
||
animationId = requestAnimationFrame(update);
|
||
}
|
||
|
||
function resetSimulation() {
|
||
if (animationId) cancelAnimationFrame(animationId);
|
||
clearMap();
|
||
const center = getMapCenter();
|
||
const defense = setupDefense();
|
||
const alertBox = document.getElementById('alertBox');
|
||
alertBox.className = 'alert-box';
|
||
alertBox.textContent = 'ALERT!';
|
||
// Clear closeup
|
||
const closeup = document.getElementById('closeupMap');
|
||
closeup.innerHTML = '<span class="closeup-title">CLOSEUP (500m)</span>';
|
||
updateData('STANDBY', 0, 0, null, null, null, null, null, null, null, null, 0);
|
||
updateIRData([null, null, null], null, null, null);
|
||
return defense;
|
||
}
|
||
|
||
// Load Monte Carlo stats (per preset) and populate the table
|
||
let statsData = {};
|
||
|
||
function presetDisplayName(key) {
|
||
const names = {
|
||
'shahed-136': 'Shahed-136',
|
||
'shahed-238': 'Shahed-238',
|
||
'cruise-missile': 'Cruise Missile',
|
||
'ballistic-reentry': 'Ballistic (Re-entry)',
|
||
'prop-drone': 'Prop Drone'
|
||
};
|
||
return names[key] || key;
|
||
}
|
||
|
||
function fmtTime(v) { return v ? `${v.toFixed(1)} s` : '--'; }
|
||
|
||
function renderStatsTable(activePreset) {
|
||
const tbody = document.getElementById('statsBody');
|
||
tbody.innerHTML = '';
|
||
const presetOrder = ['shahed-136', 'shahed-238', 'cruise-missile', 'ballistic-reentry', 'prop-drone'];
|
||
for (const key of presetOrder) {
|
||
const s = statsData[key];
|
||
if (!s) continue;
|
||
const tr = document.createElement('tr');
|
||
if (key === activePreset) tr.className = 'highlight';
|
||
const rate = s.interception_rate;
|
||
const rateColor = rate >= 80 ? '#4caf50' : (rate >= 40 ? '#ff9800' : '#f44336');
|
||
tr.innerHTML = `
|
||
<td class="preset-name">${presetDisplayName(key)}</td>
|
||
<td style="color:${rateColor};font-weight:bold;">${rate.toFixed(0)}%</td>
|
||
<td>${fmtTime(s.avg_detection_time)}</td>
|
||
<td>${fmtTime(s.avg_collision_time)}</td>
|
||
<td>${fmtTime(s.avg_impact_time)}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
fetch('montecarlo_stats.json')
|
||
.then(r => r.json())
|
||
.then(stats => {
|
||
statsData = stats;
|
||
renderStatsTable(document.getElementById('presetSelect').value);
|
||
})
|
||
.catch(() => {});
|
||
|
||
// Preset dropdown handler: prefill attack parameters on change
|
||
document.getElementById('presetSelect').addEventListener('change', (e) => {
|
||
applyPreset(e.target.value);
|
||
resetSimulation();
|
||
renderStatsTable(e.target.value);
|
||
});
|
||
|
||
// Apply the default preset on load
|
||
applyPreset(document.getElementById('presetSelect').value);
|
||
|
||
resetSimulation();
|
||
</script>
|
||
</body>
|
||
</html>
|