HomeMAL' // MANUFACTORUM
datacrypt
Dive Planner / dive_planner.html
dive_planner.html341.5 KB
<!doctype html>
<!-- Scuba dive planner (Buhlmann ZH-L16). -->
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta
    name="viewport"
    content="width=device-width,initial-scale=1"
  />
  <title>Dive Planner</title>
  <style>
    :root{
      --bg:#f0f0f0;
      --surface:#ffffff;
      --ink:#111111;
      --muted:#555555;
      --line:#c0c0c0;
      --panel:#e4e4e4;
      --accent:#0055cc;
      --accent-lt:#d6e4ff;
    }

    html{
      overflow:hidden;
    }

    body{
      margin:14px 14px 0 14px;
      padding-bottom: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);
    }

    .app{
      display:grid;
      grid-template-rows:auto auto auto;
      gap:12px;
      max-width:1200px;
      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);
    }

    .row{
      display:flex;
      gap:12px;
      flex-wrap:wrap;
      align-items:center;
    }

    .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="text"]{
      width:100%;
    }

    input[type="number"]:focus,
    input[type="text"]: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;
    }

    select{
      padding:4px 8px;
      border:1px solid var(--line);
      border-radius:5px;
      background:var(--surface);
      font-size:13px;
      font-family:inherit;
      color:var(--ink);
    }

    .pill{
      padding:3px 10px;
      border:1px solid var(--line);
      border-radius:999px;
      font-size:12px;
      color:var(--muted);
      background:var(--panel);
    }

    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;
      position:sticky;
      top:0;
      background:#cfe0fa;
      z-index:1;
      border-bottom:1px solid var(--line);
    }

    tbody tr:hover{
      background:#f0f0f0;
    }

    td input[type="number"]{
      width:90px;
    }

    td select{
      width:130px;
    }

    .mini{
      padding:3px 8px;
      border-radius:5px;
      font-size:12px;
    }

    tr.disabled{
      opacity:.4;
    }

    .mono{
      font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;
    }

    /* Gas table: fixed layout + consistent alignment */
    table.gasTable{
      table-layout:fixed;
    }

    table.gasTable th,
    table.gasTable td{
      white-space:nowrap;
    }

    table.gasTable th{
      white-space:normal;
      word-break:break-word;
      line-height:1.3;
    }

    table.gasTable td.gas-label{
      white-space:normal;
    }

    table.gasTable td.gas-num,
    table.gasTable th.gas-num{
      text-align:center;
    }

    table.gasTable td.gas-check,
    table.gasTable th.gas-check{
      text-align:center;
    }

    table.gasTable td.gas-metric,
    table.gasTable th.gas-metric{
      text-align:center;
    }

    table.gasTable td.gas-action,
    table.gasTable th.gas-action{
      text-align:center;
    }

    table.gasTable td.gas-num input[type="number"]{
      width:72px;
    }

    table.gasTable td.gas-switch{
      text-align:center;
      white-space:nowrap;
    }

    table.gasTable td.gas-switch input[type="number"]{
      width:48px;
      min-width:48px;
      max-width:48px;
      vertical-align:middle;
    }

    table.gasTable td.gas-switch .gas-unit{
      margin-left:6px;
      vertical-align:middle;
      font:inherit;
    }

    table.gasTable td.gas-role select{
      width:120px;
    }

    table.gasTable td.gas-check input[type="checkbox"]{
      transform:translateY(1px);
    }

    .topPlanner{
      display:grid;
      gap:10px;
    }

    [hidden]{display:none!important;}

    .controls{
      display:flex;
      flex-wrap:wrap;
      gap:12px;
      align-items:center;
    }

    .controls .spacer{
      flex:1 1 auto;
    }

    .segTableWrap{
      margin-top:10px;
      overflow:auto;
      max-height:240px;
      border:1px solid var(--line);
      border-radius:8px;
    }

    table.segTable th,
    table.segTable td{
      border-bottom:1px solid #e0e0e0;
      padding:6px 8px;
    }

    table.segTable td input[type="number"]{
      width:110px;
    }

    /* Keep plot size stable: do not stretch plot height to match the panel height. */
    .workspace{
      display:flex;
      gap:16px;
      align-items:flex-start;
      min-height:0;
    }

    .plot{
      flex:1 1 auto;
      min-width:0;
      display:flex;
      flex-direction:column;
    }

    #panel{
      flex:0 0 320px;
      max-width:320px;
      align-self:flex-start;
      position:sticky;
      top:14px;
      background:var(--panel);
      box-sizing:border-box;
    }

    canvas#cv{
      width:100%;
      height:520px;
      border:1px solid var(--line);
      border-radius:8px;
      background:var(--surface);
      display:block;
    }

    .panelActionRow{
      display:flex;
      gap:6px;
      margin-bottom:12px;
      padding-bottom:12px;
      border-bottom:1px solid var(--line);
    }

    .panelActionBtn{
      flex:1;
      padding:5px 0;
      font-size:12px;
      font-family:inherit;
      font-weight:500;
      border-radius:5px;
      cursor:pointer;
      text-align:center;
      transition:background .1s, border-color .1s;
    }

    .panelActionBtn--primary{
      border:1px solid var(--accent);
      background:transparent;
      color:var(--accent);
    }

    .panelActionBtn--primary:hover{ background:var(--accent-lt); }

    .panelActionBtn--secondary{
      border:1px solid var(--line);
      background:transparent;
      color:var(--ink);
    }

    .panelActionBtn--secondary:hover{ background:var(--panel); border-color:#999; }

    .panelImportRow{
      display:flex;
      align-items:center;
      gap:6px;
      margin-bottom:12px;
      padding-bottom:12px;
      border-bottom:1px solid var(--line);
    }

    .panelImportRow input[type="text"]{
      flex:1;
      min-width:0;
      font-size:12px;
      padding:4px 8px;
      border-radius:5px;
    }

    .panelImportRow .panelActionBtn{ flex:0 0 auto; padding:4px 10px; }

    body.mode-simple .segOnly{
      display:none !important;
    }

    /* In CCR mode the switch-at column has no meaning — hide header and all cells. */
    body.mode-ccr #switchColHdr,
    body.mode-ccr td.gas-switch{
      display:none !important;
    }

    body.mode-segments .simpleOnly{
      display:none !important;
    }

    @media (max-width: 980px){
      .workspace{
        flex-direction:column;
      }

      #panel{
        position:static;
        max-width:none;
        width:100%;
      }

      .segTableWrap{
        max-height:320px;
      }
    }

    .o2toxToggle{
      display:flex;
      align-items:center;
      gap:8px;
      margin:4px 0 8px 0;
      cursor:pointer;
    }

    .o2toxToggle input{
      width:15px;
      height:15px;
      flex-shrink:0;
      accent-color:var(--accent);
    }

    .o2toxToggle span{
      font-size:13px;
      color:var(--ink);
    }

    .gasControlsRow > *{
      flex: 0 0 auto;
    }

    .gasControlsRow{
      margin-bottom: 10px;
      flex-wrap: nowrap;
      align-items: flex-end;
    }

    .summaryGrid{
      display:grid;
      gap:6px;
      margin-top:8px;
    }

    /* Dive plan panel – metric cards */
    .planMetrics{
      display:grid;
      grid-template-columns:repeat(3,1fr);
      gap:6px;
      margin-bottom:10px;
    }
    .planMetric{
      background:var(--panel);
      border-radius:6px;
      padding:6px 8px;
      display:flex;
      flex-direction:column;
      gap:2px;
    }
    .planMetric .pmLabel{
      font-size:9px;
      font-weight:700;
      letter-spacing:.05em;
      text-transform:uppercase;
      color:var(--muted);
    }
    .planMetric .pmValue{
      font-size:14px;
      font-weight:700;
      color:var(--ink);
    }

    /* Dive plan panel – stop / switch tables */
    .planSubTitle{
      font-size:10px;
      font-weight:700;
      letter-spacing:.04em;
      text-transform:uppercase;
      color:var(--muted);
      margin:8px 0 4px 0;
    }
    .planStopTable{
      width:100%;
      border-collapse:collapse;
      font-size:12px;
    }
    .planStopTable td{
      padding:3px 6px;
      border-bottom:1px solid var(--line);
    }
    .planStopTable td:first-child{ color:var(--muted); width:56px; }
    .planStopTable td:last-child{ text-align:right; font-weight:600; }
    .planSwitchRow{
      display:flex;
      align-items:center;
      gap:6px;
      padding:3px 6px 3px 0;
      border-bottom:1px solid var(--line);
      font-size:12px;
    }
    .planSwitchRow:last-child{ border-bottom:none; }
    .planSwitchDot{
      width:8px; height:8px;
      border-radius:50%;
      flex-shrink:0;
    }
    .planSwitchInfo{ flex:1; color:var(--muted); }
    .planSwitchGas{ font-weight:600; color:var(--ink); }

    /* Cap deco stops table when there are many rows */
    #pStopsBlock .planStopScroll{
      max-height:180px;
      overflow-y:auto;
    }
    #pStopsBlock .planStopScroll::-webkit-scrollbar{ width:4px; }
    #pStopsBlock .planStopScroll::-webkit-scrollbar-track{ background:transparent; }
    #pStopsBlock .planStopScroll::-webkit-scrollbar-thumb{ background:var(--line); border-radius:2px; }

    .panelSection{
      border:1px solid var(--line);
      border-radius:8px;
      padding:10px 12px;
      background:var(--surface);
    }

    .panelSection + .panelSection{
      margin-top:10px;
    }

    .panelSectionTitle{
      font-size:11px;
      color:var(--muted);
      font-weight:700;
      letter-spacing:.05em;
      text-transform:uppercase;
      margin:0 0 8px 0;
    }

    /* Planner controls: force the mode + salinity row to be on its own line */
    .controlsBreak{
      flex-basis:100%;
      height:0;
    }

    .modeRow{
      display:flex;
      gap:10px;
      align-items:center;
      flex:1 1 100%;
    }

    .modeRow .spacer{
      flex:1 1 auto;
    }

    /* =============================================
       Plot area: tab switcher (Graph / Plan)
    ============================================= */
    /* Segmented-control style tab bar — full width so left/right groups can
       be pushed apart with a flex spacer. */
    .plotTabBar{
      display:flex;
      gap:4px;
      align-items:center;
      background:var(--panel);
      border:1px solid var(--line);
      border-radius:8px;
      padding:4px;
      margin-bottom:8px;
    }

    /* Pushes the right-side tabs (Plan, Saturation) to the far right */
    .tabSpacer{
      flex:1 1 auto;
    }


    .tabBtn{
      padding:4px 16px;
      border:1px solid transparent;
      background:transparent;
      border-radius:6px;
      cursor:pointer;
      font-size:12px;
      font-family:inherit;
      color:var(--muted);
      transition:background .1s,color .1s,border-color .1s;
    }

    .tabBtn:hover{
      background:#d8d8d8;
      border-color:var(--line);
      color:var(--ink);
    }

    .tabBtn.active{
      background:var(--surface);
      border-color:var(--line);
      color:var(--ink);
      font-weight:600;
      box-shadow:0 1px 3px rgba(0,0,0,.08);
    }

    .tabPanel{
      display:none;
    }

    .tabPanel.active{
      display:block;
    }

    #tabPlan{
      border:1px solid var(--line);
      border-radius:8px;
      height:520px;
      overflow-y:auto;
      background:var(--surface);
    }

    /* Plan table */
    table.planTable{
      width:100%;
      border-collapse:collapse;
      font-size:12px;
    }

    table.planTable th,
    table.planTable td{
      padding:6px 10px;
      border-bottom:1px solid #e0e0e0;
      text-align:left;
      vertical-align:middle;
      white-space:nowrap;
    }

    table.planTable th{
      font-size:10px;
      color:#555;
      font-weight:600;
      letter-spacing:.05em;
      text-transform:uppercase;
      position:sticky;
      top:0;
      background:#cfe0fa;
      z-index:1;
      border-bottom:1px solid var(--line);
    }

    /* Per-phase row tints */
    tr.pk-descent{ background:#dbeafe; }
    tr.pk-bottom { background:#fef3c7; }
    tr.pk-ascent { background:#d1fae5; }
    tr.pk-stop   { background:#fef9c3; }
    tr.pk-switch { background:#ede9fe; }
    tr.pk-surface-sep td { background:#e8edf4; color:#555; font-size:11px; font-weight:600;
      text-align:center; padding:6px 8px;
      border-top:2px solid #b0bccf; border-bottom:2px solid #b0bccf; }

    /* Repetitive dives card */
    #repDivesCard { padding-bottom: 10px; }
    .addRepBtn { font-size:11px; padding:3px 10px; border:1px solid var(--border); border-radius:5px;
      background:#fff; cursor:pointer; color:var(--accent); font-weight:600; }
    .addRepBtn:hover { background:#e8f0ff; border-color:var(--accent); }
    .repDiveRow { display:flex; align-items:center; gap:6px; flex-wrap:wrap;
      padding:6px 0 6px 4px; border-bottom:1px solid var(--border); font-size:12px;
      border-radius:5px; transition:background .15s; cursor:pointer; }
    .repDiveRow:last-child { border-bottom:none; }
    .repDiveRow.rep-active { background:var(--row-hover,#f0f5ff); outline:2px solid var(--accent); }
    .repDiveNum { font-weight:700; color:var(--accent); min-width:36px; font-size:11px; letter-spacing:.04em; }
    .repLabel { display:inline-flex; align-items:center; gap:3px; color:var(--muted); font-size:11px; }
    .repLabel input { border:1px solid var(--border); border-radius:4px; padding:2px 4px;
      font-size:12px; font-family:inherit; color:var(--text); background:var(--bg); }
    .repRemoveBtn { margin-left:auto; padding:2px 8px; font-size:11px; border:1px solid #e0c0c0;
      border-radius:4px; background:#fff5f5; color:#c00; cursor:pointer; }
    .repRemoveBtn:hover { background:#ffe0e0; }

    /* Dive selector bar (graph) */
    #diveSelectorBar { display:flex; gap:4px; padding:4px 0 6px; flex-wrap:wrap; }
    .diveSel-btn { font-size:11px; padding:3px 10px; border:1px solid var(--border); border-radius:12px;
      background:#fff; cursor:pointer; color:var(--muted); font-weight:500; transition:all .15s; }
    .diveSel-btn:hover { border-color:var(--accent); color:var(--accent); }
    .diveSel-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600; }

    #planEmpty{
      text-align:center;
      color:var(--muted);
      padding:40px 0;
      font-size:13px;
    }

    /* =============================================
       Tissues tab
    ============================================= */
    /* ID selector (1,0,0) would beat .tabPanel (0,1,0) if we set display here,
       keeping the panel permanently visible. Put only non-display properties on
       the ID; use the higher-specificity compound selector for the flex layout
       so .tabPanel { display:none } still wins when the tab is inactive. */
    #tabTissues{
      border:1px solid var(--line);
      border-radius:8px;
      height:520px;
      overflow:hidden;
      background:var(--surface);
    }

    .tabPanel.active#tabTissues{
      display:flex;
      flex-direction:column;
    }

    .tissueSliderRow{
      flex:0 0 auto;
      display:flex;
      align-items:center;
      gap:10px;
      padding:7px 12px;
      border-bottom:1px solid var(--line);
      font-size:12px;
      color:var(--muted);
    }

    .tissueSliderRow input[type="range"]{
      flex:1 1 auto;
      min-width:80px;
    }

    #cvTissues{
      display:block;
      width:100%;
      flex:1 1 auto;
    }

    /* Lost-gas checkbox list */
    /* ── Collapsible cylinder picker ── */
    .cylCollapse{
      border:1px solid var(--line);
      border-radius:4px;
      overflow:hidden;
    }
    .cylCollapseHead{
      display:flex;
      align-items:center;
      cursor:pointer;
      user-select:none;
    }
    .cylCollapseHead:hover{ background:var(--row-hover); }
    .cylCollapseHead .gasTabTable{ flex:1; }
    .cylCollapseArrow{
      font-size:10px;
      color:var(--muted);
      padding:0 8px;
      flex-shrink:0;
    }
    .cylCollapseList{
      border-top:1px solid var(--line);
      max-height:320px;
      overflow-y:auto;
    }
    .cylCollapseList .gasTabTable{ margin:0; }
    tr.cylCRow{ cursor:pointer; }
    tr.cylCRow:hover td{ background:var(--row-hover); }
    tr.cylCRow--sel td{ background:var(--accent-faint,#e8f0fe); }
    tr.cylCatSep td{
      background:#e8edf2;
      font-size:11px;
      font-weight:700;
      color:var(--ink);
      letter-spacing:.04em;
      padding:5px 8px 3px;
      pointer-events:none;
      user-select:none;
      border-bottom:1px solid #c8d4e0;
    }
    tr.cylCatSep--notfirst td{ border-top:2px solid #b0bec5; }

    .lostGasCheckList{
      display:flex;
      flex-direction:column;
      gap:4px;
      max-height:120px;
      overflow-y:auto;
    }
    .lostGasCheckList label{
      display:flex;
      align-items:center;
      gap:8px;
      font-size:13px;
      margin:0;
      cursor:pointer;
    }
    .lostGasCheckList input[type="checkbox"]{
      width:14px;
      height:14px;
      flex-shrink:0;
    }

    canvas#cvContingency{
      width:100%;
      height:520px;
      border:1px solid var(--line);
      border-radius:8px;
      background:var(--surface);
      display:block;
    }

    /* Gas tab */
    #tabGas{
      border:1px solid var(--line);
      border-radius:8px;
      height:520px;
      overflow-y:auto;
      background:var(--surface);
      box-sizing:border-box;
    }

    #gasTabContent{
      padding:16px;
    }

    table.gasTabTable{
      width:100%;
      border-collapse:collapse;
      font-size:12px;
      margin-bottom:4px;
    }

    table.gasTabTable th,
    table.gasTabTable td{
      padding:5px 8px;
      border-bottom:1px solid #e0e0e0;
      white-space:nowrap;
    }

    table.gasTabTable th{
      font-size:10px;
      color:#555;
      font-weight:600;
      letter-spacing:.04em;
      text-transform:uppercase;
      text-align:left;
      position:sticky;
      top:0;
      background:#cfe0fa;
      z-index:1;
    }

    table.gasTabTable tr.gasTabTotalRow td{
      border-top:1px solid var(--line);
      border-bottom:none;
    }

    .r{
      text-align:right !important;
    }

    /* Accent colour propagates to all native checkboxes and radio buttons */
    input[type="checkbox"],
    input[type="radio"]{
      accent-color:var(--accent);
    }

    /* Gasket section header in gas tab */
    .gasTabSection{
      font-size:10px;
      font-weight:700;
      color:var(--muted);
      text-transform:uppercase;
      letter-spacing:.06em;
      margin-bottom:7px;
    }

    /* OC / CCR breathing-mode toggle */
    .breathModeToggle{
      display:flex;
      gap:2px;
      background:var(--panel);
      border:1px solid var(--line);
      border-radius:6px;
      padding:2px;
    }

    .breathModeOpt{
      display:flex;
      align-items:center;
      gap:4px;
      padding:3px 10px;
      border-radius:5px;
      font-size:12px;
      font-weight:600;
      color:var(--muted);
      cursor:pointer;
      margin:0;
      transition:background .1s, color .1s;
    }

    .breathModeOpt:has(input:checked){
      background:var(--surface);
      color:var(--accent);
      box-shadow:0 1px 3px rgba(0,0,0,.08);
    }

    .breathModeOpt input{
      display:none;
    }

    /* CCR diluent sub-row in gas table */
    tr.ccrSubRow td{
      background:#eeeaf8;
      border-bottom:1px solid #cfc8ee;
      padding:6px 8px 8px 24px;
    }

    .ccrSubForm{
      display:flex;
      gap:0;
      flex-wrap:wrap;
      align-items:center;
      font-size:12px;
    }

    /* each group of related inputs (SP setpoints, loop params) */
    .ccrSubGroup{
      display:flex;
      gap:14px;
      align-items:center;
      flex-wrap:wrap;
      padding:2px 18px 2px 0;
    }

    /* vertical rule between groups */
    .ccrSubGroup + .ccrSubGroup{
      border-left:1px solid #cfc8ee;
      padding-left:18px;
    }

    .ccrSubForm label{
      display:flex;
      gap:5px;
      align-items:center;
      margin:0;
      white-space:nowrap;
    }

    /* label text (the word before the input) */
    .ccrSubForm label > span.ccrLbl{
      color:var(--muted);
      font-size:11px;
      font-weight:600;
      text-transform:uppercase;
      letter-spacing:.03em;
    }

    .ccrSubForm input[type="number"]{
      width:58px;
      padding:3px 5px;
    }

    .ccrSubForm .ccrUnit{
      color:var(--muted);
      font-size:11px;
    }
  </style>
</head>
<body class="mode-simple">
  <div class="app">
    <div class="topPlanner">
      <div class="card">
        <div class="titleRow">
          <strong>Planner controls</strong>
          
        </div>
        
        <div class="controls">
          <label>GF Low <input id="gfLow" type="number" value="80" min="1" max="100" step="1"></label>
          <label>GF High <input id="gfHigh" type="number" value="80" min="1" max="100" step="1"></label>
          <label>Descent speed <input id="descentRate" type="number" value="20" min="1" step="1"><span class="unitBox">m/min</span></label>
          <label>Ascent speed <input id="ascentRate" type="number" value="9" min="1" step="1"><span class="unitBox">m/min</span></label>
          <label>Last stop depth <input id="lastStopDepth" type="number" value="3" min="0" step="3" style="width:60px;"><span class="unitBox">m</span></label>
          <span class="spacer"></span>
          
          
          <span class="controlsBreak"></span>

          <label style="display:flex;gap:6px;align-items:center;margin:0;">
            <input type="checkbox" id="repDivesEnable">
            Repetitive dives
          </label>

          <span class="controlsBreak"></span>

          <div class="modeRow">
            <label style="display:flex;gap:6px;align-items:center;margin:0;">
              <input type="radio" name="mode" id="modeSimple" value="simple" checked>
              Simple
            </label>
            <label style="display:flex;gap:6px;align-items:center;margin:0;">
              <input type="radio" name="mode" id="modeSegments" value="segments">
              Segments
            </label>
            <span class="spacer"></span>

            <label style="display:flex;gap:6px;align-items:center;margin:0;">
              Salinity
              <select id="salinityModel">
                <option value="fresh">Fresh water</option>
                <option value="sea" selected>Sea water</option>
                <option value="en13319">EN13319</option>
              </select>
            </label>

            <label style="display:flex;gap:6px;align-items:center;margin:0;">
              Altitude
              <input id="altitudeM" type="number" value="0" min="0" max="4500" step="50" style="width:80px;">
              <span class="unitBox">m</span>
            </label>
            <span id="surfPressurePill" class="pill mono" style="font-size:11px;color:var(--muted);">1.013 bar</span>
          </div>
        </div>
      </div>
      
      <!-- Repetitive dives list (both modes) -->
      <div class="card" id="repDivesCard" hidden>
        <div class="titleRow">
          <strong>Repetitive dives</strong>
          <button id="addRepDiveBtn" type="button" class="addRepBtn">+ Add dive</button>
        </div>
        <div id="repDivesList"></div>
      </div>

      <!-- Surface interval card — visible only when a rep dive is selected -->
      <div class="card" id="siCard" hidden>
        <div class="titleRow">
          <strong>Surface interval</strong>
        </div>
        <div class="controls">
          <label>Duration <input id="repSIminActive" type="number" min="1" step="1" value="60"><span class="unitBox">min</span></label>
        </div>
      </div>

      <!-- Dive parameters (simple mode only) — title and inputs update when a dive is selected -->
      <div class="card" id="diveParamsCard">
        <div class="titleRow">
          <strong id="diveParamsTitle">Dive parameters</strong>
        </div>
        <!-- Dive 1 inputs -->
        <div class="controls" id="diveParamsDive1">
          <label>Bottom depth <input id="depth" type="number" value="30" min="0" step="1"><span class="unitBox">m</span></label>
          <label>Bottom time  <input id="time"  type="number" value="10" min="0" step="1"><span class="unitBox">min</span></label>
        </div>
        <!-- Rep dive inputs (hidden until a rep dive is selected) -->
        <div class="controls" id="diveParamsRep" hidden>
          <label>Bottom depth <input id="repDepthActive" type="number" min="0" step="1" value="20"><span class="unitBox">m</span></label>
          <label>Bottom time  <input id="repBTActive"    type="number" min="0" step="1" value="20"><span class="unitBox">min</span></label>
        </div>
      </div>

      <div class="card segOnly" id="segCard">
        <div class="titleRow">
          <strong id="segCardTitle">Segments</strong>
        </div>
        
        <div class="controls">
          <label>Segment depth <input id="segDepth" type="number" value="" min="0" step="1"><span class="unitBox">m</span></label>
          <label>Segment time <input id="segTime" type="number" value="" min="0" step="1"><span class="unitBox">min</span></label>
          
          <button id="segAdd" type="button">Add segment</button>
          <button id="segUndo" type="button">Undo</button>
          <button id="segClear" type="button">Clear</button>
          
          <span class="spacer"></span>
        </div>
        
        <div class="segTableWrap">
          <table class="segTable">
            <thead>
              <tr>
                <th style="width:22%;">Depth (m)</th>
                <th style="width:22%;">Duration (min)</th>
                <th style="width:28%;">Runtime (min)</th>
                <th style="width:28%;">Actions</th>
              </tr>
            </thead>
            <tbody id="segRows"></tbody>
          </table>
        </div>
      </div>
    </div>
    
    <div class="card">
      <div class="titleRow">
        <strong>Gases</strong>
        <div class="breathModeToggle" role="group" aria-label="Breathing mode">
          <label class="breathModeOpt">
            <input type="radio" name="breathMode" value="oc" checked> OC
          </label>
          <label class="breathModeOpt">
            <input type="radio" name="breathMode" value="ccr"> CCR
          </label>
        </div>
      </div>
      
      <div class="row gasControlsRow">
        <select id="preset">
          <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">
            <option value="tx21/35">Trimix 21/35</option>
            <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>
          </optgroup>
        </select>
        
        <span class="pill">Blend</span>
        <button id="addPreset" type="button">Add</button>
        <button id="addCustom" type="button">Add custom</button>
        
        <label style="display:flex;flex-direction:column;gap:4px;margin-left:12px;">
          <span style="font-size:12px;color:var(--muted);">ppO₂ min</span>
          <div style="display:flex;gap:8px;align-items:center;">
            <input id="ppO2Min" type="number" min="0.16" max="0.40" step="0.01" value="0.16" style="width:92px;" title="Minimum breathable ppO₂ (TDI absolute floor: 0.16 bar). Raise to e.g. 0.18 or 0.21 for a conservative MinOD.">
            <span class="unitBox">bar</span>
          </div>
        </label>
        <label style="display:flex;flex-direction:column;gap:4px;">
          <span style="font-size:12px;color:var(--muted);">ppO₂ bottom</span>
          <div style="display:flex;gap:8px;align-items:center;">
            <input id="ppO2Bottom" type="number" min="0.80" max="2.00" step="0.01" value="1.40" style="width:92px;">
            <span class="unitBox">bar</span>
          </div>
        </label>
        <label style="display:flex;flex-direction:column;gap:4px;">
          <span style="font-size:12px;color:var(--muted);">ppO₂ deco</span>
          <div style="display:flex;gap:8px;align-items:center;">
            <input id="ppO2Deco" type="number" min="0.80" max="2.00" step="0.01" value="1.60" style="width:92px;">
            <span class="unitBox">bar</span>
          </div>
        </label>
      </div>
      
      <div style="overflow-x:auto;overflow-y:auto;border:1px solid var(--line);border-radius:8px;">
        <table class="gasTable">
          <thead>
            <tr>
              <th style="width:26%;">Label</th>
              <th class="gas-num" style="width:8%;">O₂ %</th>
              <th class="gas-num" style="width:8%;">He %</th>
              <th style="width:12%;">Role</th>
              <th class="gas-check" style="width:8%;">Enabled</th>
              <th class="gas-metric" style="width:8%;">MinOD</th>
              <th class="gas-metric" style="width:9%;" id="modHdrBottom">MOD <span style="text-transform:none">bottom</span></th>
              <th class="gas-metric" style="width:9%;" id="modHdrDeco">MOD <span style="text-transform:none">deco</span></th>
              <th class="gas-num" style="width:7%;" id="switchColHdr">Switch</th>
              <th class="gas-action" style="width:7%;">Actions</th>
            </tr>
          </thead>
          <tbody id="gasRows"></tbody>
        </table>
      </div>

      <div class="row" style="margin-top:10px;">
        <span class="pill mono" id="warnings" hidden></span>
      </div>

    </div>

    <div class="workspace">
      <div class="plot">
        <div class="plotTabBar" role="tablist">
          <button class="tabBtn active" role="tab" aria-selected="true"  aria-controls="tabGraph"       data-tab="graph">Graph</button>
          <button class="tabBtn"        role="tab" aria-selected="false" aria-controls="tabContingency" data-tab="contingency" id="contingencyTabBtn" hidden>Contingency</button>
          <span class="tabSpacer" role="none" aria-hidden="true"></span>
          <button class="tabBtn"        role="tab" aria-selected="false" aria-controls="tabGas"         data-tab="gas">Cylinders</button>
          <button class="tabBtn"        role="tab" aria-selected="false" aria-controls="tabPlan"        data-tab="plan">Plan</button>
          <button class="tabBtn"        role="tab" aria-selected="false" aria-controls="tabTissues"     data-tab="tissues">Saturation</button>
        </div>

        <!-- Graph tab -->
        <div class="tabPanel active" id="tabGraph" role="tabpanel">
          <div id="diveSelectorBar" hidden></div>
          <canvas id="cv" width="860" height="510"></canvas>
        </div>

        <!-- Plan tab -->
        <div class="tabPanel" id="tabPlan" role="tabpanel">
          <table class="planTable" id="planTable">
            <thead>
              <tr>
                <th style="width:13%;">Phase</th>
                <th style="width:10%;">Depth</th>
                <th style="width:13%;">Duration</th>
                <th style="width:13%;">Runtime</th>
                <th style="width:20%;">Gas</th>
                <th style="width:10%;">TTS</th>
                <th style="width:10%;">SurfGF</th>
                <th style="width:11%;">ppO₂</th>
              </tr>
            </thead>
            <tbody id="planRows">
              <tr><td colspan="8" id="planEmpty">No plan yet.</td></tr>
            </tbody>
          </table>
        </div>

        <!-- Tissues tab -->
        <div class="tabPanel" id="tabTissues" role="tabpanel">
          <div class="tissueSliderRow">
            <span>t</span>
            <input type="range" id="tissueTimeSlider" min="0" max="60" step="any" value="0">
            <span id="tissueTimeLbl" class="mono" style="width:76px;flex-shrink:0;text-align:right;">— min</span>
          </div>
          <canvas id="cvTissues"></canvas>
        </div>

        <!-- Contingency tab -->
        <div class="tabPanel" id="tabContingency" role="tabpanel">
          <canvas id="cvContingency" width="860" height="520"></canvas>
        </div>

        <!-- Gas tab -->
        <div class="tabPanel" id="tabGas" role="tabpanel">
          <div id="gasTabContent"></div>
        </div>

      </div><!-- /.plot -->

      <div id="panel" class="card">
        <div class="panelActionRow">
          <button id="printPlanBtn"  class="panelActionBtn panelActionBtn--primary"   onclick="exportPlan()">Export</button>
          <button id="sharePlanBtn"  class="panelActionBtn panelActionBtn--secondary">Share</button>
          <button id="importPlanBtn" class="panelActionBtn panelActionBtn--secondary">Import</button>
        </div>
        <div id="importPlanWrap" class="panelImportRow" hidden>
          <input id="importPlanInput" type="text" placeholder="Paste URL or code…">
          <button id="importPlanGo"     class="panelActionBtn panelActionBtn--primary">Load</button>
          <button id="importPlanCancel" class="panelActionBtn panelActionBtn--secondary" style="flex:0 0 auto;padding:4px 8px;">✕</button>
        </div>
        <label class="o2toxToggle">
          <input id="o2ToxEnable" type="checkbox">
          <span>Gas toxicity</span>
        </label>

        <label class="o2toxToggle">
          <input id="tissueHeatmapEnable" type="checkbox">
          <span>Tissue heatmap</span>
        </label>

        <label class="o2toxToggle">
          <input id="gasConsEnable" type="checkbox">
          <span>Gas consumption</span>
        </label>

        <label class="o2toxToggle">
          <input id="lostGasEnable" type="checkbox">
          <span>Lost-gas contingency</span>
        </label>

        <div style="display:flex;justify-content:space-between;align-items:baseline;">
          
          <span id="meta" class="mono" style="font-size:12px;color:var(--muted);"></span>
        </div>
        
        <div id="gasToxPanel" class="panelSection" hidden>
          <div class="panelSectionTitle">Gas toxicity</div>

          <div id="toxMetrics" class="summaryGrid"></div>
        </div>

        <div id="gasConsPanel" class="panelSection" hidden>
          <div class="panelSectionTitle">Gas consumption</div>

          <div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
            <label style="display:flex;flex-direction:column;gap:4px;margin:0;">
              <span style="font-size:12px;color:var(--muted);">SAC</span>
              <div style="display:flex;gap:8px;align-items:center;">
                <input id="sacLpm" type="number" min="1" step="1" value="20" style="width:92px;">
                <span class="unitBox">L/min</span>
              </div>
            </label>
          </div>

          <div id="gasConsList" class="summaryGrid"></div>
        </div>

        <div id="lostGasPanel" class="panelSection" hidden>
          <div class="panelSectionTitle">Lost-gas contingency</div>
          <div style="margin-bottom:10px;">
            <div style="font-size:12px;color:var(--muted);margin-bottom:6px;" id="lostGasLabel">Cylinders lost</div>
            <div id="lostGasSelect" class="lostGasCheckList"></div>
          </div>
          <div id="lostGasResult"></div>
        </div>

        <div class="panelSection">
          <div class="panelSectionTitle">Dive plan</div>
          <div class="planMetrics">
            <div class="planMetric">
              <span class="pmLabel">Runtime</span>
              <span class="pmValue" id="pRuntime">—</span>
            </div>
            <div class="planMetric">
              <span class="pmLabel">Bottom</span>
              <span class="pmValue" id="pBottom">—</span>
            </div>
            <div class="planMetric">
              <span class="pmLabel">Max depth</span>
              <span class="pmValue" id="pMaxDepth">—</span>
            </div>
            <div class="planMetric">
              <span class="pmLabel">Deco time</span>
              <span class="pmValue" id="pDecoTime">—</span>
            </div>
          </div>

          <div id="pSwitchBlock" hidden>
            <div class="planSubTitle">Gas switches</div>
            <div id="pSwitchList"></div>
          </div>

          <div id="pBailoutBlock" hidden>
            <div class="planSubTitle">Bailout switches</div>
            <div id="pBailoutList"></div>
          </div>

          <div id="pStopsBlock" hidden>
            <div class="planSubTitle">Deco stops</div>
            <div class="planStopScroll">
              <table class="planStopTable" id="pStopsList"></table>
            </div>
          </div>
        </div>
      </div><!-- /#panel -->

    </div><!-- /.workspace -->

  </div><!-- /.app -->

  <script>
"use strict";

/* Educational toy. Do not use for real dive planning. */

/* ================================================================
   Physical constants
================================================================ */

/**
 * ISA sea-level surface pressure (bar) — immutable reference value.
 * Used as the pressure basis for all tissue-loading calculations
 * (Equivalent Sea-Level Depth approach): tissue loading is always computed
 * as if at sea level, which gives monotonically more conservative deco
 * obligations as altitude increases.
 */
const SURF_SL = 1.01325;

/**
 * Actual surface pressure (bar) at the dive site's altitude.
 * Updated by `updateAltitude()` via the ISA formula.
 * Used only for:
 *  - converting ceiling pressures → gauge depths (stop depths relative to the lake surface)
 *  - surface-GF readout (how supersaturated the diver actually is when they surface)
 *  - the pressure pill display
 * All tissue-loading calculations use SURF_SL instead.
 */
let SURF = 1.01325;
/** Seawater density (kg/m³), mutable via the salinity selector. */
let RHO = 1025.0;

/** Available salinity models: name → water density (kg/m³). */
const SALINITY_MODELS = {
  fresh:    1000.0,
  sea:      1025.0,
  en13319:  1019.7,
};

/** Standard gravity (m/s²). */
const G = 9.80665;
/** Respiratory quotient (CO₂ produced / O₂ consumed). Assumed 1.0 (conservative). */
const RQ = 1.0;
/** Alveolar water-vapour pressure (bar). */
const P_H2O = 0.0627;
/** Alveolar CO₂ partial pressure (bar). */
const P_CO2 = 0.0530;
/** Conversion factor: 1 bar = 100 000 Pa. */
const PA_PER_BAR = 1e5;
/** Floating-point epsilon for depth/time comparisons (bar or metres). */
const EPS = 1e-9;
/** Tiny epsilon used to guard against zero denominators. */
const EPS_DENOM = 1e-12;
/** Bühlmann binary-search precision (min). 1e-3 min ≈ 0.06 s. */
const STOP_TIME_PREC = 1e-3;

/** Pre-saturated surface air used to initialise tissue state at the start of a dive. */
const SURFACE_AIR = Object.freeze({ fN2: 0.79, fHe: 0 });

/**
 * Gas-density thresholds (kg/m³ = g/L) from Mitchell & Doolette (2013),
 * "Molecular Biology of Decompression": 5.7 = caution (elevated WOB),
 * 6.2 = danger (CO₂ retention risk).
 */
const GAS_DENSITY_CAUTION = 5.7;
const GAS_DENSITY_DANGER  = 6.2;
/** MinOD threshold (m) above which a gas is considered hypoxic-limited. */
const HYPOXIC_THRESHOLD_M = 0.5;
/** Exponent in the NOAA OTU formula: OTU/min = ((ppO₂ − 0.5) / 0.5) ^ OTU_EXPONENT. */
const OTU_EXPONENT = 0.83;
/** Universal gas constant (J mol⁻¹ K⁻¹), used for ideal-gas density. */
const IDEAL_GAS_R = 8.314462618;

/* ----------------------------------------------------------------
   Segment kind, gas role, and dive-phase string constants.
   Using these instead of bare literals prevents typo bugs and
   makes grep/refactor trivial.
---------------------------------------------------------------- */

/** Identifiers for segments stored in a dive plan array. */
const KIND = Object.freeze({
  DESCENT: "descent",
  ASCENT:  "ascent",
  BOTTOM:  "bottom",
  STOP:    "stop",
  SWITCH:  "switch",
});

/** Gas role identifiers used in the gas table and Gas objects. */
const ROLE = Object.freeze({
  AUTO:   "auto",
  BOTTOM: "bottom",
  DECO:   "deco",
  TRAVEL: "travel",
});

/** Dive-phase identifiers passed to gas-selection logic. */
const PHASE = Object.freeze({
  DESCENT: "descent",
  ASCENT:  "ascent",
  BOTTOM:  "bottom",
  STOP:    "stop",
});

/* ================================================================
   Altitude → surface pressure (ISA troposphere model)
================================================================ */

/**
 * Converts a surface altitude (m above sea level) to absolute pressure (bar)
 * using the International Standard Atmosphere troposphere formula, valid
 * from sea level up to ~11 000 m.
 *
 *   P(h) = P₀ · (1 − L·h / T₀) ^ (g·M / R·L)
 *
 * where P₀ = 101 325 Pa, T₀ = 288.15 K, L = 0.0065 K/m,
 * g = 9.80665 m/s², M = 0.0289644 kg/mol, R = 8.314 J/(mol·K).
 *
 * @param {number} altM - Altitude in metres (0–11 000).
 * @returns {number} Surface pressure in bar.
 */
function altitudeToSurfacePressure(altM){
  const h = Math.max(0, Math.min(11000, Number(altM) || 0));
  // ISA exponent = g*M / (R*L) = 9.80665*0.0289644 / (8.314462618*0.0065) ≈ 5.25588
  return 1.01325 * Math.pow(1 - 2.25577e-5 * h, 5.25588);
}

/* ================================================================
   Utility helpers
================================================================ */

/** Clamps `x` to the interval [lo, hi]. */
function clamp(x, lo, hi){ return Math.max(lo, Math.min(hi, x)); }
/** Formats a finite number to `d` decimal places, or returns "—". */
function fmt(x, d=0){ return Number.isFinite(x) ? x.toFixed(d) : "—"; }
/** Rounds `d` down to the nearest multiple of `step`. */
function snapToGrid(d, step){ return Math.floor(d / step) * step; }

/**
 * Returns a GF interpolation function for a deco-ascent phase.
 *
 * GF is interpolated linearly in pressure from gfLow (at the first stop)
 * down to gfHigh (at the surface). This matches the standard GF implementation
 * described by Baker (2010): the GF "line" connects (firstStopPressure, gfLow)
 * to (surfacePressure, gfHigh).
 *
 * @param {GradientFactors} gf - gfLow / gfHigh pair.
 * @param {number} firstStopDepthM - Depth (m) of the first required deco stop.
 *   Pass 0 to anchor the line at the surface (gfHigh everywhere).
 * @returns {function(number): number} Function that maps a depth (m) to an effective GF.
 */
function makeGfAtDepth(gf, firstStopDepthM){
  // Both pFirst and pHere use SURF_SL-based pressures (via depthToAmbientPressure),
  // so the GF line is anchored to SURF_SL at depth=0 (t=0 → gfHigh) and to pFirst
  // at the first stop (t=1 → gfLow). This is consistent with the ESLD loading model.
  const pFirst = firstStopDepthM > 0 ? depthToAmbientPressure(firstStopDepthM) : SURF_SL;
  return function gfAtDepth(depthM){
    const pHere = depthToAmbientPressure(depthM);
    const denom = Math.max(EPS_DENOM, pFirst - SURF_SL);
    const t = clamp((pHere - SURF_SL) / denom, 0, 1);
    return gf.gfLow * t + gf.gfHigh * (1 - t);
  };
}

/**
 * Converts gauge depth (m) to absolute pressure (bar) for tissue-loading calculations.
 *
 * **ESLD approach**: always uses SURF_SL (sea level, 1.01325 bar) as the surface
 * reference, regardless of actual altitude. This is equivalent to treating the dive
 * as happening at a slightly greater sea-level depth, which gives monotonically
 * more conservative deco obligations as altitude increases.
 *
 * Do NOT use this function for ppO₂ display or MOD limits — those should reflect
 * actual physical pressures and use `depthToAmbientPressureActual`.
 */
function depthToAmbientPressure(depthM){
  if (depthM < 0) throw new Error("Depth must be >= 0");
  return SURF_SL + (RHO * G * depthM) / PA_PER_BAR;
}

/**
 * Converts gauge depth (m) to actual absolute pressure (bar) at the dive site altitude.
 * Use this for ppO₂, MOD, gas density, and any display value that should reflect
 * what the diver's gauges and sensors actually read.
 */
function depthToAmbientPressureActual(depthM){
  if (depthM < 0) throw new Error("Depth must be >= 0");
  return SURF + (RHO * G * depthM) / PA_PER_BAR;
}

/**
 * Converts absolute pressure (bar) to gauge depth (m) relative to the actual
 * altitude surface. Returns 0 for pressures at or below actual surface pressure.
 */
function ambientPressureToDepth(pAmb){
  const delta = Math.max(0, pAmb - SURF);
  return (delta * PA_PER_BAR) / (RHO * G);
}

/**
 * Alveolar partial pressure of a single inert gas component.
 * Applies the Haldane correction for water vapour and CO₂ using RQ.
 * @param {number} pAmb - Absolute ambient pressure (bar).
 * @param {number} qInert - Mole fraction of the inert gas (0–1).
 */
function alveolarInertPressure(pAmb, qInert){
  return (pAmb - P_H2O + ((1.0 - RQ) / RQ) * P_CO2) * qInert;
}
/**
 * Returns [pAlvN2, pAlvHe] inspired inert-gas pressures (bar) for the given gas at pAmb.
 * Returns [0, 0] when gas is null/undefined — defensive for mid-edit UI states.
 */
function inspiredN2HePressures(pAmb, gas){
  if (!gas) return [0, 0];
  return [
    alveolarInertPressure(pAmb, gas.fN2),
    alveolarInertPressure(pAmb, gas.fHe),
  ];
}

/**
 * Haldane equation: tissue inert-gas pressure at constant inspired pressure.
 * @param {number} pBegin - Tissue pressure at start (bar).
 * @param {number} pInsp  - Constant inspired inert-gas pressure (bar).
 * @param {number} tMin   - Exposure duration (min).
 * @param {number} tHalf  - Compartment half-time (min).
 * @returns {number} Tissue pressure at end of segment (bar).
 */
function tissueUpdateConstant(pBegin, pInsp, tMin, tHalf){
  const k = Math.log(2.0) / tHalf;
  return pInsp + (pBegin - pInsp) * Math.exp(-k * tMin);
}
/**
 * Schreiner equation: tissue inert-gas pressure during a linear pressure change.
 * @param {number} pBegin - Tissue pressure at start (bar).
 * @param {number} p0     - Inspired inert-gas pressure at start of segment (bar).
 * @param {number} R      - Rate of change of inspired pressure (bar/min); negative on ascent.
 * @param {number} tMin   - Segment duration (min).
 * @param {number} tHalf  - Compartment half-time (min).
 * @returns {number} Tissue pressure at end of segment (bar).
 */
function schreiner(pBegin, p0, R, tMin, tHalf){
  const k = Math.log(2.0) / tHalf;
  return p0 + R*tMin - R/k + (pBegin - p0 + R/k) * Math.exp(-k*tMin);
}

/* -------------------------------
   ZH-L16C
-------------------------------- */
const ZH16C_N2 = [
  [5.0,   1.1696, 0.5578],
  [8.0,   1.0000, 0.6514],
  [12.5,  0.8618, 0.7222],
  [18.5,  0.7562, 0.7825],
  [27.0,  0.6200, 0.8126],
  [38.3,  0.5043, 0.8434],
  [54.3,  0.4410, 0.8693],
  [77.0,  0.4000, 0.8910],
  [109.0, 0.3750, 0.9092],
  [146.0, 0.3500, 0.9222],
  [187.0, 0.3295, 0.9319],
  [239.0, 0.3065, 0.9403],
  [305.0, 0.2835, 0.9477],
  [390.0, 0.2610, 0.9544],
  [498.0, 0.2480, 0.9602],
  [635.0, 0.2327, 0.9653],
];

const ZH16C_He = [
  [1.88,  1.6189, 0.4770],
  [3.02,  1.3830, 0.5747],
  [4.72,  1.1919, 0.6527],
  [6.99,  1.0458, 0.7223],
  [10.21, 0.9220, 0.7582],
  [14.48, 0.8205, 0.7957],
  [20.53, 0.7305, 0.8279],
  [29.11, 0.6502, 0.8553],
  [41.20, 0.5950, 0.8757],
  [55.19, 0.5545, 0.8903],
  [70.69, 0.5333, 0.8997],
  [90.34, 0.5189, 0.9073],
  [115.29,0.5181, 0.9122],
  [147.42,0.5176, 0.9171],
  [188.24,0.5172, 0.9217],
  [240.03,0.5119, 0.9267],
];

/* -------------------------------
   Model classes
-------------------------------- */
/**
 * Represents a breathing gas with its fractions, role, and oxygen limits.
 *
 * `fO2` and `fHe` are mole fractions (0–1). `fN2` is derived as `1 - fO2 - fHe`.
 * `switchDepthM` is the mandatory gas-switch depth: null means the planner
 * may switch to this gas at any depth where it is breathable.
 */
class Gas {
  constructor({label, o2, he, role="auto", enabled=true, ppo2Min=0.16, ppo2MaxBottom=1.40, ppo2MaxDeco=1.60, switchDepthM=null}){
    this.label = String(label || "");
    this.fO2 = Number(o2);
    this.fHe = Number(he);
    this.role = String(role || "auto").toLowerCase();
    this.enabled = !!enabled;
    this.ppo2Min = Number(ppo2Min);
    this.ppo2MaxBottom = Number(ppo2MaxBottom);
    this.ppo2MaxDeco = Number(ppo2MaxDeco);
    this.switchDepthM = (switchDepthM === null || switchDepthM === undefined || switchDepthM === "") ? null : Number(switchDepthM);
    if (!Number.isFinite(this.switchDepthM)) this.switchDepthM = null;
  }
  get fN2(){ return Math.max(0, 1 - (this.fO2 + this.fHe)); }
}

/**
 * Gradient factor pair (Baker 2010).
 * gfLow anchors the GF line at the first deco stop; gfHigh anchors it at the surface.
 * Both are expressed as fractions in [0, 1].
 */
class GradientFactors {
  constructor(gfLow=0.30, gfHigh=0.85){
    this.gfLow = gfLow;
    this.gfHigh = gfHigh;
  }
}

/**
 * Single ZH-L16C Bühlmann compartment holding current N₂ and He tissue pressures.
 * Half-times and a/b coefficients are stored per-instance so compartments are self-contained.
 */
class Compartment {
  constructor(tHalfN2,aN2,bN2,tHalfHe,aHe,bHe,pN2,pHe){
    this.tHalfN2=tHalfN2; this.aN2=aN2; this.bN2=bN2;
    this.tHalfHe=tHalfHe; this.aHe=aHe; this.bHe=bHe;
    this.pN2=pN2; this.pHe=pHe;
  }
}

/** Returns a deep copy of a 16-compartment state array. */
function cloneState(state){
  return state.map(c => new Compartment(c.tHalfN2,c.aN2,c.bN2,c.tHalfHe,c.aHe,c.bHe,c.pN2,c.pHe));
}

/**
 * Initialises all 16 compartments to surface equilibrium on the given gas.
 * Used as the starting state for every dive plan.
 */
function initState(gas){
  const pAmb = depthToAmbientPressure(0);
  const [pN2,pHe] = inspiredN2HePressures(pAmb, gas);
  const state=[];
  for (let i=0;i<16;i++){
    const [tN2,aN2,bN2]=ZH16C_N2[i];
    const [tHe,aHe,bHe]=ZH16C_He[i];
    state.push(new Compartment(tN2,aN2,bN2,tHe,aHe,bHe,pN2,pHe));
  }
  return state;
}

/**
 * Applies the Haldane equation to all compartments for a constant-depth segment.
 * @param {Compartment[]} state - Current compartment pressures.
 * @param {number} depth - Segment depth (m).
 * @param {Gas} gas - Breathing gas.
 * @param {number} duration - Segment duration (min).
 * @returns {Compartment[]} New state after the segment.
 */
function propagateConstantSegment(state, depth, gas, duration){
  const pAmb = depthToAmbientPressure(depth);
  const [pInspN2,pInspHe] = inspiredN2HePressures(pAmb, gas);
  return state.map(c => new Compartment(
    c.tHalfN2,c.aN2,c.bN2,
    c.tHalfHe,c.aHe,c.bHe,
    tissueUpdateConstant(c.pN2,pInspN2,duration,c.tHalfN2),
    tissueUpdateConstant(c.pHe,pInspHe,duration,c.tHalfHe)
  ));
}

/**
 * Applies the Schreiner equation to all compartments for a linear ascent/descent segment.
 * @param {Compartment[]} state - Current compartment pressures.
 * @param {number} startDepth - Segment start depth (m).
 * @param {number} endDepth   - Segment end depth (m). May be deeper (descent) or shallower (ascent).
 * @param {Gas} gas - Breathing gas.
 * @param {number} ascentRate - Speed (m/min), always positive; direction inferred from depths.
 * @returns {{ state: Compartment[], totalTime: number }}
 */
function propagateAscentSegment(state, startDepth, endDepth, gas, ascentRate){
  const totalTime = Math.abs(startDepth - endDepth) / ascentRate;
  const pAmbStart = depthToAmbientPressure(startDepth);
  // Positive for descent (pressure rising), negative for ascent (pressure falling).
  const dpAmbDt = (endDepth > startDepth ? 1 : -1) * (RHO * G * ascentRate) / PA_PER_BAR;

  const [pAlvN2_0, pAlvHe_0] = inspiredN2HePressures(pAmbStart, gas);
  const Rn2 = gas ? gas.fN2 * dpAmbDt : 0;
  const Rhe = gas ? gas.fHe * dpAmbDt : 0;

  const newState = state.map(c => new Compartment(
    c.tHalfN2,c.aN2,c.bN2,
    c.tHalfHe,c.aHe,c.bHe,
    schreiner(c.pN2, pAlvN2_0, Rn2, totalTime, c.tHalfN2),
    schreiner(c.pHe, pAlvHe_0, Rhe, totalTime, c.tHalfHe)
  ));
  return { state: newState, totalTime };
}

/* ================================================================
   CCR (Closed-Circuit Rebreather) tissue-loading helpers
================================================================ */

/**
 * Active CCR context, set by run() when CCR mode is on.
 * null → open-circuit (OC) mode.
 * @type {{ sp:number, decSp:number, diluent:Gas, loopVol:number, o2Rate:number }|null}
 */
let ccrCtx = null;

/** Currently active breathing mode: "oc" or "ccr". Driven by the radio buttons. */
let _breathMode = "oc";

/**
 * Returns an HTML string of <option> elements for the gas-role select,
 * appropriate for the current breathing mode.
 * OC  roles: auto, bottom, travel, deco, stage
 * CCR roles: diluent, bailout, deco
 */
function roleOptionsHtml(selectedRole){
  const opts = _breathMode === "ccr"
    ? [["diluent","diluent"],["bailout","bailout"],["deco","deco"]]
    : [["auto","auto"],["bottom","bottom"],["travel","travel"],["deco","deco"],["stage","stage"]];
  return opts.map(([v,l]) =>
    `<option value="${v}"${selectedRole===v?" selected":""}>${l}</option>`
  ).join("");
}

/**
 * Maps a role to an equivalent valid role in `targetMode`.
 * Used when the user switches OC ↔ CCR so no row is left with an invalid role.
 */
function mapRoleToMode(role, targetMode){
  if (targetMode === "ccr"){
    if (role === "deco") return "deco";
    if (role === "diluent" || role === "bailout") return role; // already CCR
    return "bailout"; // auto / bottom / travel / stage → bailout
  } else {
    if (role === "deco") return "deco";
    if (role === "auto" || role === "bottom" || role === "travel" || role === "stage") return role;
    return "auto"; // diluent / bailout → auto
  }
}

/**
 * Rebuilds the role <select> in every main gas row to show only the options
 * valid for the current `_breathMode`, remapping the selected value if needed.
 *
 * OC → CCR: auto-promotes the first enabled non-deco gas to "diluent" so
 *           CCR mode activates immediately and the plan changes visibly.
 * CCR → OC: "diluent" maps back to "auto" via mapRoleToMode.
 */
function rebuildAllRoleDropdowns(){
  const allMainRows = [...gasRows.querySelectorAll("tr[data-gas-row]")];

  // First pass: remap all roles to valid values for the new mode.
  for (const tr of allMainRows){
    const sel = tr.querySelector("td.gas-role select");
    if (!sel) continue;
    const newRole = mapRoleToMode(sel.value, _breathMode);
    sel.innerHTML = roleOptionsHtml(newRole);
  }

  // When entering CCR mode, ensure at least one gas is marked as diluent.
  // Without a diluent, ccrCtx stays null and the plan looks identical to OC.
  if (_breathMode === "ccr"){
    const hasDiluent = allMainRows.some(
      tr => tr.querySelector("td.gas-role select")?.value === "diluent"
    );
    if (!hasDiluent){
      // Promote the first enabled non-deco gas to diluent.
      const candidate = allMainRows.find(tr => {
        const enabled = !!tr.querySelector("td.gas-check input")?.checked;
        const role    = tr.querySelector("td.gas-role select")?.value;
        return enabled && role !== "deco";
      });
      if (candidate){
        const sel = candidate.querySelector("td.gas-role select");
        sel.innerHTML = roleOptionsHtml("diluent");
      }
    }
  }
}

/**
 * Active setpoint at a given depth, resolved from the SP schedule.
 * Schedule is [{depth, sp}, ...] sorted by depth descending.
 * The first entry whose depth ≤ current depth wins (i.e. "use this SP from
 * this depth downward").  Falls back to the last entry's SP.
 */
function ccrSetpointAt(depthM){
  if (!ccrCtx) return 0;
  const sched = ccrCtx.spSchedule;
  for (const e of sched){
    if (depthM >= e.depth - EPS) return e.sp;
  }
  return sched[sched.length - 1]?.sp ?? 1.3;
}

/**
 * Returns effective {fN2, fHe, fO2} for tissue loading on a CCR at the given
 * depth and setpoint.  The loop O₂ is held at `sp`; the rest of ambient
 * pressure is filled by inert gas from the diluent in proportion to its mix.
 */
function ccrEffectiveGas(diluent, sp, depthM){
  const pAmb    = depthToAmbientPressure(depthM);
  const spEff   = Math.min(sp, pAmb * 0.98); // setpoint cannot exceed ambient
  const pInert  = Math.max(0, pAmb - spEff);
  const fInertD = (diluent.fN2 + diluent.fHe) || 1;
  return {
    fN2: diluent.fN2 / fInertD * pInert / pAmb,
    fHe: diluent.fHe / fInertD * pInert / pAmb,
    fO2: spEff / pAmb,
  };
}

/**
 * Drop-in wrapper for propagateConstantSegment.
 * Uses CCR effective inert fractions when ccrCtx is set.
 */
function propC(state, depth, gas, duration){
  if (ccrCtx) gas = ccrEffectiveGas(ccrCtx.diluent, ccrSetpointAt(depth), depth);
  return propagateConstantSegment(state, depth, gas, duration);
}

/**
 * Drop-in wrapper for propagateAscentSegment.
 *
 * In CCR mode the segment is split at every SP-schedule switch depth that
 * falls strictly inside (endDepth, startDepth).  Each sub-segment's midpoint
 * is then entirely within one SP zone, so ccrSetpointAt returns the correct
 * setpoint for that half.  Without the split, a single midpoint evaluation
 * could straddle the switch depth and apply the wrong SP to the whole segment
 * (e.g. an ascent from 9 m to 3 m with a 6 m switch: midpoint = 6 m lands
 * exactly on the boundary and picks the deep SP for the shallow half too).
 *
 * In OC mode the gas argument is used as-is (callers split on switch depths
 * via getAscentSplitDepths before calling propA).
 */
function propA(state, startDepth, endDepth, gas, ascentRate){
  if (ccrCtx){
    // Collect SP-schedule switch depths that lie strictly inside this segment.
    // Works for both ascent (startDepth > endDepth) and descent (startDepth < endDepth).
    const [shallower, deeper] = startDepth < endDepth
      ? [startDepth, endDepth]
      : [endDepth,   startDepth];
    const splits = ccrCtx.spSchedule
      .map(e => e.depth)
      .filter(d => d > shallower + EPS && d < deeper - EPS)
      .sort((a, b) => startDepth < endDepth ? a - b : b - a); // shallow→deep for descent, deep→shallow for ascent
    splits.push(endDepth);    // sentinel: final target depth

    let curState = state;
    let totalTime = 0;
    let curDepth  = startDepth;
    for (const splitD of splits){
      const midD   = (curDepth + splitD) / 2;
      const segGas = ccrEffectiveGas(ccrCtx.diluent, ccrSetpointAt(midD), midD);
      const seg    = propagateAscentSegment(curState, curDepth, splitD, segGas, ascentRate);
      curState  = seg.state;
      totalTime += seg.totalTime;
      curDepth  = splitD;
    }
    return { state: curState, totalTime };
  }
  return propagateAscentSegment(state, startDepth, endDepth, gas, ascentRate);
}

/**
 * Gas-selection helper for plan functions.
 * In CCR mode: always return the diluent (no gas switching on the loop).
 * In OC mode: delegate to chooseBestGas.
 */
function selectGas(gases, depthM, phase, currentGas){
  if (ccrCtx) return ccrCtx.diluent;
  return chooseBestGas(gases, depthM, phase, currentGas);
}

/* -------------------------------
   Ceiling + GF
-------------------------------- */
/**
 * Pressure-weighted effective a/b Bühlmann coefficients for a compartment
 * that contains both N₂ and He. When only one gas is present the pure-gas
 * coefficients are returned.
 * @returns {[number, number]} [aEff, bEff]
 */
function effectiveCoefficients(comp){
  const total = comp.pN2 + comp.pHe;
  if (total <= 0) return [comp.aN2, comp.bN2];
  const aEff = (comp.aN2*comp.pN2 + comp.aHe*comp.pHe)/total;
  const bEff = (comp.bN2*comp.pN2 + comp.bHe*comp.pHe)/total;
  return [aEff,bEff];
}
function ceilingDepthFromPressure(pAmbTol){
  // Must use SURF_SL here, not SURF.  The whole deco algorithm works in
  // ESLD-metres: depthToAmbientPressure() references SURF_SL, so the
  // inverse (pressure → depth) must reference the same value.  Using
  // the actual altitude SURF would make the two functions non-inverse,
  // loading tissue at a deeper ESLD equivalent than the ceiling requires
  // and causing the ceiling to rise instead of fall → infinite loop.
  const delta = Math.max(0, pAmbTol - SURF_SL);
  return (delta * PA_PER_BAR) / (RHO * G);
}
/**
 * Returns the deepest ceiling across all compartments at the given GF value (0–1).
 * The ceiling is the shallowest depth to which the diver can ascend without
 * the tissue pressure exceeding the GF-scaled M-value.
 * @param {Compartment[]} state
 * @param {number} gf - Effective gradient factor at this depth.
 * @returns {number} Ceiling depth (m); 0 means no stop required.
 */
function computeCeilingGF(state, gf){
  const gff = clamp(gf,0,1);
  let best=0;
  for (const c of state){
    const pTissue = c.pN2 + c.pHe;
    const [aEff,bEff] = effectiveCoefficients(c);
    let denom = 1 + gff*(1/bEff - 1);
    if (denom <= EPS_DENOM) denom = EPS_DENOM;
    const pAmbTol = (pTissue - gff*aEff) / denom;
    const ceil = ceilingDepthFromPressure(pAmbTol);
    if (ceil > best) best=ceil;
  }
  return best;
}

/**
 * Computes the current surface GF: the worst-case ratio of tissue supersaturation
 * to the GF=1 M-value at the surface.  Values > 1 indicate tissue overpressure.
 * Used for the SurfGF readout and as a post-dive risk indicator.
 * @param {Compartment[]} state
 * @returns {number} Surface GF (0 = no supersaturation).
 */
function computeSurfGF(state){
  const pSurf = SURF;
  let best=0;
  for (const c of state){
    const pTissue = c.pN2 + c.pHe;
    const [aEff,bEff] = effectiveCoefficients(c);
    const mSurf = aEff + pSurf / Math.max(EPS_DENOM, bEff);
    const denom = mSurf - pSurf;
    if (denom <= 0) continue;
    const gfI = (pTissue - pSurf) / denom;
    if (gfI > best) best = gfI;
  }
  return best;
}

/* -------------------------------
   Roles + gas selection
-------------------------------- */
/**
 * Returns true if `gas` is allowed during `phase` given its role.
 * Role "auto" permits all phases. Other roles restrict as follows:
 *   bottom → descent, bottom, ascent
 *   deco   → stop, ascent
 *   travel → descent, ascent
 */
function gasAllowedForPhase(gas, phase){
  const ph = String(phase).toLowerCase();
  const role = String(gas.role||"auto").toLowerCase();
  if (!gas.enabled) return false;
  if (role === "bottom" || role === "stage") return ph === "bottom" || ph === "descent" || ph === "ascent" || ph === "stop";
  if (role === "deco") return ph === "stop" || ph === "ascent";
  if (role === "travel") return ph === "descent" || ph === "ascent";
  // "diluent" and "bailout" are handled by uiGases → "auto"; treat residual refs as auto too.
  return true;
}
/** ppO₂ using ESLD (conservative) pressure — used for gas selection and safety checks. */
function ppo2AtDepth(gas, depthM){
  return gas.fO2 * depthToAmbientPressure(depthM);
}
/** ppO₂ using actual altitude pressure — used for display only (plan table, hover). */
function ppo2AtDepthActual(gas, depthM){
  return gas.fO2 * depthToAmbientPressureActual(depthM);
}

const TOXICITY_LIMITS = {
  end_warn_m: 40,
  end_crit_m: 55,
  cns_warn_pct: 80,
  cns_crit_pct: 100,
  otu_warn: 300,
  otu_crit: 850,
};

function valueStatus(value, warn, crit, unitLabel) {
  if (!Number.isFinite(value)) {
    return { color: "#000", icon: "", title: "" };
  }
  if (value >= crit) {
    return { color: "#800", icon: "⛔", title: `${unitLabel} exceeds critical limit (${crit}).` };
  }
  if (value >= warn) {
    return { color: "#c00", icon: "⚠️", title: `${unitLabel} exceeds limit (${warn}).` };
  }
  return { color: "#000", icon: "", title: "" };
}

// NOAA single-exposure limits (NOAA Diving Manual). The table is undefined
// above 1.6 bar — such exposures are off-limits and already flagged red by
// the ppO₂ warnings; the lookup clamps to the 1.6 limit rather than mixing
// in exceptional-exposure values (which would be non-monotonic at 1.6).
const NOAA_CNS_SINGLE_LIMITS = [
  [0.60,720],[0.70,570],[0.80,450],[0.90,360],[1.00,300],
  [1.10,240],[1.20,210],[1.30,180],[1.40,150],[1.50,120],
  [1.60,45],
];

function noaaCnsLimitMinutes(p){
  const t = NOAA_CNS_SINGLE_LIMITS;
  if (!Number.isFinite(p)) return NaN;
  if (p <= t[0][0]) return t[0][1];
  for (let i=1;i<t.length;i++){
    const p0=t[i-1][0], m0=t[i-1][1];
    const p1=t[i][0],   m1=t[i][1];
    if (p <= p1) return m0 + (m1-m0)*(p-p0)/(p1-p0);
  }
  return t[t.length-1][1];
}

/**
 * CNS% accumulated during `tMin` minutes at ppO₂ `p`, interpolated from the
 * NOAA single-dive table.
 */
function cnsAdd(p, tMin){
  const lim = noaaCnsLimitMinutes(p);
  if (!Number.isFinite(lim) || !(lim > 0)) return 0;
  return 100 * (tMin / lim);
}

/**
 * OTU (Oxygen Toxicity Units) accumulated during `tMin` minutes at ppO₂ `p`.
 * Formula: OTU/min = ((ppO₂ − 0.5) / 0.5) ^ OTU_EXPONENT  (NOAA, 1991).
 */
function otuAdd(p, tMin){
  if (!Number.isFinite(p) || !Number.isFinite(tMin) || tMin <= 0) return 0;
  if (p <= 0.5) return 0;
  return tMin * Math.pow((p - 0.5) / 0.5, OTU_EXPONENT);
}

function oxygenDoseUpTo(plan, tMax){
  let cns=0, otu=0, t=0, d0=0;
  for (const s of plan){
    if (s.kind === "switch") continue;
    const seg = Number(s.duration||0);
    const dt = Math.min(seg, Math.max(0, tMax - t));
    if (dt <= 0) break;

    const d1 = Number(s.depth||d0);
    const gas = s.gas;
    if (gas){
      const dMid = (s.kind==="ascent"||s.kind==="descent") ? (d0 + (d1-d0)*0.5) : d1;
      // In CCR mode, ppO₂ = setpoint (capped at ambient); avoids using gas.fO₂ × pAmb.
      const p = ccrCtx
        ? Math.min(ccrSetpointAt(dMid), depthToAmbientPressureActual(dMid) * 0.98)
        : ppo2AtDepth(gas, dMid);
      cns += cnsAdd(p, dt);
      otu += otuAdd(p, dt);
    }
    t += dt;
    d0 = d1;
  }
  return {cns, otu};
}
/**
 * Selects the best breathing gas for a given depth and dive phase.
 *
 * Selection criteria (in order):
 *  1. Gas must be enabled and allowed for the phase (role check).
 *  2. ppO₂ must be within [ppo2Min, ppo2Max(phase)].
 *  3. If switching away from currentGas, switch-depth constraints apply:
 *     - Hypoxic mix (MinOD > HYPOXIC_THRESHOLD_M): only at/below its switch depth.
 *     - Deco/enriched mix: only at/above (shallower than) its switch depth.
 *  4. Prefer staying on currentGas if it remains valid (prevents flip-flop).
 *  5. Among valid candidates: explicit-role gases beat "auto"; within same
 *     roleRank, highest fO₂ wins.
 *
 * @param {Gas[]} gases - Full gas list.
 * @param {number} depthM - Current depth (m).
 * @param {string} phase - Dive phase: "descent" | "bottom" | "ascent" | "stop".
 * @param {Gas|null} currentGas - Gas currently being breathed (null on first call).
 * @returns {Gas|null} Selected gas, or null if no valid gas exists.
 */
function chooseBestGas(gases, depthM, phase, currentGas){
  const ph = String(phase).toLowerCase();
  const cur = currentGas || null;

  const eligible = gases.filter(g => gasAllowedForPhase(g, ph));
  if (!eligible.length) return null;

  const scored = eligible.map(g => {
    // Breathability (ppo2 within [min .. max(phase)])
    const ppo2 = ppo2AtDepth(g, depthM);
    const limit = (ph==="descent"||ph==="bottom") ? g.ppo2MaxBottom : g.ppo2MaxDeco;
    const breathable = (ppo2 >= g.ppo2Min - EPS) && (ppo2 <= limit + EPS);
    if (!breathable) return { g, ok:false };

    // Switch depth applies ONLY when switching TO this gas (i.e. g !== currentGas).
    // Hypoxic-limited mixes (MinOD > 0) may only be used at/below their switch depth;
    // non-hypoxic deco mixes may only be used at/above (shallower than) their switch depth.
    if (cur && g !== cur && Number.isFinite(g.switchDepthM)){
      const minODm = (g.fO2 > 0 && Number.isFinite(g.ppo2Min)) ? ambientPressureToDepth(g.ppo2Min / g.fO2) : NaN;
      const hypoxic = Number.isFinite(minODm) && minODm > HYPOXIC_THRESHOLD_M;
      if (hypoxic){
        if (depthM < g.switchDepthM - EPS) return { g, ok:false };
      } else {
        if (depthM > g.switchDepthM + EPS) return { g, ok:false };
      }
    }

    // Role priority: explicit role > auto (tie-breaker only; phase eligibility already filtered).
    const role = String(g.role || "auto").toLowerCase();
    const roleRank = (role === "auto") ? 1 : 0;

    return { g, ok:true, roleRank };
  });

  const valid = scored.filter(s => s.ok);
  if (!valid.length) return null;

  // Prefer staying on current gas if it remains valid (prevents flip-flop),
  // unless another gas is strictly better by roleRank, then fO2.
  if (cur){
    const curEntry = valid.find(s => s.g === cur);
    if (curEntry){
      // Is there any strictly better candidate?
      const better = valid.some(s => {
        if (s.g === cur) return false;
        if (s.roleRank < curEntry.roleRank) return true;
        if (s.roleRank > curEntry.roleRank) return false;
        return (s.g.fO2 > cur.fO2 + EPS);
      });
      if (!better) return cur;
    }
  }

  valid.sort((a,b)=>{
    if (a.roleRank !== b.roleRank) return a.roleRank - b.roleRank; // explicit roles first
    return b.g.fO2 - a.g.fO2; // higher O2 preferred
  });
  return valid[0].g;
}



function computeToxInputs(plan){
  let maxDens = -Infinity;
  let dMax = 0;

  let gTmp = null;
  let gAtMax = null;
  let dPrev = 0;

  for (const s of plan){
    if (s.kind === "switch"){
      if (s.gas) gTmp = s.gas;
      continue;
    }
    if (s.kind === "surface") continue;

    if (!gTmp) gTmp = s.gas || gTmp;

    const dEnd = Number(s.depth||0);
    const dHi = Math.max(dPrev, dEnd);
    dMax = Math.max(dMax, dHi);

    const dens = gTmp ? gasDensityKgM3(gTmp, dHi) : NaN;
    if (Number.isFinite(dens)) maxDens = Math.max(maxDens, dens);

    if (dHi >= dMax - EPS) gAtMax = gTmp;

    dPrev = dEnd;
  }

  if (!Number.isFinite(maxDens)) maxDens = NaN;

  // Max ppO₂ across all segments.
  // OC: gas.fO2 × pAmb at the segment's peak depth.
  // CCR: effective loop ppO₂ (setpoint, capped at pAmb × 0.98) at peak depth.
  let maxPpO2 = NaN;
  let _gT2 = null, _dP2 = 0;
  for (const s of plan){
    if (s.kind === "switch"){ if (s.gas) _gT2 = s.gas; continue; }
    if (s.kind === "surface") continue;
    if (!_gT2) _gT2 = s.gas || _gT2;
    const dEnd = Number(s.depth || 0);
    const dHi  = Math.max(_dP2, dEnd);
    const pAmb = depthToAmbientPressure(dHi);
    const p = ccrCtx
      ? Math.min(ccrSetpointAt(dHi), pAmb * 0.98)
      : (_gT2 ? _gT2.fO2 * pAmb : NaN);
    if (Number.isFinite(p)) maxPpO2 = Number.isFinite(maxPpO2) ? Math.max(maxPpO2, p) : p;
    _dP2 = dEnd;
  }

  return { maxDens, dMax, gAtMax, maxPpO2 };
}

function updateToxPanel(maxDens, dMax, gAtMax, plan, maxPpO2){
  const toxEl = $("toxMetrics");
  const toxOn = !!(o2ToxEnableEl && o2ToxEnableEl.checked);
  const toxPanelEl = $("gasToxPanel");
  if (toxPanelEl){
    toxPanelEl.hidden = !toxOn;
  }
  const toxMetricsEl = $("toxMetrics");
  if (toxMetricsEl){
    toxMetricsEl.hidden = !toxOn;
  }
  if (!toxEl) return;

  toxEl.innerHTML = "";
  if (!toxOn) return;

  // ── CCR diluent ppO₂ check ─────────────────────────────────────────────────
  // The diluent itself must stay below 1.1 bar ppO₂ at max depth so the
  // closed-circuit loop cannot cause O₂ toxicity through the diluent alone.
  if (ccrCtx && Number.isFinite(dMax) && dMax > 0){
    const dilPpO2 = ccrCtx.diluent.fO2 * depthToAmbientPressure(dMax);
    if (dilPpO2 > 1.1 + EPS){
      const rowDil = document.createElement("div");
      rowDil.innerHTML =
        `<strong title="Diluent O₂ partial pressure at maximum dive depth.">Diluent ppO₂:</strong>`
        + ` <span style="color:#c00">${dilPpO2.toFixed(2)} bar at ${Math.ceil(dMax)} m</span>`
        + ` <span style="margin-left:6px;color:#c00" title="Exceeds 1.1 bar — use a more hypoxic diluent or reduce max depth.">⚠️</span>`;
      toxEl.appendChild(rowDil);
    }
  }

  const densTxt = Number.isFinite(maxDens) ? `${maxDens.toFixed(2)} kg/m³` : "—";
  const densWarn = !Number.isFinite(maxDens) ? ""
    : maxDens > GAS_DENSITY_DANGER
      ? ` <span title="Gas density exceeds ${GAS_DENSITY_DANGER} kg/m³ — CO₂ retention risk.">⚠️</span>`
      : maxDens > GAS_DENSITY_CAUTION
        ? ` <span title="Gas density exceeds ${GAS_DENSITY_CAUTION} kg/m³ — elevated work of breathing.">⚠️</span>`
        : "";

  const rowD = document.createElement("div");
  rowD.innerHTML = `<strong>Max gas density:</strong> <span>${densTxt}${densWarn}</span>`;
  toxEl.appendChild(rowD);

  // Max ppO₂
  {
    const _maxP = maxPpO2 ?? computeToxInputs(plan || []).maxPpO2;
    if (Number.isFinite(_maxP)){
      const _pColor = _maxP > 1.6 ? "#c00" : _maxP > 1.4 ? "#c66600" : "#2e7d32";
      const _pWarn  = _maxP > 1.6
        ? ` <span style="color:#c00" title="Exceeds 1.6 bar — CNS O₂ toxicity risk.">⚠️</span>`
        : _maxP > 1.4
          ? ` <span style="color:#c66600" title="Above 1.4 bar — approaching working limit.">⚠️</span>`
          : "";
      const _label  = ccrCtx ? "Max loop ppO₂:" : "Max ppO₂:";
      const rowP = document.createElement("div");
      rowP.innerHTML = `<strong>${_label}</strong> <span style="color:${_pColor}">${_maxP.toFixed(2)} bar${_pWarn}</span>`;
      toxEl.appendChild(rowP);
    }
  }

  const fn2Max = (gAtMax && Number.isFinite(gAtMax.fN2) && gAtMax.fN2 > 0) ? gAtMax.fN2 : NaN;
  const endMax = Number.isFinite(fn2Max) ? endMeters(dMax, fn2Max) : NaN;

  const rowE = document.createElement("div");
  const endVal = Number.isFinite(endMax) ? Math.round(endMax) : NaN;
  const endS = valueStatus(endVal, TOXICITY_LIMITS.end_warn_m, TOXICITY_LIMITS.end_crit_m, "END (m)");
  if (Number.isFinite(endVal)) {
    if (endVal >= TOXICITY_LIMITS.end_crit_m) endS.title = "END above 40 m (N₂ only): increased narcosis risk."; 
    else if (endVal >= TOXICITY_LIMITS.end_warn_m) endS.title = "END above 30 m (N₂ only): exceeds common TDI target of 30 m/100 ft."; 
  }
  rowE.innerHTML = `<strong title="Equivalent Narcotic Depth (N₂ only). Depth at which the narcotic effect of a breathing gas mixture is equivalent to breathing air.">END:</strong> <span style="color:${endS.color}">${Number.isFinite(endVal)?endVal:"—"} m</span><span title="${endS.title}" style="margin-left:6px;color:${endS.color}">${endS.icon}</span>`;
  toxEl.appendChild(rowE);

  const dose = oxygenDoseUpTo(plan, plan[plan.length-1].runtime||0);
  const rowC = document.createElement("div");
  const cnsVal = Math.round(dose.cns);
  const cnsS = valueStatus(cnsVal, TOXICITY_LIMITS.cns_warn_pct, TOXICITY_LIMITS.cns_crit_pct, "CNS (%)");
  if (Number.isFinite(cnsVal)) {
    if (cnsVal >= TOXICITY_LIMITS.cns_crit_pct) cnsS.title = "CNS at/above 100%: maximum exposure limit reached."; 
    else if (cnsVal >= TOXICITY_LIMITS.cns_warn_pct) cnsS.title = "CNS above 80%: exceeds recommended working limit for technical divers."; 
  }
  rowC.innerHTML = `<strong title="Central Nervous System oxygen toxicity. Indicates acute neurological risk due to elevated oxygen partial pressure. Equivalent of breathing O₂ at atmospheric pressure for one minute">CNS:</strong> <span style="color:${cnsS.color}">${cnsVal} %</span><span title="${cnsS.title}" style="margin-left:6px;color:${cnsS.color}">${cnsS.icon}</span>`;
  const rowO = document.createElement("div");
  const otuVal = Math.round(dose.otu);
  const otuS = valueStatus(otuVal, TOXICITY_LIMITS.otu_warn, TOXICITY_LIMITS.otu_crit, "OTU");
  if (Number.isFinite(otuVal)) {
    if (otuVal >= TOXICITY_LIMITS.otu_crit) otuS.title = "OTU at/above 850: exceeds one-day upper limit; reduce exposure."; 
    else if (otuVal >= TOXICITY_LIMITS.otu_warn) otuS.title = "OTU above 300: exceeds conservative multi-day limit (TDI)."; 
  }
  rowO.innerHTML = `<strong title="Oxygen Toxicity Units. Cumulative measure of pulmonary oxygen exposure over time.">OTU:</strong> <span style="color:${otuS.color}">${otuVal}</span><span title="${otuS.title}" style="margin-left:6px;color:${otuS.color}">${otuS.icon}</span>`;
  toxEl.appendChild(rowC);
  toxEl.appendChild(rowO);

  // ── ICD warning (CCR contingency only) ───────────────────────────────────
  // Isobaric Counter Diffusion: switching from high-He CCR diluent to a
  // high-N₂ OC bailout gas at depth can cause inner-ear DCS (silent bubbles
  // in endolymph).  Flag when ΔN₂ > 20 % and the switch happens below 15 m.
  if (_breathMode === "ccr" && document.getElementById("lostGasEnable")?.checked
      && lastContingencyPlan && lastContingencyPlan.length && ccrCtx){
    const _dilHe = ccrCtx.diluent.fHe ?? 0;
    const _dilN2 = ccrCtx.diluent.fN2 ?? (1 - (ccrCtx.diluent.fO2 ?? 0) - _dilHe);
    // Find the first OC gas used at bailout start.
    const _firstBailoutGas = (lastContingencyPlan.find(s => s.kind === "switch") || lastContingencyPlan.find(s => s.kind !== "switch"))?.gas || null;
    if (_firstBailoutGas){
      const _bailN2 = _firstBailoutGas.fN2 ?? (1 - (_firstBailoutGas.fO2 ?? 0) - (_firstBailoutGas.fHe ?? 0));
      const _dN2 = _bailN2 - _dilN2;
      const _dHe = (_firstBailoutGas.fHe ?? 0) - _dilHe;
      const _swDepth = lastContingencyWorst.d;
      if (_dN2 > 0.20 - EPS && _swDepth > 15 - EPS){
        const rowICD = document.createElement("div");
        rowICD.style.cssText = "margin-top:6px;padding:5px 8px;background:#fff3cd;border:1px solid #f5c542;border-radius:4px;font-size:11px;color:#7d5200;";
        rowICD.innerHTML =
          `<strong>⚠ ICD risk at ${_swDepth} m</strong><br>`
          + `Bailout from ${escapeHtml(ccrCtx.diluent.label)} (He ${Math.round(_dilHe*100)} %)`
          + ` to ${escapeHtml(gasLabelFromObj(_firstBailoutGas) || "?")} (N₂ +${Math.round(_dN2*100)} %)`
          + ` — consider switching shallower or using a He-containing bailout.`;
        toxEl.appendChild(rowICD);
      }
    }
  }
}


/* -------------------------------
   Analytical stop-time solver
-------------------------------- */
/**
 * Computes the minimum time (min) to hold at `depth` before the GF-adjusted
 * ceiling allows ascending to `nextDepth`.
 *
 * Algorithm:
 *   1. Fast path — ceiling already clears: return 0.
 *   2. Convergence check — run tissues to near-equilibrium (10 × longest
 *      half-time ≈ 6 350 min for ZH-L16C, leaving < 0.1 % residual offset).
 *      If the ceiling still fails: the stop is genuinely divergent (GF Low >
 *      GF High pathology) → return Infinity.
 *   3. Binary search in (0, tEquil] at precision STOP_TIME_PREC.
 *      Converges in ≈ log₂(6 350 / 1e-3) ≈ 23 propC calls regardless of
 *      how long the stop actually is — O(log N) vs the old O(N) step loop.
 *
 * In CCR mode propC ignores the `gas` argument and uses the CCR effective gas
 * computed from the global ccrCtx — callers should still pass the gas they
 * computed so the function is correct in OC mode.
 *
 * @param {Compartment[]} state    Current tissue state (not mutated).
 * @param {number} depth           Stop depth (m).
 * @param {number} nextDepth       Target depth after this stop (m).
 * @param {number} gfEff           Effective gradient factor at `depth`.
 * @param {Gas|null} gas           Gas breathed during the stop.
 * @returns {number} Minimum stop time (min); Infinity if divergent.
 */
function computeMinStopTime(state, depth, nextDepth, gfEff, gas){
  if (computeCeilingGF(state, gfEff) <= nextDepth + EPS) return 0;
  const tEquil = 10 * Math.max(...state.map(c => Math.max(c.tHalfN2, c.tHalfHe)));
  if (computeCeilingGF(propC(state, depth, gas, tEquil), gfEff) > nextDepth + EPS) return Infinity;
  let lo = 0, hi = tEquil;
  while (hi - lo > STOP_TIME_PREC){
    const mid = (lo + hi) / 2;
    if (computeCeilingGF(propC(state, depth, gas, mid), gfEff) <= nextDepth + EPS) hi = mid;
    else lo = mid;
  }
  return hi;
}

/* -------------------------------
   Shared deco-ascent loop engine
-------------------------------- */
/**
 * Core deco-ascent loop shared by all five deco-planning functions.
 *
 * Drives the canonical `while (depth > 0)` loop: at each depth it decides
 * whether to ascend one stop-step or compute and apply the full stop time,
 * delegating tissue propagation and plan-building to caller-provided
 * callbacks.  Tissue state lives entirely in the caller's closure.
 *
 * Divergence (GF Low > GF High pathology) is detected by computeMinStopTime
 * returning Infinity; the callback returns false to abort the loop.  There
 * is no time cap — saturation dives with hundreds of hours of valid deco
 * work correctly because computeMinStopTime finds the exact finite stop time
 * in O(log N) propC calls rather than O(N) step-and-check iterations.
 *
 * @param {number}   startDepth  Starting depth (m), already snapped to grid.
 * @param {Function} gfAtDepth   (depth) → effective gradient factor.
 * @param {object}   opts
 * @param {Function} opts.getState      () → Compartment[] from caller closure.
 * @param {number}   opts.stopStep      Stop-grid step (m).
 * @param {number}   opts.lastStopDepth Shallowest mandatory stop (m).
 * @param {Function} opts.onAscend (depth, next) — ceiling allows ascending.
 *   Updates tissue state and plan entries in the caller's closure.
 * @param {Function} opts.onStop  (depth, next, gfEff) → true | false
 *   Ceiling does not clear; caller should invoke computeMinStopTime, apply
 *   the result via propC, and update any plan/accumulator.
 *   Return false to abort (divergent stop or stranded-gas condition).
 * @returns {boolean} false if aborted by a callback; true otherwise.
 */
function decoAscentLoop(startDepth, gfAtDepth, { getState, stopStep, lastStopDepth, onAscend, onStop }){
  let depth = startDepth;
  while (depth > 0){
    const next = (depth <= lastStopDepth + EPS) ? 0 : Math.max(lastStopDepth, depth - stopStep);
    const gfEff = gfAtDepth(depth);
    if (computeCeilingGF(getState(), gfEff) <= next + EPS){
      onAscend(depth, next);
      depth = next;
    } else {
      if (onStop(depth, next, gfEff) === false) return false;
    }
  }
  return true;
}

/* -------------------------------
   TTS (simplified but consistent with planner)
-------------------------------- */
/**
 * Estimates time-to-surface (min) from the given tissue state by simulating
 * a simplified deco-ascent using the same stop loop logic as the full planner.
 * Gas selection uses `currentGas` as the starting preference.
 *
 * The result is used for the live TTS column in the plan table and for the
 * hover-tooltip delta display (+5 min projection).
 *
 * @param {Compartment[]} state
 * @param {number} currentDepth - Depth at which TTS starts (m).
 * @param {GradientFactors} gf
 * @param {Gas[]} gases
 * @param {number} stopStep - Deco-stop grid size (m).
 * @param {number} ascentRate - m/min.
 * @param {number} minStopQuantum - Minimum stop increment (min).
 * @param {number} lastStopDepth - Depth of the last required stop (m).
 * @param {Gas|null} currentGas - Gas being breathed at the start of the ascent.
 * @returns {number} Estimated TTS in minutes.
 */
function computeTTSFromState(state, currentDepth, gf, gases, stopStep, ascentRate, minStopQuantum, lastStopDepth, currentGas){
  let st = cloneState(state);
  const firstCeil = computeCeilingGF(st, gf.gfLow);
  const depth0 = Math.max(snapToGrid(currentDepth, stopStep), snapToGrid(firstCeil, stopStep));
  let tts = 0;
  let gasNow = currentGas || null;
  const gfAtDepth = makeGfAtDepth(gf, depth0);

  decoAscentLoop(depth0, gfAtDepth, {
    getState: () => st,
    stopStep, lastStopDepth,
    onAscend(d, next){
      const gas = chooseBestGas(gases, d, "stop", gasNow);
      gasNow = gas;
      const { state: st2, totalTime } = propA(st, d, next, gas, ascentRate);
      st = st2;
      tts += totalTime;
    },
    onStop(d, next, gfEff){
      const gas = chooseBestGas(gases, d, "stop", gasNow);
      gasNow = gas;
      const t = computeMinStopTime(st, d, next, gfEff, gas);
      if (!isFinite(t)) return false;
      const tQ = Math.ceil(t / minStopQuantum) * minStopQuantum;
      st = propC(st, d, gas, tQ);
      tts += tQ;
    },
  });
  return tts;
}

/* -------------------------------
   Manual switch-depth helpers
-------------------------------- */
function getAscentSplitDepths(gases, fromDepth, toDepth){
  // Return a descending list of intermediate depths (meters) where a gas becomes eligible
  // due to a manual switch depth, plus the final toDepth.
  const a = Number(fromDepth);
  const b = Number(toDepth);
  if (!(Number.isFinite(a) && Number.isFinite(b))) return [b];
  if (a <= b + EPS) return [b];

  const ds = [];
  for (const g of (Array.isArray(gases) ? gases : [])){
    const sw = g?.switchDepthM;
    if (Number.isFinite(sw)){
      // Explicit switch depth.
      const d = Number(sw);
      if (d <= b + EPS) continue;
      if (d >= a - EPS) continue;
      ds.push(d);
    } else if ((g?.fO2 ?? 0) > 0 && Number.isFinite(g?.ppo2MaxDeco)){
      // No explicit switch depth: split at the ppO₂-derived MOD so the gas
      // becomes usable exactly at its depth limit on ascent (not one step below).
      const mod = ambientPressureToDepth(g.ppo2MaxDeco / g.fO2);
      if (!Number.isFinite(mod)) continue;
      if (mod <= b + EPS) continue;
      if (mod >= a - EPS) continue;
      ds.push(mod);
    }
  }
  ds.sort((x,y)=> y-x); // deep -> shallow
  // unique within EPS
  const uniq = [];
  for (const d of ds){
    if (!uniq.length || Math.abs(uniq[uniq.length-1] - d) > EPS) uniq.push(d);
  }
  uniq.push(b);
  return uniq;
}

/**
 * Returns an ascending list of depths (shallow → deep) at which a gas switch
 * occurs during a descent from `fromDepth` to `toDepth`, plus the final `toDepth`.
 * Mirror of {@link getAscentSplitDepths} for the downward direction.
 */
function getDescentSplitDepths(gases, fromDepth, toDepth){
  const a = Number(fromDepth);
  const b = Number(toDepth);
  if (!(Number.isFinite(a) && Number.isFinite(b))) return [b];
  if (a >= b - EPS) return [b];

  const ds = [];
  for (const g of (Array.isArray(gases) ? gases : [])){
    const sw = g?.switchDepthM;
    if (!Number.isFinite(sw)) continue;
    const d = Number(sw);
    if (d <= a + EPS) continue;
    if (d >= b - EPS) continue;
    ds.push(d);
  }
  ds.sort((x, y) => x - y); // shallow → deep
  const uniq = [];
  for (const d of ds){
    if (!uniq.length || Math.abs(uniq[uniq.length-1] - d) > EPS) uniq.push(d);
  }
  uniq.push(b);
  return uniq;
}



/* -------------------------------
   Planner: planDiveMultigasAuto
-------------------------------- */
/**
 * Plans a simple rectangular dive (single bottom depth/time) with automatic
 * multi-gas selection and Bühlmann GF deco stops.
 *
 * The planner descends at `descentRate` using the Schreiner equation (one segment
 * per gas zone), holds at `bottomDepth` for `bottomTime`, then ascends with deco
 * stops on a `stopStep` grid. Gas switches are inserted whenever `chooseBestGas`
 * selects a different gas.
 *
 * @param {number} bottomDepth - Target depth (m).
 * @param {number} bottomTime  - Time at depth (min).
 * @param {Gas[]} gases        - Available gas list (enabled/role already set).
 * @param {object} opts
 * @param {GradientFactors} opts.gf
 * @param {number} opts.stopStep        - Deco-stop grid (m), default 3.
 * @param {number} opts.ascentRate      - m/min, default 9.
 * @param {number} opts.descentRate     - m/min, default 20.
 * @param {number} opts.minStopQuantum  - Stop increment (min), default 1.
 * @param {number} opts.lastStopDepth   - Last mandatory stop depth (m), default 3.
 * @returns {object[]} Plan segment array.
 */
function planDiveMultigasAuto(bottomDepth, bottomTime, gases, opts){
  const stopStep = opts.stopStep ?? 3;
  const ascentRate = opts.ascentRate ?? 9;
  const descentRate = opts.descentRate ?? 20;
  const minStopQuantum = opts.minStopQuantum ?? 1;
  const lastStopDepth = clamp(Number(opts.lastStopDepth ?? 3), 0, 12);
  const gf = opts.gf ?? new GradientFactors(0.30,0.85);

  if (!(stopStep>0 && ascentRate>0 && descentRate>0 && minStopQuantum>0)) {
    throw new Error("Rates/steps must be positive.");
  }

  const plan=[];
  let runtime=0;

  // start gas at surface
  let gasDesc = selectGas(gases, 0, "descent", null);
  // Tissues are always pre-saturated with atmospheric air before the dive,
  // regardless of the planned gas list.  Using the descent gas (which may be
  // a hypoxic trimix) or null would under-load the initial N₂, giving an
  // artificially optimistic deco result.
  let state = opts.initTissues ? cloneState(opts.initTissues) : initState(SURFACE_AIR);
  let currentGas = gasDesc;

  function push(kind, depth, duration, gasHere){
    runtime += duration;
    const surf = computeSurfGF(state);
    // Capture state by value: state is reassigned each propC/propA call so we
    // must snapshot the reference now.  TTS is computed lazily at render time.
    const _s = state, _g = gasHere;
    plan.push({kind, depth, duration, runtime, gas: gasHere, label: gasHere.label, tts: null, surf_gf: surf,
      _computeTts: () => computeTTSFromState(_s, depth, gf, gases, stopStep, ascentRate, minStopQuantum, lastStopDepth, _g)});
    currentGas = gasHere;
  }

  // descent: one Schreiner segment per gas zone
  if (bottomDepth > 0){
    const splits = getDescentSplitDepths(ccrCtx ? [] : gases, 0, bottomDepth);
    let cur = 0;
    for (const next of splits){
      const gasHere = selectGas(gases, next, "descent", currentGas);
      if (!ccrCtx && gasHere !== currentGas){
        plan.push({kind:"switch", depth:cur, duration:0, runtime, gas:gasHere, label:gasHere.label});
        currentGas = gasHere;
      }
      const { state: st, totalTime } = propA(state, cur, next, gasHere, descentRate);
      state = st;
      push("descent", next, totalTime, gasHere);
      cur = next;
    }
  }

  // bottom
  if (bottomTime > 0){
    const gasBottom = selectGas(gases, bottomDepth, "bottom", currentGas);
    if (!ccrCtx && gasBottom !== currentGas){
      plan.push({kind:"switch", depth:bottomDepth, duration:0, runtime, gas:gasBottom, label:gasBottom.label});
      currentGas = gasBottom;
    }
    state = propC(state, bottomDepth, gasBottom, bottomTime);
    push("bottom", bottomDepth, bottomTime, gasBottom);
  }

  // ascent with stops
  const firstCeil = computeCeilingGF(state, gf.gfLow);

  // No-deco: force a continuous ascent (no stop logic) to keep the ascent line perfectly straight.
  if (firstCeil <= 1e-6){
    let curDepth = bottomDepth;
    while (curDepth > 0){
      const target = Math.max(0, curDepth - stopStep);

      // Enforce manual switch depths at arbitrary meters by splitting the ascent
      // segment at each switch depth crossed between curDepth -> target.
      const parts = getAscentSplitDepths(gases, curDepth, target);
      for (const next of parts){
        const gasAsc = selectGas(gases, curDepth, "ascent", currentGas);
        if (!ccrCtx && gasAsc && gasAsc !== currentGas){
          plan.push({kind:"switch", depth:curDepth, duration:0, runtime, gas:gasAsc, label:gasAsc.label});
          currentGas = gasAsc;
        }
        const seg = propA(state, curDepth, next, currentGas, ascentRate);
        state = seg.state;
        push("ascent", next, seg.totalTime, currentGas);
        curDepth = next;

        if (!ccrCtx){
          const gAfter = chooseBestGas(gases, curDepth, "ascent", currentGas);
          if (gAfter && gAfter !== currentGas){
            plan.push({kind:"switch", depth:curDepth, duration:0, runtime, gas:gAfter, label:gAfter.label});
            currentGas = gAfter;
          }
        }
      }
    }
    return plan;
  }
  const depth0 = Math.max(snapToGrid(bottomDepth, stopStep), snapToGrid(firstCeil, stopStep));
  const gfAtDepth = makeGfAtDepth(gf, depth0);

  decoAscentLoop(depth0, gfAtDepth, {
    getState: () => state,
    stopStep, lastStopDepth,
    // Ascend without forcing a STOP gas first — gas switches happen at split depths.
    onAscend(d, next){
      const parts = getAscentSplitDepths(ccrCtx ? [] : gases, d, next);
      for (const dNext of parts){
        const seg = propA(state, d, dNext, currentGas, ascentRate);
        state = seg.state;
        push("ascent", dNext, seg.totalTime, currentGas);
        d = dNext;
      }
    },
    // Only choose a STOP gas when we actually need to stop here.
    onStop(d, next, gfEff){
      const gasStop = selectGas(gases, d, "stop", currentGas);
      if (!ccrCtx && gasStop !== currentGas){
        plan.push({kind:"switch", depth:d, duration:0, runtime, gas:gasStop, label:gasStop.label});
        currentGas = gasStop;
      }
      const t = computeMinStopTime(state, d, next, gfEff, currentGas);
      if (!isFinite(t)) return false;
      const tQ = Math.ceil(t / minStopQuantum) * minStopQuantum;
      state = propC(state, d, currentGas, tQ);
      push("stop", d, tQ, currentGas);
    },
  });

  return plan;
}

/* ================================================================
   Lost-gas contingency planner
================================================================ */

/**
 * Plans the ascent and decompression starting from an existing tissue
 * state at `startDepth`, using only the supplied `gases`.
 *
 * This is the ascent half of {@link planDiveMultigasAuto} extracted into a
 * standalone function so it can be called from any mid-dive tissue state.
 * Used by the lost-gas contingency panel to answer "what happens if I lose
 * gas X at the worst possible moment?"
 *
 * @param {Compartment[]} initState - Tissue state at the moment of gas loss.
 * @param {number}        startDepth - Depth at the moment of gas loss (m).
 * @param {Gas[]}         gases      - Gases still available (lost gas removed).
 * @param {object}        opts       - Same option bag as planDiveMultigasAuto.
 * @returns {object[]|null} Plan segments from startDepth to surface,
 *   or null if no breathable gas exists at startDepth (fatal scenario).
 */
function planAscentFromState(initState, startDepth, gases, {
  gf, ascentRate = 9, stopStep = 3, minStopQuantum = 1, lastStopDepth = 3,
}){
  if (!gases || !gases.length) return null;

  let state = cloneState(initState);
  let runtime = 0;
  const plan = [];

  function push(kind, depth, duration, gas){
    runtime += duration;
    plan.push({ kind, depth, duration, runtime, gas });
  }

  let currentGas = chooseBestGas(gases, startDepth, "ascent", null);
  if (!currentGas) return null; // no breathable gas at this depth

  const firstCeil = computeCeilingGF(state, gf.gfLow);

  // No-deco: straight ascent with gas switches at configured switch depths.
  if (firstCeil <= 1e-6){
    let curDepth = startDepth;
    while (curDepth > 0){
      const target = Math.max(0, curDepth - stopStep);
      const parts = getAscentSplitDepths(gases, curDepth, target);
      for (const dNext of parts){
        const g2 = chooseBestGas(gases, curDepth, "ascent", currentGas);
        if (g2 && g2 !== currentGas){
          plan.push({ kind:"switch", depth:curDepth, duration:0, runtime, gas:g2 });
          currentGas = g2;
        }
        const seg = propA(state, curDepth, dNext, currentGas, ascentRate);
        state = seg.state;
        push("ascent", dNext, seg.totalTime, currentGas);
        curDepth = dNext;
      }
    }
    return plan;
  }

  // Deco ascent.
  const depth0 = Math.max(snapToGrid(startDepth, stopStep), snapToGrid(firstCeil, stopStep));
  const gfAtDepth = makeGfAtDepth(gf, depth0);

  const ok = decoAscentLoop(depth0, gfAtDepth, {
    getState: () => state,
    stopStep, lastStopDepth,
    onAscend(d, next){
      const parts = getAscentSplitDepths(gases, d, next);
      for (const dNext of parts){
        const seg = propA(state, d, dNext, currentGas, ascentRate);
        state = seg.state;
        push("ascent", dNext, seg.totalTime, currentGas);
        d = dNext;
      }
    },
    onStop(d, next, gfEff){
      const gasStop = chooseBestGas(gases, d, "stop", currentGas);
      if (gasStop && gasStop !== currentGas){
        plan.push({ kind:"switch", depth:d, duration:0, runtime, gas:gasStop });
        currentGas = gasStop;
      }
      if (!currentGas) return false; // stranded — no gas for this stop depth
      const t = computeMinStopTime(state, d, next, gfEff, currentGas);
      if (!isFinite(t)) return false;
      const tQ = Math.ceil(t / minStopQuantum) * minStopQuantum;
      state = propC(state, d, currentGas, tQ);
      push("stop", d, tQ, currentGas);
    },
  });
  if (!ok) return null; // stranded gas abort propagated from onStop

  return plan;
}

/* -------------------------------
   Planner: planDiveFromSegments
   (depth-time points, with travel)
-------------------------------- */
/**
 * Plans a multi-segment dive from an ordered list of (depth, time) waypoints.
 *
 * The planner travels between waypoints at the configured descent/ascent rate,
 * spends `time` minutes at each waypoint's depth, then performs a full GF
 * deco-stop ascent after the last waypoint.
 *
 * @param {{ depth: number, time: number }[]} points - Ordered waypoints.
 * @param {Gas[]} gases - Available gas list.
 * @param {object} opts - Same structure as {@link planDiveMultigasAuto}.
 * @returns {object[]} Plan segment array.
 */
function planDiveFromSegments(points, gases, opts){
  const stopStep = opts.stopStep ?? 3;
  const ascentRate = opts.ascentRate ?? 9;
  const descentRate = opts.descentRate ?? 20;
  const minStopQuantum = opts.minStopQuantum ?? 1;
  const lastStopDepth = clamp(Number(opts.lastStopDepth ?? 3), 0, 12);
  const gf = opts.gf ?? new GradientFactors(0.30, 0.85);

  if (!(stopStep>0 && ascentRate>0 && descentRate>0 && minStopQuantum>0)) {
    throw new Error("Rates/steps must be positive.");
  }

  const pts = (Array.isArray(points) ? points : [])
    .map(p => ({ depth: Math.max(0, Number(p.depth) || 0), time: Math.max(0, Number(p.time) || 0) }))
    .filter(p => Number.isFinite(p.depth) && Number.isFinite(p.time));
  if (!pts.length) return [];

  const plan = [];
  let runtime = 0;

  // start gas at surface — always pre-saturate with atmospheric air
  let currentGas = chooseBestGas(gases, 0, "descent", null);
  let state = opts.initTissues ? cloneState(opts.initTissues) : initState(SURFACE_AIR);
  let curDepth = 0;

  function push(kind, depth, duration, gasHere){
    runtime += duration;
    const surf = computeSurfGF(state);
    const _s = state, _g = gasHere;
    plan.push({kind, depth, duration, runtime, gas: gasHere, label: gasHere.label, tts: null, surf_gf: surf,
      _computeTts: () => computeTTSFromState(_s, depth, gf, gases, stopStep, ascentRate, minStopQuantum, lastStopDepth, _g)});
    currentGas = gasHere;
  }

  function maybeSwitch(kindDepth, phase){
    const g = chooseBestGas(gases, kindDepth, phase, currentGas);
    if (g !== currentGas){
      plan.push({kind:"switch", depth: curDepth, duration:0, runtime, gas:g, label:g.label});
      currentGas = g;
    }
    return g;
  }

  function travelToDepth(targetDepth){
    const tgt = Math.max(0, Number(targetDepth) || 0);
    if (Math.abs(tgt - curDepth) <= EPS) return;

    if (tgt > curDepth){
      // descent: one Schreiner segment per gas zone
      const splits = getDescentSplitDepths(ccrCtx ? [] : gases, curDepth, tgt);
      for (const next of splits){
        const g = maybeSwitch(next, "descent");
        const { state: st, totalTime } = propA(state, curDepth, next, g, descentRate);
        state = st;
        push("descent", next, totalTime, g);
        curDepth = next;
      }
      return;
    }

    // ascent as a continuous segment to keep line straight (but split at manual switch depths)
    while (curDepth > tgt + EPS){
      const target = Math.max(tgt, curDepth - stopStep);
      const parts = getAscentSplitDepths(ccrCtx ? [] : gases, curDepth, target);
      for (const next of parts){
        if (!ccrCtx) maybeSwitch(curDepth, "ascent");
        const seg = propA(state, curDepth, next, currentGas, ascentRate);
        state = seg.state;
        push("ascent", next, seg.totalTime, currentGas);
        curDepth = next;
        if (!ccrCtx) maybeSwitch(curDepth, "ascent");
      }
    }
  }

  // follow user points
  for (const p of pts){
    travelToDepth(p.depth);
    if (p.time > 0){
      const g = ccrCtx ? ccrCtx.diluent : maybeSwitch(curDepth, "bottom");
      state = propC(state, curDepth, g, p.time);
      push("bottom", curDepth, p.time, g);
    }
  }

  // final ascent with stops
  const firstCeil = computeCeilingGF(state, gf.gfLow);
  if (firstCeil <= 1e-6){
    while (curDepth > 0){
      const target = Math.max(0, curDepth - stopStep);
      const parts = getAscentSplitDepths(ccrCtx ? [] : gases, curDepth, target);
      for (const next of parts){
        if (!ccrCtx) maybeSwitch(curDepth, "ascent");
        const seg = propA(state, curDepth, next, currentGas, ascentRate);
        state = seg.state;
        push("ascent", next, seg.totalTime, currentGas);
        curDepth = next;
        if (!ccrCtx) maybeSwitch(curDepth, "ascent");
      }
    }
    return plan;
  }

  const depth0 = Math.max(snapToGrid(curDepth, stopStep), snapToGrid(firstCeil, stopStep));
  const gfAtDepth = makeGfAtDepth(gf, depth0);

  decoAscentLoop(depth0, gfAtDepth, {
    getState: () => state,
    stopStep, lastStopDepth,
    onAscend(d, next){
      const parts = getAscentSplitDepths(ccrCtx ? [] : gases, d, next);
      for (const dNext of parts){
        const seg = propA(state, d, dNext, currentGas, ascentRate);
        state = seg.state;
        push("ascent", dNext, seg.totalTime, currentGas);
        d = dNext;
        curDepth = dNext;
      }
    },
    onStop(d, next, gfEff){
      const gasStop = ccrCtx ? currentGas : chooseBestGas(gases, d, "stop", currentGas);
      if (!ccrCtx && gasStop !== currentGas){
        plan.push({kind:"switch", depth:d, duration:0, runtime, gas:gasStop, label:gasStop.label});
        currentGas = gasStop;
      }
      const t = computeMinStopTime(state, d, next, gfEff, currentGas);
      if (!isFinite(t)) return false;
      const tQ = Math.ceil(t / minStopQuantum) * minStopQuantum;
      state = propC(state, d, currentGas, tQ);
      push("stop", d, tQ, currentGas);
    },
  });

  return plan;
}

/* -------------------------------
   UI
-------------------------------- */
const $ = (id)=>document.getElementById(id);

function enforcePpO2Digits(id){
  const el = document.getElementById(id);
  if (!el) return;
  function normalize(){
    let v = Number(el.value);
    if (!Number.isFinite(v)) return;
    const min = Number(el.min);
    const max = Number(el.max);
    if (Number.isFinite(min)) v = Math.max(min, v);
    if (Number.isFinite(max)) v = Math.min(max, v);
    // Strict display policy (e.g. 1.50). This is intentionally applied
    // immediately on arrow/step changes to avoid transient "1.5" rendering.
    el.value = v.toFixed(2);
  }

  // Apply strict formatting as early as possible in the event loop so the
  // browser doesn't paint an intermediate value (e.g. "1.5") on step changes.
  function normalizeSoon(){
    Promise.resolve().then(normalize);
  }

  el.addEventListener("input", normalizeSoon);
  el.addEventListener("change", normalizeSoon);
  el.addEventListener("blur", normalize);
}

let cv = $("cv");
let ctx = cv.getContext("2d");
const o2ToxEnableEl = $("o2ToxEnable");



const salinitySelect = $("salinityModel");

function updateSalinityModel(){
  if (!salinitySelect) return;
  const k = String(salinitySelect.value || "sea");
  if (Object.prototype.hasOwnProperty.call(SALINITY_MODELS, k)){
    RHO = SALINITY_MODELS[k];
  }
}

if (salinitySelect){
  salinitySelect.addEventListener("change", ()=>{
    updateSalinityModel();
    run();
  });
}

const altitudeInput = $("altitudeM");
const surfPressurePill = $("surfPressurePill");

/**
 * Reads the altitude input, updates the global `SURF` via the ISA formula,
 * and refreshes the surface-pressure readout pill.
 * Called at startup and on every change of the altitude field.
 */
function updateAltitude(){
  const altM = Math.max(0, Number(altitudeInput?.value) || 0);
  SURF = altitudeToSurfacePressure(altM);
  if (surfPressurePill){
    surfPressurePill.textContent = `${SURF.toFixed(3)} bar`;
    if (altM > 0){
      // Compute the virtual depth offset for the tooltip only.
      const vdo = ((SURF_SL - SURF) * PA_PER_BAR) / (RHO * G);
      surfPressurePill.title =
        `Surface pressure at ${altM} m altitude (ISA). ` +
        `Equivalent Sea-Level Depth: tissue loading is computed as if ` +
        `the dive were ${vdo.toFixed(1)} m deeper at sea level — ` +
        `deco obligations increase monotonically with altitude.`;
    } else {
      surfPressurePill.title = "Surface pressure (sea level).";
    }
  }
}

if (altitudeInput){
  let _altRafPending = false;
  let _altDebounce   = null;

  altitudeInput.addEventListener("input", ()=>{
    // Throttle to one rAF per frame: if dozens of input events fire between
    // two frames (holding the arrow key) we only do one pill update and one
    // debounce reset per frame instead of potentially hundreds.
    if (_altRafPending) return;
    _altRafPending = true;
    requestAnimationFrame(() => {
      _altRafPending = false;
      updateAltitude();               // cheap — just sets SURF and updates pill text
      clearTimeout(_altDebounce);
      _altDebounce = setTimeout(() => { run(); }, 500); // replan 500 ms after last change
    });
  });
}
// -------------------------------
// Hover interaction state
// These globals track the most recently drawn plan and
// pointer position so that we can overlay a red vertical
// line on the canvas when the user moves the mouse across
// the graph. `lastPlan` holds the plan passed to the last
// draw() call, `hoverX` is the current mouse x-coordinate
// relative to the canvas (in CSS pixels), and `plotMetrics`
// stores dimensions used during rendering. When hovering the
// canvas, draw() will re‑render the graph including a thin red
// vertical line at `hoverX`.
let lastPlan = null;
let lastValidPlan = null;

// ── Repetitive dives ─────────────────────────────────────────────────────────
// Each entry: { siMin: number, depth: number, bottomTime: number }
let repDives = [];
let selectedDive = -1;  // -1 = show all; 0 = dive 1; 1 = rep dive 1; etc.
let _tissueSliderPos = [];  // per-dive saved slider position (null = use end-of-dive default)
let diveGasData      = [];   // per-dive serialized gas row configs (index matches dive number)
let diveTankData     = [];   // per-dive tank selections: array of [key, value] Map entries
let diveSegmentsData = [];   // per-dive segment arrays (segments mode); index matches dive number
let lastContingencyPlan   = null;          // planAscentFromState result for the selected scenario
let lastContingencyWorst  = { t: 0, d: 0 }; // dive-time / depth at which the event occurs (absolute)
let lastContingencyEvent  = "gas loss";    // label drawn on the contingency graph
let lastContingencyDispPlan  = null;   // plan slice passed to renderContingencyGraph (current dive only)
let lastContingencyDispTWorst = 0;     // tWorst normalized to the display slice's timeline
let contingencyHoverX    = null;   // mouse X over cvContingency (CSS px), or null
let contingencyHoverInfo = null;   // same structure as hoverInfo, for the contingency canvas
let contingencyPlotMetrics = { padL:0, padT:0, plotW:0, plotH:0, tMax:1, dMax:1 };
let contingencyLastPts   = null;   // polyline of the normal plan on the contingency canvas
let lastToxInputs        = null;   // { maxDens, dMax, gAtMax, plan } — used to re-render tox panel
let lastValidDepth = null;
let lastValidTime = null;
let lastValidSegments = null;
let hoverX = null;
let hoverInfo = null; // { lines: (string|{text:string,color?:string})[], x: number, y: number }
let plotMetrics = { padL: 0, padT: 0, plotW: 0, plotH: 0, tMax: 1, dMax: 1 };
let lastPts = null;

// --- Gas label/color helpers (visuals only) ---
function fmtGasLabel(label){
  const s = String(label || "").replace(/\s+/g, " ").trim();
  if (/^o2$/i.test(s)) return "O₂";
  return s;
}

function gasFractions(g){
  // Supports both UI rows (o2/he in %) and model Gas objects (fO2/fHe in 0..1)
  if (!g) return { fO2: NaN, fHe: NaN };
  const fO2 = Number.isFinite(Number(g.fO2)) ? Number(g.fO2)
    : (Number.isFinite(Number(g.o2)) ? Number(g.o2) : NaN);
  const fHe = Number.isFinite(Number(g.fHe)) ? Number(g.fHe)
    : (Number.isFinite(Number(g.he)) ? Number(g.he) : NaN);
  return { fO2, fHe };
}

// --- Gas density (ideal gas approximation) ---
/** Returns breathing-gas density (kg/m³) via the ideal-gas law at `depthM` and `tempC`. */
function gasDensityKgM3(gas, depthM, tempC = 20){
  const { fO2, fHe } = gasFractions(gas);
  if (!Number.isFinite(fO2) || !Number.isFinite(fHe) || !Number.isFinite(depthM)) return NaN;
  const fN2 = Math.max(0, 1 - (fO2 + fHe));
  // molar masses (kg/mol)
  const M_O2 = 0.031998;
  const M_N2 = 0.0280134;
  const M_HE = 0.004002602;
  const M = fO2*M_O2 + fHe*M_HE + fN2*M_N2;
  const pPa = depthToAmbientPressureActual(depthM) * PA_PER_BAR;
  const T = 273.15 + tempC;
  return (pPa * M) / (IDEAL_GAS_R * T);
}

function gasLabelFromObj(g){
  if (!g) return "";
  if (typeof g === "string") return fmtGasLabel(g);
  if (g.label) return fmtGasLabel(g.label);
  // fallback from fractions if present
  const { fO2, fHe } = gasFractions(g);
  if (Number.isFinite(fO2) && Number.isFinite(fHe)){
    const o2p = Math.round(fO2 * 100);
    const hep = Math.round(fHe * 100);
    if (o2p >= 98 && hep === 0) return "O₂";
    if (hep > 0) return `Trimix ${o2p}/${hep}`;
    if (o2p === 21) return "Air";
    return `Nitrox ${o2p}`;
  }
  return "";
}

function gasKeyFromObj(g){
  const { fO2, fHe } = gasFractions(g);
  if (Number.isFinite(fO2) && Number.isFinite(fHe)){
    return `${Math.round(fO2*100)}/${Math.round(fHe*100)}`;
  }
  return gasLabelFromObj(g);
}

function hslToHex(h, s, l){
  // h in [0,360), s/l in [0,1]
  const C = (1 - Math.abs(2*l - 1)) * s;
  const Hp = ((h % 360) + 360) % 360 / 60;
  const X = C * (1 - Math.abs((Hp % 2) - 1));
  let r1=0,g1=0,b1=0;
  if (0<=Hp && Hp<1){ r1=C; g1=X; b1=0; }
  else if (1<=Hp && Hp<2){ r1=X; g1=C; b1=0; }
  else if (2<=Hp && Hp<3){ r1=0; g1=C; b1=X; }
  else if (3<=Hp && Hp<4){ r1=0; g1=X; b1=C; }
  else if (4<=Hp && Hp<5){ r1=X; g1=0; b1=C; }
  else { r1=C; g1=0; b1=X; }
  const m = l - C/2;
  const r = Math.round((r1+m)*255);
  const g = Math.round((g1+m)*255);
  const b = Math.round((b1+m)*255);
  const toHex = (v)=>v.toString(16).padStart(2,"0");
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}

function gasColor(g){
  // Air = blue, O₂ = red, He mixes = purple.
  // Nitrox: deterministic distinct shades based on O₂ % (prevents same-color nitrox bars).
  const { fO2, fHe } = gasFractions(g);
  if (Number.isFinite(fO2) && fO2 >= 0.98) return "#E53935"; // O₂
  if (Number.isFinite(fHe) && fHe > 0.01) return "#8E24AA";  // He mixes
  if (Number.isFinite(fO2) && fO2 > 0.21){
    const o2p = clamp(Math.round(fO2 * 100), 22, 99);
    // Map 22..80% -> hues 150..95 (green-ish -> yellow-green), 80..99 -> 95..75
    let hue;
    if (o2p <= 80){
      hue = 150 + (22 - o2p) * (55 / (80 - 22)); // 150 down to ~95
    } else {
      hue = 95 + (80 - o2p) * (20 / (99 - 80));  // 95 down to ~75
    }
    const sat = 0.55;
    const light = 0.45;
    return hslToHex(hue, sat, light);
  }
  return "#1E88E5"; // Air
}


// --- Debounced run() ---
// Batches rapid UI changes (e.g. typing in an input) into a single replan
// per animation frame, preventing redundant work when multiple inputs fire
// in quick succession.
let _runScheduled = false;
function scheduleRun(){
  if (_runScheduled) return;
  _runScheduled = true;
  requestAnimationFrame(() => {
    _runScheduled = false;
    run();
  });
}

// --- Throttled hover handler ---
// Mousemove can fire ~hundreds of times per second; we throttle to one
// updateHoverInfoAndRedraw per animation frame to avoid jank.
let _hoverPending = false;
cv.addEventListener('mousemove', (e) => {
  hoverX = e.offsetX;
  if (!lastPlan || _hoverPending) return;
  _hoverPending = true;
  requestAnimationFrame(() => {
    _hoverPending = false;
    if (lastPlan) updateHoverInfoAndRedraw();
  });
});

// When the pointer leaves the canvas, clear the hover
// coordinate and redraw without the hover line.
cv.addEventListener('mouseleave', () => {
  hoverX = null;
  hoverInfo = null;
  if (lastPlan) draw(getDiveSlice(lastPlan, selectedDive), true);
});

// ── Contingency canvas hover ─────────────────────────────────────────────
let _contingencyHoverPending = false;
const cvContingency = document.getElementById("cvContingency");
if (cvContingency){
  cvContingency.addEventListener('mousemove', (e) => {
    contingencyHoverX = e.offsetX;
    if (!lastPlan || _contingencyHoverPending) return;
    _contingencyHoverPending = true;
    requestAnimationFrame(() => {
      _contingencyHoverPending = false;
      if (contingencyHoverX === null || !lastPlan) return;
      // Map pixel → time using the last known contingency layout metrics.
      // tHover is relative to the start of the contingency (t=0 = moment of gas loss).
      // Add tWorst to get the absolute dive-plan time needed by buildHoverInfo.
      const { padL, padT, plotW, tMax } = contingencyPlotMetrics;
      const x = Math.max(padL, Math.min(padL + plotW, contingencyHoverX));
      // tHoverRel is already in absolute dive-time (0 = start of dive, tWorst = gas loss).
      // Do NOT add tWorst again — the combined plan timeline starts at 0.
      const tHoverRel  = ((x - padL) / Math.max(EPS, plotW)) * tMax;
      const tHoverFull = tHoverRel;
      contingencyHoverInfo = buildHoverInfo(lastContingencyDispPlan || lastPlan, tHoverFull, x, padT);
      renderContingencyGraph(
        lastContingencyDispPlan || lastPlan, lastContingencyPlan,
        lastContingencyDispTWorst, lastContingencyWorst.d
      );
    });
  });

  cvContingency.addEventListener('mouseleave', () => {
    contingencyHoverX    = null;
    contingencyHoverInfo = null;
    renderContingencyGraph(
      lastContingencyDispPlan || lastPlan, lastContingencyPlan,
      lastContingencyDispTWorst, lastContingencyWorst.d
    );
  });
}

function pathRoundedRect(ctx, x, y, w, h, r){
  const rr = Math.max(0, Math.min(r, w/2, h/2));
  ctx.beginPath();
  ctx.moveTo(x+rr, y);
  ctx.lineTo(x+w-rr, y);
  ctx.quadraticCurveTo(x+w, y, x+w, y+rr);
  ctx.lineTo(x+w, y+h-rr);
  ctx.quadraticCurveTo(x+w, y+h, x+w-rr, y+h);
  ctx.lineTo(x+rr, y+h);
  ctx.quadraticCurveTo(x, y+h, x, y+h-rr);
  ctx.lineTo(x, y+rr);
  ctx.quadraticCurveTo(x, y, x+rr, y);
  ctx.closePath();
}

function interpDepthAtTime(pts, t){
  if (!pts || pts.length < 2) return 0;
  if (t <= pts[0].t) return pts[0].d;
  if (t >= pts[pts.length-1].t) return pts[pts.length-1].d;
  for (let i=1;i<pts.length;i++){
    const a = pts[i-1], b = pts[i];
    if (t <= b.t + EPS_DENOM){
      const dt = (b.t - a.t);
      if (dt <= EPS_DENOM) return b.d;
      const u = (t - a.t) / dt;
      return a.d + (b.d - a.d) * u;
    }
  }
  return pts[pts.length-1].d;
}

/**
 * Replays a dive plan up to time `tTarget` (min) and returns the resulting
 * tissue state, depth, and current gas.  Handles partial segments by
 * interpolating tissue loading for the fractional segment duration.
 *
 * @param {object[]} plan - Output of a planner function.
 * @param {Gas[]} gases
 * @param {{ ascentRate: number }} opts
 * @param {number} tTarget - Replay target time (min).
 * @returns {{ state: Compartment[], depth: number, gas: Gas }}
 */
function stateAtRuntime(plan, gases, opts, tTarget){
  const ascentRate = opts.ascentRate;
  // start on whatever the planner would pick at the surface
  let currentGas = chooseBestGas(gases, 0, "descent", null);
  let state = initState(SURFACE_AIR);
  let t = 0;
  let prevDepth = 0;

  for (const s of plan){
    if (s.kind === "switch"){
      if (s.gas) currentGas = s.gas;
      continue;
    }
    const dur = Number(s.duration||0);
    const dEnd = Number(s.depth||0);
    const tEnd = t + dur;

    // Surface interval: off-gas at surface on air regardless of last dive gas
    if (s.kind === "surface"){
      if (tTarget >= tEnd - EPS){
        state = propC(state, 0, SURFACE_AIR, dur);
        t = tEnd; prevDepth = 0;
      } else if (tTarget > t + EPS){
        state = propC(state, 0, SURFACE_AIR, tTarget - t);
        prevDepth = 0; t = tTarget; break;
      } else { break; }
      continue;
    }

    if (tTarget >= tEnd - EPS){
      // full segment
      if (s.kind === "ascent"){
        state = propA(state, prevDepth, dEnd, currentGas, ascentRate).state;
      } else {
        state = propC(state, dEnd, currentGas, dur);
      }
      t = tEnd;
      prevDepth = dEnd;
      continue;
    }

    if (tTarget <= t + EPS) break;

    // partial segment
    const dt = Math.max(0, tTarget - t);
    if (dt <= EPS) break;

    if (s.kind === "ascent"){
      const u = dur > EPS_DENOM ? (dt / dur) : 1;
      const dMid = prevDepth + (dEnd - prevDepth) * clamp(u, 0, 1);
      state = propA(state, prevDepth, dMid, currentGas, ascentRate).state;
      prevDepth = dMid;
    } else {
      state = propC(state, dEnd, currentGas, dt);
      prevDepth = dEnd;
    }
    t = tTarget;
    break;
  }

  return { state, depth: prevDepth, gas: currentGas };
}

/**
 * Computes the total required stop time (min) to ascend to the surface from
 * the given tissue state, without tracking travel time between stops.
 * Used by {@link computeNDLMinutesFromState} to check whether any stop exists.
 */
function computeStopTimeToSurfaceFromState(state, currentDepth, gf, gases, stopStep, ascentRate, minStopQuantum, lastStopDepth){
  let st = cloneState(state);
  const firstCeil = computeCeilingGF(st, gf.gfLow);
  const depth0 = Math.max(snapToGrid(currentDepth, stopStep), snapToGrid(firstCeil, stopStep));
  let stopTime = 0;
  const gfAtDepth = makeGfAtDepth(gf, depth0);

  decoAscentLoop(depth0, gfAtDepth, {
    getState: () => st,
    stopStep, lastStopDepth,
    onAscend(d, next){
      const gas = chooseBestGas(gases, d, "stop", null);
      st = propA(st, d, next, gas, ascentRate).state;
    },
    onStop(d, next, gfEff){
      const gas = chooseBestGas(gases, d, "stop", null);
      const t = computeMinStopTime(st, d, next, gfEff, gas);
      if (!isFinite(t)) return false;
      const tQ = Math.ceil(t / minStopQuantum) * minStopQuantum;
      st = propC(st, d, gas, tQ);
      stopTime += tQ;
    },
  });
  return stopTime;
}

/**
 * Computes the No-Decompression Limit (NDL) in minutes from the current tissue state.
 *
 * Algorithm: step forward in 1-min increments until a decompression stop first
 * appears, then binary-search within that minute for the precise boundary.
 * Returns Infinity when the NDL exceeds 600 min (practical cap).
 *
 * @param {Compartment[]} state
 * @param {number} depthM - Depth to hold at (m).
 * @param {Gas} gas - Gas breathed during the forward simulation.
 * @param {GradientFactors} gf
 * @param {Gas[]} gases - Full gas list (for stop-time estimates).
 * @param {number} stopStep
 * @param {number} ascentRate
 * @param {number} minStopQuantum
 * @returns {number} NDL in minutes, or Infinity.
 */
function computeNDLMinutesFromState(state, depthM, gas, gf, gases, stopStep, ascentRate, minStopQuantum){
  if (!state || !Number.isFinite(depthM)) return NaN;

  const baseStop = computeStopTimeToSurfaceFromState(state, depthM, gf, gases, stopStep, ascentRate, minStopQuantum, 3);
  if (baseStop > EPS) return 0;

  const STEP = 1.0;
  const MAX = 600; // minutes cap
  let t = 0;
  let st = cloneState(state);

  while (t + STEP <= MAX){
    const stNext = propC(st, depthM, gas, STEP);
    const stopNext = computeStopTimeToSurfaceFromState(stNext, depthM, gf, gases, stopStep, ascentRate, minStopQuantum, 3);
    if (stopNext > EPS){
      // binary search within [0, STEP]
      let lo = 0, hi = STEP;
      let stLo = st;
      for (let i=0;i<12;i++){
        const mid = 0.5*(lo+hi);
        const stMid = propC(st, depthM, gas, mid);
        const stopMid = computeStopTimeToSurfaceFromState(stMid, depthM, gf, gases, stopStep, ascentRate, minStopQuantum, 3);
        if (stopMid > EPS) hi = mid;
        else { lo = mid; stLo = stMid; }
      }
      return t + lo;
    }
    t += STEP;
    st = stNext;
  }
  return Infinity;
}

let _panelResizePending = false;
function autoResizeRightPanelForLines(lines){
  const panelEl = $("panel");
  const preEl = $("pStopsList");
  if (!panelEl || !preEl) return;

  // In stacked layout the panel becomes full-width; don't override that.
  if (window.matchMedia && window.matchMedia("(max-width: 980px)").matches){
    panelEl.style.width = "";
    panelEl.style.maxWidth = "";
    panelEl.style.flex = "";
    return;
  }

  let maxTextW = 0;
  if (Array.isArray(lines) && lines.length){
    const cs = window.getComputedStyle(preEl);
    const font = cs.font || `${cs.fontSize} ${cs.fontFamily}`;
    const mctx = autoResizeRightPanelForLines._ctx || (autoResizeRightPanelForLines._ctx = document.createElement("canvas").getContext("2d"));
    mctx.font = font;
    for (const ln of lines){
      const w = mctx.measureText(String(ln)).width;
      if (w > maxTextW) maxTextW = w;
    }
  }

  let csPanel = window.getComputedStyle(panelEl);
  let padLpx = parseFloat(csPanel.paddingLeft) || 0;
  let padRpx = parseFloat(csPanel.paddingRight) || 0;
  const pad = Math.max(padLpx, padRpx);

  // Force symmetric inner padding so the right-side breathing room matches the left.
  if (Math.abs(padLpx - padRpx) > 0.5){
    panelEl.style.paddingLeft = `${pad}px`;
    panelEl.style.paddingRight = `${pad}px`;
    csPanel = window.getComputedStyle(panelEl);
    padLpx = parseFloat(csPanel.paddingLeft) || pad;
    padRpx = parseFloat(csPanel.paddingRight) || pad;
  }

  const bL = parseFloat(csPanel.borderLeftWidth) || 0;
  const bR = parseFloat(csPanel.borderRightWidth) || 0;

  // Desired panel width: longest line + symmetric padding + borders.
  var target = Math.ceil(maxTextW + padLpx + padRpx + bL + bR + 2);
  target = Math.max(320, target);

  const workspace = document.querySelector(".workspace");
  const avail = workspace ? workspace.clientWidth : window.innerWidth;
  const minPlot = 360;
  const gap = 16;
  const maxPanel = Math.max(320, avail - minPlot - gap);
  const cap = Math.floor(window.innerWidth * 0.70);
  target = Math.min(target, maxPanel, cap);

  const cur = Math.round(panelEl.getBoundingClientRect().width);
  if (Math.abs(cur - target) < 1) return;

  panelEl.style.width = `${target}px`;
  panelEl.style.maxWidth = `${target}px`;
  panelEl.style.flex = `0 0 ${target}px`;

  if (_panelResizePending) return;
  _panelResizePending = true;
  requestAnimationFrame(() => {
    _panelResizePending = false;
    resizeCanvas();
    if (lastPlan) draw(getDiveSlice(lastPlan, selectedDive), true);
  });
}



/**
 * Builds a hoverInfo object for a given time position on any plan.
 * Returns null on error.
 *
 * @param {object[]} plan
 * @param {number}   tHover  - time (min) to evaluate
 * @param {number}   x       - canvas X coord for tooltip placement (CSS px)
 * @param {number}   padT    - top padding of plot area (CSS px)
 */
function buildHoverInfo(plan, tHover, x, padT, { tissuesPlan, tissuesTime } = {}){
  try{
    const gf = new GradientFactors(Number($("gfLow").value)/100, Number($("gfHigh").value)/100);
    const ascentRate = Number($("ascentRate").value) || 9;
    const gases = uiGases();
    const stopStep = 3;
    const minStopQuantum = 1;
    const lastStopDepthHover = Number(document.getElementById("lastStopDepth")?.value || 3) || 0;

    const pts = planToPolyline(plan);
    const depthHover = interpDepthAtTime(pts, tHover);

    // For rep dives, tissue state must be computed from the full plan at the
    // absolute hover time — the normalized display slice starts from clean tissues.
    const { state, depth: depthNow, gas } = stateAtRuntime(
      tissuesPlan || plan, gases, {ascentRate}, tissuesTime ?? tHover
    );
    const stopNow = computeStopTimeToSurfaceFromState(
      state, depthNow, gf, gases, stopStep, ascentRate, minStopQuantum, lastStopDepthHover
    );

    const stPlus = propC(state, depthNow, gas, 5);
    const stopPlus = computeStopTimeToSurfaceFromState(
      stPlus, depthNow, gf, gases, stopStep, ascentRate, minStopQuantum, lastStopDepthHover
    );
    const delta = Math.max(0, stopPlus - stopNow);

    const surfGF = computeSurfGF(state);

    const lines = [];
    lines.push(`Depth : ${depthNow.toFixed(0)} m`);
    lines.push(`Runtime : ${tHover.toFixed(1)} min`);
    if (stopNow > EPS){
      const ttsNow = computeTTSFromState(
        state, depthNow, gf, gases, stopStep, ascentRate, minStopQuantum, lastStopDepthHover, gas
      );
      lines.push(`TTS : ${Math.ceil(ttsNow)} min`);
    } else {
      const ndl = computeNDLMinutesFromState(state, depthNow, gas, gf, gases, stopStep, ascentRate, minStopQuantum);
      if (Number.isFinite(ndl)) lines.push(`NDL : ${ndl.toFixed(1)} min`);
      else lines.push(`NDL : ∞`);
    }

    lines.push(`Δ+5 : +${Math.ceil(delta)} min`);
    lines.push(`SurfGF : ${(surfGF*100).toFixed(0)}`);

    if (o2ToxEnableEl && o2ToxEnableEl.checked){
      const d = oxygenDoseUpTo(plan, tHover);
      const cnsV = Math.round(d.cns);
      const cnsS2 = valueStatus(cnsV, TOXICITY_LIMITS.cns_warn_pct, TOXICITY_LIMITS.cns_crit_pct, "CNS (%)");
      if (cnsV >= TOXICITY_LIMITS.cns_crit_pct) cnsS2.color = "#800";
      lines.push({ text: `CNS : ${cnsV} %`, color: cnsS2.color });
      const otuV = Math.round(d.otu);
      const otuS2 = valueStatus(otuV, TOXICITY_LIMITS.otu_warn, TOXICITY_LIMITS.otu_crit, "OTU");
      if (otuV >= TOXICITY_LIMITS.otu_crit) otuS2.color = "#800";
      lines.push({ text: `OTU : ${otuV}`, color: otuS2.color });
      const endNow = endMeters(depthNow, gas.fN2);
      const endV = Number.isFinite(endNow) ? Math.round(endNow) : NaN;
      const endS2 = valueStatus(endV, TOXICITY_LIMITS.end_warn_m, TOXICITY_LIMITS.end_crit_m, "END (m)");
      if (endV >= TOXICITY_LIMITS.end_crit_m) endS2.color = "#800";
      lines.push({ text: `END : ${Number.isFinite(endV)?endV:"—"} m`, color: endS2.color });
      const dens = gasDensityKgM3(gas, depthNow);
      const densColor = !Number.isFinite(dens) ? "#000"
        : dens > GAS_DENSITY_DANGER  ? "#c00"
        : dens > GAS_DENSITY_CAUTION ? "#c60"
        : "#000";
      lines.push({ text: `Gas density : ${Number.isFinite(dens)?dens.toFixed(2):"—"} kg/m³`, color: densColor });

      // ppO₂ lines — OC: single value; CCR: diluent ppO₂, FiO₂, loop ppO₂
      const _pAmb = depthToAmbientPressure(depthNow);
      const _ppO2Color = p => !Number.isFinite(p) ? "#000" : p > 1.6 ? "#c00" : p > 1.4 ? "#c60" : "#000";
      if (ccrCtx){
        const _sp    = Math.min(ccrSetpointAt(depthNow), _pAmb * 0.98);
        const _dilP  = ccrCtx.diluent.fO2 * _pAmb;
        const _fiO2  = _sp / _pAmb;
        lines.push({ text: `Dil ppO₂ : ${_dilP.toFixed(2)} bar`,  color: _ppO2Color(_dilP) });
        lines.push({ text: `FiO₂ : ${_fiO2.toFixed(2)}`,          color: "#000" });
        lines.push({ text: `Loop ppO₂ : ${_sp.toFixed(2)} bar`,   color: _ppO2Color(_sp) });
      } else {
        const _ppO2 = gas.fO2 * _pAmb;
        lines.push({ text: `ppO₂ : ${_ppO2.toFixed(2)} bar`, color: _ppO2Color(_ppO2) });
      }
    }

    const tissueEl = document.getElementById('tissueHeatmapEnable');
    const tissues  = (tissueEl && tissueEl.checked) ? tissueHeatmapData(state) : null;
    return { lines, x, y: padT + 10, tissues };
  } catch(_){
    return null;
  }
}

function updateHoverInfoAndRedraw(){
  hoverInfo = null;
  const _displayPlan = getDiveSlice(lastPlan, selectedDive);
  if (hoverX === null || !lastPlan || !lastPts) {
    draw(_displayPlan, true);
    return;
  }

  const { padL, padT, plotW, tMax } = plotMetrics;
  const x = Math.max(padL, Math.min(padL + plotW, hoverX));
  const tHover = ((x - padL) / Math.max(EPS, plotW)) * tMax;

  // For rep dives the display plan has normalized (slice-relative) runtimes.
  // Tissue state must be computed from the full plan at the absolute hover time
  // so that saturation carried over from previous dives is correctly reflected.
  let tissuesPlan = null, tissuesTime = null;
  if (selectedDive > 0 && lastPlan && _displayPlan && _displayPlan.length){
    // Split the full plan into per-dive slices (same logic as getDiveSlice)
    // to find the absolute start time (t0) of the displayed slice.
    const absSlices = [];
    let _cur = [];
    for (const s of lastPlan){
      if (s.kind === "surface"){ absSlices.push(_cur); _cur = []; }
      else _cur.push(s);
    }
    absSlices.push(_cur);
    const absSlice = absSlices[selectedDive] || [];
    if (absSlice.length){
      const t0 = absSlice[0].runtime - (absSlice[0].duration || 0);
      tissuesPlan = lastPlan;
      tissuesTime = tHover + t0;   // convert relative → absolute plan time
    }
  }

  hoverInfo = buildHoverInfo(_displayPlan, tHover, x, padT, { tissuesPlan, tissuesTime });
  draw(_displayPlan, true);
}

function resizeCanvas(){
  const dpr = window.devicePixelRatio || 1;
  const r = cv.getBoundingClientRect();
  const w = Math.max(320, Math.floor(r.width));
  const h = Math.max(320, Math.floor(r.height));

  // Do NOT set cv.style.width/height here.
  // The rendered size must be controlled by CSS only, otherwise modifying the
  // backing store size can trigger layout feedback loops that make the plot
  // area grow/shrink when unrelated inputs change.
  cv.width = Math.floor(w * dpr);
  cv.height = Math.floor(h * dpr);
  ctx.setTransform(dpr,0,0,dpr,0,0);
}
window.addEventListener("resize", () => { resizeCanvas(); run(); });
o2ToxEnableEl?.addEventListener("input", ()=>{ scheduleRun(); });
enforcePpO2Digits("ppO2Bottom");
enforcePpO2Digits("ppO2Deco");

// Clamp ppO2Min to the TDI absolute floor on every change and re-sync gas rows.
document.getElementById("ppO2Min")?.addEventListener("input", () => {
  const el = document.getElementById("ppO2Min");
  if (!el) return;
  const v = Number(el.value);
  if (Number.isFinite(v) && v < 0.16) el.value = "0.16";
  syncAllGasRows();
});


const PRESETS = {
  "air":     {label:"Air", o2:21, he:0, role:"auto"},
  "nx28":    {label:"Nitrox 28", o2:28, he:0, role:"bottom"},
  "nx30":    {label:"Nitrox 30", o2:30, he:0, role:"bottom"},
  "nx32":    {label:"Nitrox 32", o2:32, he:0, role:"bottom"},
  "nx34":    {label:"Nitrox 34", o2:34, he:0, role:"bottom"},
  "nx36":    {label:"Nitrox 36", o2:36, he:0, role:"bottom"},
  "nx40":    {label:"Nitrox 40", o2:40, he:0, role:"bottom"},
  "nx50":    {label:"Nitrox 50", o2:50, he:0, role:"deco"},
  "nx80":    {label:"Nitrox 80", o2:80, he:0, role:"deco"},
  "o2":      {label:"O₂", o2:100, he:0, role:"deco"},
  "tx21/35": {label:"Trimix 21/35", o2:21, he:35, role:"bottom"},
  "tx18/45": {label:"Trimix 18/45", o2:18, he:45, role:"bottom"},
  "tx15/55": {label:"Trimix 15/55", o2:15, he:55, role:"bottom"},
  "tx12/65": {label:"Trimix 12/65", o2:12, he:65, role:"bottom"},
  "tx10/70": {label:"Trimix 10/70", o2:10, he:70, role:"bottom"},
};

function presetLabelForMix(o2Pct, hePct){
  const o2r = Math.round(Number(o2Pct));
  const her = Math.round(Number(hePct));
  if (!Number.isFinite(o2r) || !Number.isFinite(her)) return null;
  for (const k in PRESETS){
    const p = PRESETS[k];
    if (Math.round(p.o2) === o2r && Math.round(p.he) === her) return p.label;
  }
  return null;
}

function modMeters(fO2, ppo2){
  if (!(fO2 > 0)) return Infinity;
  const pAmb = ppo2 / fO2;
  return ambientPressureToDepth(pAmb);
}

function minodMeters(fO2, ppo2Min){
  if (!(fO2 > 0)) return Infinity;
  const pAmb = ppo2Min / fO2;
  return ambientPressureToDepth(pAmb);
}

/** Returns the global minimum ppO₂ setting, clamped to the TDI absolute floor of 0.16 bar. */
function globalPpO2Min(){
  return Math.max(0.16, Number(document.getElementById("ppO2Min")?.value) || 0.16);
}


function endMeters(depthM, fn2){
  const d = Number(depthM);
  const f = Number(fn2);
  if (!Number.isFinite(d) || !Number.isFinite(f) || !(f > 0)) return NaN;
  const end = (((d / 10 + 1) * f / 0.79) - 1) * 10;
  return Math.max(0, end);
}


const gasRows = $("gasRows");
const warningsEl = $("warnings");

function escapeHtml(s){
  return String(s).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#039;");
}



function addGasRow({label="", o2=21, he=0, role="auto", enabled=true,
                    ccrSP=1.3, ccrDecSP=0.7, ccrLoopVol=7, ccrO2Rate=0.5,
                    spSchedule=null}){
  // Default schedule: 1.3 from 6 m down, 0.7 above — same as old two-input behaviour.
  const _sched = (spSchedule && spSchedule.length)
    ? [...spSchedule].sort((a,b) => b.depth - a.depth)
    : [{depth: 6, sp: ccrSP}, {depth: 0, sp: ccrDecSP}];
  // Derive simple 3-value representation from schedule
  const _spHigh   = _sched.find(e => e.depth > 0)?.sp    ?? 1.3;
  const _spLow    = _sched.find(e => e.depth === 0)?.sp  ?? 0.7;
  const _spSwitch = _sched.find(e => e.depth > 0)?.depth ?? 6;
  // In CCR mode auto-assign role: first gas becomes diluent; subsequent ones
  // become bailout (or keep "deco" if the preset already says deco).
  if (_breathMode === "ccr"){
    const hasDiluent = [...gasRows.querySelectorAll("tr[data-gas-row]")]
      .some(tr => tr.querySelector("td.gas-role select")?.value === "diluent");
    role = !hasDiluent ? "diluent" : (role === "deco" ? "deco" : "bailout");
  }

  const tr = document.createElement("tr");
  tr.dataset.gasRow = "1";
  tr.innerHTML = `
    <td class="gas-label"><input type="text" value="${escapeHtml(label)}"></td>
    <td class="gas-num"><input type="number" min="0" max="100" step="1" value="${Number(o2)}"></td>
    <td class="gas-num"><input type="number" min="0" max="100" step="1" value="${Number(he)}"></td>
    <td class="gas-role">
      <select>${roleOptionsHtml(role)}</select>
    </td>
    <td class="gas-check"><input type="checkbox"${enabled ? " checked":""}></td>
    <td class="mono gas-metric" data-minod>—</td>
    <td class="mono gas-metric" data-mod="bottom">—</td>
    <td class="mono gas-metric" data-mod="deco">—</td>
    <td class="mono gas-switch"><input data-switch type="number" min="0" step="1" placeholder=""><span class="gas-unit">m</span></td>
    <td class="gas-action"><button class="mini" data-act="del" type="button">Del</button></td>
  `;

  // CCR sub-row — always present, shown only when role === "diluent"
  const ccrTr = document.createElement("tr");
  ccrTr.className = "ccrSubRow";
  ccrTr.style.display = role === "diluent" ? "" : "none";
  ccrTr.innerHTML = `
    <td colspan="10">
      <div class="ccrSubForm">
        <div class="ccrSubGroup">
          <label><span class="ccrLbl">SP low</span>
            <input data-ccr-sp-low type="number" min="0.5" max="2.0" step="0.01" value="${_spLow}"
              title="Setpoint used near surface / during shallow deco (< switch depth). Lower ppO₂ reduces O₂ toxicity risk.">
            <span class="ccrUnit">bar</span>
          </label>
          <label><span class="ccrLbl">Switch at</span>
            <input data-ccr-sp-switch type="number" min="0" max="100" step="1" value="${_spSwitch}"
              title="Depth at which you switch from SP low to SP high on descent (and back on ascent).">
            <span class="ccrUnit">m</span>
          </label>
          <label><span class="ccrLbl">SP high</span>
            <input data-ccr-sp-high type="number" min="0.5" max="2.0" step="0.01" value="${_spHigh}"
              title="Setpoint used at depth (≥ switch depth). Higher ppO₂ keeps diluent consumption low.">
            <span class="ccrUnit">bar</span>
          </label>
        </div>
        <div class="ccrSubGroup">
          <label><span class="ccrLbl">Loop vol</span>
            <input data-ccr-loop-vol type="number" min="1" max="20" step="0.5" value="${Number(ccrLoopVol)}">
            <span class="ccrUnit">L</span>
          </label>
          <label><span class="ccrLbl">O₂ rate</span>
            <input data-ccr-o2-rate type="number" min="0.1" max="5" step="0.1" value="${Number(ccrO2Rate)}">
            <span class="ccrUnit">L/min</span>
          </label>
        </div>
      </div>
    </td>
  `;

  tr.addEventListener("click", (e)=>{
    const act = e.target?.getAttribute?.("data-act");
    if (act === "del"){
      // Remove sub-row first (it follows tr in the DOM), then tr itself.
      ccrTr.remove();
      tr.remove();
      syncAllGasRows();
      run();
      updateLostGasContingency(lastPlan);
    }
  });

  tr.addEventListener("input", (e)=>{
    syncGasRow(tr, true, e);
    scheduleRun();
  });

  ccrTr.addEventListener("input", () => scheduleRun());

  // (no extra wiring needed — ccrTr input events already call scheduleRun via the listener below)

  gasRows.appendChild(tr);
  gasRows.appendChild(ccrTr);
  syncGasRow(tr, false);
}

function readGasRow(tr){
  const tds = tr.querySelectorAll("td");
  const label = tds[0].querySelector("input").value.trim();
  const o2 = Number(tds[1].querySelector("input").value);
  const he = Number(tds[2].querySelector("input").value);
  const role = tds[3].querySelector("select").value;
  const enabled = !!tds[4].querySelector("input").checked;
  const swEl = tr.querySelector("input[data-switch]");
  const swRaw = swEl ? swEl.value : "";
  const sw = (swRaw === "" || swRaw === null || swRaw === undefined) ? null : Number(swRaw);

  // CCR fields — read from the sibling sub-row when role is "diluent"
  let spSchedule = [{depth:6, sp:1.3},{depth:0, sp:0.7}];
  let ccrLoopVol = 7, ccrO2Rate = 0.5;
  const ccrSub = tr.nextElementSibling?.classList.contains("ccrSubRow")
    ? tr.nextElementSibling : null;
  if (ccrSub){
    const spHigh   = Math.max(0.5, Math.min(2.0, Number(ccrSub.querySelector("[data-ccr-sp-high]")?.value)   || 1.3));
    const spLow    = Math.max(0.5, Math.min(2.0, Number(ccrSub.querySelector("[data-ccr-sp-low]")?.value)    || 0.7));
    const spSwitch = Math.max(0,                 Number(ccrSub.querySelector("[data-ccr-sp-switch]")?.value) || 6);
    spSchedule = [{depth: spSwitch, sp: spHigh}, {depth: 0, sp: spLow}].sort((a,b) => b.depth - a.depth);
    ccrLoopVol = Math.max(1,   Number(ccrSub.querySelector("[data-ccr-loop-vol]")?.value) || 7);
    ccrO2Rate  = Math.max(0.1, Number(ccrSub.querySelector("[data-ccr-o2-rate]")?.value)  || 0.5);
  }
  // Derive legacy fields for backward compat (share/export).
  const ccrSP    = spSchedule.find(e => e.depth > 0)?.sp ?? 1.3;
  const ccrDecSP = spSchedule.find(e => e.depth === 0)?.sp ?? 0.7;

  return {label, o2, he, role, enabled, switchDepthM: (Number.isFinite(sw) ? sw : null),
          spSchedule, ccrSP, ccrDecSP, ccrLoopVol, ccrO2Rate};
}

function ppo2FromRowAtDepth(o2Pct, depthM){
  if (!Number.isFinite(o2Pct) || !(o2Pct > 0)) return NaN;
  return (o2Pct/100) * depthToAmbientPressure(depthM);
}

function intervalWhereBreathable(o2Pct, ppo2Min, ppo2Max){
  // Returns [dMin, dMax] depths (meters) where ppO2 is within [min,max], or null if empty.
  // ppO2(depth) is monotonic increasing in depth.
  const fO2 = o2Pct/100;
  if (!(fO2 > 0)) return null;

  const pMin = Number(ppo2Min);
  const pMax = Number(ppo2Max);
  if (!Number.isFinite(pMin) || !Number.isFinite(pMax) || pMax < pMin) return null;

  const dMin = ambientPressureToDepth(pMin / fO2);
  const dMax = ambientPressureToDepth(pMax / fO2);
  if (!Number.isFinite(dMin) || !Number.isFinite(dMax)) return null;
  if (dMax < 0) return null;
  return [Math.max(0, dMin), Math.max(0, dMax)];
}

function syncGasRow(tr, autoLabel, evt){
  // Normalize O2/He so that O2 + He ≤ 100 (N2 is the remainder).
  // Prevents impossible mixes (e.g. O2=80, He=46).
  const labelInput = tr.querySelector("td input[type='text']");
  const tds = tr.querySelectorAll("td");
  const o2Input = tds[1]?.querySelector("input");
  const heInput = tds[2]?.querySelector("input");

  if (o2Input && heInput){
    let o2 = Number(o2Input.value);
    let he = Number(heInput.value);

    if (Number.isFinite(o2)) o2 = Math.max(0, Math.min(100, o2));
    if (Number.isFinite(he)) he = Math.max(0, Math.min(100, he));

    if (Number.isFinite(o2) && Number.isFinite(he) && (o2 + he) > 100){
      if (evt && evt.target === o2Input){
        o2 = Math.max(0, 100 - he);
      } else if (evt && evt.target === heInput){
        he = Math.max(0, 100 - o2);
      } else {
        he = Math.max(0, 100 - o2);
      }
    }

    if (Number.isFinite(o2)) o2Input.value = String(o2);
    if (Number.isFinite(he)) heInput.value = String(he);
  }

  const r = readGasRow(tr);

  // Auto-labeling rules:
  // - When O2/He changes: label becomes a known preset label if it matches, otherwise "custom".
  // - If the label is empty, initialize it the same way.
  const isMixField = !!(evt && evt.target && (evt.target === o2Input || evt.target === heInput));

  if (autoLabel && labelInput){
    const mixOk =
      Number.isFinite(r.o2) && Number.isFinite(r.he) &&
      r.o2 >= 0 && r.he >= 0 && (r.o2 + r.he) <= 100;

    if ((isMixField || !r.label) && mixOk){
      const preset = presetLabelForMix(r.o2, r.he);
      labelInput.value = preset || "custom";
    }
  }

  const bottomP = Number($("ppO2Bottom").value) || 1.4;
  const decoP = Number($("ppO2Deco").value) || 1.6;

  let modB = NaN, modD = NaN;
  if (Number.isFinite(r.o2) && Number.isFinite(r.he) && r.o2 >= 0 && r.he >= 0 && r.o2 + r.he <= 100 && r.o2 > 0){
    modB = modMeters(r.o2/100, bottomP);
    modD = modMeters(r.o2/100, decoP);
  }

    let minOD = NaN;
  if (Number.isFinite(r.o2) && Number.isFinite(r.he) && r.o2 >= 0 && r.he >= 0 && r.o2 + r.he <= 100 && r.o2 > 0){
    const pMin = globalPpO2Min();
    minOD = minodMeters(r.o2/100, pMin);
  }

  // Manual switch depth (switch TO this gas):
  // Default:
  // - hypoxic-limited (MinOD > 0): default = ceil(MinOD)
  // - non-hypoxic:                default = floor(MOD(role) - eps) (conservative, so O2@6.0 -> 5)
  //
  // Bounds shown in UI remain the breathable interval [ceil(MinOD) .. floor(MOD(role))].
  const swEl = tr.querySelector("input[data-switch]");
  if (swEl){
    const role = String(r.role || "auto").toLowerCase();
    const dLo = Number.isFinite(minOD) ? Math.max(0, minOD) : 0;
    let dHi = NaN;
    if (role === "bottom") dHi = modB;
    else if (role === "deco") dHi = modD;
    else dHi = Math.min(modB, modD);

    const loI = Math.max(0, Math.round(dLo));
    const hiI = Number.isFinite(dHi) ? Math.round(dHi) : -1;

    // Determine conservative default switch depth.
    const isHypoxic = dLo > 0.5;
    const defI = isHypoxic ? loI : (Number.isFinite(dHi) ? Math.max(loI, Math.min(hiI, Math.round(dHi))) : loI);

    // Track whether the field is in "auto default" mode.
    if (evt && evt.target === swEl){
      swEl.dataset.auto = "0";
    } else if (swEl.value === "" && swEl.dataset.auto !== "0"){
      swEl.dataset.auto = "1";
    }

    if (!(hiI >= loI)){
      swEl.value = "";
      swEl.dataset.auto = "1";
      swEl.disabled = true;
      swEl.min = "0";
      swEl.max = "0";
      swEl.placeholder = "";
    } else {
      swEl.disabled = false;
      swEl.min = String(loI);
      swEl.max = String(hiI);
      swEl.placeholder = "";

      const isAuto = swEl.dataset.auto === "1" || swEl.value === "";
      if (isAuto){
        swEl.dataset.auto = "1";
        swEl.value = String(defI);
      } else {
        let v = Number(swEl.value);
        if (!Number.isFinite(v)) v = defI;
        v = Math.max(loI, Math.min(hiI, Math.round(v)));
        swEl.value = String(v);
      }
    }
  }

  // Always show real MinOD / MOD values — for the diluent these are the bailout limits.
  tr.querySelector('[data-minod]').textContent = Number.isFinite(minOD) ? `${Math.round(minOD)} m` : "—";
  tr.querySelector('[data-mod="bottom"]').textContent = Number.isFinite(modB) ? `${Math.floor(modB)} m` : "—";
  tr.querySelector('[data-mod="deco"]').textContent = Number.isFinite(modD) ? `${Math.floor(modD)} m` : "—";
  // Switch depth is an OC concept: in CCR mode no gas has a planned switch depth
  // (diluent is always on the loop; bailout/deco gases are switched on equipment
  // failure or manually, not at a pre-planned depth).
  if (_breathMode === "ccr"){
    if (swEl){ swEl.disabled = true; swEl.value = ""; swEl.placeholder = "—"; }
  }

  const disabled = !r.enabled;
  tr.classList.toggle("disabled", disabled);

  // Show / hide the CCR sub-row based on role selection.
  const ccrSub = tr.nextElementSibling?.classList.contains("ccrSubRow")
    ? tr.nextElementSibling : null;
  if (ccrSub) ccrSub.style.display = (r.role === "diluent") ? "" : "none";

}

function syncAllGasRows(){
  for (const tr of gasRows.querySelectorAll("tr[data-gas-row]")) syncGasRow(tr, false);
  if (warningsEl) warningsEl.textContent = "";
}

function buildWarnings(){
  const rows = [...gasRows.querySelectorAll("tr[data-gas-row]")].map(readGasRow);
  const labels = new Map();
  const mixes = new Map();
  for (const r of rows){
    const lbl = (r.label||"").trim();
    if (lbl) labels.set(lbl, (labels.get(lbl)||0)+1);
    mixes.set(`${Math.round(r.o2)}/${Math.round(r.he)}`, (mixes.get(`${Math.round(r.o2)}/${Math.round(r.he)}`)||0)+1);
  }
  const lines=[];
  for (const [k,v] of labels) if (v>1) lines.push(`Duplicate label: ${k} (x${v})`);
  for (const [k,v] of mixes) if (v>1) lines.push(`Duplicate mix: ${k} (x${v})`);
  return lines.join(" | ");
}

/* segments UI */
let segments = [];
const segRows = $("segRows");

function getRates(){
  return {
    descent: Number($("descentRate").value) || 20,
    ascent: Number($("ascentRate").value) || 9,
  };
}

function computeSegmentRuntimes(){
  const {descent, ascent} = getRates();
  let rt = 0;
  let prev = 0;
  return segments.map(s=>{
    const d = Number(s.depth)||0;
    const t = Number(s.time)||0;
    const delta = Math.abs(d-prev);
    const travel = delta > 0 ? (delta / (d>=prev ? descent : ascent)) : 0;
    rt += travel + t;
    prev = d;
    return rt;
  });
}

function renderSegments(){
  if (!segRows) return;
  segRows.innerHTML = "";
  const rts = computeSegmentRuntimes();
  segments.forEach((s,i)=>{
    const tr = document.createElement("tr");
    tr.innerHTML = `
      <td><input data-k="depth" type="number" min="0" step="1" value="${Number(s.depth)||0}"></td>
      <td><input data-k="time" type="number" min="0" step="1" value="${Number(s.time)||0}"></td>
      <td>${Math.ceil(rts[i]||0)}</td>
      <td>
        <button class="mini" data-act="up" type="button">Up</button>
        <button class="mini" data-act="down" type="button">Down</button>
        <button class="mini" data-act="del" type="button">Del</button>
      </td>
    `;
    tr.addEventListener("input",(e)=>{
      const k = e.target?.getAttribute?.("data-k");
      if (!k) return;
      segments[i][k] = Number(e.target.value);
      renderSegments(); scheduleRun();
    });
    tr.addEventListener("click",(e)=>{
      const act = e.target?.getAttribute?.("data-act");
      if (!act) return;
      if (act==="del") segments.splice(i,1);
      if (act==="up" && i>0) [segments[i-1],segments[i]]=[segments[i],segments[i-1]];
      if (act==="down" && i<segments.length-1) [segments[i+1],segments[i]]=[segments[i],segments[i+1]];
      renderSegments(); run();
    });
    segRows.appendChild(tr);
  });
}

$("segAdd")?.addEventListener("click", ()=>{
  const dReq = Number($("segDepth")?.value)||0;
  const t = Number($("segTime")?.value)||0;

  // Prevent adding segments deeper than what any enabled bottom/auto gas can support.
  const gases = uiGases();
  const maxD = maxReachableDepthForBottom(gases);
  if (Number.isFinite(maxD) && dReq > maxD + EPS){
    // Freeze: do not add the segment, keep the last valid plan.
    if ($("segDepth")) $("segDepth").value = String(Math.floor(maxD));
    run();
    return;
  }

  segments.push({depth:dReq, time:t});
  renderSegments(); run();
});

$("segUndo")?.addEventListener("click", ()=>{
  segments.pop(); renderSegments(); run();
});
$("segClear")?.addEventListener("click", ()=>{
  segments = []; renderSegments(); run();
});

/* Mode UI */
function updateModeUI(){
  const seg = $("modeSegments").checked;
  document.body.classList.toggle("mode-segments", seg);
  document.body.classList.toggle("mode-simple", !seg);
  updateDiveParamsCard();
}
$("modeSegments")?.addEventListener("change", ()=>{ updateModeUI(); run(); });
$("modeSimple")?.addEventListener("change", ()=>{ updateModeUI(); run(); });

/* Plot + summary */
function setText(id, s){ const el = $(id); if (el) el.textContent = s; }

/* ================================================================
   Plan table (tab 2)
================================================================ */

/**
 * Resolves all pending TTS thunks in a plan, filling in the `tts` field.
 * Safe to call multiple times — already-resolved entries (no `_computeTts`)
 * are left untouched.
 *
 * @param {object[]} plan - Raw plan array (mutated in place).
 */
function resolvePlanTTS(plan){
  if (!plan) return;
  for (const row of plan){
    if (row._computeTts){
      row.tts = row._computeTts();
      delete row._computeTts;
    }
  }
}

/**
 * Merges consecutive descent/ascent segments that use the same gas into
 * a single display row, and collapses stop quanta at the same depth into
 * one row. The raw planner output has many 3-m descent micro-steps and
 * 1-min stop quanta; this produces the concise "one line per logical step"
 * view the plan table needs.
 *
 * @param {object[]} rawPlan - Output of a planner function.
 * @returns {object[]} Condensed array of display rows.
 */
function buildDisplayPlan(rawPlan){
  if (!Array.isArray(rawPlan) || !rawPlan.length) return [];
  const rows = [];
  for (const s of rawPlan){
    const last = rows.length ? rows[rows.length - 1] : null;

    // Merge consecutive same-direction, same-gas travel segments.
    if ((s.kind === "descent" || s.kind === "ascent") &&
        last && last.kind === s.kind && last.gas === s.gas){
      last.depth    = s.depth;
      last.duration = (last.duration || 0) + (s.duration || 0);
      last.runtime  = s.runtime;
      last.tts      = s.tts;
      last.surf_gf  = s.surf_gf;
      continue;
    }

    // Merge consecutive 1-min stop quanta at the same depth.
    if (s.kind === "stop" && last && last.kind === "stop" &&
        Math.abs((last.depth || 0) - (s.depth || 0)) < 0.01 &&
        last.gas === s.gas){
      last.duration = (last.duration || 0) + (s.duration || 0);
      last.runtime  = s.runtime;
      last.tts      = s.tts;
      last.surf_gf  = s.surf_gf;
      continue;
    }

    rows.push(Object.assign({}, s));
  }
  return rows;
}

/**
 * Populates the plan table (#planRows) from the given plan.
 * Called by run() every time the plan is recomputed, regardless of which
 * tab is currently visible.
 *
 * @param {object[]|null} plan - Current plan (may be empty or null).
 */

/* ─────────────────────────────────────────────────────────────────────────────
   Repetitive dives
───────────────────────────────────────────────────────────────────────────── */
// ── Enable/disable toggle ─────────────────────────────────────────────────
(function(){
  const toggle = document.getElementById("repDivesEnable");
  if (!toggle) return;
  function applyToggle(){
    const on = toggle.checked;
    const card = document.getElementById("repDivesCard");
    if (card) card.hidden = !on;
    if (on){ selectedDive = 0; renderRepDivesUI(); renderDiveSelector(); updateDiveParamsCard(); }
    else {
      // Restore dive-1 gases to DOM before clearing rep state
      if (selectedDive > 0){
        if (diveGasData[0])  loadGasesForDive(0);
        if (diveTankData[0]) loadTanksForDive(0);
        renderGasTab(lastPlan);
        updateGasConsumption(lastPlan);
        updateLostGasContingency(lastPlan);
      }
      repDives = []; diveGasData = diveGasData.slice(0, 1); diveTankData = diveTankData.slice(0, 1);
      selectedDive = -1; renderRepDivesUI(); renderDiveSelector(); updateDiveParamsCard();
    }
    scheduleRun();
  }
  toggle.addEventListener("change", applyToggle);
})();

document.getElementById("addRepDiveBtn")?.addEventListener("click", () => {
  const newDiveIdx = repDives.length + 1;  // 1-based: dive 2 = index 1
  repDives.push({ siMin: 60, depth: 20, bottomTime: 20 });
  // Inherit gas + tank setup from dive 1 (or current DOM if not saved yet)
  diveGasData[newDiveIdx]  = (diveGasData[0] || rawGasRowsFromDOM()).map(g => ({ ...g }));
  diveTankData[newDiveIdx] = [...(diveTankData[0] || [])];
  renderRepDivesUI();
  scheduleRun();
});

function renderRepDivesUI(){
  const list = document.getElementById("repDivesList");
  if (!list) return;
  list.innerHTML = "";

  // Helper: extract total runtime and max depth for a given 1-based diveNum from lastPlan.
  function _diveStats(diveNum){
    if (!lastPlan) return null;
    const segs = diveNum === 1
      ? lastPlan.filter(s => s.kind !== "surface" && (!s.diveNum || s.diveNum === 1))
      : lastPlan.filter(s => s.kind !== "surface" && s.diveNum === diveNum);
    if (!segs.length) return null;
    const maxD   = Math.max(...segs.map(s => Number(s.depth) || 0));
    const tStart = segs[0].runtime - (segs[0].duration || 0);
    const tEnd   = segs[segs.length - 1].runtime;
    return { maxD, total: Math.round(tEnd - tStart) };
  }

  // ── Dive 1 row (main dive) ───────────────────────────────────────────────
  const d1Stats = _diveStats(1);
  const d1Depth = d1Stats?.maxD ?? (Number($("depth")?.value) || 0);
  const d1Total = d1Stats ? `${d1Stats.total} min` : `${Number($("time")?.value) || 0} min BT`;
  const d1Row   = document.createElement("div");
  d1Row.className = "repDiveRow" + (selectedDive === 0 ? " rep-active" : "");
  // Show delete button only when there are rep dives to promote
  const d1DelBtn = repDives.length > 0
    ? `<button class="repRemoveBtn" title="Remove dive 1 (dive 2 becomes dive 1)">✕</button>`
    : "";
  d1Row.innerHTML = `
    <span class="repDiveNum">Dive 1</span>
    <span class="repLabel">Max <strong>${d1Depth} m</strong></span>
    <span class="repLabel">Total <strong>${d1Total}</strong></span>
    ${d1DelBtn}
  `;
  d1Row.addEventListener("click", () => selectDive(0));

  if (repDives.length > 0){
    d1Row.querySelector(".repRemoveBtn").addEventListener("click", e => {
      e.stopPropagation();
      // Persist current dive-0 state before we overwrite it.
      saveSegmentsToDive(0);

      // Promote dive 2 (index 1) → dive 1 (index 0).
      const promotedRD = repDives[0];           // SI + depth/BT of what was dive 2
      diveGasData[0]      = diveGasData[1]      || diveGasData[0];
      diveTankData[0]     = diveTankData[1]      || diveTankData[0];
      diveSegmentsData[0] = diveSegmentsData[1]  || [];

      // Splice the promoted dive out of the rep arrays (it's now dive 1).
      repDives.splice(0, 1);
      diveGasData.splice(1, 1);
      diveTankData.splice(1, 1);
      diveSegmentsData.splice(1, 1);

      // Update the depth / bottom-time form fields from the promoted dive's params.
      const depEl = $("depth"), btEl = $("time");
      if (depEl && promotedRD.depth   > 0) depEl.value = promotedRD.depth;
      if (btEl  && promotedRD.bottomTime > 0) btEl.value = promotedRD.bottomTime;

      // Reload gases, tanks and segments for the new dive 1 into the live DOM.
      loadGasesForDive(0);
      loadTanksForDive(0);
      loadSegmentsForDive(0);

      selectedDive = 0;
      renderRepDivesUI();
      renderDiveSelector();
      updateDiveParamsCard();
      renderGasTab(lastPlan);
      updateGasConsumption(lastPlan);
      updateLostGasContingency(lastPlan);
      scheduleRun();
    });
  }

  list.appendChild(d1Row);

  // ── Rep dives (Dive 2, Dive 3 …) — read-only labels, edited via params card ──
  repDives.forEach((rd, i) => {
    const diveNum  = i + 2;
    const stats    = _diveStats(diveNum);
    const maxDepth = stats?.maxD ?? rd.depth;
    const totalStr = stats ? `${stats.total} min` : `${rd.bottomTime} min BT`;
    const row = document.createElement("div");
    row.className = "repDiveRow" + (selectedDive === i + 1 ? " rep-active" : "");
    row.dataset.repIdx = String(i);
    row.innerHTML = `
      <span class="repDiveNum">Dive ${diveNum}</span>
      <span class="repLabel">SI <strong>${rd.siMin} min</strong></span>
      <span class="repLabel">Max <strong>${maxDepth} m</strong></span>
      <span class="repLabel">Total <strong>${totalStr}</strong></span>
      <button class="repRemoveBtn" title="Remove">✕</button>
    `;
    row.querySelector(".repRemoveBtn").addEventListener("click", e => {
      e.stopPropagation();
      repDives.splice(i, 1);
      diveGasData.splice(i + 1, 1);
      diveTankData.splice(i + 1, 1);
      if (selectedDive > repDives.length){
        selectedDive = 0;
        loadGasesForDive(0);
        loadTanksForDive(0);
        renderGasTab(lastPlan);
        updateGasConsumption(lastPlan);
        updateLostGasContingency(lastPlan);
      }
      renderRepDivesUI(); renderDiveSelector(); updateDiveParamsCard(); scheduleRun();
    });
    row.addEventListener("click", () => selectDive(i + 1));
    list.appendChild(row);
  });
}

// ── Dive parameters card switcher ────────────────────────────────────────
let _repActiveIdx = -1;   // index into repDives currently wired to the active inputs

function updateDiveParamsCard(){
  const titleEl   = document.getElementById("diveParamsTitle");
  const d1Panel   = document.getElementById("diveParamsDive1");
  const repPanel  = document.getElementById("diveParamsRep");
  const paramsCard  = document.getElementById("diveParamsCard");
  const siCard      = document.getElementById("siCard");
  const segCard     = document.getElementById("segCard");
  const segTitle    = document.getElementById("segCardTitle");
  if (!d1Panel || !repPanel) return;

  const isSeg = $("modeSegments")?.checked;
  const isRep = selectedDive > 0 && selectedDive <= repDives.length;

  // Surface interval card: only for rep dives, always visible regardless of mode.
  if (siCard) siCard.hidden = !isRep;
  // Params card (depth/BT): only in simple mode — segments card covers this in segments mode.
  if (paramsCard) paramsCard.hidden = isSeg;
  // Segment card visible in segments mode for ALL dives (dive 1 and rep dives).
  if (segCard) segCard.hidden = !isSeg;
  // Update segment card title to show which dive's segments are being edited.
  if (segTitle){
    const repOn = document.getElementById("repDivesEnable")?.checked;
    segTitle.textContent = (repOn && selectedDive >= 0)
      ? `Segments — Dive ${selectedDive + 1}`
      : "Segments";
  }

  if (!isRep){
    if (titleEl) titleEl.textContent = selectedDive === 0 ? "Dive 1 — parameters" : "Dive parameters";
    d1Panel.hidden  = false;
    repPanel.hidden = true;
    _repActiveIdx   = -1;
    return;
  }

  const rdIdx = selectedDive - 1;           // 0-based index into repDives
  const rd    = repDives[rdIdx];
  if (!rd) return;

  if (titleEl) titleEl.textContent = `Dive ${selectedDive + 1} — parameters`;
  d1Panel.hidden  = true;
  repPanel.hidden = false;

  // In segments mode depth/BT come from the segment editor — hide those inputs.
  const dep = document.getElementById("repDepthActive");
  const bt  = document.getElementById("repBTActive");
  const depLbl = dep?.closest("label");
  const btLbl  = bt?.closest("label");
  if (depLbl) depLbl.hidden = isSeg;
  if (btLbl)  btLbl.hidden  = isSeg;

  // Populate inputs
  const sim = document.getElementById("repSIminActive");
  if (sim) sim.value = String(rd.siMin);
  if (dep) dep.value = String(rd.depth);
  if (bt)  bt.value  = String(rd.bottomTime);

  // Re-wire listeners only when the active rep dive changes
  if (_repActiveIdx === rdIdx) return;
  _repActiveIdx = rdIdx;
  const wire = (id, fn) => {
    const el = document.getElementById(id);
    if (!el) return;
    el.oninput = () => { fn(el); renderRepDivesUI(); scheduleRun(); };
  };
  wire("repSIminActive", el => { repDives[rdIdx].siMin = Math.max(1, Number(el.value)||1); if (Number(el.value)<1) el.value="1"; });
  wire("repDepthActive", el => { repDives[rdIdx].depth = Number(el.value)||0; });
  wire("repBTActive",    el => { repDives[rdIdx].bottomTime = Number(el.value)||0; });
}

// ── Dive selector (graph) ─────────────────────────────────────────────────
function renderDiveSelector(){
  // Dive selection is handled by the rep-dives sidebar; hide the graph bar.
  const bar = document.getElementById("diveSelectorBar");
  if (bar) bar.hidden = true;
}

function selectDive(diveIdx){
  // Save current dive's gas + tank + segment state before switching
  const _prev = Math.max(0, selectedDive);
  saveGasesToDive(_prev);
  saveTanksToDive(_prev);
  saveSegmentsToDive(_prev);

  selectedDive = diveIdx;

  // Ensure target dive has gas data (default: copy from dive 1)
  if (!diveGasData[diveIdx]){
    diveGasData[diveIdx] = (diveGasData[0] || rawGasRowsFromDOM()).map(g => ({ ...g }));
  }
  if (!diveTankData[diveIdx]){
    diveTankData[diveIdx] = [...(diveTankData[0] || [])];
  }
  // Segments default to empty for new rep dives (they start fresh)
  if (!diveSegmentsData[diveIdx]){
    diveSegmentsData[diveIdx] = [];
  }
  loadGasesForDive(diveIdx);
  loadTanksForDive(diveIdx);
  loadSegmentsForDive(diveIdx);
  renderGasTab(lastPlan);
  updateGasConsumption(lastPlan);
  updateLostGasContingency(lastPlan);

  renderDiveSelector();
  renderRepDivesUI();   // refresh active highlight in sidebar
  updateDiveParamsCard();
  // Scroll rep dive row into view if selecting a rep dive
  if (diveIdx > 0){
    const row = document.querySelector(`.repDiveRow[data-rep-idx="${diveIdx - 1}"]`);
    row?.scrollIntoView({ behavior: "smooth", block: "nearest" });
  }
  if (lastPlan) draw(getDiveSlice(lastPlan, selectedDive));
  // Keep slider position across dive switches — renderTissueTab will clamp to sliceMax.
  if (lastPlan) renderTissueTab(lastPlan);
}

// ── Slice plan to a single dive ───────────────────────────────────────────
function getDiveSlice(plan, diveIdx){
  if (!plan || diveIdx < 0) return plan;
  // Split on surface segments into [dive0, dive1, ...]
  const slices = [];
  let cur = [];
  for (const s of plan){
    if (s.kind === "surface"){ slices.push(cur); cur = []; }
    else cur.push(s);
  }
  slices.push(cur);
  const slice = slices[diveIdx] || [];
  if (!slice.length) return slice;
  // Normalise runtime so dive starts at t=0
  const t0 = slice[0].runtime - (slice[0].duration || 0);
  return slice.map(s => ({ ...s, runtime: s.runtime - t0 }));
}

/* ─────────────────────────────────────────────────────────────────────────────
   Print / Export helpers
───────────────────────────────────────────────────────────────────────────── */

/**
 * Build the HTML for a gas + tanks section for one dive in the export.
 * @param {number} diveIdx  0 = dive 1, 1 = dive 2, …
 * @param {object[]} slicePlan  Normalized plan segments for this dive only
 * @param {object} opts  { sacVal, expMult, expHasRule, expRuleLabel, ppo2Bot, isCcr }
 */
function buildExportGasSection(diveIdx, slicePlan, opts){
  const { sacVal, expMult, expHasRule, expRuleLabel, ppo2Bot } = opts;

  // Preserve original index (rawIdx) so rowKey matches what the tank planner used.
  const rawRows   = (diveGasData[diveIdx] || rawGasRowsFromDOM())
    .map((g, rawIdx) => ({ ...g, _rawIdx: rawIdx }));
  const enabled   = rawRows.filter(g => g.enabled && g.role !== "disabled");
  const tanks     = new Map(diveTankData[diveIdx] || []);
  const tankList  = [...tanks.values()];
  const consSums  = sacVal > 0 ? consumptionSums(slicePlan, sacVal, 0) : null;
  const hasCons   = !!(consSums && consSums.size > 0);
  const hasTanks  = tankList.length > 0;
  const _NCOLS    = 7;
  const _r        = 'style="text-align:right"';

  const metric = (lbl, val, col) =>
    `<div style="display:flex;flex-direction:column;gap:2px;min-width:80px;">
      <span style="font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:#6a7fa8;font-weight:600;">${lbl}</span>
      <span style="font-size:12px;font-weight:700;color:${col||'#1a2a4a'};">${val}</span>
    </div>`;

  // Labels of gases actually breathed in the plan (used to filter unused rows).
  const _usedLabels = new Set(
    slicePlan
      .filter(s => s.gas)
      .map(s => fmtGasLabel(s.gas.label || `${Math.round((s.gas.o2||0)*100)}/${Math.round((s.gas.he||0)*100)}`))
  );

  const gasRows_ = enabled.map((g, i) => {
    const label      = g.label || `${g.o2}/${g.he}`;
    const consKey    = fmtGasLabel(label);
    const consL      = consSums?.get(consKey) || 0;
    const modB       = modMeters(g.o2/100, Number(ppo2Bot)||1.4);
    const modTxt     = g.role === "diluent" ? "SP" : `${Math.floor(modB)} m`;
    const swTxt      = g.role === "diluent" ? "—" : (g.switchDepthM != null ? `${g.switchDepthM} m` : "—");
    // Match by rowKey (exact row) so Air(auto) and Air(bailout) get separate tank info.
    const _expRowKey = `${consKey}|${g.role || "auto"}|${g._rawIdx}`;
    // Stage-slot rowKeys use the "|s{n}" suffix — match them too.
    const tanksForG  = tankList.filter(t =>
      t.rowKey === _expRowKey ||
      t.rowKey?.startsWith(_expRowKey + "|s") ||
      (t.gas === consKey && !t.rowKey)   // legacy fallback
    );
    const hasDetail  = (consL > 0 && hasCons) || tanksForG.length > 0;

    // Skip gas rows that weren't used in the computed dive:
    //  • Stage gas: only show if the diver chose a cylinder for it.
    //  • Any other gas: only show if it was actually breathed in the plan.
    if (g.role === "stage" && tanksForG.length === 0) return "";
    if (g.role !== "stage" && !_usedLabels.has(consKey)) return "";

    let rows = `<tr><td>${i+1}</td><td>${escapeHtml(label)}</td><td>${g.o2}%</td><td>${g.he}%</td><td>${g.role}</td><td>${swTxt}</td><td>${modTxt}</td></tr>`;

    if (hasDetail){
      const cells = [];
      if (hasCons && consL > 0){
        const ft = tanksForG[0];
        const tw = ft ? ft.tankWaterVol * ft.qty : 0;
        const bar = (l) => tw > 0 ? `<br><span style="font-size:10px;opacity:0.6;font-weight:400;">${Math.ceil(l/tw)} bar</span>` : '';
        cells.push(metric('Consumption', `${Math.round(consL)} L${bar(consL)?bar(consL):''}`));
        if (expHasRule) cells.push(metric('Planning vol', `${Math.round(consL*expMult)} L${bar(consL*expMult)?bar(consL*expMult):''}`));
      }
      tanksForG.forEach(t => {
        if (cells.length) cells.push(`<div style="width:1px;background:#c0cce8;align-self:stretch;margin:0 4px;"></div>`);
        cells.push(metric('Cylinder', escapeHtml(t.tank)));
        cells.push(metric('Qty', String(t.qty)));
        cells.push(metric('Total vol', Math.round(t.vol)+' L'));
        cells.push(metric('Fill to', t.fillBar+' bar', '#2563b0'));
        if (t.reserve > 0) cells.push(metric('Reserve', t.reserve+' bar', '#b05a10'));
      });
      rows += `<tr style="background:#e8eef8;border-top:none;border-left:3px solid #4a7fcb;">
        <td style="border-top:none;background:#e8eef8;"></td>
        <td colspan="${_NCOLS-1}" style="padding:6px 8px 8px 12px;border-top:none;background:#e8eef8;">
          <div style="display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;">${cells.join('')}</div>
        </td></tr>`;
    }
    return rows;
  }).join('');

  // Sum only gases that were actually used in the plan (same filter as rows above).
  const totalConsL = hasCons
    ? [...(consSums?.entries()||[])].filter(([lbl]) => _usedLabels.has(lbl)).reduce((a,[,v])=>a+v,0)
    : 0;
  const tfoot = hasCons
    ? `<tfoot><tr style="border-top:2px solid #c0c0c0;"><td colspan="${_NCOLS}"><strong>Total: ${Math.round(totalConsL)} L${expHasRule ? ` → planning: ${Math.round(totalConsL*expMult)} L` : ''}</strong></td></tr></tfoot>`
    : '';

  const heading = `Gases${hasCons ? ` — SAC ${sacVal} L/min${expHasRule ? ` — ${expRuleLabel}` : ''}` : ''}`;
  return `<section>
  <h2>${heading}</h2>
  <table>
    <thead><tr><th>#</th><th>Label</th><th>O₂</th><th>He</th><th>Role</th><th>Switch</th><th>MOD</th></tr></thead>
    <tbody>${gasRows_}</tbody>
    ${tfoot}
  </table>
</section>`;
}

/* ─────────────────────────────────────────────────────────────────────────────
   Print / Export
   Opens a styled print-ready page in a new tab and triggers the print dialog.
───────────────────────────────────────────────────────────────────────────── */
function exportPlan(){
  resolvePlanTTS(lastPlan);
  if (!lastPlan || !lastPlan.length){
    alert("No plan to export. Calculate a dive first.");
    return;
  }

  const gfLow  = $("gfLow")?.value  || "—";
  const gfHigh = $("gfHigh")?.value || "—";
  const descentRate  = $("descentRate")?.value  || "—";
  const ascentRate   = $("ascentRate")?.value   || "—";
  const lastStopD    = $("lastStopDepth")?.value || "—";
  const ppo2Bot      = $("ppO2Bottom")?.value    || "—";
  const ppo2DecoV    = $("ppO2Deco")?.value      || "—";
  const altVal       = $("altitudeM")?.value      || "0";
  const salEl        = document.getElementById("salinityModel");
  const salinity     = salEl ? salEl.options[salEl.selectedIndex]?.text : "—";
  const isCcr        = !!ccrCtx;

  // Runtime / max depth
  const lastStep = lastPlan[lastPlan.length - 1];
  const runtime  = Number(lastStep?.runtime ?? 0);
  const maxD     = Math.max(...lastPlan.map(s => Number(s.depth) || 0));

  // Bottom time = sum of bottom-phase segments
  const btMin = lastPlan.filter(s => s.kind === "bottom").reduce((a, s) => a + (Number(s.duration) || 0), 0);

  // Deco time = sum of stop-phase durations
  const decoMin = lastPlan.filter(s => s.kind === "stop").reduce((a, s) => a + (Number(s.duration) || 0), 0);

  // Gas consumption + planning rule
  const sacVal    = Math.max(0, Number($("sacLpm")?.value) || 0);
  // CCR: use bailout gas consumption; OC: standard per-gas consumption
  const _expBailoutGases = ccrCtx ? getCCRBailoutGases() : null;
  const _expPlan = ccrCtx
    ? buildCCRBailoutPlan(lastPlan, _expBailoutGases, 0)
    : lastPlan;
  const consSums  = sacVal > 0 ? consumptionSums(_expPlan, sacVal, 0) : null;
  const customMult = 100 / Math.min(99, Math.max(1, _gasTabCustomPct));
  const expRule   = GAS_RULES[_gasTabRule] ?? GAS_RULES.none;
  const expMult   = _gasTabRule === "custom" ? customMult : (expRule.mult ?? 1);
  const expHasRule = expMult > 1 + 1e-6;
  const expRuleLabel = _gasTabRule === "custom"
    ? `Custom (${_gasTabCustomPct}% of cylinder, ×${customMult.toFixed(2)})`
    : expRule.label;

  // Toxicity
  const dose = oxygenDoseUpTo(lastPlan, runtime);
  const cns  = Math.round(dose.cns);
  const otu  = Math.round(dose.otu);

  // Plan rows
  const PMETA = {
    descent:{ icon:"↓", label:"Descent" },
    bottom: { icon:"●", label:"Bottom"  },
    ascent: { icon:"↑", label:"Ascent"  },
    stop:   { icon:"⏱", label:"Stop"   },
    switch: { icon:"⇄", label:"Switch"  },
    surface:{ icon:"⬆", label:"Surface" },
  };
  const PHASE_CLS = { descent:"descent", bottom:"bottom", ascent:"ascent", stop:"stop", switch:"switch" };

  const displayRows = buildDisplayPlan(lastPlan);
  const planRowsHtml = displayRows.map(s => {
    if (s.kind === "surface"){
      const siH = Math.floor(s.duration/60); const siM = Math.round(s.duration%60);
      const siLabel = siH > 0 ? `${siH}h ${siM}min` : `${siM} min`;
      const residGF = Number.isFinite(s.surf_gf) ? `${Math.round(s.surf_gf*100)}%` : "—";
      return `<tr class="surface-sep"><td colspan="8" style="text-align:center;padding:5px 8px;background:#e8edf4;font-size:11px;font-weight:600;color:#555;border-top:2px solid #b0bccf;border-bottom:2px solid #b0bccf;">⬆ Surface interval — ${siLabel} &nbsp;·&nbsp; Residual GF ${residGF} &nbsp;·&nbsp; Dive ${s.diveNum ?? "?"}</td></tr>`;
    }
    const meta     = PMETA[s.kind] || { icon:"?", label:s.kind };
    const isSwitch = s.kind === "switch";
    const gasLabel = s.gas ? fmtGasLabel(s.gas.label || gasLabelFromObj(s.gas)) : "—";
    let ppo2Txt = "—";
    if (!isSwitch && Number.isFinite(Number(s.depth))){
      const d = Number(s.depth);
      if (isCcr){ ppo2Txt = `${ccrSetpointAt(d).toFixed(2)} SP`; }
      else if (s.gas){
        const v = ppo2AtDepthActual(s.gas, d);
        if (Number.isFinite(v)) ppo2Txt = `${v.toFixed(2)} bar`;
      }
    }
    const gfTxt  = (!isSwitch && Number.isFinite(s.surf_gf)) ? `${Math.round(s.surf_gf * 100)}%` : "—";
    const durTxt = isSwitch ? "—" : `${fmt(Number(s.duration || 0), 1)} min`;
    const ttsTxt = (!isSwitch && Number.isFinite(s.tts)) ? `${Math.ceil(s.tts)} min` : "—";
    const cls    = PHASE_CLS[s.kind] || "";
    return `<tr class="${cls}">
      <td>${meta.icon} ${meta.label}</td>
      <td>${fmt(Number(s.depth || 0), 0)} m</td>
      <td>${durTxt}</td>
      <td>${fmt(Number(s.runtime || 0), 1)} min</td>
      <td>${ttsTxt}</td>
      <td>${gasLabel}</td>
      <td>${ppo2Txt}</td>
      <td>${gfTxt}</td>
    </tr>`;
  }).join("");

  const now     = new Date().toLocaleDateString(undefined, { dateStyle: "medium" });
  const nowISO  = new Date().toISOString().slice(0, 10);
  // Capture the graph canvas at its true displayed size.
  // If the Graph tab is not active its panel is display:none → getBoundingClientRect
  // returns zeros → canvas collapses to the 320 px fallback.
  // Fix: temporarily activate the Graph tab, resize+redraw, capture, then restore.
  const _btns   = [...document.querySelectorAll(".plotTabBar .tabBtn")];
  const _panels = [...document.querySelectorAll(".tabPanel")];
  const _activeBtn   = _btns.find(b => b.classList.contains("active"));
  const _activePanel = _panels.find(p => p.classList.contains("active"));
  // Switch to Graph tab so getBoundingClientRect returns real dimensions
  _btns.forEach(b   => b.classList.toggle("active", b.dataset.tab === "graph"));
  _panels.forEach(p => p.classList.toggle("active", p.id === "tabGraph"));
  const _cv = document.getElementById('cv');
  // Capture at natural CSS size (preserves draw() layout: padL=56px etc.) but
  // with a 2× backing-store + transform so all text and lines render at 2× density.
  // This avoids the "tiny labels" bug that occurs when the CSS size is doubled.
  const _natR = _cv.getBoundingClientRect();
  const GRAPH_EXPORT_W = Math.max(320, Math.floor(_natR.width));
  const GRAPH_EXPORT_H = Math.max(200, Math.floor(_natR.height));
  const _EXP_SCALE = 2;
  _cv.width  = GRAPH_EXPORT_W * _EXP_SCALE;   // backing store 2× — resets canvas state
  _cv.height = GRAPH_EXPORT_H * _EXP_SCALE;
  ctx.setTransform(_EXP_SCALE, 0, 0, _EXP_SCALE, 0, 0);

  // Build dive slices for per-dive export sections
  const _diveSlices = [];   // [{ plan: [...], siData: null | { siMin, surf_gf, diveNum } }]
  {
    let cur = []; let siData = null;
    for (const s of lastPlan){
      if (s.kind === "surface"){
        _diveSlices.push({ plan: cur, siData });
        siData = { siMin: s.duration, surf_gf: s.surf_gf, diveNum: s.diveNum };
        cur = [];
      } else cur.push(s);
    }
    _diveSlices.push({ plan: cur, siData });
  }

  // Capture one graph image per dive slice
  const _graphImgs = _diveSlices.map(({ plan: slice }) => {
    const t0 = slice.length ? slice[0].runtime - (slice[0].duration || 0) : 0;
    const normSlice = slice.map(s => ({ ...s, runtime: s.runtime - t0 }));
    _cv.width  = GRAPH_EXPORT_W * _EXP_SCALE;
    _cv.height = GRAPH_EXPORT_H * _EXP_SCALE;
    ctx.setTransform(_EXP_SCALE, 0, 0, _EXP_SCALE, 0, 0);
    draw(normSlice, true);
    return _cv.toDataURL('image/png');
  });

  // Restore canvas to normal and redraw
  _btns.forEach(b   => b.classList.toggle("active", b === _activeBtn));
  _panels.forEach(p => p.classList.toggle("active", p === _activePanel));
  resizeCanvas(); draw(getDiveSlice(lastPlan, selectedDive), true);

  // ── TXT builder ─────────────────────────────────────────────────────────────
  // All values are plain ASCII so padEnd() widths match display widths exactly.
  const pad = (s, n) => String(s).padEnd(n);
  const PHASE_TXT = { descent:"Descent", bottom:"Bottom", ascent:"Ascent", stop:"Stop", switch:"Switch" };

  // For the TXT export, use dive 1 gases/tanks as the representative view.
  // Preserve _rawIdx so rowKey can match the keys stored in _selectedTanks.
  const _txtRawRows  = (diveGasData[0] || rawGasRowsFromDOM())
    .map((g, rawIdx) => ({ ...g, _rawIdx: rawIdx }));
  const enabledGas   = _txtRawRows.filter(g => g.enabled && g.role !== "disabled");
  const selTankList  = [...new Map(diveTankData[0] || []).values()];

  // Build gas rows data
  const gasDataRows = enabledGas.map((g, i) => {
    const label  = g.label || `${g.o2}/${g.he}`;
    const modB   = modMeters(g.o2 / 100, Number(ppo2Bot) || 1.4);
    const modTxt = g.role === "diluent" ? "SP" : `${Math.floor(modB)} m`;
    const swTxt  = g.role === "diluent" ? "-" : (g.switchDepthM != null ? `${g.switchDepthM} m` : "-");
    return { num: String(i+1), label, o2: g.o2+"%", he: g.he+"%", role: g.role, sw: swTxt, mod: modTxt };
  });
  // Compute column widths from data + headers
  const gCols = [
    { hdr:"#",      vals: gasDataRows.map(r=>r.num)   },
    { hdr:"Label",  vals: gasDataRows.map(r=>r.label) },
    { hdr:"O2",     vals: gasDataRows.map(r=>r.o2)    },
    { hdr:"He",     vals: gasDataRows.map(r=>r.he)    },
    { hdr:"Role",   vals: gasDataRows.map(r=>r.role)  },
    { hdr:"Switch", vals: gasDataRows.map(r=>r.sw)    },
    { hdr:"MOD",    vals: gasDataRows.map(r=>r.mod)   },
  ].map(c => ({ ...c, w: Math.max(c.hdr.length, ...c.vals.map(v=>v.length)) + 2 }));
  const gHdrLine  = gCols.map(c => pad(c.hdr, c.w)).join("").trimEnd();
  const gSepLine  = gCols.map(c => "-".repeat(c.w)).join("").trimEnd();
  const gDataLines = gasDataRows.map(r =>
    gCols.map((c,i) => pad([r.num,r.label,r.o2,r.he,r.role,r.sw,r.mod][i], c.w)).join("").trimEnd()
  );

  // Build plan rows data
  const planDisplayRows = buildDisplayPlan(lastPlan);
  const planDataRows = planDisplayRows.map(s => {
    const isSwitch = s.kind === "switch";
    const gasLabel = s.gas ? (s.gas.label || gasLabelFromObj(s.gas)) : "-";
    let ppo2Txt = "-";
    if (!isSwitch && s.gas){ const v = ppo2AtDepthActual(s.gas, Number(s.depth)); if(Number.isFinite(v)) ppo2Txt = v.toFixed(2)+" bar"; }
    return {
      phase:   PHASE_TXT[s.kind] || s.kind,
      depth:   fmt(Number(s.depth||0),0)+" m",
      dur:     isSwitch ? "-" : `${fmt(Number(s.duration||0),1)} min`,
      runtime: fmt(Number(s.runtime||0),1)+" min",
      tts:     (!isSwitch && Number.isFinite(s.tts)) ? `${Math.ceil(s.tts)} min` : "-",
      gas:     gasLabel,
      ppo2:    ppo2Txt,
      gf:      (!isSwitch && Number.isFinite(s.surf_gf)) ? `${Math.round(s.surf_gf*100)}%` : "-",
      kind:    s.kind,
    };
  });
  const pCols = [
    { hdr:"Phase",    vals: planDataRows.map(r=>r.phase)   },
    { hdr:"Depth",    vals: planDataRows.map(r=>r.depth)   },
    { hdr:"Duration", vals: planDataRows.map(r=>r.dur)     },
    { hdr:"Runtime",  vals: planDataRows.map(r=>r.runtime) },
    { hdr:"TTS",      vals: planDataRows.map(r=>r.tts)     },
    { hdr:"Gas",      vals: planDataRows.map(r=>r.gas)     },
    { hdr:"ppO2",     vals: planDataRows.map(r=>r.ppo2)    },
    { hdr:"GF surf",  vals: planDataRows.map(r=>r.gf)      },
  ].map(c => ({ ...c, w: Math.max(c.hdr.length, ...c.vals.map(v=>v.length)) + 2 }));
  const pHdrLine  = pCols.map(c => pad(c.hdr, c.w)).join("").trimEnd();
  const pSepLine  = pCols.map(c => "-".repeat(c.w)).join("").trimEnd();
  const pDataLines = planDataRows.map(r =>
    pCols.map((c,i) => pad([r.phase,r.depth,r.dur,r.runtime,r.tts,r.gas,r.ppo2,r.gf][i], c.w)).join("").trimEnd()
  );

  // ── Unified TXT GASES section: technical info + consumption + selected tank ─
  // One row per gas; extra rows if multiple tanks selected for same gas.
  const _txtGasTitle = ccrCtx
    ? `GASES${sacVal > 0 ? ` — SAC ${sacVal} L/min — bailout OC ascent${expHasRule ? ` — ${expRuleLabel}` : ''}` : ''}`
    : `GASES${sacVal > 0 ? ` — SAC ${sacVal} L/min${expHasRule ? ` — ${expRuleLabel}` : ''}` : ''}`;

  // Build unified rows: one entry per (gas, tank) combination
  const txtGasRows = [];
  enabledGas.forEach((g, i) => {
    const label    = g.label || `${g.o2}/${g.he}`;
    const consKey  = fmtGasLabel(label);
    const consL    = consSums?.get(consKey) || 0;
    const modB     = modMeters(g.o2 / 100, Number(ppo2Bot) || 1.4);
    const modTxt   = g.role === "diluent" ? "SP" : `${Math.floor(modB)} m`;
    const swTxt    = g.role === "diluent" ? "-" : (g.switchDepthM != null ? `${g.switchDepthM} m` : "-");
    const _txtRowKey   = `${consKey}|${g.role || "auto"}|${g._rawIdx}`;
    const tanksForGas  = selTankList.filter(r => r.rowKey === _txtRowKey).length
      ? selTankList.filter(r => r.rowKey === _txtRowKey)
      : selTankList.filter(r => r.gas === consKey && !r.rowKey); // legacy fallback
    const _firstTxtTank = tanksForGas[0];
    const _txtWater    = _firstTxtTank ? _firstTxtTank.tankWaterVol * _firstTxtTank.qty : 0;
    const _txtBar = (l) => _txtWater > 0 ? Math.ceil(l / _txtWater)+" bar" : "-";
    const consStr  = sacVal > 0 && consL > 0 ? Math.round(consL)+" L" : "-";
    const consBar  = sacVal > 0 && consL > 0 ? _txtBar(consL) : "-";
    const planStr  = sacVal > 0 && consL > 0 && expHasRule ? Math.round(consL*expMult)+" L" : (sacVal > 0 && expHasRule ? "-" : "");
    const planBar  = sacVal > 0 && consL > 0 && expHasRule ? _txtBar(consL*expMult) : (sacVal > 0 && expHasRule ? "-" : "");

    const base = { num: String(i+1), label, o2: g.o2+"%", he: g.he+"%", role: g.role, sw: swTxt, mod: modTxt, cons: consStr, cons_bar: consBar, plan: planStr, plan_bar: planBar };
    if (tanksForGas.length === 0){
      txtGasRows.push({ ...base, tank: "-", qty: "-", vol: "-", fill: "-", reserve: "-", cons_bar: consBar, plan_bar: planBar });
    } else {
      tanksForGas.forEach((t, k) => {
        const reserveStr = t.reserve > 0 ? t.reserve+" bar" : "-";
        txtGasRows.push(k === 0
          ? { ...base, tank: t.tank, qty: String(t.qty), vol: Math.round(t.vol)+" L", fill: t.fillBar+" bar", reserve: reserveStr }
          : { num:"", label:"", o2:"", he:"", role:"", sw:"", mod:"", cons:"", cons_bar:"", plan:"", plan_bar:"", tank: t.tank, qty: String(t.qty), vol: Math.round(t.vol)+" L", fill: t.fillBar+" bar", reserve: reserveStr }
        );
      });
    }
  });

  const _hasTxtCons  = sacVal > 0 && consSums && consSums.size > 0;
  const _hasTxtTanks = selTankList.length > 0;
  const tgCols = [
    { hdr:"#",     key:"num"   },
    { hdr:"Label", key:"label" },
    { hdr:"O2",    key:"o2"    },
    { hdr:"He",    key:"he"    },
    { hdr:"Role",  key:"role"  },
    { hdr:"Switch",key:"sw"    },
    { hdr:"MOD",   key:"mod"   },
    ...(_hasTxtCons  ? [{ hdr:"Consumption", key:"cons" }, { hdr:"(bar)", key:"cons_bar" }] : []),
    ...(_hasTxtCons && expHasRule ? [{ hdr:"Planning vol", key:"plan" }, { hdr:"(bar)", key:"plan_bar" }] : []),
    ...(_hasTxtTanks ? [{ hdr:"Cylinder", key:"tank" }, { hdr:"Qty", key:"qty" }, { hdr:"Vol", key:"vol" }, { hdr:"Fill to", key:"fill" }, { hdr:"Reserve", key:"reserve" }] : []),
  ].map(c => ({ ...c, w: Math.max(c.hdr.length, ...txtGasRows.map(r => r[c.key].length)) + 2 }));

  const tgHdr  = tgCols.map(c => pad(c.hdr, c.w)).join("").trimEnd();
  const tgSep  = tgCols.map(c => "-".repeat(c.w)).join("").trimEnd();
  const tgRows = txtGasRows.map(r => tgCols.map(c => pad(r[c.key], c.w)).join("").trimEnd());

  // Total row for consumption
  const _txtTotalL = _hasTxtCons ? [...consSums.values()].reduce((a,v)=>a+v,0) : 0;
  const tgTotal = _hasTxtCons ? (() => {
    const cells = tgCols.map(c => {
      if (c.key === "num")    return pad("TOTAL", c.w);
      if (c.key === "cons")   return pad(Math.round(_txtTotalL)+" L", c.w);
      if (c.key === "plan")   return pad(Math.round(_txtTotalL*expMult)+" L", c.w);
      return pad("", c.w);
    });
    return [cells.join("").trimEnd()];
  })() : [];

  const txtLines = [
    `DIVE PLAN  ${nowISO}`,
    `${isCcr ? "CCR" : "Open Circuit"}  GF ${gfLow}/${gfHigh}  ${salinity}  ${altVal} m altitude`,
    "",
    "PARAMETERS",
    `Max Depth    : ${maxD} m`,
    `Bottom Time  : ${fmt(btMin,1)} min`,
    `Runtime      : ${fmt(runtime,1)} min`,
    `Descent Rate : ${descentRate} m/min`,
    `Ascent Rate  : ${ascentRate} m/min`,
    `Last Stop    : ${lastStopD} m`,
    `Deco Time    : ${decoMin > 0 ? fmt(decoMin,1)+" min" : "-"}`,
    `ppO2 Bottom  : ${ppo2Bot} bar`,
    `ppO2 Deco    : ${ppo2DecoV} bar`,
    `GF Lo / Hi   : ${gfLow} / ${gfHigh}`,
    `CNS / OTU    : ${cns}% / ${otu}`,
    "",
    _txtGasTitle,
    tgHdr, tgSep, ...tgRows,
    ...(_hasTxtCons ? [tgSep, ...tgTotal] : []),
    "",
    "DIVE PLAN",
    pHdrLine, pSepLine, ...pDataLines,
  ].join("\n");

  // ── HTML page ───────────────────────────────────────────────────────────────
  const html = `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<title>Dive Plan – ${now}</title>
<style>
  *{box-sizing:border-box;margin:0;padding:0;}
  body{font-family:Helvetica,Arial,sans-serif;font-size:12px;color:#111;background:#f0f0f0;padding:14px;}
  .toolbar{display:flex;gap:8px;margin-bottom:14px;}
  .toolbar button{padding:6px 14px;border:1px solid #c0c0c0;border-radius:6px;background:#fff;font-size:12px;cursor:pointer;font-family:inherit;}
  .toolbar button:hover{background:#e8f0ff;border-color:#0055cc;color:#0055cc;}
  h1{font-size:16px;font-weight:700;color:#111;margin-bottom:4px;}
  .sub{font-size:11px;color:#555;margin-bottom:14px;}
  [contenteditable]{outline:none;border-radius:3px;padding:1px 3px;margin:-1px -3px;cursor:text;}
  [contenteditable]:hover{background:#f0f4ff;}
  [contenteditable]:focus{background:#e8f0ff;box-shadow:0 0 0 2px #4a7fcb44;}
  section{background:#fff;border:1px solid #c0c0c0;border-radius:8px;padding:14px 16px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.06);}
  h2{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#555;margin:0 0 10px 0;}
  .graph-img{border-radius:8px;display:block;width:100%;height:auto;}
  .si-separator{background:#e8edf4;border:1px solid #b0bccf;border-radius:8px;padding:10px 16px;margin-bottom:12px;text-align:center;font-size:11px;font-weight:600;color:#555;letter-spacing:.04em;}
  .dive-header-section{background:#eef3fb;border-color:#b4c8e8;}
  h2.dive-num-header{font-size:13px;color:#003a8c;letter-spacing:.03em;}
  .grid{display:grid;gap:6px;}.g3{grid-template-columns:repeat(3,1fr);}
  .cell{background:#f0f0f0;border-radius:5px;padding:6px 10px;}
  .cell strong{font-size:10px;text-transform:uppercase;color:#555;display:block;margin-bottom:2px;}
  .cell span{font-size:13px;font-weight:700;color:#111;}
  table{width:100%;border-collapse:collapse;}
  thead{display:table-header-group;}
  th{background:#cfe0fa;color:#003a8c;font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:5px 8px;text-align:left;white-space:nowrap;}
  td{border-bottom:1px solid #e0e0e0;padding:5px 8px;font-size:12px;white-space:nowrap;}
  tr{break-inside:avoid;page-break-inside:avoid;}
  h2{break-after:avoid;page-break-after:avoid;}
  section{break-inside:avoid;page-break-inside:avoid;}
  section.breakable{break-inside:auto;page-break-inside:auto;}
  tr.descent{background:#dbeafe;}tr.bottom{background:#fef3c7;}
  tr.ascent{background:#d1fae5;}tr.stop{background:#fef9c3;}tr.switch{background:#ede9fe;}
  @page{size:A4;margin:0;}
  @media print{
    body{background:#fff;padding:12mm 14mm 0;}
    .toolbar{display:none;}
    section{box-shadow:none;}
    [contenteditable]:hover,[contenteditable]:focus{background:transparent;box-shadow:none;}
  }
</style>
</head><body>

<div class="toolbar">
  <button id="pdfBtn">📄 PDF</button>
  <button id="txtBtn">📝 TXT</button>
</div>

<div id="exportContent">
<h1 contenteditable="true" spellcheck="false">Dive Plan</h1>
<p class="sub"><span contenteditable="true" spellcheck="false">${now}</span>&nbsp;·&nbsp;${isCcr ? "CCR" : "Open Circuit"}&nbsp;·&nbsp;GF ${gfLow}/${gfHigh}&nbsp;·&nbsp;${salinity}&nbsp;·&nbsp;${altVal} m altitude</p>

${_diveSlices.map(({ plan: _slice, siData: _si }, _di) => {
  const _expOpts = { sacVal, expMult, expHasRule, expRuleLabel, ppo2Bot };
  // SI separator before each dive except the first
  const _siBlock = (_si && _di > 0) ? (() => {
    const _siH = Math.floor(_si.siMin/60); const _siM = Math.round(_si.siMin%60);
    const _siLabel = _siH > 0 ? `${_siH}h ${_siM}min` : `${_siM} min`;
    const _residGF = Number.isFinite(_si.surf_gf) ? `${Math.round(_si.surf_gf*100)}%` : "—";
    return `<div class="si-separator">⬆ Surface interval — ${_siLabel} &nbsp;·&nbsp; Residual GF ${_residGF}</div>`;
  })() : "";

  // Per-dive stats
  const _dSlice = _slice.filter(s => s.kind !== "switch");
  const _dMaxD = _dSlice.reduce((m,s) => Math.max(m, Number(s.depth||0)), 0);
  const _dBtMin = _dSlice.filter(s=>s.kind==="bottom").reduce((m,s)=>m+Number(s.duration||0),0);
  const _t0 = _slice.length ? _slice[0].runtime - (_slice[0].duration||0) : 0;
  const _dRT  = _slice.length ? _slice[_slice.length-1].runtime - _t0 : 0;
  const _dDecoMin = _dSlice.filter(s=>s.kind==="stop").reduce((m,s)=>m+Number(s.duration||0),0);
  const _dLastStop = _dSlice.filter(s=>s.kind==="stop").reduce((m,s)=>Math.min(m,Number(s.depth||99)),99);

  // Normalise slice runtimes to 0 for plan table display
  const _normSlice = _slice.map(s => ({ ...s, runtime: s.runtime - _t0 }));
  const _dRows = buildDisplayPlan(_normSlice).map(s => {
    const _meta = PMETA[s.kind] || { icon:"?", label:s.kind };
    const _sw = s.kind === "switch";
    const _gl = s.gas ? fmtGasLabel(s.gas.label || gasLabelFromObj(s.gas)) : "—";
    let _p2 = "—";
    if (!_sw && Number.isFinite(Number(s.depth))){ const _d=Number(s.depth); if(isCcr){_p2=`${ccrSetpointAt(_d).toFixed(2)} SP`;}else if(s.gas){const v=ppo2AtDepthActual(s.gas,_d);if(Number.isFinite(v))_p2=`${v.toFixed(2)} bar`;} }
    const _gf=(!_sw&&Number.isFinite(s.surf_gf))?`${Math.round(s.surf_gf*100)}%`:"—";
    const _dr=_sw?"—":`${fmt(Number(s.duration||0),1)} min`;
    const _tt=(!_sw&&Number.isFinite(s.tts))?`${Math.ceil(s.tts)} min`:"—";
    const _cls=PHASE_CLS[s.kind]||"";
    return `<tr class="${_cls}"><td>${_meta.icon} ${_meta.label}</td><td>${fmt(Number(s.depth||0),0)} m</td><td>${_dr}</td><td>${fmt(Number(s.runtime||0),1)} min</td><td>${_tt}</td><td>${_gl}</td><td>${_p2}</td><td>${_gf}</td></tr>`;
  }).join("");

  return `${_siBlock}
<section class="dive-header-section">
  <h2 class="dive-num-header">Dive ${_di + 1}</h2>
  <div class="grid g3" style="margin-bottom:0">
    <div class="cell"><strong>Max Depth</strong><span>${_dMaxD} m</span></div>
    <div class="cell"><strong>Bottom Time</strong><span>${fmt(_dBtMin,1)} min</span></div>
    <div class="cell"><strong>Runtime</strong><span>${fmt(_dRT,1)} min</span></div>
    <div class="cell"><strong>Deco Time</strong><span>${_dDecoMin > 0 ? fmt(_dDecoMin,1)+' min' : '—'}</span></div>
    <div class="cell"><strong>Last Stop</strong><span>${_dLastStop < 99 ? _dLastStop+' m' : '—'}</span></div>
    <div class="cell"><strong>GF Lo / Hi</strong><span>${gfLow} / ${gfHigh}</span></div>
  </div>
</section>
<section>
  <img class="graph-img" src="${_graphImgs[_di]}" alt="Dive ${_di+1} profile">
</section>
${buildExportGasSection(_di, _normSlice, _expOpts)}
<section class="breakable">
  <h2>Dive ${_di + 1} — Plan</h2>
  <table>
    <thead><tr><th>Phase</th><th>Depth</th><th>Duration</th><th>Runtime</th><th>TTS</th><th>Gas</th><th>ppO₂</th><th>GF surf</th></tr></thead>
    <tbody>${_dRows}</tbody>
  </table>
</section>`;
}).join("\n")}

</div>

<script>
  var filename = 'dive-plan-${nowISO}';

  document.getElementById('pdfBtn').addEventListener('click', function(){
    window.print();
  });

  document.getElementById('txtBtn').addEventListener('click', function(){
    var txt = ${JSON.stringify(txtLines)};
    var a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([txt], {type:'text/plain'}));
    a.download = filename + '.txt';
    a.click();
  });
<\/script>
</body></html>`;

  const w = window.open("", "_blank");
  if (!w){ alert("Allow pop-ups to export the dive plan."); return; }
  w.document.write(html);
  w.document.close();
}

function renderPlanTable(plan){
  resolvePlanTTS(plan);
  const tbody = document.getElementById("planRows");
  if (!tbody) return;
  tbody.innerHTML = "";

  if (!plan || !plan.length){
    const tr = document.createElement("tr");
    tr.innerHTML = `<td colspan="8" id="planEmpty" style="text-align:center;color:var(--muted);padding:40px 0;font-size:13px;">No plan yet.</td>`;
    tbody.appendChild(tr);
    return;
  }

  const PHASE_META = {
    descent: { icon: "↓", label: "Descent", cls: "pk-descent" },
    bottom:  { icon: "●", label: "Bottom",  cls: "pk-bottom"  },
    ascent:  { icon: "↑", label: "Ascent",  cls: "pk-ascent"  },
    stop:    { icon: "⏱", label: "Stop",    cls: "pk-stop"    },
    switch:  { icon: "⇄", label: "Switch",  cls: "pk-switch"  },
    surface: { icon: "⬆", label: "Surface", cls: "pk-surface" },
  };

  const rows = buildDisplayPlan(plan);

  for (const s of rows){
    // Surface interval: full-width separator row
    if (s.kind === "surface"){
      const siH = Math.floor(s.duration / 60);
      const siM = Math.round(s.duration % 60);
      const siLabel = siH > 0 ? `${siH}h ${siM}min` : `${siM} min`;
      const residualGF = Number.isFinite(s.surf_gf) ? `${Math.round(s.surf_gf*100)} %` : "—";
      const tr = document.createElement("tr");
      tr.className = "pk-surface-sep";
      tr.innerHTML = `<td colspan="8" style="text-align:center;padding:6px 8px;font-size:11px;font-weight:600;background:var(--bg-surface-sep,#f0f4f8);color:var(--muted);letter-spacing:.04em;border-top:2px solid #c0c8d8;border-bottom:2px solid #c0c8d8;">
        ⬆ Surface interval — ${siLabel} &nbsp;·&nbsp; Residual GF ${residualGF} &nbsp;·&nbsp; Dive ${s.diveNum ?? "?"}
      </td>`;
      tbody.appendChild(tr);
      continue;
    }

    const meta = PHASE_META[s.kind] || { icon: "?", label: s.kind, cls: "" };
    const isSwitch = s.kind === "switch";

    // Gas cell: coloured dot + label (+ "loop" badge when on CCR).
    const gasCol   = s.gas ? gasColor(s.gas) : "#888";
    const gasLabel = s.gas ? fmtGasLabel(s.gas.label || gasLabelFromObj(s.gas)) : "—";
    const loopBadge = (ccrCtx && !isSwitch)
      ? `<span style="font-size:10px;color:#7c3aed;font-weight:600;margin-left:3px;">loop</span>`
      : "";
    const gasCell  = `<span style="display:inline-flex;align-items:center;gap:5px;">
      <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${gasCol};flex-shrink:0;"></span>
      <span style="font-size:11px;font-family:ui-monospace,monospace;">${gasLabel}</span>${loopBadge}
    </span>`;

    // ppO₂ at segment depth.
    // CCR: show the active setpoint (SP controls ppO₂, not the gas fO₂).
    // OC:  show fO₂ × p_amb, colour-coded against limits.
    let ppo2Cell = "—";
    if (!isSwitch && Number.isFinite(Number(s.depth))){
      const d = Number(s.depth);
      if (ccrCtx){
        const sp = ccrSetpointAt(d);
        ppo2Cell = `<span style="color:var(--muted);">${sp.toFixed(2)}</span>`
                 + `<span style="font-size:10px;color:var(--muted);"> bar SP</span>`;
      } else if (s.gas){
        const ppo2 = ppo2AtDepthActual(s.gas, d);
        if (Number.isFinite(ppo2)){
          const ppColor = ppo2 > s.gas.ppo2MaxDeco + EPS ? "#a00"
                        : ppo2 > s.gas.ppo2MaxBottom + EPS ? "#c60"
                        : "#000";
          ppo2Cell = `<span style="color:${ppColor}">${ppo2.toFixed(2)} bar</span>`;
        }
      }
    }

    const dep  = `${fmt(Number(s.depth   || 0), 0)} m`;
    const rt   = `${fmt(Number(s.runtime || 0), 1)} min`;
    const dur  = isSwitch ? "—" : `${fmt(Number(s.duration || 0), 1)} min`;
    const tts  = (!isSwitch && Number.isFinite(s.tts))     ? `${Math.ceil(s.tts)} min`          : "—";
    const gf   = (!isSwitch && Number.isFinite(s.surf_gf)) ? `${Math.round(s.surf_gf * 100)} %` : "—";

    const muted = "color:var(--muted)";
    const tr = document.createElement("tr");
    tr.className = meta.cls;
    tr.innerHTML = `
      <td>
        <span style="font-size:12px;font-weight:600;">${meta.icon}</span>
        <span style="font-size:12px;${muted};margin-left:4px;">${meta.label}</span>
      </td>
      <td class="mono" style="font-size:12px;">${dep}</td>
      <td class="mono" style="font-size:12px;${isSwitch ? muted : ""}">${dur}</td>
      <td class="mono" style="font-size:12px;">${rt}</td>
      <td style="font-size:12px;">${gasCell}</td>
      <td class="mono" style="font-size:12px;${isSwitch ? muted : ""}">${tts}</td>
      <td class="mono" style="font-size:12px;${isSwitch ? muted : ""}">${gf}</td>
      <td class="mono" style="font-size:12px;${isSwitch ? muted : ""}">${ppo2Cell}</td>
    `;
    tbody.appendChild(tr);
  }
}

/**
 * Converts a plan segment array to a polyline suitable for canvas rendering.
 * Consecutive ascent/descent micro-steps are merged into a single straight line
 * so the plotted slope is continuous rather than stairstepped.
 * @param {object[]} plan
 * @returns {{ t: number, d: number }[]} Polyline points (t = runtime min, d = depth m).
 */
function planToPolyline(plan, { startDepth = 0, startTime = 0 } = {}){
  const pts = [{ t: startTime, d: startDepth }];
  let t = startTime;
  let d = startDepth;

  let motionKind = null;   // "ascent" | "descent" | null
  let motionT0 = 0;
  let motionD0 = 0;

  function flushMotion(t1, d1){
    if (!motionKind) return;
    const last = pts[pts.length - 1];
    if (Math.abs(last.t - motionT0) > EPS || Math.abs(last.d - motionD0) > EPS){
      pts.push({ t: motionT0, d: motionD0 });
    }
    pts.push({ t: t1, d: d1 });
    motionKind = null;
  }

  for (const s of plan){
    if (s.kind === "switch") continue;
    const d2 = Number(s.depth);
    const dt = Number(s.duration);

    // Surface interval: flat line at surface for the SI duration
    if (s.kind === "surface"){
      flushMotion(t, d);
      if (d > EPS){ pts.push({t, d: 0}); d = 0; }
      const last = pts[pts.length-1];
      if (Math.abs(last.t - t) > EPS || Math.abs(last.d) > EPS) pts.push({t, d: 0});
      t += dt;
      pts.push({t, d: 0});
      continue;
    }

    if (s.kind === "descent" || s.kind === "ascent"){
      if (motionKind && s.kind !== motionKind){
        flushMotion(t, d);
      }
      if (!motionKind){
        motionKind = s.kind;
        motionT0 = t;
        motionD0 = d;
      }
      t += dt;
      d = d2;
      continue;
    }

    // stop / bottom (constant depth)
    flushMotion(t, d);
    d = d2;
    const last = pts[pts.length - 1];
    if (Math.abs(last.t - t) > EPS || Math.abs(last.d - d) > EPS){
      pts.push({ t, d });
    }
    t += dt;
    pts.push({ t, d });
  }

  flushMotion(t, d);
  return pts;
}

/**
 * Draws a hover tooltip box onto any canvas context.
 *
 * @param {CanvasRenderingContext2D} c       - Target context
 * @param {{ lines:Array, x:number, y:number }} info - Hover info from buildHoverInfo
 * @param {{ padL, padT, plotW, plotH }} metrics
 */
function drawHoverTooltip(c, info, { padL, padT, plotW, plotH }){
  if (!info || !info.lines || !info.lines.length) return;
  c.save();
  c.font = "12px system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
  c.textBaseline = "top";
  const pad   = 6;
  const lineH = 14;
  let maxW = 0;
  for (const ln of info.lines){
    const s = (ln && typeof ln === "object") ? String(ln.text ?? "") : String(ln);
    maxW = Math.max(maxW, c.measureText(s).width);
  }
  const boxW = Math.ceil(maxW) + pad * 2;
  const boxH = info.lines.length * lineH + pad * 2;

  let bx = info.x + 12;
  let by = info.y;
  bx = Math.min(bx, padL + plotW - boxW - 2);
  bx = Math.max(bx, padL + 2);
  by = Math.min(by, padT + plotH - boxH - 2);
  by = Math.max(by, padT + 2);

  // Opaque white background
  c.globalAlpha = 1;
  c.fillStyle   = "#fff";
  c.strokeStyle = "#bbb";
  c.lineWidth   = 1;
  pathRoundedRect(c, bx, by, boxW, boxH, 6);
  c.fill();
  c.stroke();

  let ty = by + pad;
  for (const ln of info.lines){
    const s   = (ln && typeof ln === "object") ? String(ln.text ?? "") : String(ln);
    const col = (ln && typeof ln === "object" && ln.color) ? String(ln.color) : "#000";
    c.fillStyle = col;
    c.fillText(s, bx + pad, ty);
    ty += lineH;
  }
  c.restore();
}

/**
 * Renders the dive profile to the canvas. Draws axes, depth grid, the profile
 * polyline, gas-timeline lane, switch markers, and the hover overlay.
 * Also populates all summary text fields in the right panel.
 *
 * @param {object[]|null} plan - Current plan, or null/[] to clear the canvas.
 * @param {boolean} skipResize - Pass true to skip the canvas resize step
 *   (used by the hover handler to avoid triggering layout feedback loops).
 */
function draw(plan, skipResize = false, {
  tMaxOverride = 0, profileDash = [], startDepth = 0, profileColor = "#000", noSummary = false,
  // Optional canvas override: draw onto a specific canvas without touching globals.
  // When provided the caller receives { plotMetrics, lastPts } in the return value.
  canvas: _cvParam = null, ctx: _ctxParam = null,
  hoverX: _hoverXParam = undefined, hoverInfo: _hiParam = undefined,
} = {}){
  const _isOvr = _cvParam !== null;
  if (!skipResize && !_isOvr) resizeCanvas();
  const _cv  = _cvParam  ?? cv;
  const _ctx = _ctxParam ?? ctx;
  const _hoverX = _hoverXParam !== undefined ? _hoverXParam : hoverX;
  const _hi     = _hiParam     !== undefined ? _hiParam     : hoverInfo;

  const r = _cv.getBoundingClientRect();
  const w = Math.floor(r.width), h = Math.floor(r.height);
  _ctx.clearRect(0,0,w,h);
  if (!plan || !plan.length){
    const _emptyPts = [{t:0,d:0}];
    if (!_isOvr) lastPts = _emptyPts;
    setText("pBottom","—");
    setText("pMaxDepth","—");
    setText("pRuntime","—");
    setText("pGasDensityMax","—");
    setText("pSurfGFMax","—");
    setText("pDecoTime","—");
    setText("pEnd","—");
    const _sb = $("pSwitchBlock"); if (_sb) _sb.hidden = true;
    const _stb = $("pStopsBlock"); if (_stb) _stb.hidden = true;
    const _bb = $("pBailoutBlock"); if (_bb) _bb.hidden = true;
    const o2Rows = $("o2Rows"); if (o2Rows) o2Rows.innerHTML = "";
    return { plotMetrics: null, lastPts: _emptyPts };
  }

  const pts = planToPolyline(plan, { startDepth });
  // Avoid spread operator on potentially large arrays (call-stack limit).
  let tMax = Math.max(1, tMaxOverride), dMax = Math.max(1, startDepth);
  for (const p of pts){ if (p.t > tMax) tMax = p.t; if (p.d > dMax) dMax = p.d; }
  let gAtMax = null;

  // cache for hover (only for the main canvas)
  if (!_isOvr) lastPts = pts;
  const _lastPts = pts;

  // Leave room at the top for the gas timeline lane AND an overlay legend.
  // The legend is used when segments are too cramped to display their labels.
  const padL=56, padR=16, padB=36;
  const legendH = 14;
  const legendGap = 4;
  const laneY = 8 + legendH + legendGap;
  const laneH = 14;
  const laneGap = 6;
  const padT = laneY + laneH + laneGap;
  const plotW = w - padL - padR;
  const plotH = h - padT - padB;

  // update global metrics so that hover handlers know where
  // the plotting area resides on the canvas. These values
  // remain in CSS pixels because drawing functions scale
  // appropriately via ctx.setTransform.
  const _plotMetrics = { padL, padT, plotW, plotH, tMax, dMax };
  if (!_isOvr) plotMetrics = _plotMetrics;

  const xOf = (t)=> padL + (t/tMax)*plotW;
  const yOf = (d)=> padT + (d/dMax)*plotH;

  // -----------------
  // Gas timeline lane
  // -----------------
  const switchEvents = plan.filter(s=>s.kind==="switch");
  let currentGas = (plan.find(s=>s.kind!=="switch")||{}).gas || null;
  let t0 = 0;
  const intervals = [];
  for (const sw of switchEvents){
    const tSw = Number(sw.runtime||0);
    if (tSw > t0 + EPS) intervals.push({t0, t1:tSw, gas: currentGas});
    currentGas = sw.gas || currentGas;
    t0 = tSw;
  }
  intervals.push({t0, t1:tMax, gas: currentGas});

  // draw lane background
  _ctx.save();
  _ctx.fillStyle = "#f6f6f6";
  _ctx.fillRect(padL, laneY, plotW, laneH);
  _ctx.strokeStyle = "#ddd";
  _ctx.strokeRect(padL, laneY, plotW, laneH);

  // draw colored segments + labels
  _ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
  _ctx.textBaseline = "middle";
  for (const it of intervals){
    const x0 = xOf(it.t0);
    const x1 = xOf(it.t1);
    if (x1 - x0 < 1) continue;
    const col = gasColor(it.gas);
    _ctx.globalAlpha = 0.25;
    _ctx.fillStyle = col;
    _ctx.fillRect(x0, laneY, x1-x0, laneH);
    _ctx.globalAlpha = 0.9;
    _ctx.strokeStyle = col;
    _ctx.strokeRect(x0, laneY, x1-x0, laneH);

    const label = gasLabelFromObj(it.gas);
    if (label){
      const pad = 3;
      const maxW = Math.max(0, (x1 - x0) - 2*pad);
      if (maxW > 6){
        // Draw clipped + ellipsized label so each segment keeps its own name
        // even when the segment is narrow.
        _ctx.save();
        _ctx.beginPath();
        _ctx.rect(x0 + pad, laneY + 1, (x1 - x0) - 2*pad, laneH - 2);
        _ctx.clip();

        _ctx.globalAlpha = 1;
        _ctx.fillStyle = "#000";

        let txt = String(label);
        const ell = "…";
        if (_ctx.measureText(txt).width > maxW){
          // Keep at least 1 char + ellipsis.
          while (txt.length > 1 && _ctx.measureText(txt + ell).width > maxW){
            txt = txt.slice(0, -1);
          }
          txt = (txt.length > 1) ? (txt + ell) : txt.slice(0, 1);
        }

        const tw = _ctx.measureText(txt).width;
        _ctx.fillText(txt, x0 + ((x1-x0) - tw)/2, laneY + laneH/2);
        _ctx.restore();
      }
    }
  }
  _ctx.restore();

  // In CCR mode overdraw the diluent segments with a stripe pattern so they
  // are visually distinct from OC gases.  Only applies to the main canvas
  // (not the contingency canvas override) — the contingency graph calls the
  // helper explicitly for just the pre-loss portion.
  if (!_isOvr && _breathMode === "ccr" && plan && plan.length){
    applyCCRDiluentLaneOverdraw(_cv, { padL, padT, plotW, tMax }, plan);
  }

  // axes
  _ctx.lineWidth=1;
  _ctx.strokeStyle="#999";
  _ctx.beginPath();
  _ctx.moveTo(padL,padT);
  _ctx.lineTo(padL,padT+plotH);
  _ctx.lineTo(padL+plotW,padT+plotH);
  _ctx.stroke();

  // grid labels
  _ctx.fillStyle="#000";
  _ctx.font="12px system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
  for (let i=0;i<=6;i++){
    const d = (i/6)*dMax;
    const y = yOf(d);
    _ctx.strokeStyle="#eee";
    _ctx.beginPath(); _ctx.moveTo(padL,y); _ctx.lineTo(padL+plotW,y); _ctx.stroke();
    _ctx.fillText(`${d.toFixed(0)} m`, 8, y+4);
  }
  for (let i=0;i<=10;i++){
    const t = (i/10)*tMax;
    const x = xOf(t);
    _ctx.strokeStyle="#eee";
    _ctx.beginPath(); _ctx.moveTo(x,padT); _ctx.lineTo(x,padT+plotH); _ctx.stroke();
    _ctx.fillText(`${t.toFixed(0)}`, x-8, padT+plotH+18);
  }

  // profile line
  _ctx.save();

  _ctx.strokeStyle = profileColor;
  _ctx.lineWidth = 2;
  _ctx.lineJoin = "round";
  _ctx.lineCap = "round";
  if (profileDash.length) _ctx.setLineDash(profileDash);

  _ctx.beginPath();
  _ctx.moveTo(xOf(pts[0].t), yOf(pts[0].d));
  for (let i = 1; i < pts.length; i++) {
    _ctx.lineTo(xOf(pts[i].t), yOf(pts[i].d));
  }
  _ctx.stroke();
  _ctx.setLineDash([]);

  _ctx.restore();

  // -----------------
  // Gas switch markers (T2): vertical markers (lane + plot)
  // -----------------
  for (const s of switchEvents){
    const tSw = Number(s.runtime||0);
    const x = xOf(tSw);
    const col = gasColor(s.gas);

    // strong marker in lane
    _ctx.save();
    _ctx.strokeStyle = col;
    _ctx.lineWidth = 3;
    _ctx.beginPath();
    _ctx.moveTo(x, laneY);
    _ctx.lineTo(x, laneY + laneH);
    _ctx.stroke();
    _ctx.restore();

    // dashed marker through plot (make it very visible)
    _ctx.save();
    _ctx.strokeStyle = col;
    _ctx.globalAlpha = 0.85;
    _ctx.lineWidth = 3;
    _ctx.setLineDash([5,5]);
    _ctx.beginPath();
    _ctx.moveTo(x, padT);
    _ctx.lineTo(x, padT + plotH);
    _ctx.stroke();
    _ctx.restore();
  }

  // ── CCR setpoint transition markers ─────────────────────────────────────
  // Draw a teal dashed line + circle + label at each depth where the SP
  // changes (descent: LOW→HIGH; ascent: HIGH→LOW).
  if (ccrCtx && ccrCtx.spSchedule && ccrCtx.spSchedule.length > 1){
    const swDepths = ccrCtx.spSchedule.slice(0, -1).map(e => Number(e.depth));
    let prevD = 0, prevT = 0;
    const spMarkers = [];
    for (const seg of plan){
      if (seg.kind === "switch") continue;
      const d2 = Number(seg.depth ?? prevD);
      const t2 = Number(seg.runtime ?? prevT);
      const t1 = t2 - Number(seg.duration ?? 0);
      for (const swD of swDepths){
        if (seg.kind === "descent" && prevD < swD - EPS && d2 >= swD - EPS){
          const frac = (swD - prevD) / Math.max(EPS, d2 - prevD);
          spMarkers.push({ t: t1 + frac * (t2 - t1), depth: swD, sp: ccrSetpointAt(swD + 1) });
        }
        if (seg.kind === "ascent" && prevD >= swD - EPS && d2 < swD - EPS){
          const frac = (prevD - swD) / Math.max(EPS, prevD - d2);
          spMarkers.push({ t: t1 + frac * (t2 - t1), depth: swD, sp: ccrSetpointAt(swD - 1) });
        }
      }
      prevD = d2; prevT = t2;
    }
    _ctx.save();
    _ctx.font = "bold 10px system-ui, -apple-system, sans-serif";
    for (const m of spMarkers){
      const mx = xOf(m.t), my = yOf(m.depth);
      // Dashed vertical line
      _ctx.strokeStyle = "#00897b"; _ctx.globalAlpha = 0.45;
      _ctx.lineWidth = 1; _ctx.setLineDash([3, 3]);
      _ctx.beginPath(); _ctx.moveTo(mx, padT); _ctx.lineTo(mx, padT + plotH);
      _ctx.stroke(); _ctx.setLineDash([]);
      // Circle on profile
      _ctx.globalAlpha = 1; _ctx.fillStyle = "#00897b";
      _ctx.beginPath(); _ctx.arc(mx, my, 3.5, 0, Math.PI * 2); _ctx.fill();
      // Pill label to the right of the marker
      const txt = `SP ${m.sp.toFixed(1)}`;
      const tw  = _ctx.measureText(txt).width;
      const lPad = 4, lH = 14, lX = mx + 6, lY = my - lH / 2;
      _ctx.fillStyle = "#00897b";
      _ctx.globalAlpha = 0.92;
      const r = 3;
      _ctx.beginPath();
      _ctx.moveTo(lX + r, lY); _ctx.lineTo(lX + tw + lPad * 2 - r, lY);
      _ctx.quadraticCurveTo(lX + tw + lPad * 2, lY, lX + tw + lPad * 2, lY + r);
      _ctx.lineTo(lX + tw + lPad * 2, lY + lH - r);
      _ctx.quadraticCurveTo(lX + tw + lPad * 2, lY + lH, lX + tw + lPad * 2 - r, lY + lH);
      _ctx.lineTo(lX + r, lY + lH); _ctx.quadraticCurveTo(lX, lY + lH, lX, lY + lH - r);
      _ctx.lineTo(lX, lY + r); _ctx.quadraticCurveTo(lX, lY, lX + r, lY);
      _ctx.closePath(); _ctx.fill();
      _ctx.globalAlpha = 1; _ctx.fillStyle = "#fff";
      _ctx.textBaseline = "middle"; _ctx.textAlign = "left";
      _ctx.fillText(txt, lX + lPad, lY + lH / 2);
    }
    _ctx.restore();
  }

  // draw a thin red vertical line under the cursor if the
  // hoverX value is defined. The line spans the plotting
  // area and is clamped so it does not extend beyond the
  // axes. Because _ctx is scaled by devicePixelRatio, we
  // supply coordinates in CSS pixels.
  if (_hoverX !== null){
    // clamp to plotting region
    const x = Math.max(padL, Math.min(padL + plotW, _hoverX));
    _ctx.strokeStyle = "#f00";
    _ctx.lineWidth = 1;
    _ctx.beginPath();
    _ctx.moveTo(x, padT);
    _ctx.lineTo(x, padT + plotH);
    _ctx.stroke();
  }

  // Hover info box
  if (_hi && _hi.lines && _hi.lines.length && _hoverX !== null){
    drawHoverTooltip(_ctx, _hi, { padL, padT, plotW, plotH });
  }

  // Tissue heatmap overlay — drawn in the bottom-left corner of the plot
  // when the "Tissue heatmap" checkbox is ticked and the user is hovering.
  if (_hi && _hi.tissues && _hoverX !== null){
    const n    = _hi.tissues.length; // 16
    const oRowH = 13;
    const oPad  = 5;
    const oW    = 220;   // wider to accommodate index + half-time label column
    const oH    = oPad * 2 + n * oRowH;
    const ox    = padL + plotW - oW - 4;
    const oy    = padT + plotH - oH - 4;
    drawTissueHeatmap(_ctx, _hi.tissues, ox, oy, oW, oH, { compact: true });
  }

  // summary
  if (!noSummary){
    const bottom = [...plan].reverse().find(s=>s.kind==="bottom") || null;
    const runtime = Number(plan[plan.length-1].runtime||tMax);
    const surfVals = plan.map(s=>Number(s.surf_gf)).filter(v=>Number.isFinite(v));
    const maxSurf = surfVals.length ? Math.max(...surfVals) : NaN;

    setText("pBottom", bottom ? `${Math.ceil(Number(bottom.duration))} min` : "0 min");
    setText("pMaxDepth", `${Math.ceil(dMax)} m`);
    setText("pRuntime", `${Math.ceil(runtime)} min`);
    setText("pSurfGFMax", Number.isFinite(maxSurf) ? `${Math.round(maxSurf*100)} %` : "—");

    // END (N₂ only), referenced to air (0.79)
    let gasAtMax = null;
    let depthAtMax = 0;
    for (const s of plan){
      if (!s || s.kind === "switch") continue;
      const d = Number(s.depth||0);
      if (d >= depthAtMax - EPS){ depthAtMax = d; if (s.gas) gasAtMax = s.gas; }
    }
    const endAtMax = (gasAtMax && Number.isFinite(gasAtMax.fN2)) ? endMeters(depthAtMax, gasAtMax.fN2) : NaN;
    setText("pEnd", Number.isFinite(endAtMax) ? `${Math.round(endAtMax)} m` : "—");

    // deco time
    let decoStop = 0;
    for (const s of plan){ if (s.kind === "stop") decoStop += Number(s.duration||0); }
    setText("pDecoTime", `${Math.ceil(decoStop)} min`);

    // Max gas density (kg/m³)
    let maxDens = -Infinity;
    let gNow = (plan.find(s=>s.kind!=="switch")||{}).gas || null;
    let dPrevD = 0;
    for (const s of plan){
      if (s.kind === "switch"){ if (s.gas) gNow = s.gas; continue; }
      const dEnd = Number(s.depth||0);
      const dHi = Math.max(dPrevD, dEnd);
      const dens = gasDensityKgM3(gNow, dHi);
      if (Number.isFinite(dens)) maxDens = Math.max(maxDens, dens);
      dPrevD = dEnd;
    }
    setText("pGasDensityMax", Number.isFinite(maxDens) ? `${maxDens.toFixed(2)} kg/m³` : "—");
    const densEl = document.getElementById("pGasDensityMax");
    if (densEl){
      densEl.style.color = "";
      densEl.textContent = Number.isFinite(maxDens) ? `${maxDens.toFixed(2)} kg/m³` : "—";
      if (Number.isFinite(maxDens) && maxDens > GAS_DENSITY_CAUTION){
        densEl.style.color = (maxDens > GAS_DENSITY_DANGER) ? "#7A0000" : "#D00000";
        const title = (maxDens > GAS_DENSITY_DANGER)
          ? `Gas density above ${GAS_DENSITY_DANGER} kg/m³ — CO₂ retention risk.`
          : `Gas density above ${GAS_DENSITY_CAUTION} kg/m³ — elevated work of breathing.`;
        densEl.innerHTML = `${maxDens.toFixed(2)} kg/m³ <span title="${title}">⚠️</span>`;
      }
    }

    // stops + gas switches (right panel)
    const stops = plan.filter(s=>s.kind==="stop");
    const byDepth = new Map();
    for (const s of stops){
      const k = Number(s.depth).toFixed(0);
      byDepth.set(k, (byDepth.get(k)||0) + Number(s.duration||0));
    }
    const stopEntries = [...byDepth.entries()]
      .sort((a,b)=>Number(b[0])-Number(a[0]));

    const initialGas = (plan.find(s=>s.kind!=="switch")||{}).gas || null;
    let lastGasKey = gasKeyFromObj(initialGas) || "";
    const switchData = [];
    for (const sw of switchEvents){
      const gLab = gasLabelFromObj(sw.gas);
      const gKey = gasKeyFromObj(sw.gas);
      if (!gLab) continue;
      if (gKey && gKey === lastGasKey) continue;
      if (gKey) lastGasKey = gKey;
      switchData.push({ runtime: Number(sw.runtime||0), depth: Number(sw.depth||0), gas: sw.gas, label: gLab });
    }

    // Render gas switches block
    const switchBlock = $("pSwitchBlock");
    const switchList  = $("pSwitchList");
    if (switchBlock && switchList){
      if (switchData.length){
        switchList.innerHTML = switchData.map(sw => {
          const col = gasColor(sw.gas);
          return `<div class="planSwitchRow">
            <span class="planSwitchDot" style="background:${col}"></span>
            <span class="planSwitchInfo">${sw.depth.toFixed(0)} m · ${sw.runtime.toFixed(1)} min</span>
            <span class="planSwitchGas">${fmtGasLabel(sw.label)}</span>
          </div>`;
        }).join("");
        switchBlock.hidden = false;
      } else {
        switchBlock.hidden = true;
      }
    }

    // Render CCR bailout switch block (visible only during lost-gas contingency)
    {
      const bailoutBlock = $("pBailoutBlock");
      const bailoutList  = $("pBailoutList");
      if (bailoutBlock && bailoutList){
        const _contingOn = document.getElementById("lostGasEnable")?.checked;
        const _cp = lastContingencyPlan;
        if (_breathMode === "ccr" && _contingOn && _cp && _cp.length){
          // Collect unique gas switches from the contingency plan.
          const bSwitches = [];
          let _bGas = (_cp.find(s => s.kind !== "switch") || {}).gas || null;
          let _bDepthPrev = lastContingencyWorst.d;
          for (const s of _cp){
            if (s.kind === "switch" && s.gas && s.gas !== _bGas){
              bSwitches.push({ depth: Number(s.depth ?? _bDepthPrev), runtime: lastContingencyWorst.t + Number(s.runtime || 0), gas: s.gas });
              _bGas = s.gas;
            }
            if (s.kind !== "switch") _bDepthPrev = Number(s.depth ?? _bDepthPrev);
          }
          // First gas (at bailout start) always listed
          const _firstGas = (_cp.find(s => s.kind !== "switch") || {}).gas || null;
          const allBail = _firstGas
            ? [{ depth: lastContingencyWorst.d, runtime: lastContingencyWorst.t, gas: _firstGas }, ...bSwitches]
            : bSwitches;
          if (allBail.length){
            bailoutList.innerHTML = allBail.map(sw => {
              const col = gasColor(sw.gas);
              const lbl = gasLabelFromObj(sw.gas) || "?";
              return `<div class="planSwitchRow">
                <span class="planSwitchDot" style="background:${col}"></span>
                <span class="planSwitchInfo">${sw.depth.toFixed(0)} m · ${sw.runtime.toFixed(1)} min</span>
                <span class="planSwitchGas">${fmtGasLabel(lbl)}</span>
              </div>`;
            }).join("");
            bailoutBlock.hidden = false;
          } else {
            bailoutBlock.hidden = true;
          }
        } else {
          bailoutBlock.hidden = true;
        }
      }
    }

    // Render deco stops block
    const stopsBlock = $("pStopsBlock");
    const stopsList  = $("pStopsList");
    if (stopsBlock && stopsList){
      if (stopEntries.length){
        stopsList.innerHTML = stopEntries.map(([d, t]) =>
          `<tr><td>${d} m</td><td>${Math.ceil(t)} min</td></tr>`
        ).join("");
        stopsBlock.hidden = false;
      } else {
        stopsBlock.hidden = true;
      }
    }

    // Keep legacy autoResize working (based on line count estimate)
    const legacyLines = [
      ...switchData.map(sw=>`${sw.depth.toFixed(0)} m  ${sw.runtime.toFixed(1)} min  ${sw.label}`),
      ...stopEntries.map(([d,t])=>`${d} m : ${Math.ceil(t)} min`)
    ];
    autoResizeRightPanelForLines(legacyLines);
    setText("meta", "");
  }
  return { plotMetrics: _plotMetrics, lastPts: _lastPts };
}

/* build plan from UI */
// ── Per-dive gas + tank helpers ───────────────────────────────────────────
function rawGasRowsFromDOM(){
  return [...gasRows.querySelectorAll("tr[data-gas-row]")].map(readGasRow);
}

function saveSegmentsToDive(diveIdx){
  if (diveIdx < 0) return;
  diveSegmentsData[diveIdx] = JSON.parse(JSON.stringify(segments));
}

function loadSegmentsForDive(diveIdx){
  const saved = diveSegmentsData[diveIdx];
  segments = saved ? JSON.parse(JSON.stringify(saved)) : [];
  renderSegments();
}

function saveGasesToDive(diveIdx){
  if (diveIdx < 0) return;
  diveGasData[diveIdx] = rawGasRowsFromDOM();
}

function saveTanksToDive(diveIdx){
  if (diveIdx < 0) return;
  diveTankData[diveIdx] = [..._selectedTanks.entries()];
}

function loadTanksForDive(diveIdx){
  _selectedTanks = new Map(diveTankData[diveIdx] || []);
}

function loadGasesForDive(diveIdx){
  const data = diveGasData[diveIdx];
  if (!data || !data.length) return;
  gasRows.innerHTML = "";
  data.forEach(g => {
    addGasRow(g);
    if (g.switchDepthM != null){
      const rows = [...gasRows.querySelectorAll("tr[data-gas-row]")];
      const swEl = rows[rows.length - 1]?.querySelector("input[data-switch]");
      if (swEl) swEl.value = String(g.switchDepthM);
    }
  });
  syncAllGasRows();
}

function uiGases(rawOverride){
  const bottomP = Number($("ppO2Bottom").value) || 1.4;
  const decoP = Number($("ppO2Deco").value) || 1.6;

  const raw = rawOverride
    ? rawOverride.map(g => ({ ...g }))
    : [...gasRows.querySelectorAll("tr[data-gas-row]")].map(readGasRow);

  // Validation policy:
  // - Never throw on "no enabled gases" (planner must freeze silently).
  // - If an enabled row has invalid numbers, treat it as disabled for planning.
  for (const g of raw){
    if (!(g && g.enabled && g.role !== "disabled")) continue;
    if (!Number.isFinite(g.o2) || !Number.isFinite(g.he)) { g.enabled = false; continue; }
    if (g.o2 < 0 || g.he < 0 || g.o2 + g.he > 100) { g.enabled = false; continue; }
  }

  return raw.map(g => new Gas({
    label: g.label || `${Math.round(g.o2)}/${Math.round(g.he)}`,
    o2: g.o2/100,
    he: g.he/100,
    // Normalise roles for the core Bühlmann / gas-selection logic:
    //   diluent  → "auto"   (CCR loop gas; selectGas() bypasses chooseBestGas in CCR mode)
    //   bailout  → "auto"   (OC emergency gas; available for all phases on bailout)
    //   stage    → "bottom" (side-mounted cylinder; usable on descent/bottom/ascent)
    role: g.role === "diluent" || g.role === "bailout" ? "auto"
        : g.role === "stage"   ? "bottom"
        : g.role,
    enabled: g.enabled,
    ppo2Min: globalPpO2Min(),
    ppo2MaxBottom: bottomP,
    ppo2MaxDeco: decoP,
    switchDepthM: g.switchDepthM,
  }));
}

function maxReachableDepthForBottom(gases){
  const bottomP = Number($("ppO2Bottom").value) || 1.4;
  let m = -Infinity;
  for (const g of gases || []){
    if (!g || !g.enabled) continue;
    const role = String(g.role || "auto").toLowerCase();
    if (role !== "auto" && role !== "bottom") continue;
    if (!(g.fO2 > 0)) continue;
    const mod = modMeters(g.fO2, bottomP);
    if (Number.isFinite(mod)) m = Math.max(m, mod);
  }
  return m;
}


/**
 * Reads UI state and calls the appropriate planner.
 *
 * Freeze policy: returns null (instead of throwing) whenever the requested
 * dive exceeds the MOD of the deepest enabled bottom/auto gas.  The caller
 * (`run`) then falls back to displaying the last valid plan rather than
 * clearing the canvas.
 *
 * @returns {object[]|null} Plan array, empty array (no waypoints), or null (freeze).
 */
function buildPlan(){
  const mode = $("modeSegments").checked ? "segments" : "simple";
  const gf = new GradientFactors(Number($("gfLow").value)/100, Number($("gfHigh").value)/100);
  const ascentRate = Number($("ascentRate").value) || 9;
  const descentRate = Number($("descentRate").value) || 20;
  const lastStopDepth = Number($("lastStopDepth")?.value ?? 3) || 0;

  // Snapshot current gas + tank + segment state into per-dive store before planning
  const _curDiveIdx = Math.max(0, selectedDive);
  saveGasesToDive(_curDiveIdx);
  saveTanksToDive(_curDiveIdx);
  saveSegmentsToDive(_curDiveIdx);

  const gases = uiGases(diveGasData[0]);

  // ── CCR context ───────────────────────────────────────────────────────────
  // CCR mode is active when any enabled gas row has role "ccr".
  // Read settings from that row's sub-row inputs; use the matching Gas object
  // as the diluent.  gases[] has role remapped to "auto" already, so look up
  // the original raw row data to find the CCR row.
  {
    const rawRows = [...gasRows.querySelectorAll("tr[data-gas-row]")].map(readGasRow);
    const ccrRowIdx = rawRows.findIndex(r => r.enabled && r.role === "diluent");
    if (ccrRowIdx !== -1){
      const cr = rawRows[ccrRowIdx];
      const diluent = gases[ccrRowIdx] ?? gases[0];
      ccrCtx = diluent ? {
        spSchedule: cr.spSchedule ?? [{depth:6, sp: cr.ccrSP ?? 1.3},{depth:0, sp: cr.ccrDecSP ?? 0.7}],
        sp:         cr.ccrSP,    // kept for legacy references
        decSp:      cr.ccrDecSP,
        diluent,
        loopVol:    cr.ccrLoopVol,
        o2Rate:     cr.ccrO2Rate,
      } : null;
    } else {
      ccrCtx = null;
    }
  }

  // Freeze policy: never throw for gas availability. Instead, refuse to build
  // a plan that would require going deeper than the MOD of any enabled bottom/auto gas.
  const maxD = maxReachableDepthForBottom(gases);
  const hasEnabled = (gases || []).some(g => g && g.enabled && String(g.role||"").toLowerCase() !== "disabled");

  if (!hasEnabled || !Number.isFinite(maxD)){
    return null; // freeze
  }

  const _diveOpts = { gf, ascentRate, descentRate, stopStep: 3, minStopQuantum: 1, lastStopDepth };

  let plan;
  if (mode === "segments"){
    if (!segments.length) return [];
    const segMax = Math.max(...segments.map(s => Number(s.depth)||0), 0);
    if (segMax > maxD + EPS) return null; // freeze
    plan = planDiveFromSegments(segments, gases, _diveOpts);
  } else {
    const bottomDepth = Number($("depth").value) || 0;
    const bottomTime = Number($("time").value) || 0;
    if (bottomDepth > maxD + EPS) return null; // freeze
    plan = planDiveMultigasAuto(bottomDepth, bottomTime, gases, _diveOpts);
  }

  // ── Chain repetitive dives ────────────────────────────────────────────────
  const _repEnabled = document.getElementById("repDivesEnable")?.checked;
  if (_repEnabled && plan && plan.length && repDives.length) {
    repDives.forEach((rd, idx) => {
      if (!(rd.siMin > 0)) return;
      const repDiveNum = idx + 2;           // Dive 2, Dive 3 … (dive 1 = main)
      const prevEndRT = plan[plan.length - 1].runtime;
      const prevState = stateAtRuntime(plan, gases, { ascentRate }, prevEndRT).state;
      const stateAfterSI = propagateConstantSegment(prevState, 0, SURFACE_AIR, rd.siMin);
      plan = plan.concat([{
        kind: "surface",
        depth: 0, duration: rd.siMin,
        runtime: prevEndRT + rd.siMin,
        gas: SURFACE_AIR, tts: 0,
        surf_gf: computeSurfGF(stateAfterSI),
        diveNum: repDiveNum,
      }]);
      const tOff    = prevEndRT + rd.siMin;
      const rdGases = uiGases(diveGasData[idx + 1] || diveGasData[0] || rawGasRowsFromDOM());
      const rdOpts  = { ..._diveOpts, initTissues: stateAfterSI };

      // Use segments for this rep dive if in segments mode and segments exist.
      const rdSegs = diveSegmentsData[idx + 1];
      let rdPlan;
      if (mode === "segments" && rdSegs && rdSegs.length) {
        const segMax = Math.max(...rdSegs.map(s => Number(s.depth)||0), 0);
        if (segMax <= maxD + EPS){
          rdPlan = planDiveFromSegments(rdSegs, rdGases, rdOpts);
        }
      }
      // Fallback to simple depth/BT if no segments or wrong mode.
      if (!rdPlan){
        if (!(rd.depth > 0) || !(rd.bottomTime > 0)) return;
        rdPlan = planDiveMultigasAuto(rd.depth, rd.bottomTime, rdGases, rdOpts);
      }

      // Prepend an explicit gas-switch to this dive's starting gas.
      // stateAtRuntime() replays the full accumulated plan with dive 1's gas list,
      // so without this marker it would use the wrong gas for rep-dive segments
      // (especially critical for dives 3+ where dive 2 segments are replayed).
      const rdStartGas = chooseBestGas(rdGases, 0, "descent", null) || rdGases[0];
      const rdStartSwitch = rdStartGas
        ? [{ kind: "switch", depth: 0, duration: 0, runtime: tOff, gas: rdStartGas, label: rdStartGas.label, diveNum: repDiveNum }]
        : [];

      plan = plan.concat(
        rdStartSwitch,
        (rdPlan || []).map(s => ({ ...s, runtime: s.runtime + tOff, diveNum: repDiveNum }))
      );
    });
  }

  return plan;
}

/* ================================================================
   Tissue saturation heatmap
================================================================ */

/**
 * Computes saturation % for all 16 compartments relative to the
 * GF = 1 Bühlmann M-value at the surface (SURF_SL reference).
 * pct = (pN₂ + pHe) / (a + SURF_SL/b) × 100.
 * The leading compartment is the one with the highest ratio — it is
 * the one that forces the deepest deco ceiling at this moment.
 *
 * @param {Compartment[]} state
 * @returns {{ halfTime: number, pct: number, isLeading: boolean }[]}
 */
function tissueHeatmapData(state){
  let maxPct = -Infinity;
  const data = state.map(c => {
    const pTissue = c.pN2 + c.pHe;
    const [aEff, bEff] = effectiveCoefficients(c);
    const m0 = aEff + SURF_SL / Math.max(EPS_DENOM, bEff);
    const pct = m0 > EPS ? (pTissue / m0) * 100 : 0;
    if (pct > maxPct) maxPct = pct;
    return { halfTime: c.tHalfN2, pct, isLeading: false };
  });
  for (const d of data) d.isLeading = maxPct > 0 && Math.abs(d.pct - maxPct) < 0.01;
  return data;
}

/** Bar fill colour: green → yellow (80 %) → orange (90 %) → red (100 %+). */
function tissueBarColor(pct){
  if (pct >= 100) return '#c62828';
  if (pct >= 90)  return '#e65100';
  if (pct >= 80)  return '#f9a825';
  return '#388e3c';
}

/**
 * Draws the 16-compartment saturation bar chart onto `ctx2` inside [x,y,w,h].
 * Works in two modes:
 *   compact = true  — small overlay drawn on the graph canvas while hovering.
 *   compact = false — full-size view for the Tissues tab canvas.
 *
 * @param {CanvasRenderingContext2D} ctx2
 * @param {{ halfTime:number, pct:number, isLeading:boolean }[]} data
 * @param {number} x  @param {number} y  @param {number} w  @param {number} h
 * @param {{ compact?: boolean }} [opts]
 */
function drawTissueHeatmap(ctx2, data, x, y, w, h, { compact = false } = {}){
  const n        = data.length;
  const pad      = compact ? 5 : 10;
  const rowH     = (h - pad * 2) / n;
  // Label column holds "N · half-time′" — compartment index (1-16) + half-time.
  // Widened vs. the half-time-only original to accommodate the number prefix.
  const labelW   = compact ? 52 : 80;
  const pctW     = compact ? 38 : 54;   // pct + leading marker column
  const barAreaX = x + pad + labelW + 2;
  const barAreaW = w - pad * 2 - labelW - 2 - pctW;
  const maxBar   = 120;                 // bars saturate at 120 %
  const ref100X  = barAreaX + barAreaW * (100 / maxBar);
  const fs       = compact ? 9 : 11;
  const ff       = "ui-monospace,'SF Mono',Menlo,monospace";

  ctx2.save();

  // Panel background
  ctx2.globalAlpha = compact ? 0.94 : 1;
  ctx2.fillStyle = '#fff';
  pathRoundedRect(ctx2, x, y, w, h, compact ? 6 : 0);
  ctx2.fill();
  if (compact){
    ctx2.globalAlpha = 0.55;
    ctx2.strokeStyle = '#bbb';
    ctx2.lineWidth = 1;
    pathRoundedRect(ctx2, x, y, w, h, 6);
    ctx2.stroke();
  }
  ctx2.globalAlpha = 1;

  // "100%" tick label at top of bar area (full view only)
  if (!compact){
    ctx2.font = `9px ${ff}`;
    ctx2.fillStyle = '#bbb';
    ctx2.textAlign = 'center';
    ctx2.textBaseline = 'top';
    ctx2.fillText('M-value', ref100X, y + 2);
  }

  for (let i = 0; i < n; i++){
    const { halfTime, pct, isLeading } = data[i];
    const ry   = y + pad + i * rowH;
    const midY = ry + rowH * 0.5;
    const barH = Math.max(2, rowH - (compact ? 2 : 4));
    const barY = ry + (rowH - barH) * 0.5;

    // Alternating zebra stripe (full view)
    if (!compact && i % 2 === 0){
      ctx2.fillStyle = '#f7f7f7';
      ctx2.fillRect(x, ry, w, rowH);
    }
    // Leading row tint (full view)
    if (!compact && isLeading){
      ctx2.fillStyle = 'rgba(198,40,40,0.05)';
      ctx2.fillRect(x, ry, w, rowH);
    }

    // Bar track
    ctx2.fillStyle = '#ebebeb';
    ctx2.fillRect(barAreaX, barY, barAreaW, barH);

    // Bar fill
    const fillW = barAreaW * Math.min(pct, maxBar) / maxBar;
    ctx2.fillStyle = tissueBarColor(pct);
    ctx2.globalAlpha = isLeading ? 1.0 : (compact ? 0.68 : 0.78);
    ctx2.fillRect(barAreaX, barY, fillW, barH);
    ctx2.globalAlpha = 1;

    // 100 % reference dashed tick
    ctx2.strokeStyle = '#aaa';
    ctx2.lineWidth = 0.5;
    ctx2.setLineDash([2, 2]);
    ctx2.beginPath();
    ctx2.moveTo(ref100X, barY);
    ctx2.lineTo(ref100X, barY + barH);
    ctx2.stroke();
    ctx2.setLineDash([]);

    // Label: compartment number (1-indexed) left-aligned, half-time right-aligned.
    const htStr = halfTime < 10  ? halfTime.toFixed(1)
                : halfTime < 100 ? String(Math.round(halfTime))
                :                  String(Math.round(halfTime));
    const numStr = String(i + 1);

    ctx2.font = `${fs}px ${ff}`;
    ctx2.textBaseline = 'middle';
    ctx2.fillStyle = '#888';

    // Compartment number — left edge of the label column
    ctx2.textAlign = 'left';
    ctx2.fillText(numStr, x + pad, midY);

    // Half-time — right edge of the label column
    ctx2.fillStyle = '#444';
    ctx2.textAlign = 'right';
    ctx2.fillText(`${htStr}′`, x + pad + labelW - 2, midY);

    // Pct label + leading marker
    ctx2.textAlign = 'left';
    const pctStr = `${Math.round(pct)} %`;
    ctx2.font = isLeading
      ? `bold ${fs}px ${ff}`
      : `${fs}px ${ff}`;
    ctx2.fillStyle = isLeading ? tissueBarColor(pct) : '#555';
    ctx2.fillText(pctStr, barAreaX + barAreaW + 3, midY);

    // "◀" leading marker in full view
    if (!compact && isLeading){
      const shift = ctx2.measureText(pctStr).width + 4;
      ctx2.font = `${fs}px system-ui,sans-serif`;
      ctx2.fillText('◀', barAreaX + barAreaW + 3 + shift, midY);
    }
  }

  ctx2.restore();
}

/* ----------------------------------------------------------------
   Tissues tab rendering
---------------------------------------------------------------- */
/**
 * Redraws the Tissues tab canvas at the time selected by the slider.
 * Safe to call when the tab is hidden — getBoundingClientRect() returns
 * zeros while hidden, and we bail early to avoid a zero-size canvas.
 *
 * @param {object[]|null} plan
 */
function renderTissueTab(plan){
  const cvT    = document.getElementById('cvTissues');
  const slider = document.getElementById('tissueTimeSlider');
  const lbl    = document.getElementById('tissueTimeLbl');
  if (!cvT || !cvT.getContext) return;

  // ── Compute slice bounds for the selected dive ────────────────────────────
  // When selectedDive > 0 the slider must span only that dive's duration, and
  // the stateAtRuntime call must use absolute plan time (dive 1 + SI + offset).
  let absT0    = 0;   // absolute start time (plan time) of the displayed dive
  let sliceMax = plan && plan.length ? Number(plan[plan.length - 1]?.runtime || 0) : 0;

  if (plan && plan.length && selectedDive > 0){
    const absSlices = [];
    let _cur = [];
    for (const s of plan){
      if (s.kind === "surface"){ absSlices.push(_cur); _cur = []; } else _cur.push(s);
    }
    absSlices.push(_cur);
    const absSlice = absSlices[selectedDive] || [];
    if (absSlice.length){
      absT0    = absSlice[0].runtime - (absSlice[0].duration || 0);
      sliceMax = absSlice[absSlice.length - 1].runtime - absT0;
    }
  }

  // Update slider range and restore saved position for this dive.
  if (slider){
    slider.max = String(sliceMax);
    const saved = _tissueSliderPos[Math.max(0, selectedDive)];
    if (saved == null){
      // No saved position → default to end of dive.
      slider.value = String(sliceMax);
    } else {
      // Restore saved position, clamped to new range.
      slider.value = String(Math.min(saved, sliceMax));
    }
  }

  const r = cvT.getBoundingClientRect();
  if (r.width < 1) return; // tab is hidden — skip; will re-render on tab switch

  // Resize backing store to match CSS size × device pixel ratio.
  const dpr = window.devicePixelRatio || 1;
  const cw  = Math.max(100, Math.floor(r.width));
  const ch  = Math.max(100, Math.floor(r.height));
  cvT.width  = Math.floor(cw * dpr);
  cvT.height = Math.floor(ch * dpr);
  const c2 = cvT.getContext('2d');
  c2.setTransform(dpr, 0, 0, dpr, 0, 0);
  c2.clearRect(0, 0, cw, ch);

  if (!plan || !plan.length){
    if (lbl) lbl.textContent = '— min';
    return;
  }

  // tSel is relative to the start of the displayed dive; convert to absolute.
  const tSel = slider ? Math.min(Number(slider.value), sliceMax) : sliceMax;
  if (lbl) lbl.textContent = `${tSel.toFixed(1)} min`;
  const absT = tSel + absT0;

  try {
    const ascentRate = Number(document.getElementById('ascentRate')?.value) || 9;
    const gases = uiGases();
    const { state } = stateAtRuntime(plan, gases, { ascentRate }, absT);
    const data = tissueHeatmapData(state);
    drawTissueHeatmap(c2, data, 0, 0, cw, ch, { compact: false });
  } catch(_){ /* silent — plan may be stale during a replan */ }
}

/**
 * Main update function. Reads UI, builds a plan, and redraws everything.
 *
 * On success the new plan is saved as `lastValidPlan`.
 * On freeze (null return from buildPlan) the display reverts to the last
 * valid plan and the offending inputs are clamped to their last valid values.
 *
 * Prefer calling {@link scheduleRun} from input event handlers to batch
 * rapid successive changes into a single frame.
 */
/* ----------------------------------------------------------------
   Gas tab — consumption summary + tank planner
---------------------------------------------------------------- */

const COMMON_TANKS = [
  // { name, vol (water litres), max (bar), cat, cyl }
  // cyl = number of individual cylinders this entry represents.
  // The planner caps total cylinders on the diver at 4.
  { name:"Alu 3L/200",       vol:3,    max:200, cat:"alu",     cyl:1 },
  { name:"Alu 5L/200",       vol:5,    max:200, cat:"alu",     cyl:1 },
  { name:"AL40 (5.7L/207)",  vol:5.7,  max:207, cat:"alu",     cyl:1 },
  { name:"Alu 7L/200",       vol:7,    max:200, cat:"alu",     cyl:1 },
  { name:"AL63 (9L/207)",    vol:9,    max:207, cat:"alu",     cyl:1 },
  { name:"AL80 (11.1L/207)", vol:11.1, max:207, cat:"alu",     cyl:1 },
  { name:"Steel 7L/200",     vol:7,    max:200, cat:"steel",   cyl:1 },
  { name:"Steel 7L/232",     vol:7,    max:232, cat:"steel",   cyl:1 },
  { name:"Steel 10L/200",    vol:10,   max:200, cat:"steel",   cyl:1 },
  { name:"Steel 10L/232",    vol:10,   max:232, cat:"steel",   cyl:1 },
  { name:"Steel 10L/300",    vol:10,   max:300, cat:"steel",   cyl:1 },
  { name:"Steel 12L/200",    vol:12,   max:200, cat:"steel",   cyl:1 },
  { name:"Steel 12L/232",    vol:12,   max:232, cat:"steel",   cyl:1 },
  { name:"Steel 12L/300",    vol:12,   max:300, cat:"steel",   cyl:1 },
  { name:"Steel 15L/200",    vol:15,   max:200, cat:"steel",   cyl:1 },
  { name:"Steel 15L/232",    vol:15,   max:232, cat:"steel",   cyl:1 },
  { name:"Steel 18L/232",    vol:18,   max:232, cat:"steel",   cyl:1 },
  { name:"2×7L/300",         vol:14,   max:300, cat:"twinset", cyl:2 },
  { name:"2×8L/232",         vol:16,   max:232, cat:"twinset", cyl:2 },
  { name:"2×8.5L/232",       vol:17,   max:232, cat:"twinset", cyl:2 },
  { name:"2×AL80 (22.2L/207)", vol:22.2, max:207, cat:"twinset", cyl:2 },
  { name:"2×10L/232",        vol:20,   max:232, cat:"twinset", cyl:2 },
  { name:"2×12L/232",        vol:24,   max:232, cat:"twinset", cyl:2 },
  { name:"2×15L/232",        vol:30,   max:232, cat:"twinset", cyl:2 },
];

let _gasTabReserve         = 50;      // bar — persists across renders
let _gasTabRule            = "none";  // "none" | "thirds" | "fourths" | "bailout" | "custom"
let _lastContingencyRule   = null;    // rule active when contingency dropdown was last built

/**
 * User-selected tank choices for export.
 * Key  : "${gasLabel}|${tankName}|${qty}"  — stable across re-renders
 * Value: { gas, tank, qty, vol (gas L at max), tankWaterVol (water L per tank), fillBar, reserve }   — always refreshed on renderGasTab
 */
let _selectedTanks = new Map();
let _stageSlots    = new Map();       // rowKey → slot count (default 1) for stage gas rows
let _gasTabCustomPct       = 33;      // percent — used when rule === "custom"
let _gasTabDecoRule        = "same";  // deco-gas planning rule: "same"|"none"|"double"|"custom"
let _gasTabDecoCustomPct   = 50;      // percent — used when deco rule === "custom"

/* Planning rules: multiplier applied to round-trip consumption.
   Rule of thirds : 1/3 in · 1/3 out · 1/3 reserve → use 2/3 → ×1.5
   Rule of fourths: 1/4 in · 1/4 out · 2/4 reserve → use 1/2 → ×2
   Custom %       : use X% of tank                  → ×(100/X)        */
const GAS_RULES = {
  none:    { label: "None",                           mult: 1,    hint: "" },
  thirds:  { label: "Rule of thirds (cave/overhead)", mult: 3/2,  hint: "⅓ in · ⅓ out · ⅓ reserve. Bring 1.5× consumption." },
  fourths: { label: "Rule of fourths (technical)",   mult: 2,    hint: "¼ in · ¼ out · ½ reserve. Bring 2× consumption." },
  bailout: { label: "Bailout (independent reserve)",  mult: 2,    hint: "Two independent sources: dive-plan cylinders + emergency-ascent cylinders sized separately." },
  custom:  { label: "Custom fraction",               mult: null, hint: "" },
};

/**
 * Returns Gas[] available for CCR emergency ascent planning.
 *
 * On loop failure the diver's primary OC source is the diluent cylinder, so
 * the diluent is always prepended (role "auto", no switch depth) — it covers
 * the initial ascent from whatever depth the failure occurs.  Explicit
 * bailout/deco gases follow, and chooseBestGas will switch up to the richest
 * breathable mix as the diver ascends.
 *
 * Without the diluent, a dive deeper than EAN40's MOD (30 m) would have no
 * breathable OC gas available for the ascent.
 */
function getCCRBailoutGases(){
  const _bottomP = Number($("ppO2Bottom")?.value) || 1.4;
  const _decoP   = Number($("ppO2Deco")?.value)   || 1.6;

  const rows = [...gasRows.querySelectorAll("tr[data-gas-row]")].map(readGasRow);

  // The diluent is part of the CCR loop and is unavailable after loop failure.
  // Only explicit bailout / deco cylinders are OC-breathable in an emergency.
  return rows
    .filter(g => g.enabled && (g.role === "bailout" || g.role === "deco"))
    .map(g => new Gas({
      label: g.label || `${Math.round(g.o2)}/${Math.round(g.he)}`,
      o2: g.o2 / 100, he: g.he / 100,
      role: g.role === "deco" ? "deco" : "auto",
      enabled: true,
      ppo2Min: globalPpO2Min(), ppo2MaxBottom: _bottomP, ppo2MaxDeco: _decoP,
      switchDepthM: g.switchDepthM,
    }));
}

/**
 * Remaps a plan's segments to appropriate OC bailout gases (for CCR emergency ascent).
 * Each segment is assigned the best breathable bailout/deco gas at its midpoint depth.
 * @param {object[]} plan
 * @param {Gas[]}    bailoutGases  from getCCRBailoutGases()
 * @param {number}   [startDepth=0]
 * @returns {object[]} plan with each segment's .gas replaced by the appropriate bailout gas
 */
function buildCCRBailoutPlan(plan, bailoutGases, startDepth = 0){
  if (!bailoutGases || !bailoutGases.length) return [];
  const result = [];
  let curGas = null;
  let dPrev  = startDepth;
  for (const s of plan){
    if (s.kind === "switch") continue;
    const dEnd = Number(s.depth ?? dPrev);
    const dMid = (s.kind === "ascent" || s.kind === "descent")
      ? (dPrev + dEnd) * 0.5 : dEnd;
    const phase = s.kind === "stop" ? "stop"
                : s.kind === "ascent" ? "ascent"
                : s.kind === "descent" ? "descent" : "bottom";
    const gas = chooseBestGas(bailoutGases, dMid, phase, curGas) || bailoutGases[0];
    if (gas && gas !== curGas) {
      // Emit a switch event at the start of this segment when the gas changes.
      // Skip the very first assignment (curGas===null) — the initial gas is
      // implicit; emitting a switch at t=0 would create a spurious lane entry.
      if (curGas !== null){
        const swRuntime = Math.max(0, Number(s.runtime || 0) - Number(s.duration || 0));
        result.push({ kind: "switch", depth: dPrev, duration: 0, runtime: swRuntime, gas });
      }
      curGas = gas;
    }
    if (curGas) result.push({ ...s, gas: curGas });
    dPrev = dEnd;
  }
  return result;
}

function renderGasTab(plan){
  const el = document.getElementById("gasTabContent");
  if (!el) return;

  const sacEl = document.getElementById("sacLpm");
  const sac = Math.max(0, Number(sacEl?.value) || 0);

  if (!plan || !plan.length || sac <= 0){
    el.innerHTML = `<p style="color:var(--muted);padding:20px 0 0;">
      Enter a dive plan and set a SAC rate to compute gas requirements.</p>`;
    return;
  }

  // ── Consumption maps ────────────────────────────────────────────────────
  const contingEnabled = document.getElementById("lostGasEnable")?.checked;

  // In CCR mode: compute bailout gas consumption by remapping plan segments to
  // the appropriate bailout/deco gas (OC emergency ascent simulation).
  // In OC mode: standard per-gas consumption via consumptionSums.
  let normalSums, combinedSums = null;
  let emergencySums = null;  // emergency-ascent-only consumption (for bailout rule split)
  let _bailoutGasObjs = null;   // set in CCR branch, checked for "no bailout gases" message

  if (ccrCtx) {
    _bailoutGasObjs = getCCRBailoutGases();

    normalSums = consumptionSums(buildCCRBailoutPlan(plan, _bailoutGasObjs, 0), sac, 0);

    if (contingEnabled && lastContingencyPlan && lastContingencyPlan.length) {
      const tW = lastContingencyWorst.t;
      const dW = lastContingencyWorst.d;
      const preLoss  = buildCCRBailoutPlan(plan.filter(s => Number(s.runtime || 0) <= tW + 1e-6), _bailoutGasObjs, 0);
      const contBail = buildCCRBailoutPlan(lastContingencyPlan, _bailoutGasObjs, dW);
      const preLossSums = consumptionSums(preLoss, sac, 0);
      const cSums = consumptionSums(contBail, sac, dW);
      emergencySums = cSums;
      combinedSums = new Map(preLossSums);
      for (const [lbl, L] of cSums) combinedSums.set(lbl, (combinedSums.get(lbl) || 0) + L);
    }
  } else {
    // OC mode: standard consumption
    normalSums = consumptionSums(plan, sac, 0);
    if (contingEnabled && lastContingencyPlan && lastContingencyPlan.length) {
      const tW = lastContingencyWorst.t;
      const dW = lastContingencyWorst.d;
      const preLossSums = consumptionSums(
        plan.filter(s => Number(s.runtime || 0) <= tW + 1e-6), sac, 0
      );
      const cSums = consumptionSums(lastContingencyPlan, sac, dW);
      emergencySums = cSums;
      combinedSums = new Map(preLossSums);
      for (const [lbl, L] of cSums) combinedSums.set(lbl, (combinedSums.get(lbl) || 0) + L);
    }
  }

  // Bailout rule always splits into independent primary + emergency cylinder sections.
  // When a contingency plan exists the emergency section is sized for the actual
  // ascent; otherwise it falls back to the same consumption as the dive plan
  // (conservative: carry an equal independent source).
  const _bailoutSplit = _gasTabRule === "bailout";

  const allLabels = [...new Set([...normalSums.keys(), ...(combinedSums?.keys() ?? [])])].sort();

  // Per-row entries for the tank planner — one section per gas row, so that
  // "Air (auto)" and "Air (bailout)" / "(stage)" each get their own cylinder
  // selector.  Uses rawRole (pre-uiGases normalisation) for display and keying.
  // Always read from the live DOM for the currently-selected dive so that role
  // changes in the dropdown are reflected instantly (diveGasData is only flushed
  // on plan-build or dive-switch, so it may lag behind UI edits).
  const _dIdxGT = Math.max(0, selectedDive);
  const _tankPlannerRows = rawGasRowsFromDOM()
    .map((g, rawIdx) => ({
      rawIdx,
      label:   fmtGasLabel(g.label || `${Math.round(g.o2 || 0)}/${Math.round(g.he || 0)}`),
      rawRole: g.role || "auto",
      enabled: !!g.enabled && g.role !== "disabled",
    }))
    .filter(e => e.enabled)
    .map(e => ({
      ...e,
      rowKey:       `${e.label}|${e.rawRole}|${e.rawIdx}`,
      displayLabel: (e.rawRole && e.rawRole !== "auto")
        ? `${e.label} (${e.rawRole})`
        : e.label,
    }));

  // ── Planning rule ────────────────────────────────────────────────────────
  // CCR mode only allows "bailout" and "custom" — bailout is a one-way exit ascent,
  // not a round-trip, so thirds/fourths don't apply; "none" has no safety margin.
  const CCR_VALID_RULES = new Set(["bailout", "custom"]);
  if (ccrCtx && !CCR_VALID_RULES.has(_gasTabRule)) _gasTabRule = "bailout";

  const customMult = 100 / Math.min(99, Math.max(1, _gasTabCustomPct));
  const rule    = GAS_RULES[_gasTabRule] ?? GAS_RULES.none;
  // When the bailout rule has independent sections, each section is sized exactly
  // (no extra multiplier — the independence *is* the safety margin).
  // CCR bailout is a one-way emergency ascent — the plan volume is exact.
  const mult    = _gasTabRule === "custom" ? customMult
                : (_bailoutSplit || (ccrCtx && _gasTabRule === "bailout")) ? 1
                : (rule.mult ?? 1);
  const hasRule = mult > 1 + 1e-6;

  // ── Deco-gas planning rule (OC only, not applicable to CCR bailout) ──────
  const decoCustomMult = 100 / Math.min(99, Math.max(1, _gasTabDecoCustomPct));
  const decoMult = ccrCtx ? mult                       // CCR: deco always follows bailout rule
    : _gasTabDecoRule === "same"   ? mult
    : _gasTabDecoRule === "double" ? 2
    : _gasTabDecoRule === "custom" ? decoCustomMult
    : 1;  // "none"
  const hasDecoRule = decoMult > 1 + 1e-6;
  const rulesDiffer = !ccrCtx && _gasTabDecoRule !== "same" && Math.abs(decoMult - mult) > 1e-6;

  // Helper: choose the right multiplier for a gas row by role.
  const _multFor = (rawRole) => rawRole === "deco" ? decoMult : mult;
  const _hasRuleFor = (rawRole) => _multFor(rawRole) > 1 + 1e-6;

  // For the consumption table (keyed by label, no role info): mark labels that
  // appear ONLY as deco rows so we apply decoMult there.
  const _decoOnlyLabels = new Set(
    _tankPlannerRows
      .filter(e => e.rawRole === "deco")
      .map(e => e.label)
      .filter(lbl => _tankPlannerRows.every(e => e.label !== lbl || e.rawRole === "deco"))
  );
  const _multForLabel  = (lbl) => _decoOnlyLabels.has(lbl) ? decoMult : mult;
  const _hasRuleForLbl = (lbl) => _multForLabel(lbl) > 1 + 1e-6;

  // ── HTML ─────────────────────────────────────────────────────────────────
  let h = "";

  // ── Section 0: planning rules selector ──
  h += `<div class="gasTabSection">Planning rules</div>`;

  // Bottom / travel gas rule row
  h += `<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:6px;font-size:12px;">`;
  if (!ccrCtx) h += `<span style="color:var(--muted);min-width:44px;">Bottom</span>`;
  h += `<select id="gasTabRule" style="font-size:12px;">`;
  for (const [key, r] of Object.entries(GAS_RULES)){
    if (ccrCtx && !CCR_VALID_RULES.has(key)) continue;
    h += `<option value="${key}"${_gasTabRule === key ? " selected" : ""}>${r.label}</option>`;
  }
  h += `</select>`;
  h += `<span id="gasTabCustomWrap" style="${_gasTabRule === "custom" ? "display:flex" : "display:none"};gap:6px;align-items:center;">`;
  h += `Use <input id="gasTabCustomPct" type="number" min="1" max="99" step="1" value="${_gasTabCustomPct}" style="width:52px;"> % of cylinder`;
  h += `</span>`;
  const hintText = _gasTabRule === "custom"
    ? `Using ${_gasTabCustomPct}% of cylinder → bring ${customMult.toFixed(2)}× consumption.`
    : (ccrCtx && _gasTabRule === "bailout")
      ? "One-way emergency ascent from bottom including all deco stops. Size cylinder to exact consumption."
    : (_bailoutSplit && emergencySums)
      ? "Independent sources: dive-plan cylinders + emergency-ascent cylinders sized from the contingency plan."
    : (_bailoutSplit)
      ? "Independent sources: both sections sized for the full dive. Enable Lost-gas contingency to optimise the emergency cylinder."
    : rule.hint;
  if (hintText)
    h += `<span style="color:var(--muted);font-style:italic;">${escapeHtml(hintText)}</span>`;
  h += `</div>`;

  // Deco gas rule row (OC only)
  if (!ccrCtx){
    h += `<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px;font-size:12px;">`;
    h += `<span style="color:var(--muted);min-width:44px;">Deco</span>`;
    h += `<select id="gasTabDecoRule" style="font-size:12px;">`;
    const _decoOpts = [
      { v:"same",   l:"Same as bottom" },
      { v:"none",   l:"None (1×)" },
      { v:"double", l:"Double (2×)" },
      { v:"custom", l:"Custom fraction" },
    ];
    for (const o of _decoOpts)
      h += `<option value="${o.v}"${_gasTabDecoRule === o.v ? " selected" : ""}>${o.l}</option>`;
    h += `</select>`;
    h += `<span id="gasTabDecoCustomWrap" style="${_gasTabDecoRule === "custom" ? "display:flex" : "display:none"};gap:6px;align-items:center;">`;
    h += `Use <input id="gasTabDecoCustomPct" type="number" min="1" max="99" step="1" value="${_gasTabDecoCustomPct}" style="width:52px;"> % of cylinder`;
    h += `</span>`;
    if (_gasTabDecoRule === "custom")
      h += `<span style="color:var(--muted);font-style:italic;">Using ${_gasTabDecoCustomPct}% → bring ${decoCustomMult.toFixed(2)}× deco consumption.</span>`;
    else if (rulesDiffer)
      h += `<span style="color:var(--muted);font-style:italic;">Deco gases sized at ${decoMult.toFixed(2)}× consumption.</span>`;
    h += `</div>`;
  }

  // ── Section 1: consumption summary ──
  const _isCCR = !!ccrCtx;
  h += `<div class="gasTabSection">${_isCCR ? "Bailout consumption (OC emergency ascent)" : "Consumption"}</div>`;

  // CCR with no bailout gases defined → show a hint and skip both tables
  const _noBailout = _isCCR && _bailoutGasObjs && !_bailoutGasObjs.length;
  if (_noBailout) {
    h += `<p style="font-size:12px;color:var(--muted);margin:0 0 14px;">
      Add gases with role <em>bailout</em> or <em>deco</em> to compute emergency OC consumption.</p>`;
  } else {
  h += `<table class="gasTabTable"><thead><tr>`;
  h += `<th>Gas</th><th class="r">Dive plan</th>`;
  if (combinedSums) h += `<th class="r">+ Contingency</th>`;
  if (hasRule || hasDecoRule) h += `<th class="r">Planning vol</th>`;
  h += `</tr></thead><tbody>`;

  // Determine which column drives the planning volume.
  // Each gas is sized for the LARGER of the normal dive and the contingency scenario:
  //  - Lost gas:     normal > pre-loss only  → sized for the full dive (not shrunk by contingency)
  //  - Deco/other:   contingency ≥ normal    → sized for the contingency ascent
  const planningMap = (() => {
    if (!combinedSums) return normalSums;
    const m = new Map(normalSums);
    for (const [lbl, L] of combinedSums) {
      if (L > (m.get(lbl) || 0)) m.set(lbl, L);
    }
    return m;
  })();

  // Per volume, keep only the highest-pressure variant — lower-pressure tanks of the
  // same size are strictly dominated and add visual noise.
  // Sort by category first (so category headers are never repeated), then by volume.
  const CAT_ORDER = { alu: 0, steel: 1, twinset: 2 };
  const dedupedTanks = (() => {
    const best = new Map();
    for (const t of COMMON_TANKS){
      if (t.cat === "twinset") continue;  // twinsets not shown in planner list
      // Deduplicate per (cat, vol) pair so an alu 7L and a steel 7L both survive.
      const key = `${t.cat}|${t.vol}`;
      const prev = best.get(key);
      if (!prev || t.max > prev.max) best.set(key, t);
    }
    return [...best.values()].sort((a, b) => {
      const co = (CAT_ORDER[a.cat] ?? 9) - (CAT_ORDER[b.cat] ?? 9);
      return co !== 0 ? co : a.vol - b.vol;
    });
  })();

  let normalTotal = 0, combTotal = 0, planTotal = 0;
  for (const lbl of allLabels){
    const n = normalSums.get(lbl) || 0;
    const c = combinedSums ? (combinedSums.get(lbl) || 0) : null;
    const p = (planningMap.get(lbl) || 0) * _multForLabel(lbl);
    normalTotal += n;
    if (c !== null) combTotal += c;
    planTotal   += p;

    // Bar only shown when the user has explicitly selected a tank for this gas
    const _selTank  = [..._selectedTanks.values()].find(e => e.gas === lbl);
    const _tankWater = _selTank && _selTank.tankWaterVol > 0 ? _selTank.tankWaterVol * _selTank.qty : 0;
    const _bar  = (l) => _tankWater > 0 ? `<br><span style="font-size:0.8em;opacity:0.6;">${Math.ceil(l / _tankWater)} bar</span>` : '';

    h += `<tr><td>${escapeHtml(lbl)}</td>`;
    h += `<td class="mono r">${Math.round(n)} L${_bar(n)}</td>`;
    if (combinedSums)          h += `<td class="mono r">${c !== null ? Math.round(c) + ' L' + _bar(c) : "—"}</td>`;
    if (hasRule || hasDecoRule) h += `<td class="mono r"><strong>${Math.round(p)} L</strong>${_bar(p)}</td>`;
    h += `</tr>`;
  }
  h += `<tr class="gasTabTotalRow"><td><strong>Total</strong></td>`;
  h += `<td class="mono r"><strong>${Math.round(normalTotal)} L</strong></td>`;
  if (combinedSums)          h += `<td class="mono r"><strong>${Math.round(combTotal)} L</strong></td>`;
  if (hasRule || hasDecoRule) h += `<td class="mono r"><strong>${Math.round(planTotal)} L</strong></td>`;
  h += `</tr></tbody></table>`;

  // ── Section 2: cylinder planner ──
  h += `<div class="gasTabSection" style="margin-top:18px;">Cylinder planner</div>`;
  h += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:12px;color:var(--muted);">`;
  h += `Reserve <input id="gasTabReserve" type="number" min="0" max="200" step="5"
          value="${_gasTabReserve}" style="width:60px;"> bar`;
  if (hasRule && !rulesDiffer) h += `&nbsp;·&nbsp;<em>Fill pressure includes ×${mult.toFixed(2)} planning factor + reserve.</em>`;
  else if (hasRule || hasDecoRule) h += `&nbsp;·&nbsp;<em>Fill pressure includes planning factor + reserve (bottom ×${mult.toFixed(2)}, deco ×${decoMult.toFixed(2)}).</em>`;
  h += `</div>`;

  const reserve = _gasTabReserve;

  // Fill bar / format helpers (shared across all row sections)
  const barFor = (litres, tank, qty = 1) => litres / (tank.vol * qty) + reserve;
  const fmtBar = (bar, tank) => {
    const b = Math.ceil(bar);
    if (b > tank.max) return `<span style="color:#bbb;">—</span>`;
    const col = b <= tank.max * 0.80 ? "#2e7d32"
              : b <= tank.max * 0.95 ? "#e65100"
              : "#c62828";
    return `<span style="color:${col};">${b} bar</span>`;
  };

  // ── Collapsible cylinder picker ─────────────────────────────────────────────
  // Collapsed: shows the selected row (or placeholder) in a table row.
  // Expanded:  shows the full gasTabTable with headers and all options.
  //
  // rows: [{ t, qty, fillBar, fillN?, fillC?, fillP?, neededP? }]
  function _cylCollapse({ sectionRowKey, rows, hasRule: _hr, hasCont: _hc }){
    const curKey   = [..._selectedTanks.keys()].find(k => _selectedTanks.get(k).rowKey === sectionRowKey);
    const selEntry = curKey ? _selectedTanks.get(curKey) : null;
    const selRow   = curKey ? rows.find(r => `${sectionRowKey}|${r.t.name}|${r.qty}` === curKey) : null;

    // Keep fill bar in _selectedTanks current (only when qty hasn't been overridden).
    if (curKey && selRow){
      const cur = _selectedTanks.get(curKey);
      if (cur) _selectedTanks.set(curKey, { ...cur, fillBar: selRow.fillBar });
    }

    const gas = sectionRowKey.split("|")[0];

    // Format fill bar — always show value even if over tank max (red).
    const fmtBarAny = (bar, tank) => {
      const b = Math.ceil(bar);
      if (b > tank.max){
        return `<span style="color:#c62828;" title="Exceeds rated max (${tank.max} bar)">${b} bar ⚠</span>`;
      }
      return fmtBar(bar, tank);
    };

    // Build one <tr> for a list row.
    function _tr(row, sk, isSel){
      const totalL = Math.round(row.t.vol * row.t.max * row.qty);
      const attrs  = `class="cylCRow${isSel ? " cylCRow--sel" : ""}" ` +
        `data-selkey="${escapeHtml(sk)}" data-rowkey="${escapeHtml(sectionRowKey)}" ` +
        `data-gas="${escapeHtml(gas)}" data-tank="${escapeHtml(row.t.name)}" ` +
        `data-qty="${row.qty}" data-vol="${row.t.vol * row.t.max * row.qty}" ` +
        `data-water-vol="${row.t.vol}" data-tank-max="${row.t.max}" ` +
        `data-fill="${row.fillBar}" data-reserve="${_gasTabReserve}" ` +
        `data-needed-p="${row.neededP ?? 0}"`;
      let r = `<tr ${attrs}>`;
      r += `<td>${escapeHtml(row.t.name)}</td>`;
      r += `<td class="mono r">${row.qty}</td>`;
      r += `<td class="mono r">${totalL} L</td>`;
      r += `<td class="mono r">${fmtBar(row.fillBar, row.t)}</td>`;
      if (_hc && row.fillC != null) r += `<td class="mono r">${fmtBar(row.fillC, row.t)}</td>`;
      if (_hr && row.fillP != null) r += `<td class="mono r"><strong>${fmtBar(row.fillP, row.t)}</strong></td>`;
      r += `</tr>`;
      return r;
    }

    // Build the head row for a selected cylinder — qty is an editable input.
    function _trHead(entry, sk, tankObj){
      const qty    = entry.qty;
      const wVol   = entry.tankWaterVol;
      const tMax   = entry.tankMax || tankObj?.max || 0;
      const totalL = Math.round(wVol * tMax * qty);
      const fill   = entry.fillBar;
      let r = `<tr class="cylCRow cylCRow--sel">`;
      r += `<td>${escapeHtml(entry.tank)}</td>`;
      r += `<td class="mono r">` +
        `<input type="number" class="cylQtyOverride" ` +
        `data-selkey="${escapeHtml(sk)}" data-rowkey="${escapeHtml(sectionRowKey)}" ` +
        `data-tank="${escapeHtml(entry.tank)}" data-water-vol="${wVol}" ` +
        `data-tank-max="${tMax}" data-needed-p="${entry.neededP ?? 0}" ` +
        `data-reserve="${entry.reserve ?? _gasTabReserve}" ` +
        `min="1" max="20" step="1" value="${qty}" ` +
        `style="width:44px;font-size:12px;text-align:right;border:1px solid var(--line);` +
        `border-radius:3px;padding:1px 2px;background:var(--surface);font-family:inherit;" ` +
        `onclick="event.stopPropagation()" onmousedown="event.stopPropagation()">` +
        `</td>`;
      r += `<td class="mono r">${totalL} L</td>`;
      r += `<td class="mono r">${fmtBarAny(fill, { max: tMax })}</td>`;
      if (_hc) r += `<td></td>`;
      if (_hr) r += `<td></td>`;
      r += `</tr>`;
      return r;
    }

    let h = `<div class="cylCollapse" data-sectionrowkey="${escapeHtml(sectionRowKey)}">`;

    // ── Head: always visible, acts as toggle ──
    h += `<div class="cylCollapseHead">`;
    h += `<table class="gasTabTable" style="margin:0;"><tbody>`;
    if (selEntry){
      const tankObj = COMMON_TANKS.find(t => t.name === selEntry.tank) || null;
      h += _trHead(selEntry, curKey, tankObj);
    } else {
      // Placeholder spanning all columns
      const colSpan = 4 + (_hc ? 1 : 0) + (_hr ? 1 : 0);
      h += `<tr class="cylCRow"><td colspan="${colSpan}" style="color:var(--muted);font-style:italic;">— select cylinder —</td></tr>`;
    }
    h += `</tbody></table>`;
    h += `<span class="cylCollapseArrow">▾</span>`;
    h += `</div>`;

    // ── List: full table with headers, hidden by default ──
    const colSpanAll = 4 + (_hc ? 1 : 0) + (_hr ? 1 : 0);
    h += `<div class="cylCollapseList" hidden>`;
    h += `<table class="gasTabTable" style="margin:0;"><thead><tr>`;
    h += `<th>Cylinder</th><th class="r">Qty</th><th class="r">Total vol</th><th class="r">Fill</th>`;
    if (_hc) h += `<th class="r">+Contingency</th>`;
    if (_hr) h += `<th class="r">+Rule</th>`;
    h += `</tr></thead><tbody>`;
    const CAT_LABEL = { alu: "Aluminium", steel: "Steel", twinset: "Twinsets" };
    let lastCat = null;
    for (const row of rows){
      const cat = row.t.cat || "steel";
      if (cat !== lastCat){
        h += `<tr class="cylCatSep${lastCat ? " cylCatSep--notfirst" : ""}">` +
          `<td colspan="${colSpanAll}">${CAT_LABEL[cat] || cat}</td></tr>`;
        lastCat = cat;
      }
      const sk = `${sectionRowKey}|${row.t.name}|${row.qty}`;
      h += _tr(row, sk, sk === curKey);
    }
    h += `</tbody></table></div></div>`;
    return h;
  }

  // ── Multi-select collapsible for stage gas rows ─────────────────────────────
  // Identical look to _cylCollapse but checkboxes allow any number of cylinders
  // to be selected simultaneously (one per tank-type/qty combination).
  function _cylMultiSelect({ sectionRowKey, rows, hasRule: _hr, hasCont: _hc }){
    const selKeys = new Set(
      [..._selectedTanks.keys()].filter(k => _selectedTanks.get(k).rowKey === sectionRowKey)
    );

    // Keep fill bars current for already-selected rows.
    for (const sk of selKeys){
      const cur = _selectedTanks.get(sk);
      if (!cur) continue;
      const matchRow = rows.find(r => `${sectionRowKey}|${r.t.name}|${r.qty}` === sk);
      if (matchRow) _selectedTanks.set(sk, { ...cur, fillBar: matchRow.fillBar });
    }

    const gas      = sectionRowKey.split("|")[0];
    const colSpan  = 4 + (_hc ? 1 : 0) + (_hr ? 1 : 0);

    // Head row: selected cylinder (no checkbox — clicking head opens/closes list).
    function _trHead(row){
      const totalL = Math.round(row.t.vol * row.t.max * row.qty);
      let r = `<tr class="cylCRow cylCRow--sel">`;
      r += `<td>${escapeHtml(row.t.name)}</td>`;
      r += `<td class="mono r">${row.qty}</td>`;
      r += `<td class="mono r">${totalL} L</td>`;
      r += `<td class="mono r">${fmtBar(row.fillBar, row.t)}</td>`;
      if (_hc && row.fillC != null) r += `<td class="mono r">${fmtBar(row.fillC, row.t)}</td>`;
      if (_hr && row.fillP != null) r += `<td class="mono r"><strong>${fmtBar(row.fillP, row.t)}</strong></td>`;
      r += `</tr>`;
      return r;
    }

    // List row: checkbox + full data attributes for the click handler.
    function _trList(row, sk, isChecked){
      const totalL = Math.round(row.t.vol * row.t.max * row.qty);
      const attrs  = `class="cylCRow${isChecked ? " cylCRow--sel" : ""}" ` +
        `data-selkey="${escapeHtml(sk)}" data-rowkey="${escapeHtml(sectionRowKey)}" ` +
        `data-gas="${escapeHtml(gas)}" data-tank="${escapeHtml(row.t.name)}" ` +
        `data-qty="${row.qty}" data-vol="${row.t.vol * row.t.max * row.qty}" ` +
        `data-water-vol="${row.t.vol}" data-fill="${row.fillBar}" data-reserve="${_gasTabReserve}"`;
      let r = `<tr ${attrs}>`;
      r += `<td><input type="checkbox" class="cylMultiCb"${isChecked ? " checked" : ""} ` +
           `style="margin-right:6px;pointer-events:none;">${escapeHtml(row.t.name)}</td>`;
      r += `<td class="mono r">${row.qty}</td>`;
      r += `<td class="mono r">${totalL} L</td>`;
      r += `<td class="mono r">${fmtBar(row.fillBar, row.t)}</td>`;
      if (_hc && row.fillC != null) r += `<td class="mono r">${fmtBar(row.fillC, row.t)}</td>`;
      if (_hr && row.fillP != null) r += `<td class="mono r"><strong>${fmtBar(row.fillP, row.t)}</strong></td>`;
      r += `</tr>`;
      return r;
    }

    let h = `<div class="cylCollapse cylMultiSelect" data-sectionrowkey="${escapeHtml(sectionRowKey)}">`;

    // ── Head: shows all currently-selected cylinders (or placeholder) ──
    h += `<div class="cylCollapseHead">`;
    h += `<table class="gasTabTable" style="margin:0;"><tbody>`;
    const selRows = rows.filter(r => selKeys.has(`${sectionRowKey}|${r.t.name}|${r.qty}`));
    if (selRows.length){
      for (const row of selRows) h += _trHead(row);
    } else {
      h += `<tr class="cylCRow"><td colspan="${colSpan}" style="color:var(--muted);font-style:italic;">— select cylinders —</td></tr>`;
    }
    h += `</tbody></table>`;
    h += `<span class="cylCollapseArrow">▾</span>`;
    h += `</div>`;

    // ── List: full table with checkboxes ──
    h += `<div class="cylCollapseList" hidden>`;
    h += `<table class="gasTabTable" style="margin:0;"><thead><tr>`;
    h += `<th>Cylinder</th><th class="r">Qty</th><th class="r">Total vol</th><th class="r">Fill</th>`;
    if (_hc) h += `<th class="r">+Contingency</th>`;
    if (_hr) h += `<th class="r">+Rule</th>`;
    h += `</tr></thead><tbody>`;
    for (const row of rows){
      const sk = `${sectionRowKey}|${row.t.name}|${row.qty}`;
      h += _trList(row, sk, selKeys.has(sk));
    }
    h += `</tbody></table></div></div>`;
    return h;
  }

  // section = { neededL, sectionRowKey, sectionLabel }
  function _renderCylSection({ neededL, sectionRowKey, sectionLabel }){
    if (neededL < 1) return "";
    const _rows = [];
    for (const t of dedupedTanks){
      const maxQty = t.cyl > 1 ? 1 : 8;
      for (let qty = 1; qty <= maxQty; qty++){
        const fillP = barFor(neededL, t, qty);
        if (fillP <= t.max){
          _rows.push({ t, qty, fillBar: Math.ceil(fillP), neededP: neededL });
          break;
        }
      }
    }
    let _h = `<div style="margin-bottom:14px;">`;
    _h += `<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:4px;">${escapeHtml(sectionLabel)}</div>`;
    if (!_rows.length){
      _h += `<p style="font-size:12px;color:#c62828;">No cylinder fits — try reducing the planning volume or adding a larger cylinder type.</p>`;
    } else {
      _h += _cylCollapse({ sectionRowKey, rows: _rows, hasRule: false, hasCont: false });
    }
    _h += `</div>`;
    return _h;
  }

  for (const re of _tankPlannerRows){
    const { label: lbl, rowKey, displayLabel } = re;

    if (_bailoutSplit && re.rawRole !== "deco"){
      // ── Bailout rule: independent dive-plan and emergency-ascent sections ──
      // Deco gases are single-source (switch gases during stops) — no bailout split.
      const diveL  = normalSums.get(lbl) || 0;
      // Emergency sized from contingency plan when available; otherwise same as
      // dive plan (carry an equal independent source — the ×2 conservative approach
      // expressed as two separate cylinders).
      const emergL = (emergencySums ? (emergencySums.get(lbl) || 0) : 0) || diveL;
      if (diveL < 1 && emergL < 1) continue;

      h += `<div style="margin-bottom:24px;">`;
      h += `<div style="font-size:12px;font-weight:700;margin-bottom:8px;">${escapeHtml(displayLabel)}</div>`;
      h += _renderCylSection({ neededL: diveL,  sectionRowKey: `${rowKey}|primary`,   sectionLabel: "Dive plan" });
      h += _renderCylSection({ neededL: emergL, sectionRowKey: `${rowKey}|emergency`, sectionLabel: "Bailout" });
      h += `</div>`;

    } else {
      // ── Standard rules: one section, planning multiplier applied ──
      const neededN = normalSums.get(lbl) || 0;
      const neededC = combinedSums ? (combinedSums.get(lbl) || 0) : null;
      const _rowMult = _multFor(re.rawRole);
      const neededP = (planningMap.get(lbl) || 0) * _rowMult;
      const _rowHasRule = _hasRuleFor(re.rawRole);
      // Stage gases with zero consumption are still shown so the user can
      // select a cylinder to carry. Other zero-consumption gases are skipped.
      const isStageRow = re.rawRole === "stage";
      if (neededP < 1 && !isStageRow) continue;

      h += `<div style="margin-bottom:18px;">`;
      h += `<div style="font-size:12px;font-weight:600;margin-bottom:4px;">${escapeHtml(displayLabel)}</div>`;

      if (isStageRow){
        // ── Stage gas: optional carry-on cylinder, not sized to consumption ──
        // Show every available tank at its rated max fill — the user simply picks
        // what they're carrying; volume / pressure aren't driven by the dive plan.
        const stageRows = dedupedTanks.map(t => ({
          t, qty: 1,
          fillBar: t.max,   // show max fill (full cylinder)
          fillC: null,
          fillP: null,
        }));
        const slotCount = _stageSlots.get(rowKey) || 0;
        for (let si = 0; si < slotCount; si++){
          const slotRK = `${rowKey}|s${si}`;
          h += `<div style="display:flex;align-items:stretch;gap:4px;margin-bottom:6px;">`;
          h += `<div style="flex:1;">`;
          h += _cylCollapse({ sectionRowKey: slotRK, rows: stageRows, hasRule: false, hasCont: false });
          h += `</div>`;
          h += `<button class="stageSlotRemove" data-rowkey="${escapeHtml(rowKey)}" data-slot="${si}" ` +
            `title="Remove this cylinder" ` +
            `style="padding:0 8px;border:1px solid var(--line);border-radius:4px;background:transparent;` +
            `color:var(--muted);cursor:pointer;font-size:16px;line-height:1;flex-shrink:0;">×</button>`;
          h += `</div>`;
        }
        h += `<button class="stageSlotAdd" data-rowkey="${escapeHtml(rowKey)}" ` +
          `style="margin-top:4px;padding:3px 10px;border:1px dashed var(--line);border-radius:4px;` +
          `background:transparent;color:var(--muted);cursor:pointer;font-size:11px;width:100%;">` +
          `+ Add cylinder</button>`;

      } else {
        // ── Normal gas: size to consumption ──
        const effectiveP = neededP > 0 ? neededP : _gasTabReserve;
        const rows = [];
        for (const t of dedupedTanks){
          const maxQty = t.cyl > 1 ? 1 : 8;
          for (let qty = 1; qty <= maxQty; qty++){
            const fillP = barFor(effectiveP, t, qty);
            if (fillP <= t.max){
              rows.push({ t, qty,
                fillN: barFor(Math.max(neededN, _gasTabReserve), t, qty),
                fillC: neededC !== null ? barFor(Math.max(neededC, _gasTabReserve), t, qty) : null,
                fillP,
              });
              break;
            }
          }
        }
        if (!rows.length){
          h += `<p style="font-size:12px;color:#c62828;">No cylinder fits — try reducing the planning volume or adding a larger cylinder type.</p>`;
        } else {
          const colRows = rows.map(({ t, qty, fillN, fillC, fillP }) => ({
            t, qty,
            fillBar: Math.ceil(_rowHasRule ? fillP : fillN),
            fillC:   (combinedSums && !_bailoutSplit && fillC !== null) ? fillC : null,
            fillP:   _rowHasRule ? fillP : null,
            neededP,
          }));
          const _hc = combinedSums && !_bailoutSplit;
          h += _cylCollapse({ sectionRowKey: rowKey, rows: colRows, hasRule: _rowHasRule, hasCont: _hc });
        }
      }
      h += `</div>`;
    }
  }
  } // end: !_noBailout

  // ── Section 3: CCR loop gas consumption ──
  if (ccrCtx){
    const totalRuntime = plan[plan.length - 1]?.runtime ?? 0;  // minutes

    // ── O₂ cylinder ──
    // Pure O₂ is injected continuously to keep ppO₂ at the setpoint.
    // Consumption ≈ metabolic O₂ uptake rate (typically 0.3–1.0 L/min ATPD).
    const o2L = ccrCtx.o2Rate * totalRuntime;

    // ── Diluent cylinder ──
    // Total diluent = initial loop charge + re-inflation on every descent.
    //
    // At the surface the loop is filled from empty:
    //   V_initial = loop_vol × P_surface  (≈ loop_vol litres at 1 bar)
    //
    // On each descent the diver opens the add-valve to stop the loop
    // from collapsing as ambient pressure rises:
    //   ΔV_descent = loop_vol × (P_end − P_start)  per segment
    //
    // On ascent the OPV vents excess loop gas — no diluent is injected.
    // That vented gas was already consumed from the cylinder on the way
    // down, so ascent segments do not add to the cylinder total.
    //
    // Pre- and post-dive loop flushes (typically 2–3 loop volumes for
    // CO₂ and moisture purging) are NOT included here — add them manually.
    let diluentL = ccrCtx.loopVol; // initial surface charge
    let runningDepth = 0;
    for (const seg of plan){
      if (seg.kind === "switch") continue;
      const endDepth = Number(seg.depth ?? 0);
      if (seg.kind === "descent"){
        const pStart = depthToAmbientPressure(runningDepth);
        const pEnd   = depthToAmbientPressure(endDepth);
        diluentL += ccrCtx.loopVol * Math.max(0, pEnd - pStart);
      }
      runningDepth = endDepth;
    }

    h += `<div class="gasTabSection" style="margin-top:18px;">CCR loop consumption</div>`;
    h += `<table class="gasTabTable"><thead><tr>`;
    h += `<th>Cylinder</th><th class="r">Volume</th><th>How it's used</th>`;
    h += `</tr></thead><tbody>`;

    h += `<tr>`;
    h += `<td><strong>O₂</strong></td>`;
    h += `<td class="mono r">${Math.round(o2L)} L</td>`;
    h += `<td style="font-size:11px;color:var(--muted);">Injected at ${ccrCtx.o2Rate} L/min to hold ppO₂ setpoint`;
    h += ` (${totalRuntime.toFixed(0)} min dive)</td>`;
    h += `</tr>`;

    h += `<tr>`;
    h += `<td><strong>Diluent</strong> — ${escapeHtml(ccrCtx.diluent.label)}</td>`;
    h += `<td class="mono r">${Math.round(diluentL)} L</td>`;
    h += `<td style="font-size:11px;color:var(--muted);">Initial charge + add-valve re-inflation on descent`;
    h += ` (loop ${ccrCtx.loopVol} L × depth pressure). Pre/post-dive flushes not included.</td>`;
    h += `</tr>`;

    h += `</tbody></table>`;

    if (!_noBailout) {
      h += `<p style="font-size:11px;color:var(--muted);margin:8px 0 0;">`;
      h += `<strong>Bailout gases</strong> consumption (OC emergency ascent) is shown in the section above.`;
      h += `</p>`;
    }
  }

  el.innerHTML = h;

  // ── Wire tank selection checkboxes ───────────────────────────────────────
  // ── Collapsible cylinder picker: toggle open / select option ───────────────
  el.querySelectorAll(".cylCollapseHead").forEach(head => {
    head.addEventListener("click", () => {
      const list = head.closest(".cylCollapse")?.querySelector(".cylCollapseList");
      if (list) list.hidden = !list.hidden;
    });
  });

  // ── Single-select rows (non-stage gases) ────────────────────────────────────
  el.querySelectorAll(".cylCollapse:not(.cylMultiSelect) .cylCollapseList tr.cylCRow").forEach(row => {
    row.addEventListener("click", () => {
      const rk = row.dataset.rowkey;
      // Deselect any existing choice for this section.
      for (const [k, v] of _selectedTanks){
        if (v.rowKey === rk) _selectedTanks.delete(k);
      }
      const sk = row.dataset.selkey;
      if (sk){
        _selectedTanks.set(sk, {
          gas:          row.dataset.gas,
          rowKey:       rk,
          tank:         row.dataset.tank,
          qty:          Number(row.dataset.qty),
          vol:          Number(row.dataset.vol),
          tankWaterVol: Number(row.dataset.waterVol) || 0,
          tankMax:      Number(row.dataset.tankMax)  || 0,
          fillBar:      Number(row.dataset.fill),
          reserve:      Number(row.dataset.reserve)  || 0,
          neededP:      Number(row.dataset.neededP)  || 0,
        });
      }
      renderGasTab(lastPlan);
      updateGasConsumption(lastPlan);
      updateLostGasContingency(lastPlan);
    });
  });

  // ── Stage slot: add a new cylinder row ──────────────────────────────────────
  el.querySelectorAll(".stageSlotAdd").forEach(btn => {
    btn.addEventListener("click", () => {
      const rk = btn.dataset.rowkey;
      _stageSlots.set(rk, (_stageSlots.get(rk) || 0) + 1);
      renderGasTab(lastPlan);
      updateGasConsumption(lastPlan);
      updateLostGasContingency(lastPlan);
    });
  });

  // ── Qty override: user edits the qty input in the collapsed head ────────────
  el.querySelectorAll(".cylQtyOverride").forEach(inp => {
    inp.addEventListener("change", e => {
      e.stopPropagation();
      const newQty = Math.max(1, Math.min(20, Math.round(Number(inp.value)) || 1));
      inp.value = String(newQty);
      const oldKey  = inp.dataset.selkey;
      const rk      = inp.dataset.rowkey;
      const cur     = _selectedTanks.get(oldKey);
      if (!cur) return;
      // Recalculate fill bar with new qty (using stored neededP).
      const wVol    = Number(inp.dataset.waterVol) || cur.tankWaterVol;
      const tMax    = Number(inp.dataset.tankMax)  || cur.tankMax || 999;
      const neededP = Number(inp.dataset.neededP)  || cur.neededP || 0;
      const res     = Number(inp.dataset.reserve)  || cur.reserve || 0;
      const newFill = neededP > 0 ? neededP / (wVol * newQty) + res : cur.fillBar;
      const newKey  = `${rk}|${cur.tank}|${newQty}`;
      _selectedTanks.delete(oldKey);
      _selectedTanks.set(newKey, { ...cur, qty: newQty, tankMax: tMax, fillBar: newFill });
      renderGasTab(lastPlan);
      updateGasConsumption(lastPlan);
      updateLostGasContingency(lastPlan);
    });
  });

  // ── Stage slot: remove last cylinder row ─────────────────────────────────────
  el.querySelectorAll(".stageSlotRemove").forEach(btn => {
    btn.addEventListener("click", e => {
      e.stopPropagation();
      const rk    = btn.dataset.rowkey;
      const si    = Number(btn.dataset.slot);
      const slotRK = `${rk}|s${si}`;
      // Clear any cylinder selected for this slot.
      for (const [k, v] of _selectedTanks){
        if (v.rowKey === slotRK) _selectedTanks.delete(k);
      }
      // Shift later slots down by one to keep keys contiguous.
      const count = _stageSlots.get(rk) || 0;
      for (let j = si + 1; j < count; j++){
        const fromRK = `${rk}|s${j}`;
        const toRK   = `${rk}|s${j - 1}`;
        for (const [k, v] of _selectedTanks){
          if (v.rowKey === fromRK){
            _selectedTanks.delete(k);
            const newSK = k.replace(fromRK, toRK);
            _selectedTanks.set(newSK, { ...v, rowKey: toRK });
          }
        }
      }
      _stageSlots.set(rk, Math.max(0, count - 1));  // allow going back to 0 (no stage)
      renderGasTab(lastPlan);
      updateGasConsumption(lastPlan);
      updateLostGasContingency(lastPlan);
    });
  });

  // ── Wire persistent inputs ───────────────────────────────────────────────
  const ri = document.getElementById("gasTabReserve");
  if (ri) ri.addEventListener("input", () => {
    _gasTabReserve = Math.max(0, Number(ri.value) || 0);
    renderGasTab(lastPlan);
  });

  const ruleEl = document.getElementById("gasTabRule");
  if (ruleEl) ruleEl.addEventListener("change", () => {
    _gasTabRule = ruleEl.value;
    // Rule change invalidates all cylinder selections (rowKey format changes).
    _selectedTanks.clear();
    renderGasTab(lastPlan);
    updateLostGasContingency(lastPlan);
  });

  const pctEl = document.getElementById("gasTabCustomPct");
  if (pctEl) pctEl.addEventListener("input", () => {
    _gasTabCustomPct = Math.min(99, Math.max(1, Number(pctEl.value) || 33));
    renderGasTab(lastPlan);
  });

  const decoRuleEl = document.getElementById("gasTabDecoRule");
  if (decoRuleEl) decoRuleEl.addEventListener("change", () => {
    _gasTabDecoRule = decoRuleEl.value;
    _selectedTanks.clear();   // cylinder sizes change when rule changes
    renderGasTab(lastPlan);
    updateLostGasContingency(lastPlan);
  });

  const decoPctEl = document.getElementById("gasTabDecoCustomPct");
  if (decoPctEl) decoPctEl.addEventListener("input", () => {
    _gasTabDecoCustomPct = Math.min(99, Math.max(1, Number(decoPctEl.value) || 50));
    renderGasTab(lastPlan);
  });

  // Show/hide custom fraction input when deco rule changes
  const decoCustomWrap = document.getElementById("gasTabDecoCustomWrap");
  if (decoRuleEl && decoCustomWrap){
    decoRuleEl.addEventListener("change", () => {
      decoCustomWrap.style.display = decoRuleEl.value === "custom" ? "flex" : "none";
    });
  }
}

function run(){
  // Snapshot UI state before attempting to build a plan.
  const mode = $("modeSegments").checked ? "segments" : "simple";
  const depthBefore = Number($("depth")?.value) || 0;
  const timeBefore = Number($("time")?.value) || 0;

  syncAllGasRows();
  updateSalinityModel();
  updateAltitude();
  let plan = null;
  try{
    plan = buildPlan();
  } catch (_e){
    plan = null;
  }

  if (plan === null){
    // Freeze to last valid plan and revert inputs to the last valid values.
    if (lastValidPlan){
      lastPlan = lastValidPlan;
      draw(getDiveSlice(lastValidPlan, selectedDive));
      renderPlanTable(lastValidPlan);
      const tox = computeToxInputs(lastValidPlan);
      lastToxInputs = { ...tox, plan: lastValidPlan };
      updateToxPanel(tox.maxDens, tox.dMax, tox.gAtMax, lastValidPlan, tox.maxPpO2);

      if (mode === "simple" && lastValidDepth !== null && $("depth")){
        $("depth").value = String(lastValidDepth);
      }
      if (mode === "simple" && lastValidTime !== null && $("time")){
        $("time").value = String(lastValidTime);
      }
      if (mode === "segments" && Array.isArray(lastValidSegments)){
        segments = JSON.parse(JSON.stringify(lastValidSegments));
        renderSegments();
      }
    }
    setText("meta", "");
    return;
  }

  // Successful plan: record as last valid. Reset per-dive slider positions so
  // each dive defaults to end-of-dive until the user moves the slider manually.
  _tissueSliderPos = [];
  lastPlan = plan;
  updateGasConsumption(lastPlan);
  lastValidPlan = plan;
  lastValidDepth = depthBefore;
  lastValidTime = timeBefore;
  lastValidSegments = JSON.parse(JSON.stringify(segments));
  renderDiveSelector();
  renderRepDivesUI();   // refresh Dive 1 depth/BT display
  updateDiveParamsCard();
  draw(getDiveSlice(plan, selectedDive));
  renderPlanTable(plan);
  // Reset slider to end of the current dive's slice (renderTissueTab will set .max properly).
  const _ts = document.getElementById("tissueTimeSlider");
  if (_ts) _ts.value = _ts.max || "0";
  renderTissueTab(plan);
  updateLostGasContingency(plan);
  renderGasTab(plan);
  const tox = computeToxInputs(plan);
  lastToxInputs = { ...tox, plan };
  updateToxPanel(tox.maxDens, tox.dMax, tox.gAtMax, plan, tox.maxPpO2);
  setText("meta", "");
}

$("lastStopDepth")?.addEventListener("input", ()=>{
  scheduleRun();
});
/* wire */
$("addPreset").addEventListener("click", ()=>{
  const p = PRESETS[$("preset").value];
  if (!p) return;
  addGasRow(p);
  syncAllGasRows();
  run();
});
$("addCustom").addEventListener("click", ()=>{
  addGasRow({label:"", o2:21, he:0, role:"auto", enabled:true});
  syncAllGasRows();
  run();
});

$("depth")?.addEventListener("input", ()=>{
  const el = $("depth");
  if (!el) return;
  const v = Number(el.value) || 0;
  const maxD = maxReachableDepthForBottom(uiGases());
  if (Number.isFinite(maxD) && v > maxD + EPS){
    el.value = String(Math.round(maxD));
  }
  scheduleRun();
});

["time","segDepth","segTime","gfLow","gfHigh","descentRate","ascentRate","ppO2Min","ppO2Bottom","ppO2Deco"].forEach(id=>{
  $(id).addEventListener("input", ()=>{ scheduleRun(); });
});
["modeSimple","modeSegments"].forEach(id=>{
  $(id).addEventListener("change", ()=>{
    updateModeUI();
    renderSegments();
    run();
  });
});

/* ----------------------------------------------------------------
   Tab switching
---------------------------------------------------------------- */
(function setupPlotTabs(){
  const btns   = document.querySelectorAll(".plotTabBar .tabBtn");
  const panels = document.querySelectorAll(".tabPanel");

  btns.forEach(btn => {
    btn.addEventListener("click", () => {
      const target = btn.dataset.tab;
      if (!target) return; // not a tab button (e.g. Export)

      // Update button states
      btns.forEach(b => {
        b.classList.toggle("active", b.dataset.tab === target);
        b.setAttribute("aria-selected", String(b.dataset.tab === target));
      });

      // Show correct panel
      panels.forEach(p => {
        const match = p.id === "tab" + target.charAt(0).toUpperCase() + target.slice(1);
        p.classList.toggle("active", match);
      });

      // When switching back to the graph, the canvas may have been sized
      // to the fallback minimum (320px) while hidden — remeasure and redraw.
      if (target === "graph" && lastPlan){
        resizeCanvas();
        draw(getDiveSlice(lastPlan, selectedDive), true);
      }
      // Tissues tab: remeasure and redraw whenever it becomes visible.
      if (target === "tissues" && lastPlan){
        renderTissueTab(lastPlan);
      }
      // Contingency tab: draw whenever it becomes active.
      if (target === "contingency"){
        renderContingencyGraph(
          lastContingencyDispPlan || lastPlan,
          lastContingencyPlan,
          lastContingencyDispTWorst,
          lastContingencyWorst.d
        );
      }
      // Gas tab: render whenever it becomes active.
      if (target === "gas"){
        renderGasTab(lastPlan);
      }
    });
  });
})();

/* init */
gasRows.innerHTML = "";
addGasRow(PRESETS.air);
syncAllGasRows();
document.body.classList.toggle("mode-ccr", _breathMode === "ccr");
renderSegments();
updateModeUI();
updateAltitude();
resizeCanvas();
run();

// ── Shareable URL ─────────────────────────────────────────────────────────────
// ── Compression helpers (deflate-raw via built-in CompressionStream) ─────────
async function _compress(str) {
  const bytes = new TextEncoder().encode(str);
  const cs    = new CompressionStream("deflate-raw");
  const w     = cs.writable.getWriter();
  w.write(bytes); w.close();
  const buf = await new Response(cs.readable).arrayBuffer();
  // URL-safe base64 (no padding)
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function _decompress(b64url) {
  const b64  = b64url.replace(/-/g, "+").replace(/_/g, "/");
  const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
  const ds   = new DecompressionStream("deflate-raw");
  const w    = ds.writable.getWriter();
  w.write(bytes); w.close();
  return new TextDecoder().decode(await new Response(ds.readable).arrayBuffer());
}

// ── Compact state object (short keys to reduce size before compression) ───────
function _planState() {
  saveGasesToDive(Math.max(0, selectedDive));
  saveSegmentsToDive(Math.max(0, selectedDive));
  const val = (id) => document.getElementById(id)?.value ?? "";
  const chk = (id) => !!document.getElementById(id)?.checked;
  return {
    v:2,
    gl: Number(val("gfLow"))         || 80,
    gh: Number(val("gfHigh"))        || 80,
    ar: Number(val("ascentRate"))    || 9,
    ls: Number(val("lastStopDepth")) || 3,
    sa: val("salinityModel"),
    al: Number(val("altitudeM"))     || 0,
    sc: Number(val("sacLpm"))        || 20,
    pm2: Math.max(0.16, Number(val("ppO2Min")) || 0.16),
    pb: Number(val("ppO2Bottom"))    || (_breathMode === "ccr" ? 1.6 : 1.4),
    pd: Number(val("ppO2Deco"))      || 1.6,
    bm: _breathMode,
    pm: chk("modeSegments") ? 1 : 0,
    ro: chk("repDivesEnable") ? 1 : 0,
    d:  Number(val("depth")) || 30,
    t:  Number(val("time"))  || 10,
    gr: _gasTabRule,
    gd: _gasTabDecoRule,
    gp: _gasTabCustomPct,
    dp: _gasTabDecoCustomPct,
    re: _gasTabReserve,
    // Per-dive gases — only keep fields that differ from defaults
    gs: diveGasData.map(d => (d||[]).map(g => {
      const o = { l:g.label, o:g.o2, h:g.he, r:g.role, e:g.enabled?1:0 };
      if (g.switchDepthM != null) o.sw = g.switchDepthM;
      if (g.role === "diluent"){
        // Serialize full SP schedule; keep legacy sp/ds for old clients.
        o.ss = g.spSchedule ?? [{depth:6,sp:g.ccrSP??1.3},{depth:0,sp:g.ccrDecSP??0.7}];
        o.sp = g.ccrSP; o.ds = g.ccrDecSP;
        o.lv = g.ccrLoopVol; o.or = g.ccrO2Rate;
      }
      return o;
    })),
    sg: diveSegmentsData.map(d => d||[]),
    rd: repDives.map(r => [r.siMin, r.depth, r.bottomTime]),
  };
}

async function sharePlan() {
  const btn = document.getElementById("sharePlanBtn");
  if (btn) { btn.textContent = "…"; btn.disabled = true; }
  try {
    const compressed = await _compress(JSON.stringify(_planState()));
    const url = `${location.origin}${location.pathname}#plan=${compressed}`;
    try {
      await navigator.clipboard.writeText(url);
      if (btn) { btn.textContent = "Copied!"; btn.style.color = "#2e7d32"; }
    } catch {
      prompt("Copy this link:", url);
      if (btn) { btn.textContent = "Share"; }
    }
  } finally {
    if (btn) {
      btn.disabled = false;
      setTimeout(() => { btn.textContent = "Share"; btn.style.color = ""; }, 2000);
    }
  }
}

async function restorePlan(encoded) {
  const json  = await _decompress(encoded);
  const s     = JSON.parse(json);
  if (!s || (s.v !== 1 && s.v !== 2)) return;

  const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = String(v); };

  set("gfLow",         s.gl ?? s.gfLow  ?? 80);
  set("gfHigh",        s.gh ?? s.gfHigh ?? 80);
  set("ascentRate",    s.ar ?? s.ascentRate  ?? 9);
  set("lastStopDepth", s.ls ?? s.lastStop    ?? 3);
  set("altitudeM",     s.al ?? s.altitude    ?? 0);
  set("sacLpm",        s.sc ?? s.sac         ?? 20);
  set("ppO2Min",       Math.max(0.16, s.pm2 ?? 0.16));
  set("ppO2Bottom",    s.pb ?? s.ppO2Bot     ?? (_breathMode === "ccr" ? 1.6 : 1.4));
  set("ppO2Deco",      s.pd ?? s.ppO2Deco    ?? 1.6);
  const salEl = document.getElementById("salinityModel");
  if (salEl && (s.sa ?? s.salinity)) salEl.value = s.sa ?? s.salinity;

  const bm = s.bm ?? s.breathMode ?? "oc";
  _breathMode = bm;
  document.body.classList.toggle("mode-ccr", _breathMode === "ccr");
  document.querySelectorAll('input[name="breathMode"]').forEach(r => r.checked = r.value === bm);
  rebuildAllRoleDropdowns();

  const modeEl = document.getElementById("modeSegments");
  if (modeEl) modeEl.checked = !!(s.pm ?? (s.planMode === "segments" ? 1 : 0));

  set("depth", s.d ?? s.depth ?? 30);
  set("time",  s.t ?? s.time  ?? 10);

  if (s.gr ?? s.gasRule)      _gasTabRule          = s.gr ?? s.gasRule;
  if (s.gd ?? s.gasDecoRule)  _gasTabDecoRule      = s.gd ?? s.gasDecoRule;
  if (s.gp != null)           _gasTabCustomPct     = s.gp ?? s.gasCustomPct;
  if (s.dp != null)           _gasTabDecoCustomPct = s.dp ?? s.gasDecoPct;
  if (s.re != null)           _gasTabReserve       = s.re ?? s.gasReserve;

  // Expand compact gas format back to full objects
  const expandGas = (g) => ({
    label: g.l ?? g.label ?? "",
    o2: g.o ?? g.o2 ?? 21, he: g.h ?? g.he ?? 0,
    role: g.r ?? g.role ?? "auto",
    enabled: !!(g.e ?? g.enabled ?? true),
    switchDepthM: g.sw ?? g.switchDepthM ?? null,
    spSchedule: g.ss ?? null,   // full schedule takes priority
    ccrSP: g.sp ?? g.ccrSP ?? 1.3,
    ccrDecSP: g.ds ?? g.ccrDecSP ?? 0.7,
    ccrLoopVol: g.lv ?? g.ccrLoopVol ?? 7,
    ccrO2Rate: g.or ?? g.ccrO2Rate ?? 0.5,
  });

  diveGasData      = (s.gs ?? s.gases    ?? []).map(d => (d||[]).map(expandGas));
  diveSegmentsData = (s.sg ?? s.segments ?? []).map(d => d||[]);
  repDives         = (s.rd ?? s.repDives ?? []).map(r =>
    Array.isArray(r)
      ? { siMin: r[0], depth: r[1], bottomTime: r[2] }
      : { siMin: r.siMin ?? 60, depth: r.depth ?? 20, bottomTime: r.bt ?? 20 }
  );

  const repToggle = document.getElementById("repDivesEnable");
  if (repToggle) repToggle.checked = !!(s.ro ?? s.repOn);

  selectedDive = 0;
  loadGasesForDive(0);
  loadSegmentsForDive(0);
  updateAltitude();
  updateSalinityModel();
  renderRepDivesUI();
  renderDiveSelector();
  updateDiveParamsCard();
  scheduleRun();
}

// Gas toxicity card visibility
document.addEventListener("DOMContentLoaded", () => {
  const toggle = document.getElementById("o2ToxEnable");
  const panel = document.getElementById("gasToxPanel");
  if (!toggle || !panel) return;

  const sync = () => { panel.hidden = !toggle.checked; };
  toggle.addEventListener("change", sync);
  sync();
});


/**
 * Sums gas consumption (surface litres) for each gas label across a plan.
 * @param {object[]} plan
 * @param {number}   sac        Surface consumption rate in L/min.
 * @param {number}  [startDepth=0]  Depth (m) at the start of the first segment.
 * @returns {Map<string,number>}  label → total surface litres.
 */
function consumptionSums(plan, sac, startDepth = 0){
  const sums = new Map();
  let dPrev = startDepth;
  for (const s of plan){
    if (!s || s.kind === "switch" || s.kind === "surface") continue;
    const dur = Number(s.duration || 0);
    if (!(dur > 0)){ dPrev = Number(s.depth || dPrev); continue; }
    const dEnd = Number(s.depth || dPrev);
    const dMid = (s.kind === "ascent" || s.kind === "descent")
      ? (dPrev + (dEnd - dPrev) * 0.5)
      : dEnd;
    const liters = sac * depthToAmbientPressure(Math.max(0, dMid)) * dur;
    const g = s.gas;
    const label = (g && g.label) ? fmtGasLabel(g.label) : (s.label ? fmtGasLabel(s.label) : "—");
    sums.set(label, (sums.get(label) || 0) + liters);
    dPrev = dEnd;
  }
  return sums;
}

/** Renders one gas-consumption block (entries + total) into a container element. */
function renderConsumptionBlock(container, sums){
  const entries = Array.from(sums.entries()).sort((a, b) => b[1] - a[1]);
  let total = 0;
  for (const [, v] of entries) total += v;
  for (const [label, liters] of entries){
    // Only show bar when the user has explicitly selected a tank — avoids
    // ambiguity (bar depends on tank size, sidebar has no tank context).
    const selEntry = [..._selectedTanks.values()].find(e => e.gas === label);
    const tankWater = selEntry && selEntry.tankWaterVol > 0
      ? selEntry.tankWaterVol * selEntry.qty : 0;
    const barStr = tankWater > 0 ? `<div style="font-size:0.8em;opacity:0.6;margin-left:1em;">${Math.ceil(liters / tankWater)} bar</div>` : '';
    const row = document.createElement("div");
    row.style.marginBottom = barStr ? '4px' : '';
    row.innerHTML = `<strong>${label}:</strong> <span>${Math.round(liters)} L</span>${barStr}`;
    container.appendChild(row);
  }
  const sep = document.createElement("div");
  sep.style.cssText = "border-top:1px solid var(--line);margin-top:8px;padding-top:8px;";
  sep.innerHTML = `<strong>Total:</strong> <span>${Math.round(total)} L</span>`;
  container.appendChild(sep);
}

function updateGasConsumption(plan){
  const enabledEl = document.getElementById("gasConsEnable");
  const panelEl   = document.getElementById("gasConsPanel");
  const listEl    = document.getElementById("gasConsList");
  const sacEl     = document.getElementById("sacLpm");
  if (!enabledEl || !panelEl || !listEl || !sacEl) return;

  const on = !!enabledEl.checked;
  panelEl.hidden = !on;
  if (!on){ listEl.innerHTML = ""; return; }

  const sac = Math.max(0, Number(sacEl.value) || 0);
  listEl.innerHTML = "";

  if (!Array.isArray(plan) || !plan.length || sac <= 0){
    const row = document.createElement("div");
    row.innerHTML = `<strong>Total:</strong> <span>—</span>`;
    listEl.appendChild(row);
    return;
  }

  const contingEnabled = document.getElementById("lostGasEnable");
  const showConting = contingEnabled && contingEnabled.checked &&
                      lastContingencyPlan && lastContingencyPlan.length;

  // In CCR mode, show bailout gas consumption (OC emergency ascent) not diluent OC consumption.
  const _bgObjs = ccrCtx ? getCCRBailoutGases() : null;
  const _effectivePlan = (p, sd) =>
    ccrCtx ? buildCCRBailoutPlan(p, _bgObjs, sd) : p;

  if (ccrCtx && _bgObjs && !_bgObjs.length) {
    const note = document.createElement("div");
    note.style.cssText = "font-size:11px;color:var(--muted);";
    note.textContent   = "Add bailout/deco gases to see CCR emergency consumption.";
    listEl.appendChild(note);
    return;
  }

  if (showConting){
    // Combined consumption: pre-loss portion of the normal plan + contingency ascent
    const tW = lastContingencyWorst.t;
    const dW = lastContingencyWorst.d;
    const preLossPlan   = _effectivePlan(plan.filter(s => Number(s.runtime || 0) <= tW + 1e-6), 0);
    const preLossSums   = consumptionSums(preLossPlan, sac, 0);
    const contingSums   = consumptionSums(_effectivePlan(lastContingencyPlan, dW), sac, dW);
    const combined      = new Map(preLossSums);
    for (const [label, liters] of contingSums){
      combined.set(label, (combined.get(label) || 0) + liters);
    }
    const note = document.createElement("div");
    note.style.cssText = "margin-bottom:6px;font-size:11px;color:var(--muted);";
    note.textContent   = ccrCtx ? "Bailout (OC) + contingency" : "Including contingency ascent";
    listEl.appendChild(note);
    renderConsumptionBlock(listEl, combined);
  } else {
    renderConsumptionBlock(listEl, consumptionSums(_effectivePlan(plan, 0), sac, 0));
  }

  // ── CCR loop consumption (O₂ cylinder + diluent) ─────────────────────────
  if (ccrCtx && Array.isArray(plan) && plan.length){
    const totalRuntime = plan[plan.length - 1]?.runtime ?? 0;
    const o2L = ccrCtx.o2Rate * totalRuntime;

    let diluentL = ccrCtx.loopVol; // surface charge
    let _runD = 0;
    for (const seg of plan){
      if (seg.kind === "switch") continue;
      const endD = Number(seg.depth ?? 0);
      if (seg.kind === "descent"){
        diluentL += ccrCtx.loopVol * Math.max(0, depthToAmbientPressure(endD) - depthToAmbientPressure(_runD));
      }
      _runD = endD;
    }

    const sep2 = document.createElement("div");
    sep2.style.cssText = "border-top:1px solid var(--line);margin-top:10px;padding-top:8px;font-size:11px;color:var(--muted);margin-bottom:4px;";
    sep2.textContent = "CCR loop";
    listEl.appendChild(sep2);

    const o2Row = document.createElement("div");
    o2Row.innerHTML = `<strong>O₂:</strong> <span>${Math.round(o2L)} L</span>`;
    listEl.appendChild(o2Row);

    const dilRow = document.createElement("div");
    dilRow.innerHTML = `<strong>${escapeHtml(ccrCtx.diluent.label)} (dil):</strong> <span>${Math.round(diluentL)} L</span>`;
    listEl.appendChild(dilRow);
  }
}


/* ----------------------------------------------------------------
   Lost-gas contingency UI
---------------------------------------------------------------- */

/**
 * Overdraws CCR diluent gas-lane segments with a diagonal stripe pattern
 * so they are visually distinct from OC bailout segments of the same gas.
 *
 * @param {HTMLCanvasElement} canvas
 * @param {{ padL, padT, plotW, tMax }} pm   Layout metrics returned by draw()
 * @param {object[]} segments                Plan segments to scan for diluent intervals
 * @param {number}   [tEnd=Infinity]         Only overdraw up to this runtime (exclusive)
 */
function applyCCRDiluentLaneOverdraw(canvas, pm, segments, tEnd = Infinity){
  if (!canvas || !pm || !segments || !segments.length) return;
  const _rawRows = rawGasRowsFromDOM();
  const _dilRow  = _rawRows.find(g => g.enabled && g.role === "diluent");
  if (!_dilRow) return;
  const _dilLabel = fmtGasLabel((_dilRow.label || `${Math.round(_dilRow.o2)}/${Math.round(_dilRow.he)}`).trim());

  const { padL, padT, plotW, tMax } = pm;
  const laneH = 14, laneGap = 6;
  const laneY = padT - laneH - laneGap;
  const xOf   = t => padL + (t / Math.max(tMax, 1)) * plotW;

  // Build gas intervals from segments.
  let curGas = null, t0 = 0;
  const allIntervals = [];
  for (const s of segments){
    if (s.kind === "switch"){
      if (curGas) allIntervals.push({ t0, t1: Number(s.runtime || 0), gas: curGas });
      curGas = s.gas || curGas;
      t0 = Number(s.runtime || 0);
    } else {
      if (!curGas) curGas = s.gas || null;
    }
  }
  if (curGas) allIntervals.push({ t0, t1: tMax, gas: curGas });

  const dilIntervals = allIntervals.filter(it =>
    it.gas && (gasLabelFromObj(it.gas) || "").trim() === _dilLabel && it.t0 < tEnd
  ).map(it => ({ ...it, t1: Math.min(it.t1, tEnd) }));
  if (!dilIntervals.length) return;

  // Build diagonal-stripe tile.
  const patCanvas = document.createElement("canvas");
  patCanvas.width = patCanvas.height = 6;
  const pCtx = patCanvas.getContext("2d");
  pCtx.fillStyle = "#f6f6f6";
  pCtx.fillRect(0, 0, 6, 6);
  pCtx.strokeStyle = "rgba(0,0,0,0.18)";
  pCtx.lineWidth = 1;
  pCtx.beginPath(); pCtx.moveTo(0, 6);  pCtx.lineTo(6, 0);  pCtx.stroke();
  pCtx.beginPath(); pCtx.moveTo(-3, 3); pCtx.lineTo(3, -3); pCtx.stroke();
  pCtx.beginPath(); pCtx.moveTo(3, 9);  pCtx.lineTo(9, 3);  pCtx.stroke();

  const ctx = canvas.getContext("2d");
  ctx.save();
  ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
  ctx.textBaseline = "middle";

  for (const it of dilIntervals){
    const x0 = xOf(it.t0);
    const x1 = Math.min(xOf(it.t1), padL + plotW);
    if (x1 - x0 < 1) continue;
    const col = gasColor(it.gas);

    ctx.fillStyle = "#f6f6f6";
    ctx.fillRect(x0, laneY, x1 - x0, laneH);

    ctx.globalAlpha = 1;
    ctx.fillStyle = ctx.createPattern(patCanvas, "repeat");
    ctx.fillRect(x0, laneY, x1 - x0, laneH);

    ctx.globalAlpha = 0.20;
    ctx.fillStyle = col;
    ctx.fillRect(x0, laneY, x1 - x0, laneH);

    ctx.globalAlpha = 0.85;
    ctx.strokeStyle = col;
    ctx.lineWidth = 1;
    ctx.strokeRect(x0, laneY, x1 - x0, laneH);

    const label = _dilLabel ? _dilLabel + " (diluent)" : "(diluent)";
    const pad  = 3;
    const maxW = Math.max(0, x1 - x0 - 2 * pad);
    if (maxW > 6){
      ctx.save();
      ctx.beginPath();
      ctx.rect(x0 + pad, laneY + 1, x1 - x0 - 2 * pad, laneH - 2);
      ctx.clip();
      ctx.globalAlpha = 1;
      ctx.fillStyle = "#000";
      let txt = String(label);
      const ell = "…";
      while (txt.length > 1 && ctx.measureText(txt + ell).width > maxW) txt = txt.slice(0, -1);
      const finalTxt = ctx.measureText(label).width <= maxW ? label : (txt.length > 1 ? txt + ell : "");
      const tw = ctx.measureText(finalTxt).width;
      ctx.fillText(finalTxt, x0 + (x1 - x0 - tw) / 2, laneY + laneH / 2);
      ctx.restore();
    }
  }

  ctx.globalAlpha = 1;
  ctx.strokeStyle = "#ddd";
  ctx.lineWidth   = 1;
  ctx.strokeRect(padL, laneY, plotW, laneH);
  ctx.restore();
}

/**
 * Draws the contingency graph on canvas#cvContingency.
 *
 * Shows the full dive context:
 *  - Descent + bottom phase (from the normal plan up to tWorst) in black
 *  - Contingency ascent from (tWorst, dWorst) to the surface in red
 *  - Gas lane reflects contingency gases from tWorst onward
 *  - A thin dashed vertical marker at tWorst marks the moment of gas loss
 *
 * @param {object[]|null} plan         Full normal dive plan (for pre-loss context + hover)
 * @param {object[]|null} contingPlan  Ascent plan from planAscentFromState (0-based time)
 * @param {number}        tWorst       Runtime (min) at the moment of gas loss
 * @param {number}        dWorst       Depth (m)    at the moment of gas loss
 */
function renderContingencyGraph(plan, contingPlan, tWorst, dWorst){
  const canvas = document.getElementById("cvContingency");
  if (!canvas || !plan || !plan.length) return;

  // ── Build the combined plan: pre-loss segments + contingency segments ─────
  // Take only the segments up to and including the gas-loss moment.
  const prePlan = plan.filter(s => Number(s.runtime || 0) <= tWorst + 1e-6);

  // contingPlan is already built from the correct OC bailout gases (diluent excluded).
  const displayContingPlan = contingPlan;

  // Shift contingency-plan runtimes so they start at tWorst.
  const contingShifted = displayContingPlan
    ? displayContingPlan.map(s => ({ ...s, runtime: Number(s.runtime || 0) + tWorst }))
    : [];

  const combinedPlan = [...prePlan, ...contingShifted];

  // Draw the combined plan onto the contingency canvas without touching any globals.
  const { plotMetrics: _pm, lastPts: _lp } = draw(combinedPlan, false, {
    noSummary: true,
    canvas: canvas,
    ctx:    canvas.getContext("2d"),
    hoverX: contingencyHoverX,
    hoverInfo: null,   // suppress tooltip during base draw; repaint it last
  });

  // Capture layout for the hover handler and the red-overlay pass below.
  contingencyPlotMetrics = _pm;
  contingencyLastPts     = _lp;
  const { padL, padT, plotW, plotH, tMax, dMax } = _pm;

  // ── Overdraw gas lane for the post-loss period ────────────────────────────
  // draw() builds the lane from the combined plan's switch events, but the
  // pre-loss gas (Trimix) can bleed past the gas-loss moment if the first
  // contingency switch event doesn't arrive at exactly tWorst.
  // We erase and repaint the lane from tWorst→tMax using the contingency plan.
  if (displayContingPlan && displayContingPlan.length){
    const ctxLane = canvas.getContext("2d");
    const laneH   = 14;
    const laneGap = 6;
    const laneY   = padT - laneH - laneGap;   // mirrors layout constants in draw()
    const xOf2    = t => padL + (t / Math.max(tMax, 1)) * plotW;
    const xLoss   = xOf2(tWorst);
    const xEnd    = padL + plotW;

    // Build gas intervals from the contingency plan's switch events (shifted).
    // Start with the first breathable gas used in the contingency plan.
    let cgGas = (displayContingPlan.find(s => s.kind !== "switch") || {}).gas || null;
    let cgT0  = tWorst;
    const cgIntervals = [];
    for (const sw of contingShifted){
      if (sw.kind !== "switch") continue;
      const tSw = Number(sw.runtime || 0);
      if (cgGas && tSw > cgT0 + EPS) cgIntervals.push({ t0: cgT0, t1: tSw, gas: cgGas });
      cgGas = sw.gas || cgGas;
      cgT0  = tSw;
    }
    if (cgGas) cgIntervals.push({ t0: cgT0, t1: tMax, gas: cgGas });

    // Find the last gas active in the pre-loss lane (at tWorst).
    // If the contingency starts on that same gas, don't redraw from tWorst —
    // the pre-loss band already shows it, and repeating it adds a visual seam.
    // NOTE: uiGases() creates fresh Gas objects on every call, so we compare
    // by label (not object reference) to detect the same gas across call sites.
    let preLossLastGas = (prePlan.find(s => s.kind !== "switch") || {}).gas || null;
    for (const sw of prePlan){ if (sw.kind === "switch" && sw.gas) preLossLastGas = sw.gas; }
    const sameGas = (a, b) =>
      a && b && (a === b || (gasLabelFromObj(a) || "") === (gasLabelFromObj(b) || ""));
    // In OC mode: skip leading intervals whose gas is identical to the pre-loss
    // gas so the lane reads as one continuous band across the gas-loss marker.
    // In CCR mode: always redraw from tWorst — the pre-loss diluent gets stripes
    // while the post-loss OC bailout must appear as a solid band, even if both
    // happen to carry the same gas label (e.g. Air diluent + Air bailout).
    let eraseFrom = tWorst;
    let drawFrom  = 0;
    if (_breathMode !== "ccr"){
      while (drawFrom < cgIntervals.length &&
             sameGas(cgIntervals[drawFrom].gas, preLossLastGas)){
        eraseFrom = cgIntervals[drawFrom].t1;
        drawFrom++;
      }
    }

    // Erase the post-loss lane from the first point where the gas changes
    // (if the same gas runs across the loss marker, we keep that band intact).
    const xEraseStart = xOf2(eraseFrom);
    ctxLane.save();
    ctxLane.fillStyle = "#f6f6f6";
    ctxLane.fillRect(xEraseStart, laneY, xEnd - xEraseStart, laneH);

    // Draw each contingency gas interval (skip leading same-gas intervals).
    ctxLane.font = "12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
    ctxLane.textBaseline = "middle";
    for (let i = drawFrom; i < cgIntervals.length; i++){
      const it = cgIntervals[i];
      const x0 = Math.max(xOf2(it.t0), xEraseStart);
      const x1 = Math.min(xOf2(it.t1), xEnd);
      if (x1 - x0 < 1 || !it.gas) continue;
      const col = gasColor(it.gas);
      ctxLane.globalAlpha = 0.25;
      ctxLane.fillStyle = col;
      ctxLane.fillRect(x0, laneY, x1 - x0, laneH);
      ctxLane.globalAlpha = 0.9;
      ctxLane.strokeStyle = col;
      ctxLane.strokeRect(x0, laneY, x1 - x0, laneH);
      const label = gasLabelFromObj(it.gas);
      if (label){
        const pad = 3;
        const maxW = Math.max(0, x1 - x0 - 2 * pad);
        if (maxW > 6){
          ctxLane.save();
          ctxLane.beginPath();
          ctxLane.rect(x0 + pad, laneY + 1, x1 - x0 - 2 * pad, laneH - 2);
          ctxLane.clip();
          ctxLane.globalAlpha = 1;
          ctxLane.fillStyle = "#000";
          let txt = String(label);
          const ell = "…";
          while (txt.length > 1 && ctxLane.measureText(txt + ell).width > maxW)
            txt = txt.slice(0, -1);
          ctxLane.fillText(
            ctxLane.measureText(label).width <= maxW ? label : (txt.length > 1 ? txt + ell : ""),
            x0 + pad, laneY + laneH / 2
          );
          ctxLane.restore();
        }
      }
    }
    // Redraw the border over the whole lane so it remains crisp.
    ctxLane.globalAlpha = 1;
    ctxLane.strokeStyle = "#ddd";
    ctxLane.lineWidth   = 1;
    ctxLane.strokeRect(padL, laneY, plotW, laneH);
    ctxLane.restore();
  }

  // ── In CCR mode: overdraw pre-loss gas lane with diagonal stripes ────────
  if (_breathMode === "ccr") applyCCRDiluentLaneOverdraw(canvas, _pm, prePlan, tWorst);

  if (!displayContingPlan || !displayContingPlan.length) return;

  // ── Overdraw contingency ascent in red ────────────────────────────────────
  const ctx2 = canvas.getContext("2d");
  const xOf  = t => padL + (t / Math.max(tMax, 1)) * plotW;
  const yOf  = d => padT + (d / Math.max(dMax, 1)) * plotH;

  // Dashed vertical marker at the moment of gas loss
  ctx2.save();
  ctx2.strokeStyle = "#e53935";
  ctx2.lineWidth   = 1.5;
  ctx2.globalAlpha = 0.7;
  ctx2.setLineDash([5, 4]);
  ctx2.beginPath();
  ctx2.moveTo(xOf(tWorst), padT);
  ctx2.lineTo(xOf(tWorst), padT + plotH);
  ctx2.stroke();
  ctx2.setLineDash([]);
  ctx2.globalAlpha = 1;

  // Small "gas loss" label just inside the plot area, to the right of the line
  ctx2.font         = "10px system-ui, -apple-system, sans-serif";
  ctx2.fillStyle    = "#e53935";
  ctx2.textBaseline = "top";
  ctx2.textAlign    = "left";
  ctx2.fillText(lastContingencyEvent, xOf(tWorst) + 4, padT + 4);
  ctx2.restore();

  // Red contingency profile (overwrites the black combined-plan profile from tWorst onward)
  const contingPts = planToPolyline(displayContingPlan, { startDepth: dWorst, startTime: tWorst });
  ctx2.save();
  ctx2.strokeStyle = "#e53935";
  ctx2.lineWidth   = 2.5;
  ctx2.lineJoin    = "round";
  ctx2.lineCap     = "round";
  ctx2.beginPath();
  ctx2.moveTo(xOf(contingPts[0].t), yOf(contingPts[0].d));
  for (let i = 1; i < contingPts.length; i++){
    ctx2.lineTo(xOf(contingPts[i].t), yOf(contingPts[i].d));
  }
  ctx2.stroke();
  ctx2.restore();

  // Hover tooltip — drawn last so it sits on top of all other canvas content.
  if (contingencyHoverInfo){
    drawHoverTooltip(ctx2, contingencyHoverInfo, { padL, padT, plotW, plotH });
  }
}

/**
 * Refreshes the gas <select> in the lost-gas panel with the currently
 * enabled gases, restoring the previous selection when possible.
 *
 * @param {Gas[]} gases
 */
/**
 * Returns a display label for a gas row entry: "Label (role) — Tank × qty" when
 * a tank has been selected, or just "Label (role)" otherwise.
 */
// g may be a normalised Gas object or a raw gas-row record.
// rawRole (optional) overrides g.role so we can show the pre-normalisation role
// (uiGases maps "diluent"/"bailout" → "auto", losing the display qualifier).
// rowKey (optional): the per-row identifier "${label}|${rawRole}|${rawIdx}" used as
// the key in _selectedTanks.  When supplied, the lookup prefers an exact rowKey match
// so that two rows with the same gas label (e.g. Air auto + Air bailout) get their
// own independent tank annotation.
function _gasRowDisplayLabel(g, diveIdx, rawRole, rowKey){
  const _dIdx = diveIdx >= 0 ? diveIdx : 0;
  const tanks  = new Map(diveTankData[_dIdx] || []);
  const label  = g.label || (g.o2 != null ? `${Math.round(g.o2)}/${Math.round(g.he)}` : "?");
  const fmtLbl = fmtGasLabel(label);
  // Prefer exact row match; fall back to first entry with matching gas label.
  const tankEntry = rowKey
    ? ([...tanks.values()].find(t => t.rowKey === rowKey) ||
       [...tanks.values()].find(t => t.gas === fmtLbl))
    : [...tanks.values()].find(t => t.gas === fmtLbl);
  const role = rawRole !== undefined ? rawRole : g.role;
  const roleSuffix = (role && role !== "auto") ? ` (${role})` : "";
  const tankSuffix = tankEntry ? ` — ${tankEntry.tank}${tankEntry.qty > 1 ? " ×"+tankEntry.qty : ""}` : "";
  return label + roleSuffix + tankSuffix;
}

function populateLostGasDropdown(gases){
  const container = document.getElementById("lostGasSelect");
  if (!container) return;

  // When the gas rule changes the cylinder list is rebuilt from scratch — don't
  // carry over stale checked states from the old rule.
  const _ruleChanged = _gasTabRule !== _lastContingencyRule;
  _lastContingencyRule = _gasTabRule;

  // Remember previously checked stable keys before rebuilding (only if rule unchanged).
  const prevChecked    = _ruleChanged ? new Set() : new Set(
    [...container.querySelectorAll("input[type=checkbox]")]
      .filter(el => el.checked).map(el => el.dataset.stableKey || el.value)
  );
  const prevLoopFailed = _ruleChanged ? true
    : (container.querySelector("#lostGasCb_ccr_loop")?.checked ?? true);

  container.innerHTML = "";

  const _dIdx = Math.max(0, selectedDive);
  // Raw rows keep the original role strings before uiGases() normalisation.
  const _rawRows = diveGasData[_dIdx] || rawGasRowsFromDOM();

  // ── Build cylinder list from live _selectedTanks (current dive) ─────────────
  // _selectedTanks is always in sync with the current dive (loadTanksForDive
  // restores it on dive-switch; renderGasTab keeps it refreshed).
  // Each value has { gas (label), rowKey, tank, qty, … }.
  // rawIdx = parseInt(rowKey.split("|")[2]) maps back to allGases[rawIdx].
  const _cyls    = [..._selectedTanks.values()].filter(c => c.rowKey);
  const _hasCyls = _cyls.length > 0;

  // Update the section header label
  const _lbl = document.getElementById("lostGasLabel");
  if (_lbl) _lbl.textContent = _hasCyls ? "Cylinders lost" : "Gases lost";

  // Helper: append one checkbox row.
  function _appendCheck({ value, stableKey, text, indent, defaultChecked }){
    const lbl = document.createElement("label");
    if (indent) lbl.style.marginLeft = "20px";
    const cb = document.createElement("input");
    cb.type = "checkbox";
    cb.value = String(value);
    cb.dataset.stableKey = stableKey;
    cb.checked = prevChecked.size > 0 ? prevChecked.has(stableKey) : defaultChecked;
    lbl.appendChild(cb);
    lbl.appendChild(document.createTextNode(" " + text));
    container.appendChild(lbl);
    return cb;
  }

  // Helper: cylinder display label — "Steel 12L/300 × 2 — Air (bailout)"
  function _cylLabel(cyl){
    const qtyTxt  = cyl.qty > 1 ? ` × ${cyl.qty}` : "";
    const parts   = (cyl.rowKey || "").split("|");
    const rawRole = parts[1] || "";
    const suffix  = parts[parts.length - 1] || "";
    // Deco gases are never bailout (switch gases during stops, not independent sources).
    const isBailout = rawRole !== "deco" && (rawRole === "bailout" || suffix === "emergency");
    return `${cyl.tank}${qtyTxt} — ${cyl.gas}${isBailout ? " (bailout)" : ""}`;
  }

  if (_breathMode === "ccr"){
    // ── CCR mode ──────────────────────────────────────────────────────────────
    // Primary failure: loop failure checkbox (always shown).
    const loopLbl = document.createElement("label");
    loopLbl.style.cssText = "font-weight:600;";
    const loopCb  = document.createElement("input");
    loopCb.type    = "checkbox";
    loopCb.id      = "lostGasCb_ccr_loop";
    loopCb.value   = "__ccr_loop__";
    loopCb.dataset.stableKey = "__ccr_loop__";
    loopCb.checked = prevChecked.size === 0 ? false : prevLoopFailed;
    loopLbl.appendChild(loopCb);
    loopLbl.appendChild(document.createTextNode(" CCR loop failure"));
    container.appendChild(loopLbl);

    // Sub-header for additionally-lost bailout cylinders.
    const sub = document.createElement("div");
    sub.style.cssText = "font-size:11px;color:var(--muted);margin:6px 0 3px 20px;";
    sub.textContent   = _hasCyls ? "Bailout cylinders also lost:" : "Bailout gases also lost:";
    container.appendChild(sub);

    if (_hasCyls){
      // Show selected cylinders (non-diluent rows only).
      _cyls.forEach(cyl => {
        const rawIdx = parseInt((cyl.rowKey || "").split("|")[2]);
        if (!Number.isFinite(rawIdx)) return;
        const rawRole = _rawRows[rawIdx]?.role;
        if (rawRole === "diluent") return;
        _appendCheck({ value: rawIdx, stableKey: cyl.rowKey, text: _cylLabel(cyl), indent: true, defaultChecked: false });
      });
    } else {
      // Fallback: gas rows (no cylinders selected yet).
      gases.forEach((g, i) => {
        const rawRole = _rawRows[i]?.role;
        if (!g.enabled || rawRole === "diluent") return;
        const rowKey = `${g.label}|${rawRole || "auto"}|${i}`;
        _appendCheck({ value: i, stableKey: rowKey, text: _gasRowDisplayLabel(g, _dIdx, rawRole, rowKey), indent: true, defaultChecked: false });
      });
    }

  } else {
    // ── OC mode ───────────────────────────────────────────────────────────────
    if (_hasCyls){
      // Show one checkbox per selected cylinder.
      _cyls.forEach((cyl, ci) => {
        const rawIdx = parseInt((cyl.rowKey || "").split("|")[2]);
        if (!Number.isFinite(rawIdx)) return;
        _appendCheck({ value: rawIdx, stableKey: cyl.rowKey, text: _cylLabel(cyl), indent: false, defaultChecked: false });
      });
    } else {
      // Fallback: one checkbox per gas row (no cylinders selected yet).
      gases.forEach((g, i) => {
        if (!g.enabled) return;
        const rawRole = _rawRows[i]?.role;
        const rowKey  = `${g.label}|${rawRole || "auto"}|${i}`;
        _appendCheck({ value: i, stableKey: rowKey, text: _gasRowDisplayLabel(g, _dIdx, rawRole, rowKey), indent: false, defaultChecked: false });
      });
    }
  }

}

/**
 * Recomputes and renders the lost-gas contingency result in the panel.
 *
 * Worst-case moment: end of the last "bottom" segment (maximum tissue
 * loading before any deco gas has been used).  Falls back to end of the
 * deepest descent segment for segment-mode dives with no explicit bottom.
 *
 * @param {object[]|null} plan
 */
function updateLostGasContingency(plan){
  const enabledEl = document.getElementById("lostGasEnable");
  const panelEl   = document.getElementById("lostGasPanel");
  const selectEl  = document.getElementById("lostGasSelect");
  const resultEl  = document.getElementById("lostGasResult");
  if (!enabledEl || !panelEl || !selectEl || !resultEl) return;

  const contingTabBtn = document.getElementById("contingencyTabBtn");

  const on = !!enabledEl.checked;
  panelEl.hidden = !on;
  if (!on){
    resultEl.innerHTML = "";
    // Hide the contingency tab button and clear stored state
    if (contingTabBtn) contingTabBtn.hidden = true;
    lastContingencyPlan  = null;
    lastContingencyWorst = { t: 0, d: 0 };
    // Remove the contingency sub-sections from all panels
    updateGasConsumption(plan);
    renderGasTab(plan);
    updateHoverInfoAndRedraw();
    if (lastToxInputs) updateToxPanel(lastToxInputs.maxDens, lastToxInputs.dMax, lastToxInputs.gAtMax, lastToxInputs.plan, lastToxInputs.maxPpO2);
    return;
  }

  // Use the selected dive's gas data (not raw DOM, which may show a different dive)
  const _cgDiveIdx = Math.max(0, selectedDive);
  const allGases = uiGases(diveGasData[_cgDiveIdx] || undefined);
  populateLostGasDropdown(allGases);

  if (!plan || !plan.length){
    resultEl.innerHTML = `<span style="color:var(--muted)">No plan.</span>`;
    if (contingTabBtn) contingTabBtn.hidden = true;
    lastContingencyPlan  = null;
    lastContingencyWorst = { t: 0, d: 0 };
    return;
  }

  // ── Build lost / remaining gas sets depending on mode ──────────────────────
  let lostGases, remainingGases, lostLabel, scenarioLabel;

  // Cylinders currently selected (recomputed here — _hasCyls/_cyls are local
  // to populateLostGasDropdown and not accessible in this scope).
  const _cgCyls    = [..._selectedTanks.values()].filter(c => c.rowKey);
  const _cgHasCyls = _cgCyls.length > 0;

  if (_breathMode === "ccr"){
    // CCR: primary scenario is loop failure; individual cylinder losses compound.
    const loopCb = selectEl.querySelector("#lostGasCb_ccr_loop");
    if (!loopCb?.checked){
      resultEl.innerHTML =
        `<span style="color:var(--muted)">Check "CCR loop failure" to plan an OC bailout.</span>`;
      if (contingTabBtn) contingTabBtn.hidden = true;
      lastContingencyPlan  = null;
      lastContingencyWorst = { t: 0, d: 0 };
      return;
    }

    // Gases additionally lost on top of loop failure.
    // A gas is only lost when ALL its cylinders (for that rawIdx) are checked.
    const extraLost = new Set();
    {
      const _rawRows2 = diveGasData[_cgDiveIdx] || rawGasRowsFromDOM();
      if (_cgHasCyls){
        // Count total non-diluent cylinders per rawIdx in _selectedTanks.
        const _cylTotal   = new Map();
        const _cylChecked = new Map();
        for (const cyl of _cgCyls){
          const ri = parseInt((cyl.rowKey || "").split("|")[2]);
          if (!Number.isFinite(ri)) continue;
          if (_rawRows2[ri]?.role === "diluent") continue;
          _cylTotal.set(ri, (_cylTotal.get(ri) || 0) + 1);
        }
        for (const el of selectEl.querySelectorAll("input[type=checkbox]:checked")){
          if (el.id === "lostGasCb_ccr_loop") continue;
          const ri = Number(el.value);
          if (_cylTotal.has(ri)) _cylChecked.set(ri, (_cylChecked.get(ri) || 0) + 1);
        }
        for (const [ri, total] of _cylTotal){
          if ((_cylChecked.get(ri) || 0) >= total) extraLost.add(ri);
        }
      } else {
        // Fallback (no cylinders selected): each checked checkbox = one lost gas.
        [...selectEl.querySelectorAll("input[type=checkbox]:checked")]
          .filter(el => el.id !== "lostGasCb_ccr_loop")
          .forEach(el => extraLost.add(Number(el.value)));
      }
    }
    // The diluent is part of the CCR loop — it is unavailable after loop failure.
    // Identify diluent indices from the raw gas rows.
    const _rawRows2b = diveGasData[_cgDiveIdx] || rawGasRowsFromDOM();
    const _dilIndices = new Set(
      _rawRows2b.map((g, i) => g.role === "diluent" ? i : -1).filter(i => i >= 0)
    );
    lostGases      = allGases.filter((_, i) =>  extraLost.has(i));
    remainingGases = allGases.filter((_, i) => !extraLost.has(i) && !_dilIndices.has(i));
    lostLabel      = extraLost.size > 0
      ? `loop + ${allGases.filter((_, i) => extraLost.has(i)).map(g => g.label).join(" + ")}`
      : "loop";
    scenarioLabel  = "CCR loop failure";

  } else {
    // OC: a gas is lost only when ALL its cylinders (same rawIdx) are checked.
    let checkedIdx;
    if (_cgHasCyls){
      // Count total and checked cylinders per rawIdx from _selectedTanks.
      const _cylTotal   = new Map();
      const _cylChecked = new Map();
      for (const cyl of _cgCyls){
        const ri = parseInt((cyl.rowKey || "").split("|")[2]);
        if (!Number.isFinite(ri)) continue;
        _cylTotal.set(ri, (_cylTotal.get(ri) || 0) + 1);
      }
      for (const el of selectEl.querySelectorAll("input[type=checkbox]:checked")){
        const ri = Number(el.value);
        if (_cylTotal.has(ri)) _cylChecked.set(ri, (_cylChecked.get(ri) || 0) + 1);
      }
      checkedIdx = new Set(
        [..._cylTotal.keys()].filter(ri => (_cylChecked.get(ri) || 0) >= _cylTotal.get(ri))
      );
    } else {
      // Fallback (no cylinders selected): each checked checkbox = one lost gas.
      checkedIdx = new Set(
        [...selectEl.querySelectorAll("input[type=checkbox]:checked")].map(el => Number(el.value))
      );
    }
    if (checkedIdx.size === 0){
      resultEl.innerHTML = `<span style="color:var(--muted)">Select at least one cylinder.</span>`;
      if (contingTabBtn) contingTabBtn.hidden = true;
      lastContingencyPlan  = null;
      lastContingencyWorst = { t: 0, d: 0 };
      return;
    }
    lostGases      = allGases.filter((_, i) =>  checkedIdx.has(i));
    remainingGases = allGases.filter((_, i) => !checkedIdx.has(i));
    lostLabel      = lostGases.map(g => g.label).join(" + ");
    scenarioLabel  = "cylinder loss";
  }

  // Worst moment: end of last bottom segment (or deepest descent) of the
  // currently selected dive only — avoid landing on a rep-dive's bottom when
  // the user is editing dive 1.
  const _repOn = document.getElementById("repDivesEnable")?.checked;
  const _curDiveNum = (_repOn && selectedDive >= 0) ? selectedDive + 1 : null; // 1-based
  const _planForWorst = _curDiveNum
    ? plan.filter(s => s.kind !== "surface" &&
        (_curDiveNum === 1 ? (!s.diveNum || s.diveNum === 1) : s.diveNum === _curDiveNum))
    : plan;

  let tWorst = 0, dWorst = 0;
  for (const s of _planForWorst){
    if (s.kind === "bottom"){ tWorst = Number(s.runtime); dWorst = Number(s.depth); }
  }
  if (!tWorst){
    for (const s of _planForWorst){
      if (s.kind === "descent"){ tWorst = Number(s.runtime); dWorst = Number(s.depth); }
    }
  }

  const gf = new GradientFactors(
    Number(document.getElementById("gfLow").value) / 100,
    Number(document.getElementById("gfHigh").value) / 100
  );
  const ascentRate    = Number(document.getElementById("ascentRate")?.value) || 9;
  const lastStopDepth = Number(document.getElementById("lastStopDepth")?.value ?? 3) || 0;
  const opts = { gf, ascentRate, stopStep: 3, minStopQuantum: 1, lastStopDepth };

  try {
    const { state } = stateAtRuntime(plan, allGases, { ascentRate }, tWorst);

    // Normal TTS from this state (with all gases including the lost ones).
    // Use the first lost gas as the "preferred current gas" hint.
    const normalTTS = computeTTSFromState(
      state, dWorst, gf, allGases, 3, ascentRate, 1, lastStopDepth, lostGases[0]
    );

    // Contingency ascent plan without the lost gas.
    // Force OC physics: on CCR the loop is unavailable during bailout.
    const _savedCcr = ccrCtx;
    ccrCtx = null;
    const contingPlan = planAscentFromState(state, dWorst, remainingGases, opts);
    ccrCtx = _savedCcr;

    // Compute display plan (current-dive slice) and normalized tWorst for the graph.
    const _repOn2 = document.getElementById("repDivesEnable")?.checked;
    let _dispPlan = plan;
    let _tWorstDisp = tWorst;
    if (_repOn2 && selectedDive >= 0 && plan && plan.length) {
      const _dn = selectedDive + 1;
      const _firstSeg = selectedDive === 0
        ? plan.find(s => s.kind !== "surface" && (!s.diveNum || s.diveNum === 1))
        : plan.find(s => s.diveNum === _dn && s.kind !== "surface");
      if (_firstSeg) {
        const _t0 = Number(_firstSeg.runtime) - Number(_firstSeg.duration || 0);
        _tWorstDisp = tWorst - _t0;
        _dispPlan   = getDiveSlice(plan, selectedDive) || plan;
      }
    }

    // Always store the worst-moment so the tab can at least draw the marker.
    lastContingencyPlan      = contingPlan;   // may be null — graph handles that
    lastContingencyWorst     = { t: tWorst, d: dWorst };
    lastContingencyDispPlan  = _dispPlan;
    lastContingencyDispTWorst = _tWorstDisp;
    lastContingencyEvent     = scenarioLabel;

    // Show the contingency tab as soon as there is a dive plan to visualise.
    if (contingTabBtn) contingTabBtn.hidden = false;

    // Refresh gas consumption panel, gas tab, toxicity panel and plan summary
    // so the contingency sub-sections (ICD warning, bailout switch list) appear.
    updateGasConsumption(plan);
    renderGasTab(plan);
    updateHoverInfoAndRedraw();  // redraws plan switches block (incl. bailout block)
    if (lastToxInputs) updateToxPanel(lastToxInputs.maxDens, lastToxInputs.dMax, lastToxInputs.gAtMax, lastToxInputs.plan, lastToxInputs.maxPpO2);

    // Refresh the graph if the contingency tab is currently active.
    const contingPanel = document.getElementById("tabContingency");
    if (contingPanel && contingPanel.classList.contains("active")){
      renderContingencyGraph(_dispPlan, contingPlan, _tWorstDisp, dWorst);
    }

    if (!contingPlan){
      resultEl.innerHTML =
        `<span style="color:#c62828;font-weight:600;">` +
        `⚠ No breathable gas available at ${dWorst} m.` +
        `</span>`;
      return;
    }

    // ── Breathability gap check ──────────────────────────────────────────────
    // Scan every metre of the ascent range.  Find contiguous bands where NO
    // remaining gas is breathable (ppO₂ below min — typically hypoxic trimix
    // that cannot be breathed near the surface).
    // planAscentFromState silently keeps the last gas even when it goes hypoxic,
    // so the plan may be non-null yet unbreathable in a depth band.
    const _gapRanges = [];
    let _hasRichGap = false;   // gap caused by ppO₂ exceeding the deco limit (too rich at depth)
    let _hasLeanGap = false;   // gap caused by ppO₂ below ppo2Min (hypoxic near surface)
    {
      let _inGap = false, _gapDeep = 0;
      for (let d = dWorst; d >= 0; d--) {
        let tooRich = false, tooLean = false;
        const ok = remainingGases.some(g => {
          const ppo2 = ppo2AtDepth(g, d);
          // During an emergency ascent the diver is never on the bottom,
          // so the deco limit is the correct ceiling at every depth.
          const lim  = g.ppo2MaxDeco;
          if (ppo2 > lim  + EPS) { tooRich = true; return false; }
          if (ppo2 < g.ppo2Min - EPS) { tooLean = true; return false; }
          return true;
        });
        if (!ok) {
          if (tooRich) _hasRichGap = true;
          if (tooLean) _hasLeanGap = true;
          if (!_inGap) { _inGap = true; _gapDeep = d; }
        }
        if ( ok &&  _inGap) { _inGap = false; _gapRanges.push(`${d + 1}–${_gapDeep} m`); }
      }
      if (_inGap) _gapRanges.push(`0–${_gapDeep} m`);
    }
    const _gapAdvice = (_hasRichGap && _hasLeanGap)
      ? "Add a lower-O₂ gas for the deep section and a higher-O₂ gas for the shallow section."
      : _hasRichGap
        ? "Add a lower-O₂ gas (e.g. Air, EAN32) that is breathable at this depth."
        : "Add a higher-O₂ gas (e.g. EAN50, O₂) that covers this range.";

    const contingTTS   = contingPlan[contingPlan.length - 1]?.runtime || 0;
    const deltaMins    = Math.max(0, Math.ceil(contingTTS) - Math.ceil(normalTTS));
    const normalTTSStr = normalTTS < 0.5 ? "NDL" : `${Math.ceil(normalTTS)} min`;
    const deltaColor   = deltaMins > 20 ? "#c62828" : deltaMins > 8 ? "#e65100" : "#2e7d32";

    // Aggregate stop durations by depth.
    const stopMap = {};
    for (const s of contingPlan){
      if (s.kind !== "stop") continue;
      const d = Math.round(s.depth);
      stopMap[d] = (stopMap[d] || 0) + (s.duration || 0);
    }
    const stopKeys = Object.keys(stopMap).map(Number).sort((a, b) => b - a);
    const stopStr  = stopKeys.map(d => `${d} m × ${Math.ceil(stopMap[d])}′`).join(" · ");

    const _gapWarningHtml = _gapRanges.length ? `
      <div style="margin-top:8px;padding:7px 10px;background:#fff3cd;border:1px solid #f5c542;border-radius:5px;font-size:12px;color:#7d5200;">
        <strong>⚠ No breathable gas in ${_gapRanges.join(", ")}.</strong>
        ${_gapAdvice}
      </div>` : "";

    resultEl.innerHTML = `
      <div style="color:var(--muted);font-size:11px;margin-bottom:6px;">
        ${escapeHtml(scenarioLabel)} at end of bottom · t = ${tWorst.toFixed(1)} min · ${dWorst} m
      </div>
      <div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;align-items:baseline;font-size:12px;">
        <span style="color:var(--muted);">Normal TTS</span>
        <span class="mono">${normalTTSStr}</span>
        <span style="color:var(--muted);">Contingency</span>
        <span class="mono" style="color:${deltaColor};font-weight:600;">${Math.ceil(contingTTS)} min
          <span style="color:#888;font-weight:normal;">(+${deltaMins} min)</span></span>
      </div>
      ${stopKeys.length ? `
      <div style="margin-top:8px;">
        <div style="color:var(--muted);font-size:11px;margin-bottom:3px;">
          OC bailout stops without ${escapeHtml(lostLabel)}
        </div>
        <div class="mono" style="font-size:11px;line-height:1.6;word-break:break-word;">
          ${escapeHtml(stopStr)}
        </div>
      </div>` : `<div style="margin-top:6px;font-size:12px;color:#2e7d32;">No stops required.</div>`}
      ${_gapWarningHtml}
    `;

  } catch(_){
    resultEl.innerHTML = `<span style="color:var(--muted);">Unable to compute.</span>`;
    if (contingTabBtn) contingTabBtn.hidden = true;
    lastContingencyPlan  = null;
  }
}

// Gas consumption card visibility + recompute on inputs
document.addEventListener("DOMContentLoaded", () => {
  const enabledEl = document.getElementById("gasConsEnable");
  const sacEl = document.getElementById("sacLpm");
  if (enabledEl) enabledEl.addEventListener("change", () => updateGasConsumption(lastPlan));
  if (sacEl) sacEl.addEventListener("input", () => { updateGasConsumption(lastPlan); renderGasTab(lastPlan); });

  // Tissue heatmap: slider scrubs through the dive; checkbox toggles overlay.
  const tissueSlider = document.getElementById("tissueTimeSlider");
  if (tissueSlider) tissueSlider.addEventListener("input", () => {
    // Save position for the current dive so switching and coming back restores it.
    _tissueSliderPos[Math.max(0, selectedDive)] = Number(tissueSlider.value);
    renderTissueTab(lastPlan);
  });

  // Lost-gas contingency: recompute whenever the checkbox or gas selection changes.
  const lostGasToggle = document.getElementById("lostGasEnable");
  const lostGasSelect = document.getElementById("lostGasSelect");
  if (lostGasToggle) lostGasToggle.addEventListener("change", () => updateLostGasContingency(lastPlan));
  if (lostGasSelect) lostGasSelect.addEventListener("change", () => updateLostGasContingency(lastPlan));

  // Breathing mode toggle (OC / CCR): rebuild role dropdowns then re-plan.
  // Also update ppO2Bottom to the mode-appropriate default: CCR loops run at
  // 1.6 bar (same as deco); OC bottom-gas default is the more conservative 1.4.
  document.querySelectorAll('input[name="breathMode"]').forEach(radio => {
    radio.addEventListener("change", () => {
      _breathMode = radio.value;
      document.body.classList.toggle("mode-ccr", _breathMode === "ccr");
      const ppO2BottomEl = document.getElementById("ppO2Bottom");
      if (ppO2BottomEl) ppO2BottomEl.value = (_breathMode === "ccr") ? "1.60" : "1.40";
      rebuildAllRoleDropdowns();
      syncAllGasRows();
      lastContingencyPlan  = null;
      lastContingencyWorst = { t: 0, d: 0 };
      // If the contingency tab is currently active, switch back to the graph tab.
      {
        const _btns   = document.querySelectorAll(".plotTabBar .tabBtn");
        const _panels = document.querySelectorAll(".tabPanel");
        if ([..._panels].some(p => p.id === "tabContingency" && p.classList.contains("active"))){
          _btns.forEach(b => {
            b.classList.toggle("active", b.dataset.tab === "graph");
            b.setAttribute("aria-selected", String(b.dataset.tab === "graph"));
          });
          _panels.forEach(p => p.classList.toggle("active", p.id === "tabGraph"));
        }
      }
      scheduleRun();
    });
  });

  // ── Share button ─────────────────────────────────────────────────────────
  document.getElementById("sharePlanBtn")?.addEventListener("click", () => {
    sharePlan().catch(e => console.warn("Share failed:", e));
  });

  // ── Import ────────────────────────────────────────────────────────────────
  const importBtn    = document.getElementById("importPlanBtn");
  const importWrap   = document.getElementById("importPlanWrap");
  const importInput  = document.getElementById("importPlanInput");
  const importGo     = document.getElementById("importPlanGo");
  const importCancel = document.getElementById("importPlanCancel");

  function _showImport(){ if(importWrap){ importWrap.hidden = false; importWrap.style.display="flex"; } importInput.value = ""; importInput?.focus(); }
  function _hideImport(){ if(importWrap){ importWrap.hidden = true; importWrap.style.display=""; } }

  function _doImport(){
    let raw = importInput.value.trim();
    // Accept full URL or bare code
    const hashIdx = raw.indexOf("#plan=");
    if (hashIdx !== -1) raw = raw.slice(hashIdx + 6);
    if (!raw){ importInput.focus(); return; }
    _hideImport();
    restorePlan(raw).catch(e => {
      alert("Could not import plan — the code may be invalid or from an older version.");
      console.warn("Import failed:", e);
    });
  }

  importBtn?.addEventListener("click",   _showImport);
  importCancel?.addEventListener("click", _hideImport);
  importGo?.addEventListener("click",    _doImport);
  importInput?.addEventListener("keydown", e => {
    if (e.key === "Enter")  _doImport();
    if (e.key === "Escape") _hideImport();
  });

  // ── Restore from URL hash on load ────────────────────────────────────────
  if (location.hash.startsWith("#plan=")) {
    restorePlan(location.hash.slice(6)).catch(e => console.warn("Could not restore plan from URL:", e));
  }
});
  </script>
  <script>
    // When embedded in an iframe, notify the parent whenever content height changes.
    // We measure .app's bounding rect (not scrollHeight) so the value reflects the
    // actual content size and shrinks correctly when content is removed.
    // scrollHeight would stay large because it measures the scroll container, which
    // is already stretched to the iframe's current height.
    if (window.parent !== window) {
      const _sendHeight = () => {
        const app = document.querySelector('.app');
        if (!app) return;
        // rect.bottom = px from viewport top to bottom of .app.
        // Body margin is 14px top + 14px bottom padding.
        const h = Math.ceil(app.getBoundingClientRect().bottom) + 14;
        window.parent.postMessage({ type: 'divePlannerResize', height: h }, '*');
      };
      const _target = document.querySelector('.app') || document.body;
      new ResizeObserver(_sendHeight).observe(_target);
    }
  </script>
</body>
</html>