// ── 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 = [ `${distLabel}`, `${velLabel}`, wtGr !== null ? 'E (J)' : '', 'TOF (s)', `Drop (mm)`, `Drop (${uLabel})`, `Wind (mm)`, `Wind (${uLabel})`, ].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 = `${ke.toFixed(0)}`; } return ` ${distDisp}${isZero ? ' ★' : ''} ${vel.toFixed(0)} ${eTd} ${p.t.toFixed(3)} ${fmt(dropMm, 0, true)} ${fmt(dropCorr, 2, true)} ${windAbs < 0.5 ? '—' : fmt(windAbs, 0) + ' ' + windDir} ${windAbs < 0.5 || windCorr === null ? '—' : fmt(windCorr, 2) + ' ' + windDir} `; }); 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; }); } });