Files
ShooterHub/frontend/js/ballistics.js

296 lines
13 KiB
JavaScript
Raw Normal View History

// ── Ballistic Calculator — point-mass trajectory engine ──────────────────────
// No server dependency. All computation is done in-browser.
// ── Drag tables [Mach, Cd_ref] ─────────────────────────────────────────────
// Cd values for the reference projectile (G7 or G1).
// Used in: a_drag = (rho/rho_std) * Cd(M) * v² / (2 * BC_SI)
// BC_SI (kg/m²) = BC_lb_in2 * 703.07
const DRAG_G7 = [
[0.00,0.1198],[0.05,0.1197],[0.10,0.1196],[0.20,0.1194],[0.30,0.1194],
[0.40,0.1194],[0.50,0.1194],[0.60,0.1193],[0.70,0.1194],[0.80,0.1193],
[0.83,0.1193],[0.86,0.1194],[0.88,0.1194],[0.90,0.1194],[0.92,0.1198],
[0.95,0.1210],[0.975,0.1215],[1.00,0.1212],[1.025,0.1206],[1.05,0.1202],
[1.10,0.1208],[1.15,0.1220],[1.20,0.1240],[1.30,0.1262],[1.40,0.1280],
[1.50,0.1283],[1.60,0.1290],[1.80,0.1297],[2.00,0.1298],[2.20,0.1295],
[2.50,0.1290],[3.00,0.1280],[4.00,0.1245],[5.00,0.1210],
];
const DRAG_G1 = [
[0.00,0.2629],[0.05,0.2558],[0.10,0.2568],[0.20,0.2581],[0.30,0.2578],
[0.40,0.2561],[0.50,0.2537],[0.60,0.2528],[0.65,0.2591],[0.70,0.2668],
[0.75,0.2760],[0.80,0.2983],[0.825,0.3163],[0.85,0.3285],[0.875,0.3449],
[0.90,0.3680],[0.925,0.4000],[0.95,0.4258],[0.975,0.4465],[1.00,0.4581],
[1.025,0.4547],[1.05,0.4420],[1.10,0.4152],[1.20,0.3736],[1.30,0.3449],
[1.40,0.3231],[1.50,0.3064],[1.60,0.2927],[1.80,0.2722],[2.00,0.2573],
[2.50,0.2313],[3.00,0.2144],[4.00,0.1875],[5.00,0.1695],
];
const G = 9.80665; // m/s²
const RHO_STD = 1.2250; // ICAO std sea-level density, kg/m³
const BC_CONV = 703.0696; // lb/in² → kg/m²
const SUBSONIC = 340; // m/s threshold for subsonic display
// ── Physics helpers ───────────────────────────────────────────────────────────
function dragCd(mach, table) {
if (mach <= table[0][0]) return table[0][1];
if (mach >= table[table.length - 1][0]) return table[table.length - 1][1];
for (let i = 1; i < table.length; i++) {
if (mach <= table[i][0]) {
const [m0, c0] = table[i - 1], [m1, c1] = table[i];
return c0 + (c1 - c0) * (mach - m0) / (m1 - m0);
}
}
return table[table.length - 1][1];
}
// Air density (kg/m³) — accounts for temp, pressure and humidity.
function airDensity(temp_c, press_hpa, humid_pct) {
const T = temp_c + 273.15;
const P = press_hpa * 100;
const Psat = 610.78 * Math.exp(17.27 * temp_c / (temp_c + 237.3));
const Pv = (humid_pct / 100) * Psat;
return (P - Pv) / (287.058 * T) + Pv / (461.495 * T);
}
// Pressure at altitude h (m) via ISA lapse rate.
function pressureAtAltitude(h) {
return 1013.25 * Math.pow(1 - 0.0065 * h / 288.15, 5.2561);
}
// Speed of sound (m/s) at temperature.
function speedOfSound(temp_c) {
return 331.3 * Math.sqrt(1 + temp_c / 273.15);
}
// ── Trajectory integration (Euler, dt = 0.5 ms) ──────────────────────────────
//
// Coordinate system:
// x — range (forward)
// y — vertical (up = positive)
// z — lateral (right = positive)
//
// wind_cross_ms: effective lateral wind component.
// positive = wind FROM the right → bullet drifts LEFT (z becomes negative)
//
// Returns array of { x, y, z, v, t } sampled every step_m meters of range.
function integrate(v0, theta, bc_si, rho, sound, dragTable, scopeH_m, wind_cross_ms, step_m, max_m) {
const dt = 0.0005;
let x = 0, y = -scopeH_m, z = 0;
let vx = v0 * Math.cos(theta), vy = v0 * Math.sin(theta), vz = 0;
let t = 0;
let nextRecord = 0;
const pts = [];
while (x <= max_m + step_m * 0.5 && t < 15) {
if (x >= nextRecord - 1e-6) {
pts.push({ x: nextRecord, y, z, v: Math.hypot(vx, vy), t });
nextRecord += step_m;
}
const vb = Math.hypot(vx, vy);
const mach = vb / sound;
const cd = dragCd(mach, dragTable);
const k = (rho / RHO_STD) * cd / (2 * bc_si);
// Drag decelerates bullet along its velocity vector; wind creates lateral drag.
// az = -k*vb*(vz + wind_cross_ms) [wind from right → bullet pushed left]
const ax = -k * vb * vx;
const ay = -k * vb * vy - G;
const az = -k * vb * (vz + wind_cross_ms);
vx += ax * dt; vy += ay * dt; vz += az * dt;
x += vx * dt; y += vy * dt; z += vz * dt;
t += dt;
if (vb < 50) break;
}
return pts;
}
// Binary-search for the elevation angle (rad) that gives y = 0 at zero_m.
function findZeroAngle(v0, zero_m, bc_si, rho, sound, dragTable, scopeH_m, wind_cross_ms) {
let lo = -0.01, hi = 0.15;
for (let i = 0; i < 60; i++) {
const mid = (lo + hi) / 2;
const pts = integrate(v0, mid, bc_si, rho, sound, dragTable, scopeH_m, wind_cross_ms, zero_m, zero_m);
const y = pts.length ? pts[pts.length - 1].y : -999;
if (y > 0) hi = mid; else lo = mid;
}
return (lo + hi) / 2;
}
// ── Angular conversion helpers ────────────────────────────────────────────────
function moaFromMm(dist_m, mm) { return dist_m > 0 ? mm / (dist_m * 0.29089) : null; }
function mradFromMm(dist_m, mm) { return dist_m > 0 ? mm / (dist_m * 0.1) : null; }
function angFromMm(dist_m, mm, unit) {
return unit === 'MOA' ? moaFromMm(dist_m, mm) : mradFromMm(dist_m, mm);
}
function toMeters(val, unit) { return unit === 'yd' ? val * 0.9144 : val; }
function fmt(n, dec, showSign = false) {
if (n === null || n === undefined || isNaN(n)) return '—';
const s = Math.abs(n).toFixed(dec);
if (showSign) return (n >= 0 ? '+' : '') + s;
return s;
}
// ── Main entry point ──────────────────────────────────────────────────────────
function calculate() {
const alertEl = document.getElementById('calcAlert');
alertEl.classList.add('d-none');
// Read + validate inputs
const v0Raw = parseFloat(document.getElementById('v0').value);
const v0Unit = document.getElementById('v0Unit').value;
const bcRaw = parseFloat(document.getElementById('bc').value);
const bcModel = document.getElementById('dragModel').value;
const wtGr = parseFloat(document.getElementById('bulletWeight').value) || null;
const zeroRaw = parseFloat(document.getElementById('zeroDist').value);
const zeroUnit = document.getElementById('zeroUnit').value;
const scopeHMm = parseFloat(document.getElementById('scopeHeight').value) || 50;
let tempRaw = parseFloat(document.getElementById('temp').value);
const tempUnit = document.getElementById('tempUnit').value;
const press = parseFloat(document.getElementById('pressure').value) || 1013.25;
const humid = parseFloat(document.getElementById('humidity').value) || 50;
const altM = parseFloat(document.getElementById('altitude').value) || 0;
const windSpdRaw = parseFloat(document.getElementById('windSpeed').value) || 0;
const windUnit = document.getElementById('windUnit').value;
const windDirDeg = parseFloat(document.getElementById('windDir').value) || 0;
const outUnit = document.getElementById('outputUnit').value;
const distFrom = parseFloat(document.getElementById('distFrom').value) || 0;
const distTo = parseFloat(document.getElementById('distTo').value) || 800;
const distStep = parseFloat(document.getElementById('distStep').value) || 25;
if (!v0Raw || v0Raw <= 0) return showAlert('Muzzle velocity is required and must be > 0.');
if (!bcRaw || bcRaw <= 0) return showAlert('Ballistic coefficient is required and must be > 0.');
if (!zeroRaw || zeroRaw<=0) return showAlert('Zero distance is required and must be > 0.');
if (distTo <= distFrom) return showAlert('"To" must be greater than "From".');
if (distTo > 3000) return showAlert('Maximum distance is 3000 m/yd.');
// Convert to SI
const v0 = v0Unit === 'fps' ? v0Raw * 0.3048 : v0Raw;
const zero_m = toMeters(zeroRaw, zeroUnit);
const max_m = toMeters(distTo, zeroUnit);
const from_m = toMeters(distFrom, zeroUnit);
const step_m = toMeters(distStep, zeroUnit);
const scopeH = scopeHMm / 1000;
if (tempUnit === 'f') tempRaw = (tempRaw - 32) * 5 / 9;
// Wind: convert speed to m/s, compute crosswind component
let windMs = windSpdRaw;
if (windUnit === 'kph') windMs /= 3.6;
else if (windUnit === 'mph') windMs *= 0.44704;
// Positive windCross = wind FROM the right → bullet drifts left (z < 0)
const windCross = windMs * Math.sin(windDirDeg * Math.PI / 180);
// Atmosphere
const pressEff = (altM > 0 && press === 1013.25) ? pressureAtAltitude(altM) : press;
const rho = airDensity(tempRaw, pressEff, humid);
const sound = speedOfSound(tempRaw);
const bc_si = bcRaw * BC_CONV;
const table = bcModel === 'G7' ? DRAG_G7 : DRAG_G1;
// Find zero elevation angle, then compute full trajectory
const theta = findZeroAngle(v0, zero_m, bc_si, rho, sound, table, scopeH, windCross);
const pts = integrate(v0, theta, bc_si, rho, sound, table, scopeH, windCross, step_m, max_m + step_m);
const shown = pts.filter(p => p.x >= from_m - 0.1 && p.x <= max_m + 0.1);
if (!shown.length) return showAlert('No trajectory points in the requested range.');
renderResults(shown, { zero_m, zeroUnit, v0Raw, v0Unit, outUnit, wtGr, rho, sound });
}
function showAlert(msg) {
const el = document.getElementById('calcAlert');
el.textContent = msg;
el.classList.remove('d-none');
}
// ── Render trajectory table ───────────────────────────────────────────────────
function renderResults(pts, { zero_m, zeroUnit, v0Raw, v0Unit, outUnit, wtGr, rho, sound }) {
document.getElementById('resultsPlaceholder').classList.add('d-none');
document.getElementById('resultsSection').classList.remove('d-none');
const distLabel = zeroUnit === 'yd' ? 'yd' : 'm';
const velLabel = v0Unit === 'fps' ? 'fps' : 'm/s';
const uLabel = outUnit;
document.getElementById('trajHead').innerHTML = [
`<th>${distLabel}</th>`,
`<th>${velLabel}</th>`,
wtGr !== null ? '<th>E (J)</th>' : '',
'<th>TOF (s)</th>',
`<th>Drop (mm)</th>`,
`<th>Drop (${uLabel})</th>`,
`<th>Wind (mm)</th>`,
`<th>Wind (${uLabel})</th>`,
].join('');
// Find first subsonic distance
const subPt = pts.find(p => p.v < SUBSONIC);
const rows = pts.map(p => {
const distDisp = zeroUnit === 'yd'
? (p.x / 0.9144).toFixed(0)
: p.x.toFixed(0);
const vel = v0Unit === 'fps' ? p.v / 0.3048 : p.v;
const isZero = Math.abs(p.x - zero_m) < 0.5;
const isSub = p.v < SUBSONIC;
// Drop: y is negative when bullet is below LOS.
// We show how much bullet is BELOW (positive = drop below), correction sign = hold up (+).
const dropMm = p.y * 1000; // negative = below LOS
const dropCorr = angFromMm(p.x, -dropMm, outUnit); // positive = aim up / dial up
// Wind: z is negative when bullet drifts left (wind from right).
const windMm = p.z * 1000;
const windAbs = Math.abs(windMm);
const windDir = windMm < -0.5 ? 'L' : windMm > 0.5 ? 'R' : '';
const windCorr = windAbs > 0.5 ? angFromMm(p.x, windAbs, outUnit) : null;
// Energy
let eTd = '';
if (wtGr !== null) {
const mass_kg = wtGr / 15432.4;
const ke = 0.5 * mass_kg * p.v * p.v;
eTd = `<td>${ke.toFixed(0)}</td>`;
}
return `<tr class="${isZero ? 'zero-row' : ''}${isSub ? ' subsonic' : ''}">
<td>${distDisp}${isZero ? ' ★' : ''}</td>
<td>${vel.toFixed(0)}</td>
${eTd}
<td>${p.t.toFixed(3)}</td>
<td>${fmt(dropMm, 0, true)}</td>
<td>${fmt(dropCorr, 2, true)}</td>
<td>${windAbs < 0.5 ? '—' : fmt(windAbs, 0) + ' ' + windDir}</td>
<td>${windAbs < 0.5 || windCorr === null ? '—' : fmt(windCorr, 2) + ' ' + windDir}</td>
</tr>`;
});
document.getElementById('trajBody').innerHTML = rows.join('');
// Summary line
document.getElementById('resultsSummary').innerHTML =
`ρ = ${rho.toFixed(4)} kg/m³ | Speed of sound = ${sound.toFixed(1)} m/s` +
(subPt ? ` | Subsonic at ~${subPt.x.toFixed(0)} m` : '');
}
// ── Keep distance step unit label in sync ────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const zeroUnitSel = document.getElementById('zeroUnit');
const stepLabel = document.getElementById('distStepUnit');
if (zeroUnitSel && stepLabel) {
zeroUnitSel.addEventListener('change', () => {
stepLabel.textContent = zeroUnitSel.value;
});
}
});