Files
ShooterHub/frontend/js/ballistics.js
2026-04-02 11:24:30 +02:00

296 lines
13 KiB
JavaScript
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.
// ── 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;
});
}
});