HomeMAL' // MANUFACTORUM
datacrypt
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 &amp; 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>