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