MOD + END Calculator / mod_end_calculator.html
mod_end_calculator.html14.9 KB
<!DOCTYPE html>
<!-- Depth limit calculator for breathing gas mixes. -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MOD + END</title>
<style>
/* ---------- Page layout ---------- */
body {
font-family: system-ui, sans-serif;
background: #fff;
display: flex;
justify-content: center;
padding: 40px 12px;
overflow-y:hidden;
}
/* ---------- Main container ---------- */
.app {
background: #fff;
width: 420px;
max-width: 100%;
padding: 18px;
border-radius: 8px;
}
/* ---------- Result boxes ---------- */
.box {
border: 2px solid #000;
padding: 12px;
margin-bottom: 14px;
text-align: center;
border-radius: 6px;
}
/* MOD box emphasized */
.box.mod {
border-color: #c33;
}
/* MinOD box emphasized */
.box.minod {
border-color: #36c;
}
/* Numeric result */
.box .value {
font-size: 32px;
font-weight: 700;
}
/* Explanatory text */
.box .sub {
font-size: 12px;
opacity: 0.75;
margin-top: 6px;
line-height: 1.35;
}
/* ---------- Input rows ---------- */
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px;
gap: 8px;
align-items: center;
margin: 10px 0;
}
input, select {
width: 100%;
padding: 8px;
font-size: 16px;
box-sizing: border-box;
}
/* Right-hand label pill */
.pill {
width: 96px;
padding: 6px 8px;
border: 1px solid #000;
border-radius: 6px;
background: #fff;
font-size: 14px;
white-space: nowrap;
box-sizing: border-box;
text-align: center;
justify-self: stretch;
}
/* ---------- Number input with unit + arrows ---------- */
.input-unit {
position: relative;
width: 100%;
min-width: 0;
}
/* Hide native number spinners */
.input-unit input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.input-unit input[type="number"]::-webkit-outer-spin-button,
.input-unit input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Reserve space for unit and arrows */
.input-unit input {
width: 100%;
padding-right: 90px;
}
/* Unit text inside input */
.input-unit .unit {
position: absolute;
right: 56px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
opacity: 0.6;
pointer-events: none;
}
/* Custom spinner arrows */
.input-unit .stepper {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 0;
}
.input-unit .stepper button {
width: 24px;
height: 14px;
line-height: 12px;
padding: 0;
border: none;
background: transparent;
font-size: 11px;
cursor: pointer;
opacity: 0.7;
}
.input-unit .stepper button:hover {
opacity: 1;
}
/* ---------- Gas composition block ---------- */
.blend {
border: 1px solid #000;
border-radius: 6px;
overflow: hidden;
margin-top: 12px;
}
.gas {
display: flex;
gap: 8px;
align-items: center;
padding: 10px;
border-bottom: 1px solid #000;
}
.gas:last-child {
border-bottom: none;
}
.gas label {
width: 64px;
}
.box .pp-values {
font-size: 14px;
font-weight: bold;
}
/* ---------- Error message ---------- */
.err {
color: #b00;
font-size: 12px;
margin-top: 6px;
min-height: 16px;
}
</style>
</head>
<body>
<div class="app">
<!-- MOD output -->
<div class="box mod">
<div>Maximum Operating Depth (MOD)</div>
<div class="value" id="mod">– m</div>
</div>
<!-- MinOD output -->
<div class="box minod" id="minodBox" style="display:none;">
<div>Minimum Operating Depth (MinOD)</div>
<div class="value" id="minod">– m</div>
<div class="sub">
Minimum depth where PpO₂ reaches the minimum threshold.
</div>
</div>
<!-- Partial pressures output -->
<div class="box" id="ppBox" style="display:none;">
<div>Partial pressures @ MOD</div>
<div class="sub pp-values" id="ppOut">–</div>
</div>
<!-- END output -->
<div class="box">
<div>END @ MOD</div>
<div class="value" id="end">–</div>
<div class="sub">
Equivalent narcotic depth evaluated at MOD.<br>
Referenced to air (N₂ only).
</div>
</div>
<!-- PpO2 max input -->
<div class="row">
<div class="input-unit">
<input id="ppo2" type="text" inputmode="decimal" value="1.4" />
<span class="unit">bar</span>
<span class="stepper">
<button type="button" id="ppo2Up">▲</button>
<button type="button" id="ppo2Down">▼</button>
</span>
</div>
<span class="pill">PpO₂max</span>
</div>
<!-- Salinity selector -->
<div class="row">
<select id="salinity">
<option value="sea">Sea water</option>
<option value="fresh">Fresh water</option>
<option value="en13319">EN13319</option>
</select>
<span class="pill">Salinity</span>
</div>
<!-- Altitude input -->
<div class="row">
<div class="input-unit">
<input id="altitude" type="number" min="0" max="10000" step="10" value="0" />
<span class="unit">m</span>
<span class="stepper">
<button type="button" id="altitudeUp">▲</button>
<button type="button" id="altitudeDown">▼</button>
</span>
</div>
<span class="pill">Altitude</span>
</div>
<!-- Blend preset selector -->
<div class="row">
<select id="preset">
<option value="custom">Custom</option>
<optgroup label="Basic">
<option value="air">Air</option>
</optgroup>
<optgroup label="Nitrox">
<option value="nx28">Nitrox 28</option>
<option value="nx30">Nitrox 30</option>
<option value="nx32">Nitrox 32</option>
<option value="nx34">Nitrox 34</option>
<option value="nx36">Nitrox 36</option>
<option value="nx40">Nitrox 40</option>
</optgroup>
<optgroup label="Deco mixes">
<option value="nx50">Nitrox 50</option>
<option value="nx80">Nitrox 80</option>
<option value="o2">O₂</option>
</optgroup>
<optgroup label="Trimix (normoxic)">
<option value="tx21/35">Trimix 21/35</option>
</optgroup>
<optgroup label="Trimix (hypoxic)">
<option value="tx18/45">Trimix 18/45</option>
<option value="tx15/55">Trimix 15/55</option>
<option value="tx12/65">Trimix 12/65</option>
<option value="tx10/70">Trimix 10/70</option>
<option value="tx4/90">Trimix 4/90</option>
</optgroup>
<optgroup label="Other Helium mixes">
<option value="hx10/90">Heliox 10/90</option>
<option value="helitrox 21/20">Helitrox 21/20</option>
</optgroup>
<optgroup label="Hydrogen mixes">
<option value="hydrox 4/96">Hydrox 4/96</option>
<option value="hydreliox 4/30">Hydreliox 4/30</option>
</optgroup>
</select>
<span class="pill">Blend</span>
</div>
<!-- Gas fractions -->
<div class="blend">
<div class="gas"><label>O₂</label><input id="o2" type="number" min="0" max="100" step="0.1" value="21"><span>%</span></div>
<div class="gas"><label>He</label><input id="he" type="number" min="0" max="100" step="0.1" value="0"><span>%</span></div>
<div class="gas"><label>H₂</label><input id="h2" type="number" min="0" max="100" step="0.1" value="0"><span>%</span></div>
<div class="gas"><label>N₂</label><input id="n2" disabled><span>%</span></div>
</div>
<div class="err" id="err"></div>
</div>
<script>
/* ---------- DOM helpers ---------- */
const el = id => document.getElementById(id);
/* ---------- Inputs ---------- */
const ppo2El = el('ppo2');
const salinityEl = el('salinity');
const altitudeEl = el('altitude');
const presetEl = el('preset');
const o2El = el('o2');
const heEl = el('he');
const h2El = el('h2');
const n2El = el('n2');
const ppo2UpEl = el('ppo2Up');
const ppo2DownEl = el('ppo2Down');
const altitudeUpEl = el('altitudeUp');
const altitudeDownEl = el('altitudeDown');
/* ---------- Outputs ---------- */
const modEl = el('mod');
const minodBoxEl = el('minodBox');
const minodEl = el('minod');
const ppBoxEl = el('ppBox');
const ppOutEl = el('ppOut');
const endEl = el('end');
const errEl = el('err');
/* ---------- Model parameters ---------- */
const PPO2_MIN = 0.16;
const PPO2_MAX_MIN = 0.5;
const PPO2_MAX_MAX = 2.0;
const SALINITY_FACTORS = {
sea: { label: 'Sea water', metersPerBar: 100000 / (1025 * 9.80665) },
fresh: { label: 'Fresh water', metersPerBar: 100000 / (1000 * 9.80665) },
en13319: { label: 'EN13319', metersPerBar: 10.0 }
};
/* ---------- Preset definitions ---------- */
const PRESETS = {
air:{o2:21,he:0,h2:0},
nx28:{o2:28,he:0,h2:0},
nx30:{o2:30,he:0,h2:0},
nx32:{o2:32,he:0,h2:0},
nx34:{o2:34,he:0,h2:0},
nx36:{o2:36,he:0,h2:0},
nx40:{o2:40,he:0,h2:0},
nx50:{o2:50,he:0,h2:0},
nx80:{o2:80,he:0,h2:0},
o2:{o2:100,he:0,h2:0},
"tx21/35":{o2:21,he:35,h2:0},
"tx18/45":{o2:18,he:45,h2:0},
"tx15/55":{o2:15,he:55,h2:0},
"tx12/65":{o2:12,he:65,h2:0},
"tx10/70":{o2:10,he:70,h2:0},
"tx4/90": {o2:4, he:90,h2:0},
"hx10/90":{o2:10,he:90,h2:0},
"helitrox 21/20":{o2:21,he:20,h2:0},
"hydrox 4/96":{o2:4,he:0,h2:96},
"hydreliox 4/30":{o2:4,he:30,h2:66}
};
/* ---------- Utility ---------- */
const parseDecimal = s => parseFloat(String(s).replace(',', '.'));
const clamp = (v,min,max) =>
Math.min(max, Math.max(min, Number(v) || 0));
const formatPercent = v => {
const rounded1 = Math.round(v * 10) / 10;
return Number.isInteger(rounded1) ? String(rounded1) : rounded1.toFixed(1);
};
const formatPpO2 = v => {
const r = Math.round(v * 10) / 10;
return Number.isInteger(r) ? String(r) : r.toFixed(1);
};
const clearOutputs = () => {
modEl.textContent = '– m';
endEl.textContent = '–';
minodEl.textContent = '– m';
ppOutEl.textContent = '–';
ppBoxEl.style.display = 'none';
minodBoxEl.style.display = 'none';
};
/* ---------- Physics ---------- */
const surfacePressureBar = altitudeMeters => {
const h = clamp(altitudeMeters, 0, 10000);
return Math.pow(1 - 2.25577e-5 * h, 5.25588);
};
const modMeters = (ppO2max, fo2, metersPerBar, pSurfaceBar) =>
((ppO2max / fo2) - pSurfaceBar) * metersPerBar;
const minodMeters = (ppO2min, fo2, metersPerBar, pSurfaceBar) =>
((ppO2min / fo2) - pSurfaceBar) * metersPerBar;
const endMeters = (mod, fn2, metersPerBar, pSurfaceBar) =>
(((mod / metersPerBar + pSurfaceBar) * fn2 / 0.79) - pSurfaceBar) * metersPerBar;
/* ---------- Recompute ---------- */
function recompute(changedEl) {
errEl.textContent = '';
let o2 = clamp(o2El.value, 0, 100);
let he = clamp(heEl.value, 0, 100);
let h2 = clamp(h2El.value, 0, 100);
const ppO2max = clamp(parseDecimal(ppo2El.value), PPO2_MAX_MIN, PPO2_MAX_MAX);
const altitude = clamp(altitudeEl.value, 0, 10000);
const salinity = SALINITY_FACTORS[salinityEl.value] || SALINITY_FACTORS.en13319;
const metersPerBar = salinity.metersPerBar;
const pSurfaceBar = surfacePressureBar(altitude);
// Cross-field clamping: clamp the edited field so that O2+He+H2 ≤ 100.
if (o2 + he + h2 > 100) {
if (changedEl === o2El) { o2 = Math.max(0, 100 - he - h2); }
else if (changedEl === heEl) { he = Math.max(0, 100 - o2 - h2); }
else if (changedEl === h2El) { h2 = Math.max(0, 100 - o2 - he); }
}
o2El.value = formatPercent(o2);
heEl.value = formatPercent(he);
h2El.value = formatPercent(h2);
ppo2El.value = formatPpO2(ppO2max);
altitudeEl.value = String(Math.round(altitude));
const n2 = 100 - o2 - he - h2;
n2El.value = formatPercent(n2);
const fo2 = o2 / 100;
if (fo2 <= 0) {
errEl.textContent = 'Invalid blend: O₂ fraction must be greater than 0%.';
clearOutputs();
return;
}
/* MOD */
const mod = modMeters(ppO2max, fo2, metersPerBar, pSurfaceBar);
if (mod < 0) {
errEl.textContent = 'Gas exceeds ppO₂ limit at the surface — no valid operating depth.';
clearOutputs();
return;
}
modEl.textContent = Math.round(mod) + ' m';
/* MinOD */
const rawMinod = minodMeters(PPO2_MIN, fo2, metersPerBar, pSurfaceBar);
const minod = Math.max(0, rawMinod);
if (rawMinod >= 0) {
minodEl.textContent = Math.round(minod) + ' m';
minodBoxEl.style.display = '';
} else {
minodBoxEl.style.display = 'none';
}
/* Partial pressures at MOD
Absolute pressure at depth (bar): Pabs = depth/10 + 1
Partial pressure (bar): Pp_gas = F_gas × Pabs
Do not display gases with 0% fraction.
*/
const pAbs = mod / metersPerBar + pSurfaceBar;
const parts = [];
if (o2 > 0) {
const ppO2 = pAbs * (o2 / 100);
parts.push(`PpO<sub>2</sub>: ${ppO2.toFixed(2)} bar`);
}
if (he > 0) {
const ppHe = pAbs * (he / 100);
parts.push(`PpHe: ${ppHe.toFixed(2)} bar`);
}
if (h2 > 0) {
const ppH2 = pAbs * (h2 / 100);
parts.push(`PpH<sub>2</sub>: ${ppH2.toFixed(2)} bar`);
}
if (n2 > 0) {
const ppN2 = pAbs * (n2 / 100);
parts.push(`PpN<sub>2</sub>: ${ppN2.toFixed(2)} bar`);
}
if (parts.length) {
ppOutEl.innerHTML = parts.join('<br>');
ppBoxEl.style.display = '';
} else {
ppOutEl.textContent = '–';
ppBoxEl.style.display = 'none';
}
/* END */
if (n2 <= 0) {
endEl.textContent = 'Undefined';
return;
}
const end = Math.max(0, endMeters(mod, n2 / 100, metersPerBar, pSurfaceBar));
endEl.textContent = Math.round(end) + ' m';
}
/* ---------- Events ---------- */
presetEl.onchange = () => {
const p = PRESETS[presetEl.value];
if (p) {
o2El.value = p.o2;
heEl.value = p.he;
h2El.value = p.h2;
}
recompute();
};
[o2El, heEl, h2El].forEach(e =>
e.oninput = () => {
if (presetEl.value !== 'custom') presetEl.value = 'custom';
recompute(e);
}
);
salinityEl.onchange = recompute;
altitudeEl.oninput = recompute;
ppo2El.oninput = recompute;
ppo2UpEl.onclick = () => {
const v = clamp(parseDecimal(ppo2El.value), PPO2_MAX_MIN, PPO2_MAX_MAX);
ppo2El.value = formatPpO2(clamp(Math.round((v + 0.1) * 10) / 10, PPO2_MAX_MIN, PPO2_MAX_MAX));
recompute();
};
ppo2DownEl.onclick = () => {
const v = clamp(parseDecimal(ppo2El.value), PPO2_MAX_MIN, PPO2_MAX_MAX);
ppo2El.value = formatPpO2(clamp(Math.round((v - 0.1) * 10) / 10, PPO2_MAX_MIN, PPO2_MAX_MAX));
recompute();
};
altitudeUpEl.onclick = () => { altitudeEl.stepUp(); recompute(); };
altitudeDownEl.onclick = () => { altitudeEl.stepDown(); recompute(); };
/* ---------- Init ---------- */
salinityEl.value = 'sea';
presetEl.value = 'air';
presetEl.onchange();
</script>
</body>
</html>
