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("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
}
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} · Residual GF ${residGF} · 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> · ${isCcr ? "CCR" : "Open Circuit"} · GF ${gfLow}/${gfHigh} · ${salinity} · ${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} · 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} · Residual GF ${residualGF} · 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 += ` · <em>Fill pressure includes ×${mult.toFixed(2)} planning factor + reserve.</em>`;
else if (hasRule || hasDecoRule) h += ` · <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>