Buoyancy Calculator / buoyancy_calculator.html
buoyancy_calculator.html45.2 KB
<!doctype html>
<!-- Scuba diving weighting calculator. -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Buoyancy Calculator</title>
<style>
:root{
--bg:#f0f0f0;
--surface:#ffffff;
--ink:#111111;
--muted:#555555;
--line:#c0c0c0;
--panel:#e4e4e4;
--accent:#0055cc;
--accent-lt:#d6e4ff;
}
body{
margin:14px;
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
font-size:13px;
line-height:1.45;
color:var(--ink);
background:var(--bg);
}
h1{
font-size:22px;
font-weight:700;
margin:0 auto 4px;
max-width:1160px;
letter-spacing:-.02em;
}
.app{
display:grid;
grid-template-columns:1fr 1fr;
gap:12px;
max-width:1160px;
margin:0 auto;
}
.card{
border:1px solid var(--line);
border-radius:8px;
padding:12px 16px;
background:var(--surface);
box-shadow:0 1px 3px rgba(0,0,0,.06);
}
.card.full{ grid-column:1 / -1; }
.row{
display:flex;
gap:12px;
flex-wrap:wrap;
align-items:center;
}
.row + .row{ margin-top:8px; }
.lbl{ display:inline-block; width:85px; }
.titleRow{
display:flex;
justify-content:space-between;
align-items:baseline;
gap:12px;
margin-bottom:10px;
padding-bottom:8px;
border-bottom:1px solid var(--line);
}
.titleRow strong{
font-size:13px;
font-weight:700;
letter-spacing:.01em;
color:var(--ink);
}
.titleRow .hint{
font-size:12px;
color:var(--muted);
}
label{
display:inline-flex;
gap:8px;
align-items:center;
}
.unitBox{
padding:4px 8px;
border:1px solid var(--line);
border-radius:5px;
background:var(--panel);
font-size:12px;
color:var(--muted);
}
input[type="number"],
input[type="text"]{
padding:4px 7px;
border:1px solid var(--line);
border-radius:5px;
background:var(--surface);
font-size:13px;
font-family:inherit;
color:var(--ink);
}
input[type="number"]{ width:110px; }
input[type="number"]:focus,
select:focus{
outline:2px solid var(--accent);
outline-offset:1px;
border-color:var(--accent);
}
button{
padding:6px 12px;
border:1px solid #aaaaaa;
background:#d8d8d8;
border-radius:6px;
cursor:pointer;
font-size:13px;
font-family:inherit;
color:var(--ink);
font-weight:500;
}
button:hover{ background:#c8c8c8; border-color:#888888; }
button:active{ background:#b8b8b8; }
button.mini{ padding:3px 8px; font-size:12px; }
select{
padding:4px 8px;
border:1px solid var(--line);
border-radius:5px;
background:var(--surface);
font-size:13px;
font-family:inherit;
color:var(--ink);
height:28px;
}
table{
width:100%;
border-collapse:collapse;
}
th, td{
border-bottom:1px solid #e0e0e0;
padding:6px 8px;
text-align:left;
vertical-align:middle;
}
th{
font-size:11px;
color:#003a8c;
font-weight:600;
letter-spacing:.04em;
text-transform:uppercase;
}
td{ font-size:13px; }
.resultGrid{
display:grid;
grid-template-columns:1fr 1fr;
gap:8px 16px;
}
.resultItem{
display:flex;
justify-content:space-between;
align-items:baseline;
padding:4px 0;
border-bottom:1px dotted var(--line);
}
.resultLabel{
font-size:12px;
color:var(--muted);
}
.resultValue{
font-weight:600;
font-size:13px;
}
.bigResult{
text-align:center;
padding:12px;
background:var(--accent-lt);
border-radius:6px;
margin-bottom:12px;
}
.bigResult .value{
font-size:28px;
font-weight:800;
color:var(--accent);
}
.bigResult .label{
font-size:12px;
color:var(--muted);
margin-top:2px;
}
.bigResults{
display:flex;
gap:12px;
}
.bigResults .bigResult{ flex:1; }
.cellOk{ color:#1a7f37; }
.cellWarn{ color:#c05000; font-weight:600; }
.cellBad{ color:#cc0000; font-weight:600; }
.tankRow{ margin-bottom:8px; }
.tankRowMain{
display:flex;
gap:10px;
align-items:center;
flex-wrap:wrap;
}
.tankRowMain select{ max-width:380px; }
.section{ margin-top:10px; }
.checkRow{
display:flex;
gap:12px;
align-items:center;
flex-wrap:wrap;
}
.checkRow label{
gap:4px;
font-size:13px;
}
.checkRow input[type="checkbox"]{ margin:0; }
.thicknessInput{
width:50px !important;
}
@media (max-width: 980px){
.app{
grid-template-columns:1fr;
}
.card.full{ grid-column:1; }
.bigResults{ flex-direction:column; }
.resultGrid{ grid-template-columns:1fr; }
}
</style>
</head>
<body>
<h1>Buoyancy Calculator</h1>
<div class="app">
<!-- Diver & Dive -->
<div class="card">
<div class="titleRow">
<strong>Diver & Dive</strong>
</div>
<div class="row">
<label><span class="lbl">Water</span>
<select id="waterType">
<option value="salt">Salt</option>
<option value="fresh">Fresh</option>
</select>
</label>
</div>
<div class="row">
<label><span class="lbl">Height</span><input type="number" id="height" value="170" step="1"><span class="unitBox">cm</span></label>
<label><span class="lbl">Weight</span><input type="number" id="weight" value="80" step="1"><span class="unitBox">kg</span></label>
</div>
<div class="row">
<label><span class="lbl">Max depth</span><input type="number" id="maxDepth" value="30" step="1"><span class="unitBox">m</span></label>
</div>
</div>
<!-- Personal Buoyancy -->
<div class="card">
<div class="titleRow">
<strong>Personal Buoyancy</strong>
<span class="hint">Optional — improves accuracy</span>
</div>
<div class="row">
<label>Method
<select id="personalBuoyancyMode">
<option value="none">Skip (assume neutral)</option>
<option value="measured_weight">Float test — weight held</option>
<option value="measured_flotation">Float test — volume displaced</option>
<option value="manual">Enter value directly</option>
</select>
</label>
</div>
<div id="pbMeasuredSection" hidden>
<div class="row">
<label>Test water
<select id="personalBuoyancyWaterType">
<option value="salt">Salt</option>
<option value="fresh">Fresh</option>
</select>
</label>
</div>
<div class="row" id="pbWeightRow" hidden>
<label>Weight held <input type="number" id="personalBuoyancyWeight" value="0" step="0.1"><span class="unitBox">kg</span></label>
</div>
<div class="row" id="pbFlotRow" hidden>
<label>Water displaced <input type="number" id="personalBuoyancyFlotation" value="0" step="1"><span class="unitBox">mL</span></label>
</div>
</div>
<div id="pbManualSection" hidden>
<div class="row">
<label>Personal buoyancy <input type="number" id="personalBuoyancyManual" value="0" step="0.1"><span class="unitBox">kg</span></label>
</div>
</div>
</div>
<!-- Exposure Suit -->
<div class="card full">
<div class="titleRow">
<strong>Exposure Suit</strong>
</div>
<div class="row">
<label>Type
<select id="suitType">
<option value="wet">Wetsuit</option>
<option value="dry">Drysuit</option>
<option value="none">No suit / skin</option>
</select>
</label>
</div>
<div id="wetSection" class="section">
<div class="row">
<label>Style
<select id="wetStyle">
<option value="full">Full suit</option>
<option value="shorty">Shorty</option>
<option value="farmerjohn">Farmer John (2-piece)</option>
</select>
</label>
<label>Thickness
<select id="wetThickness">
<option value="1">1 mm</option>
<option value="2">2 mm</option>
<option value="3">3 mm</option>
<option value="5">5 mm</option>
<option value="6">6 mm</option>
<option value="7" selected>7 mm</option>
<option value="9">9 mm</option>
</select>
</label>
<label>Condition
<select id="wetCondition">
<option value="standard">Standard</option>
<option value="new_soft">New / Soft neoprene</option>
<option value="old">Old (1yr+ heavy use)</option>
</select>
</label>
</div>
<div class="row" id="accWetOnly">
<label><input type="checkbox" id="hasVest"> Vest
<input type="number" id="vestThickness" class="thicknessInput" value="3" min="1" max="9" step="1" disabled> mm
</label>
</div>
</div>
<div id="drySection" class="section" hidden>
<div class="row">
<label>Shell
<select id="drysuitMaterial">
<option value="trilaminate">Trilaminate / Membrane</option>
<option value="neoprene">Neoprene</option>
<option value="compressed">Compressed neoprene</option>
<option value="crushed">Crushed neoprene</option>
</select>
</label>
<label id="dryShellThicknessLabel" hidden>Shell thickness
<select id="drysuitShellThickness">
<option value="1">1 mm</option>
<option value="2">2 mm</option>
<option value="3" selected>3 mm</option>
<option value="4">4 mm</option>
<option value="5">5 mm</option>
<option value="6">6 mm</option>
<option value="7">7 mm</option>
</select>
</label>
<label>Undersuit
<select id="drysuitLinerThickness">
<option value="0">None (0 mm)</option>
<option value="2">Thin (2 mm)</option>
<option value="3" selected>Medium (3 mm)</option>
<option value="5">Thick (5 mm)</option>
<option value="7">Heavy (7 mm)</option>
<option value="9">Extreme (9 mm)</option>
</select>
</label>
</div>
</div>
<div class="section">
<div class="row" style="font-size:12px; color:var(--muted); margin-bottom:4px;">Accessories</div>
<div class="checkRow">
<label><input type="checkbox" id="hasGloves" checked> Gloves
<input type="number" id="gloveThickness" class="thicknessInput" value="5" min="0" max="9" step="1"> mm
</label>
<label><input type="checkbox" id="hasBoots" checked> Boots
<input type="number" id="bootieThickness" class="thicknessInput" value="6" min="0" max="9" step="1"> mm
</label>
<label><input type="checkbox" id="hasHood" checked> Hood
<input type="number" id="hoodThickness" class="thicknessInput" value="7" min="0" max="9" step="1"> mm
</label>
</div>
</div>
<div class="section">
<div class="row">
<label>Measured suit buoyancy (optional override) <input type="number" id="measuredSuitBuoyancy" placeholder="auto" step="0.1" style="width:80px;"><span class="unitBox">kg</span></label>
</div>
</div>
</div>
<!-- Tanks -->
<div class="card full">
<div class="titleRow">
<strong>Tanks</strong>
<button class="mini" id="addTankBtn">+ Add tank</button>
</div>
<div id="tankRows"></div>
</div>
<!-- Rig & Lift -->
<div class="card">
<div class="titleRow">
<strong>Rig</strong>
<span class="hint">Non-tank gear buoyancy</span>
</div>
<div class="row">
<label><span class="lbl">BCD / wing</span><input type="number" id="rigBCD" value="0" step="0.1"><span class="unitBox">kg</span></label>
</div>
<div class="row">
<label><span class="lbl">Regulators</span><input type="number" id="rigRegs" value="-0.7" step="0.1"><span class="unitBox">kg</span></label>
</div>
<div class="row">
<label><span class="lbl">Other</span><input type="number" id="rigOther" value="0" step="0.1"><span class="unitBox">kg</span></label>
</div>
</div>
<!-- Results -->
<div class="card full">
<div class="titleRow">
<strong>Results</strong>
</div>
<div class="bigResults">
<div class="bigResult">
<div class="value" id="resRecommended">—</div>
<div class="label">Recommended weight</div>
</div>
<div class="bigResult">
<div class="value" id="resLift">—</div>
<div class="value" id="resLiftLbs" style="font-size:16px;color:var(--muted);font-weight:600;">—</div>
<div class="label">BCD lift needed</div>
</div>
</div>
<div class="resultGrid">
<div class="resultItem">
<span class="resultLabel">Suit buoyancy (surface)</span>
<span class="resultValue" id="resSuitSurface">—</span>
</div>
<div class="resultItem">
<span class="resultLabel">Personal buoyancy</span>
<span class="resultValue" id="resPersonal">—</span>
</div>
<div class="resultItem">
<span class="resultLabel">Tank buoyancy (empty)</span>
<span class="resultValue" id="resTankEmpty">—</span>
</div>
<div class="resultItem">
<span class="resultLabel">Gas carried (weight)</span>
<span class="resultValue" id="resGasCarried">—</span>
</div>
<div class="resultItem">
<span class="resultLabel">Rig hardware</span>
<span class="resultValue" id="resRigFixed">—</span>
</div>
<div class="resultItem">
<span class="resultLabel">Rig buoyancy (loaded)</span>
<span class="resultValue" id="resRigBuoy">—</span>
</div>
</div>
</div>
<!-- Buoyancy at depth breakdown -->
<div class="card full">
<div class="titleRow">
<strong>Buoyancy at Depth</strong>
<span class="hint">Net buoyancy with recommended weight at key depths</span>
</div>
<table>
<thead>
<tr>
<th>Depth</th>
<th>Full tank</th>
<th>Planned end</th>
</tr>
</thead>
<tbody id="depthBody"></tbody>
</table>
</div>
</div>
</div>
<script>
"use strict";
/* ================================================================
Optimal Buoyancy Calculator
Port of Robert Singler's "Optimal Buoyancy Computer" spreadsheet
(v71, August 2019) to an interactive web tool.
Educational tool only — not for dive planning.
================================================================ */
/* ----------------------------------------------------------------
Constants
---------------------------------------------------------------- */
/** Water density in kg/cm³. Used to compute displacement buoyancy. */
const SALT_WATER_DENSITY_KG_CM3 = 0.001027;
const FRESH_WATER_DENSITY_KG_CM3 = 0.001;
/** Standard atmosphere in pascals, for depth-to-pressure conversion. */
const ATM_PA = 101325;
/** Gravitational acceleration (m/s²). */
const G = 9.80665;
/** Density of air at 20 °C / 1 atm (kg/L). */
const AIR_DENSITY_KG_L = 0.001225;
/**
* Molecular masses (g/mol) for breathing gas density computation.
* Gas density scales linearly with molecular mass at the same T and P,
* so the ratio to air gives a multiplier on AIR_DENSITY_KG_L.
*/
const MOL_O2 = 32.0;
const MOL_N2 = 28.0;
const MOL_HE = 4.0;
const MOL_AIR = 28.97;
/**
* Density of closed-cell neoprene foam (kg/cm³).
* Rubber matrix (~1.23 g/cm³) plus ~70 % gas cells by volume.
* Subtracting this from water density gives the net buoyancy per cm³
* of neoprene, rather than treating the material as weightless.
*/
const NEOPRENE_FOAM_DENSITY_KG_CM3 = 0.00025;
/**
* Density of crushed neoprene (kg/cm³).
* Gas cells are fully collapsed, leaving mostly solid rubber.
*/
const CRUSHED_NEOPRENE_DENSITY_KG_CM3 = 0.00110;
/** Typical shell thickness of crushed neoprene drysuits (mm). */
const CRUSHED_SHELL_THICKNESS_MM = 2;
/* ----------------------------------------------------------------
Tank database (extracted from spreadsheet v71)
---------------------------------------------------------------- */
const TANK_DB = [{"material":"Steel","size_L":3,"wp_bar":232,"capacity_L":696,"buoyancy_kg":-1.7,"weight_kg":4.8},{"material":"Steel","size_L":3,"wp_bar":300,"capacity_L":900,"buoyancy_kg":-1.7,"weight_kg":4.7},{"material":"Steel","size_L":5,"wp_bar":232,"capacity_L":1160,"buoyancy_kg":-1.4,"weight_kg":6.8},{"material":"Steel","size_L":7,"wp_bar":232,"capacity_L":1624,"buoyancy_kg":-1.4,"weight_kg":9.1},{"material":"Steel","size_L":7,"wp_bar":300,"capacity_L":2100,"buoyancy_kg":-1.9,"weight_kg":9.7},{"material":"Steel","size_L":8,"wp_bar":232,"capacity_L":1856,"buoyancy_kg":-1.0,"weight_kg":9.8},{"material":"Steel","size_L":8,"wp_bar":300,"capacity_L":2400,"buoyancy_kg":-1.9,"weight_kg":10.9},{"material":"Steel","size_L":10,"wp_bar":232,"capacity_L":2320,"buoyancy_kg":-1.0,"weight_kg":12.2},{"material":"Steel","size_L":10,"wp_bar":300,"capacity_L":3000,"buoyancy_kg":-3.9,"weight_kg":15.6},{"material":"Steel","size_L":12,"wp_bar":232,"capacity_L":2784,"buoyancy_kg":-0.9,"weight_kg":14.5},{"material":"Steel","size_L":12,"wp_bar":300,"capacity_L":3600,"buoyancy_kg":-3.7,"weight_kg":17.6},{"material":"Steel","size_L":15,"wp_bar":232,"capacity_L":3480,"buoyancy_kg":-1.0,"weight_kg":18.2},{"material":"Steel","size_L":18,"wp_bar":232,"capacity_L":4176,"buoyancy_kg":-0.4,"weight_kg":21.0},{"material":"Steel","size_L":20,"wp_bar":232,"capacity_L":4640,"buoyancy_kg":0.1,"weight_kg":22.8},{"material":"Aluminum","size_L":3,"wp_bar":232,"capacity_L":696,"buoyancy_kg":-0.1,"weight_kg":4.0},{"material":"Aluminum","size_L":7,"wp_bar":200,"capacity_L":1400,"buoyancy_kg":1.1,"weight_kg":8.7},{"material":"Aluminum","size_L":7,"wp_bar":232,"capacity_L":1624,"buoyancy_kg":1.1,"weight_kg":8.8},{"material":"Aluminum","size_L":10,"wp_bar":232,"capacity_L":2320,"buoyancy_kg":2.1,"weight_kg":12.1},{"material":"Aluminum","size_L":11.1,"wp_bar":207,"capacity_L":2297.7,"buoyancy_kg":1.9,"weight_kg":14.3}];
/* ----------------------------------------------------------------
State
---------------------------------------------------------------- */
const S = {
units: "metric",
height: 170, weight: 80,
maxDepth: 30, waterType: "salt",
personalBuoyancyMode: "none",
personalBuoyancyWaterType: "salt",
personalBuoyancyWeight: 0,
personalBuoyancyFlotation: 0,
suitType: "wet",
wetStyle: "full",
wetThickness: 7,
wetCondition: "standard",
hasVest: false, vestThickness: 3,
hasGloves: true, gloveThickness: 5,
hasBoots: true, bootieThickness: 6,
hasHood: true, hoodThickness: 7,
measuredSuitBuoyancy: null,
drysuitMaterial: "trilaminate",
drysuitShellThickness: 0,
drysuitLinerThickness: 3,
rigBCD: 0,
rigRegs: -0.7,
rigOther: 0,
tanks: [{ idx: 9, qty: 1, startPressure: null, endPressure: null, gasMix: "air", gasO2: 32, gasHe: 0 }],
};
/* ----------------------------------------------------------------
Helpers
---------------------------------------------------------------- */
function $(id) { return document.getElementById(id); }
function round1(x) { return Math.round(x * 10) / 10; }
function round2(x) { return Math.round(x * 100) / 100; }
function densityFactor() {
return S.waterType === "salt" ? SALT_WATER_DENSITY_KG_CM3 : FRESH_WATER_DENSITY_KG_CM3;
}
/**
* Convert depth in metres to absolute pressure in atmospheres.
* P = 1 atm + (depth × water_density × g) / 101 325
* Salt water ≈ 1 atm per 10.06 m; fresh water ≈ 1 atm per 10.33 m.
*/
function depthToAtm(depth) {
const rhoKgM3 = densityFactor() * 1e6; // kg/cm³ → kg/m³
return 1 + (depth * rhoKgM3 * G) / ATM_PA;
}
/* ----------------------------------------------------------------
BSA & Body Volume (DuBois method)
---------------------------------------------------------------- */
function bsa() {
return 0.007184 * Math.pow(S.height, 0.725) * Math.pow(S.weight, 0.425);
}
function ebv() {
return bsa() * (51.44 * S.weight / S.height + 15.3);
}
/* ----------------------------------------------------------------
Personal Buoyancy
At eye level, skull above water ≈ 2.2 lb / 1 kg positive.
---------------------------------------------------------------- */
function personalBuoyancy() {
if (S.personalBuoyancyMode === "none") return 0;
if (S.personalBuoyancyMode === "manual") {
return S.personalBuoyancyWeight || 0;
}
const skull = 1.0;
let pb;
if (S.personalBuoyancyMode === "measured_weight") {
pb = skull - Math.abs(S.personalBuoyancyWeight || 0);
} else {
const flotMl = Math.abs(S.personalBuoyancyFlotation || 0);
const flotKg = flotMl / 1000 * densityFactor() * 1e6;
pb = skull + flotKg;
}
if (S.personalBuoyancyWaterType !== S.waterType) {
const bodyVol = ebv();
const correction = bodyVol * (SALT_WATER_DENSITY_KG_CM3 - FRESH_WATER_DENSITY_KG_CM3) * 1000;
pb += (S.waterType === "salt" ? 1 : -1) * correction;
}
return pb;
}
/* ----------------------------------------------------------------
Neoprene Compression Polynomial
6th order polynomial approximating compression ratio vs. atmospheres.
Standard & Old neoprene only; New Soft uses simple inverse.
---------------------------------------------------------------- */
function neopreneCompressionFactor(atm) {
return 2.621
- 2.82 * Math.pow(atm, 1)
+ 1.618 * Math.pow(atm, 2)
- 0.492 * Math.pow(atm, 3)
+ 0.08 * Math.pow(atm, 4)
- 0.0066 * Math.pow(atm, 5)
+ 0.0002174 * Math.pow(atm, 6);
}
/* ----------------------------------------------------------------
Suit Buoyancy
---------------------------------------------------------------- */
function suitSurfaceBuoyancy() {
if (S.measuredSuitBuoyancy !== null && S.measuredSuitBuoyancy !== "") {
return parseFloat(S.measuredSuitBuoyancy);
}
if (S.suitType === "none") return accessoryBuoyancy();
if (S.suitType === "dry") return drysuitSurfaceBuoyancy();
return wetsuitSurfaceBuoyancy();
}
/**
* Net buoyancy of a wetsuit at the surface.
* Computed as volume × (water_density − neoprene_density) × aging,
* accounting for the weight of the neoprene foam itself.
*/
function wetsuitSurfaceBuoyancy() {
const b = bsa();
const dNet = densityFactor() - NEOPRENE_FOAM_DENSITY_KG_CM3;
const t = S.wetThickness;
const isOld = S.wetCondition === "old";
const aging = isOld ? 0.8 : 1.0;
let bsaFraction;
if (S.wetStyle === "shorty") bsaFraction = 0.60;
else if (S.wetStyle === "farmerjohn") bsaFraction = 0.78 * 1.3;
else bsaFraction = 0.78;
const suitBuoy = b * bsaFraction * 10000 * t / 10 * dNet * aging;
return suitBuoy + accessoryBuoyancy();
}
/**
* Net buoyancy of neoprene accessories (vest, gloves, boots, hood).
* Uses (water_density − neoprene_density) for net lift per cm³.
*/
function accessoryBuoyancy() {
const b = bsa();
const dNet = densityFactor() - NEOPRENE_FOAM_DENSITY_KG_CM3;
const isOld = S.suitType === "wet" && S.wetCondition === "old";
const aging = isOld ? 0.8 : 1.0;
let acc = 0;
if (S.hasVest && S.suitType === "wet")
acc += b * 0.31 * 10000 * S.vestThickness / 10 * dNet * aging;
if (S.hasGloves)
acc += b * 0.06 * 10000 * S.gloveThickness / 10 * dNet * aging;
if (S.hasBoots)
acc += b * 0.07 * 10000 * S.bootieThickness / 10 * dNet * aging;
if (S.hasHood)
acc += b * 0.095 * 10000 * S.hoodThickness / 10 * dNet * aging;
return acc;
}
/**
* Net buoyancy of a drysuit at the surface.
* Liner buoyancy uses (water_density − neoprene_density) for net lift.
* Crushed neoprene shell is BSA-proportional using solid rubber density.
*/
function drysuitSurfaceBuoyancy() {
const b = bsa();
const d = densityFactor();
const dNet = d - NEOPRENE_FOAM_DENSITY_KG_CM3;
const lt = S.drysuitLinerThickness;
const mat = S.drysuitMaterial;
let buoy;
const st = S.drysuitShellThickness;
if (mat === "trilaminate") {
buoy = b * 0.78 * 10000 * (lt + 2.5) / 8 * dNet;
} else if (mat === "neoprene") {
buoy = b * 0.78 * 10000 * (lt + 1.25 + st) / 8 * dNet;
} else if (mat === "compressed") {
buoy = b * 0.78 * 10000 * (lt + 1.75 + st) / 8 * dNet;
} else {
/* Crushed neoprene: liner uses foam density, shell uses solid rubber density */
let extra;
if (lt < 2) extra = lt + 3.5;
else if (lt < 3) extra = lt + 3;
else extra = lt + 2.5;
buoy = b * 0.78 * 10000 * extra / 8 * dNet;
/* Shell: all gas cells destroyed → solid rubber, denser than water → sinks */
buoy += b * 0.975 * 10000 * CRUSHED_SHELL_THICKNESS_MM / 10 * (d - CRUSHED_NEOPRENE_DENSITY_KG_CM3);
}
const acc = accessoryBuoyancy();
return buoy + acc;
}
function suitBuoyancyAtDepth(depthVal) {
if (S.suitType === "none") {
const atm = depthToAtm(depthVal);
const surfAcc = accessoryBuoyancy();
if (surfAcc === 0) return 0;
if (S.wetCondition === "new_soft") return surfAcc / atm;
return surfAcc * neopreneCompressionFactor(atm);
}
if (S.suitType === "dry") {
return drysuitBuoyancyAtDepth(depthVal);
}
const surfBuoy = suitSurfaceBuoyancy();
const atm = depthToAtm(depthVal);
if (S.wetCondition === "new_soft") {
return surfBuoy / atm;
}
return surfBuoy * neopreneCompressionFactor(atm);
}
/**
* Drysuit buoyancy at depth.
* Liner compresses with depth; shell behaviour depends on material:
* - trilaminate: no neoprene shell, neutral
* - neoprene / compressed: shell compresses per polynomial
* - crushed: gas cells already destroyed, shell is BSA-proportional dead weight
*/
function drysuitBuoyancyAtDepth(depthVal) {
const b = bsa();
const d = densityFactor();
const dNet = d - NEOPRENE_FOAM_DENSITY_KG_CM3;
const lt = S.drysuitLinerThickness;
const mat = S.drysuitMaterial;
const atm = depthToAtm(depthVal);
let linerBuoy;
if (mat === "trilaminate") {
linerBuoy = b * 0.78 * 10000 * (lt + 2.5) / 8 * dNet;
} else if (mat === "neoprene") {
linerBuoy = b * 0.78 * 10000 * (lt + 1.25) / 8 * dNet;
} else if (mat === "compressed") {
linerBuoy = b * 0.78 * 10000 * (lt + 1.55) / 8 * dNet;
} else {
let extra;
if (lt < 2) extra = lt + 3.5;
else if (lt < 3) extra = lt + 3;
else extra = lt + 2.5;
linerBuoy = b * 0.78 * 10000 * extra / 8 * dNet;
}
let shellBuoy;
if (mat === "trilaminate") {
shellBuoy = 0;
} else if (mat === "crushed") {
/* Crushed shell is incompressible solid rubber — constant negative buoyancy */
shellBuoy = b * 0.975 * 10000 * CRUSHED_SHELL_THICKNESS_MM / 10 * (d - CRUSHED_NEOPRENE_DENSITY_KG_CM3);
} else {
const shellMM = S.drysuitShellThickness;
const surfShell = b * 0.975 * 10000 * shellMM / 10 * dNet;
shellBuoy = surfShell * neopreneCompressionFactor(atm);
}
const acc = accessoryNeopreneBuoyancyAtDepth(depthVal);
return linerBuoy + shellBuoy + acc;
}
function accessoryNeopreneBuoyancyAtDepth(depthVal) {
const surfAcc = accessoryBuoyancy();
if (surfAcc === 0) return 0;
const atm = depthToAtm(depthVal);
return surfAcc * neopreneCompressionFactor(atm);
}
/* ----------------------------------------------------------------
Rig Buoyancy (fixed weight, excluding BCD air and diver)
---------------------------------------------------------------- */
function rigFixedWeight() {
return S.rigBCD + S.rigRegs + S.rigOther;
}
/* ----------------------------------------------------------------
Tank Buoyancy & Gas Weight
---------------------------------------------------------------- */
function getSelectedTanks() {
return S.tanks.map(t => {
const tank = TANK_DB[t.idx];
if (!tank) return null;
return { ...tank, qty: t.qty || 1, startPressure: t.startPressure, endPressure: t.endPressure };
}).filter(Boolean);
}
function tankEmptyBuoyancy() {
/* DB values are salt-water buoyancy. In fresh water the displacement is
less by size_L × (saltDensity − freshDensity) per litre of tank volume. */
const waterCorr = (densityFactor() - SALT_WATER_DENSITY_KG_CM3) * 1000; // 0 for salt, −0.027 for fresh
let total = 0;
for (const t of getSelectedTanks()) {
total += (t.buoyancy_kg + t.size_L * waterCorr) * t.qty;
}
return total;
}
/**
* Density ratio of a breathing gas relative to air, from molar masses.
* Air = 21 % O₂ + 79 % N₂ → M = 28.97 g/mol.
* @param {number} fO2 - O₂ fraction (0–1)
* @param {number} fHe - He fraction (0–1)
* @returns {number} multiplier on AIR_DENSITY_KG_L
*/
function gasDensityRatio(fO2, fHe) {
const fN2 = 1 - fO2 - fHe;
return (fO2 * MOL_O2 + fHe * MOL_HE + fN2 * MOL_N2) / MOL_AIR;
}
/**
* Weight of gas in a tank at a given pressure (kg, returned positive).
* Uses the tank's gas mix to adjust density relative to air.
* @param {Object} tank - tank DB entry
* @param {number} pressureBar - fill pressure
* @param {Object} tankState - state entry with gasMix / gasO2 / gasHe
*/
function gasWeightForTank(tank, pressureBar, tankState) {
if (!tank.capacity_L || !tank.wp_bar) return 0;
const volL = tank.capacity_L * (pressureBar / tank.wp_bar);
let ratio = 1;
if (tankState) {
const mix = tankState.gasMix || "air";
if (mix === "custom") ratio = gasDensityRatio((tankState.gasO2 || 21) / 100, (tankState.gasHe || 0) / 100);
/* mix === "air" → ratio stays 1 */
}
return volL * AIR_DENSITY_KG_L * ratio;
}
function tankStartPressureBar(t, tankData) {
if (t.startPressure != null && t.startPressure !== "") {
return parseFloat(t.startPressure);
}
return tankData.wp_bar || 232;
}
function tankEndPressureBar(t) {
if (t.endPressure != null && t.endPressure !== "") {
return parseFloat(t.endPressure);
}
return 50;
}
/** Total weight of gas carried at start pressure (returned negative = weighs diver down). */
function totalGasCarriedWeight() {
let total = 0;
for (const t of S.tanks) {
const tank = TANK_DB[t.idx];
if (!tank) continue;
const bar = tankStartPressureBar(t, tank);
total += gasWeightForTank(tank, bar, t) * (t.qty || 1);
}
return -total;
}
/** Total weight of gas remaining at planned end pressure. */
function totalEndDiveGasWeight() {
let total = 0;
for (const t of S.tanks) {
const tank = TANK_DB[t.idx];
if (!tank) continue;
const bar = tankEndPressureBar(t);
total += gasWeightForTank(tank, bar, t) * (t.qty || 1);
}
return -total;
}
/* ----------------------------------------------------------------
Core Calculations
All buoyancy values: positive = floats, negative = sinks
---------------------------------------------------------------- */
function netBuoyancyAtDepth(depthVal, gasWeight) {
const pb = personalBuoyancy();
const suit = suitBuoyancyAtDepth(depthVal);
const rig = rigFixedWeight();
const tankEmpty = tankEmptyBuoyancy();
return pb + suit + rig + tankEmpty + gasWeight;
}
function plannedWeightReq() {
const safetyDepth = 5;
const endGas = totalEndDiveGasWeight();
return netBuoyancyAtDepth(safetyDepth, endGas);
}
function recommendedWeight() {
return plannedWeightReq();
}
function liftRequirement() {
const w = recommendedWeight();
const fullGas = totalGasCarriedWeight();
const buoyAtDepth = netBuoyancyAtDepth(S.maxDepth, fullGas) - w;
return Math.abs(Math.min(0, buoyAtDepth)) + (1);
}
function rigBuoyancyAlone() {
const rig = rigFixedWeight();
const tankEmpty = tankEmptyBuoyancy();
const fullGas = totalGasCarriedWeight();
return rig + tankEmpty + fullGas;
}
/* ----------------------------------------------------------------
Depth buoyancy breakdown
Net buoyancy at key depths (surface, 5 m, half, max) for three
gas scenarios: full tank, planned end pressure, emergency (20 bar).
---------------------------------------------------------------- */
function computeDepthTable() {
const fullGas = totalGasCarriedWeight();
const endGas = totalEndDiveGasWeight();
const ceilW = Math.ceil(recommendedWeight());
const safetyD = 5;
const halfD = S.maxDepth / 2;
const depths = [0, safetyD, halfD, S.maxDepth];
const labels = ["Surface", "5 m", round1(halfD) + " m", S.maxDepth + " m"];
const rows = [];
for (let i = 0; i < depths.length; i++) {
const d = depths[i];
rows.push({
label: labels[i],
fullTank: round1(netBuoyancyAtDepth(d, fullGas) - ceilW),
plannedEnd: round1(netBuoyancyAtDepth(d, endGas) - ceilW),
});
}
return rows;
}
/* ----------------------------------------------------------------
QuikResults
---------------------------------------------------------------- */
function computeResults() {
const recommended = Math.ceil(plannedWeightReq());
const liftNeeded = Math.ceil(liftRequirement());
return {
recommended,
liftNeeded,
rigBuoy: round1(rigBuoyancyAlone()),
suitSurface: round1(suitSurfaceBuoyancy()),
personalBuoy: round1(personalBuoyancy()),
tankEmptyBuoy: round1(tankEmptyBuoyancy()),
gasCarried: round1(totalGasCarriedWeight()),
rigFixed: round1(rigFixedWeight()),
};
}
/* ----------------------------------------------------------------
UI Rendering
---------------------------------------------------------------- */
const TANK_GROUPS = [
{ label: "Steel", filter: t => t.material === "Steel" },
{ label: "Aluminum", filter: t => t.material === "Aluminum" },
];
function tankOptionText(t) {
const kg = round1(t.buoyancy_kg);
const sign = kg > 0 ? "+" : "";
return `${t.size_L}L — ${t.wp_bar} bar (${sign}${kg} kg)`;
}
function renderTankOptions() {
const sel = $("tankSelect0");
if (!sel) return;
sel.innerHTML = "";
for (const g of TANK_GROUPS) {
const group = document.createElement("optgroup");
group.label = g.label;
let hasItems = false;
TANK_DB.forEach((t, i) => {
if (!g.filter(t)) return;
hasItems = true;
const opt = document.createElement("option");
opt.value = i;
opt.textContent = tankOptionText(t);
group.appendChild(opt);
});
if (hasItems) sel.appendChild(group);
}
sel.value = S.tanks[0].idx;
}
function renderTankRows() {
const container = $("tankRows");
if (!container) return;
container.innerHTML = "";
S.tanks.forEach((t, i) => {
const tank = TANK_DB[t.idx];
const row = document.createElement("div");
row.className = "tankRow";
const mix = t.gasMix || "air";
const showCustom = mix === "custom";
row.innerHTML = `
<div class="tankRowMain">
<label>Tank ${i + 1}
<select class="tankSel" data-i="${i}">
${buildTankOptionsHTML(t.idx)}
</select>
</label>
<label>Qty <input type="number" class="tankQty" data-i="${i}" value="${t.qty || 1}" min="1" max="4" step="1" style="width:50px;"></label>
<label>Gas
<select class="tankMix" data-i="${i}">
<option value="air"${mix === "air" ? " selected" : ""}>Air</option>
<option value="custom"${mix === "custom" ? " selected" : ""}>Custom</option>
</select>
</label>
<span class="tankCustomGas" data-i="${i}" style="${showCustom ? "" : "display:none;"}">
<label>O₂ <input type="number" class="tankO2" data-i="${i}" value="${t.gasO2 || 21}" min="1" max="100" step="1" style="width:50px;">%</label>
<label>He <input type="number" class="tankHe" data-i="${i}" value="${t.gasHe || 0}" min="0" max="99" step="1" style="width:50px;">%</label>
</span>
<label>Start <input type="number" class="tankStart" data-i="${i}" value="${t.startPressure ?? ''}" placeholder="${tank ? tank.wp_bar : 232}" step="1" style="width:80px;"><span class="unitBox">bar</span></label>
<label>End <input type="number" class="tankEnd" data-i="${i}" value="${t.endPressure ?? ''}" placeholder="50" step="1" style="width:80px;"><span class="unitBox">bar</span></label>
${i > 0 ? `<button class="mini removeTank" data-i="${i}" title="Remove">✕</button>` : ''}
</div>
`;
container.appendChild(row);
});
container.querySelectorAll(".tankSel").forEach(el => el.addEventListener("change", e => {
S.tanks[+e.target.dataset.i].idx = +e.target.value;
update();
}));
container.querySelectorAll(".tankQty").forEach(el => el.addEventListener("input", e => {
S.tanks[+e.target.dataset.i].qty = +e.target.value || 1;
update();
}));
container.querySelectorAll(".tankStart").forEach(el => el.addEventListener("input", e => {
const v = e.target.value;
S.tanks[+e.target.dataset.i].startPressure = v === "" ? null : +v;
update();
}));
container.querySelectorAll(".tankEnd").forEach(el => el.addEventListener("input", e => {
const v = e.target.value;
S.tanks[+e.target.dataset.i].endPressure = v === "" ? null : +v;
update();
}));
container.querySelectorAll(".tankMix").forEach(el => el.addEventListener("change", e => {
const i = +e.target.dataset.i;
S.tanks[i].gasMix = e.target.value;
/* Show/hide custom O₂/He inputs */
const customSpan = container.querySelector(`.tankCustomGas[data-i="${i}"]`);
if (customSpan) customSpan.style.display = e.target.value === "custom" ? "" : "none";
update();
}));
container.querySelectorAll(".tankO2").forEach(el => el.addEventListener("input", e => {
S.tanks[+e.target.dataset.i].gasO2 = +e.target.value || 21;
update();
}));
container.querySelectorAll(".tankHe").forEach(el => el.addEventListener("input", e => {
S.tanks[+e.target.dataset.i].gasHe = +e.target.value || 0;
update();
}));
container.querySelectorAll(".removeTank").forEach(el => el.addEventListener("click", e => {
S.tanks.splice(+e.target.dataset.i, 1);
renderTankRows();
update();
}));
}
function buildTankOptionsHTML(selectedIdx) {
let html = "";
for (const g of TANK_GROUPS) {
let groupHtml = "";
TANK_DB.forEach((t, i) => {
if (!g.filter(t)) return;
const sel = i === selectedIdx ? " selected" : "";
groupHtml += `<option value="${i}"${sel}>${tankOptionText(t)}</option>`;
});
if (groupHtml) html += `<optgroup label="${g.label}">${groupHtml}</optgroup>`;
}
return html;
}
function renderResults() {
const r = computeResults();
const u = "kg";
$("resRecommended").textContent = `${r.recommended} ${u}`;
$("resLift").textContent = `${r.liftNeeded} ${u}`;
$("resLiftLbs").textContent = `${Math.round(r.liftNeeded * 2.20462)} lbs`;
$("resSuitSurface").textContent = `${r.suitSurface} ${u}`;
$("resPersonal").textContent = `${r.personalBuoy} ${u}`;
$("resTankEmpty").textContent = `${r.tankEmptyBuoy} ${u}`;
$("resGasCarried").textContent = `${r.gasCarried} ${u}`;
$("resRigFixed").textContent = `${r.rigFixed} ${u}`;
$("resRigBuoy").textContent = `${r.rigBuoy} ${u}`;
}
/* ----------------------------------------------------------------
Main update loop
---------------------------------------------------------------- */
function update() {
renderResults();
renderDepthTable();
if (typeof resizeParent === "function") resizeParent();
}
/**
* Render the depth buoyancy breakdown table.
* Colour-codes cells: green for near-neutral, orange for moderate,
* red for strongly negative buoyancy.
*/
function renderDepthTable() {
const tbody = $("depthBody");
if (!tbody) return;
tbody.innerHTML = "";
const rows = computeDepthTable();
for (const row of rows) {
const tr = document.createElement("tr");
const cls = n => n >= 0 ? (n > 2.5 ? "cellWarn" : "cellOk") : (n < -2.5 ? "cellBad" : "");
tr.innerHTML = `
<td>${row.label}</td>
<td class="${cls(row.fullTank)}">${row.fullTank}</td>
<td class="${cls(row.plannedEnd)}">${row.plannedEnd}</td>
`;
tbody.appendChild(tr);
}
}
/* ----------------------------------------------------------------
Event binding
---------------------------------------------------------------- */
function bindInputs() {
const bind = (id, key, parse) => {
const el = $(id);
if (!el) return;
el.addEventListener("input", () => {
S[key] = parse ? parse(el.value) : el.value;
update();
});
el.addEventListener("change", () => {
S[key] = parse ? parse(el.value) : el.value;
update();
});
};
const num = v => parseFloat(v) || 0;
const numOrNull = v => v === "" ? null : parseFloat(v);
bind("height", "height", num);
bind("weight", "weight", num);
bind("maxDepth", "maxDepth", num);
bind("waterType", "waterType");
bind("personalBuoyancyMode", "personalBuoyancyMode");
bind("personalBuoyancyWaterType", "personalBuoyancyWaterType");
bind("personalBuoyancyWeight", "personalBuoyancyWeight", num);
bind("personalBuoyancyManual", "personalBuoyancyWeight", num);
bind("personalBuoyancyFlotation", "personalBuoyancyFlotation", num);
bind("suitType", "suitType");
bind("wetStyle", "wetStyle");
bind("wetThickness", "wetThickness", num);
bind("wetCondition", "wetCondition");
bind("vestThickness", "vestThickness", num);
bind("gloveThickness", "gloveThickness", num);
bind("bootieThickness", "bootieThickness", num);
bind("hoodThickness", "hoodThickness", num);
bind("measuredSuitBuoyancy", "measuredSuitBuoyancy", numOrNull);
bind("drysuitMaterial", "drysuitMaterial");
bind("drysuitShellThickness", "drysuitShellThickness", num);
bind("drysuitLinerThickness", "drysuitLinerThickness", num);
bind("rigBCD", "rigBCD", num);
bind("rigRegs", "rigRegs", num);
bind("rigOther", "rigOther", num);
// Checkboxes for accessories
const thicknessMap = { hasVest: "vestThickness", hasGloves: "gloveThickness", hasBoots: "bootieThickness", hasHood: "hoodThickness" };
["hasVest", "hasGloves", "hasBoots", "hasHood"].forEach(key => {
const el = $(key);
if (!el) return;
const thick = $(thicknessMap[key]);
if (thick) thick.disabled = !el.checked;
el.addEventListener("change", () => {
S[key] = el.checked;
if (thick) thick.disabled = !el.checked;
update();
});
});
// Suit type toggle
const suitEl = $("suitType");
if (suitEl) suitEl.addEventListener("change", () => {
S.suitType = suitEl.value;
toggleSuitSections();
toggleDrysuitShell();
update();
});
// Drysuit material toggle — show/hide shell thickness
const dryMatEl = $("drysuitMaterial");
if (dryMatEl) dryMatEl.addEventListener("change", () => {
S.drysuitMaterial = dryMatEl.value;
toggleDrysuitShell();
update();
});
const pbMode = $("personalBuoyancyMode");
if (pbMode) pbMode.addEventListener("change", () => {
S.personalBuoyancyMode = pbMode.value;
togglePBSections();
update();
});
// Add tank button
const addBtn = $("addTankBtn");
if (addBtn) addBtn.addEventListener("click", () => {
if (S.tanks.length < 6) {
S.tanks.push({ idx: 0, qty: 1, startPressure: null, endPressure: null, gasMix: "air", gasO2: 21, gasHe: 0 });
renderTankRows();
update();
}
});
}
function toggleSuitSections() {
const wet = $("wetSection");
const dry = $("drySection");
const accWet = $("accWetOnly");
if (wet) wet.hidden = S.suitType !== "wet";
if (dry) dry.hidden = S.suitType !== "dry";
if (accWet) accWet.hidden = S.suitType !== "wet";
}
function toggleDrysuitShell() {
const label = $("dryShellThicknessLabel");
const sel = $("drysuitShellThickness");
const mat = S.drysuitMaterial;
const show = mat === "neoprene" || mat === "compressed";
if (label) label.hidden = !show;
if (!show) {
S.drysuitShellThickness = 0;
} else {
const def = mat === "compressed" ? 2 : 5;
S.drysuitShellThickness = def;
if (sel) sel.value = def;
}
}
function togglePBSections() {
const m = S.personalBuoyancyMode;
const measuredSection = $("pbMeasuredSection");
const manualSection = $("pbManualSection");
if (measuredSection) measuredSection.hidden = (m !== "measured_weight" && m !== "measured_flotation");
if (manualSection) manualSection.hidden = m !== "manual";
const weightRow = $("pbWeightRow");
const flotRow = $("pbFlotRow");
if (weightRow) weightRow.hidden = m !== "measured_weight";
if (flotRow) flotRow.hidden = m !== "measured_flotation";
}
/* ----------------------------------------------------------------
Init
---------------------------------------------------------------- */
function init() {
bindInputs();
toggleSuitSections();
toggleDrysuitShell();
togglePBSections();
renderTankRows();
update();
}
/**
* Resize the parent iframe to match content height.
* Uses postMessage for cross-document communication, and also
* tries direct access to the iframe element as a fallback.
*/
function resizeParent() {
const h = document.body.scrollHeight + 28;
if (window.parent !== window) {
window.parent.postMessage({ type: "divePlannerResize", height: h }, "*");
/* Direct fallback: try to resize the iframe element from inside */
try { window.frameElement.style.height = h + "px"; } catch(e) {}
}
}
document.addEventListener("DOMContentLoaded", init);
document.addEventListener("DOMContentLoaded", resizeParent);
window.addEventListener("load", resizeParent);
new ResizeObserver(resizeParent).observe(document.body);
</script>
</body>
</html>
