Module Reference - matej-hron/decojs GitHub Wiki
Per-file API walkthrough. Signatures, line references, dependencies. For the math behind the algorithm functions listed here, see Decompression-Model and the individual algorithm chapters.
All paths are relative to the repository root. Line numbers are kept current per the wiki-update rule in CLAUDE.md.
A barrel module. Since v0.6.154 the implementation lives in js/deco/*.js; decoModel.js only re-exports the public surface, so every existing importer keeps working unchanged.
| Module | Lines | Contents |
|---|---|---|
js/deco/constants.js |
55 |
CALC_INTERVAL, SURFACE_PRESSURE, WATER_VAPOR_PRESSURE, N2_FRACTION, STANDARD_GRAVITY, WATER_TYPES, WATER_DENSITIES, PRESSURE_PER_METER, DEFAULT_GF_LOW/HIGH, DESCENT_SPEED, ASCENT_SPEED, STOP_INCREMENT
|
js/deco/config.js |
52 |
DECO_MODES, DECISION_AUDIT_VERSION, getDecoMode, DECO_STOP_MAX_MINUTES, DecoCapExceededError
|
js/deco/environment.js |
66 |
getPressureAtAltitude, getSurfacePressure, getPressurePerMeter, getAmbientPressure
|
js/deco/gasKinetics.js |
127 |
getAlveolarN2Pressure, getInitialTissueN2, haldaneEquation, schreinerEquation, simulateDepthTime, simulateDepthChange
|
js/deco/gradients.js |
209 |
getMValue, getAdjustedMValue, calculateInstantGF, calculateMaxGF, getCompartmentCeiling, getDiveCeiling, interpolateGF
|
js/deco/ceiling.js |
437 |
findFirstStopAtGFLow, findFirstStagedStopAtGFLow, getFirstStopDepth, calculateCeilingTimeSeries, calculateCeilingTimeSeriesDetailed, evaluateDirectAscent
|
js/deco/schedule.js |
718 |
calculateNDL, generateDecoSchedule
|
js/deco/profile.js |
294 | calculateTissueLoading |
The dependency graph is acyclic and flows strictly downwards: constants → config → environment → gasKinetics → gradients → ceiling → schedule/profile.
Symbols that are exported from a js/deco/*.js module purely so a sibling module can use them (DESCENT_SPEED, STOP_INCREMENT, findFirstStagedStopAtGFLow, evaluateDirectAscent) are not re-exported by the barrel and remain private to the deco engine. ASCENT_SPEED is re-exported so the dive-plan table (renderDivePlanTableHTML, practical runtime) moves between stops at the same rate as the scheduler and the profile chart.
Together they implement Haldane and Schreiner kinetics, M-values, gradient-factor interpolation, NDL search, and the deco-stop scheduling loop — the largest body of code in the code base (~1900 lines).
Imports: COMPARTMENTS, getRateConstant from tissueCompartments.js.
Imported by: diveSetup.js, tissueEducation.js, and every chart in js/charts/.
Constants
| Name | Value | Purpose |
|---|---|---|
CALC_INTERVAL |
10 |
Simulation time step, seconds |
SURFACE_PRESSURE |
1.01325 |
1 atm in bar |
WATER_VAPOR_PRESSURE |
0.0627 |
At 37 °C, in bar |
N2_FRACTION |
0.7902 |
Lumps argon with N₂ (standard deco-model convention) |
PRESSURE_PER_METER |
0.1 |
EN 13319 default, bar per metre |
ASCENT_SPEED |
10 |
Planner ascent rate, m/min (scheduler, profile chart, practical plan-table runtime) |
WATER_TYPES |
standard, fresh, sea
|
Supported depth-to-pressure modes |
WATER_DENSITIES |
1019.716, 1000, 1025 kg/m³ | Documented reference densities |
DEFAULT_GF_LOW, DEFAULT_GF_HIGH
|
1.0, 1.0
|
As fractions (not percentages) |
DECO_STOP_MAX_MINUTES |
300 |
Safety cap per stop; exceeding throws DecoCapExceededError
|
DecoCapExceededError |
class | Thrown when a single stop would need > 5 h |
Exports — Pressure & alveolar
| Signature | Line | Description |
|---|---|---|
getPressureAtAltitude(altitude=0) |
21 | Standard-atmosphere conversion from metres to bar |
getSurfacePressure(environment=null) |
36 | Resolves explicit surfacePressure or derives it from altitude
|
getPressurePerMeter(environment=null) |
— | Resolves EN 13319 (0.1), freshwater (0.0980665), or seawater (0.1005181625 bar/m) |
getAmbientPressure(depth, surfacePressure=1.01325, pressurePerMeter=0.1) |
153 | surfacePressure + depth × pressurePerMeter |
getAlveolarN2Pressure(ambient, n2=0.7902) |
110 | (ambient − 0.0627) × n2Frac |
getInitialTissueN2(n2=0.7902, surfacePressure=1.01325) |
119 | Alveolar N₂ at the selected dive-site surface |
Exports — Kinetics
| Signature | Line | Description |
|---|---|---|
haldaneEquation(P0, Palv, t, halfTime) |
108 | Constant-depth exponential loading. See Model-02-Haldane-Equation. |
schreinerEquation(P0, Palv0, R, t, halfTime) |
125 | Linear-rate loading. See Model-03-Schreiner-Equation. |
simulateDepthTime(tissues, depth, t, n2, surfacePressure=1.01325, pressurePerMeter=0.1) |
996 | Vector-apply Haldane across all 16 compartments |
simulateDepthChange(tissues, startDepth, endDepth, t, n2, surfacePressure=1.01325, pressurePerMeter=0.1) |
1024 | Vector-apply Schreiner across all 16 compartments |
Exports — M-values & ceilings
| Signature | Line | Description |
|---|---|---|
getMValue(ambient, a, b) |
145 |
a + ambient/b — raw Bühlmann limit |
getAdjustedMValue(ambient, a, b, gf) |
160 | ambient + gf × (M − ambient) |
getCompartmentCeiling(Pt, a, b, gf) |
244 | Minimum ambient pressure this compartment permits |
getDiveCeiling(tissues, gf, surfacePressure=1.01325, pressurePerMeter=0.1) |
348 | Deepest (most-restrictive) ceiling across 16 compartments; returns {ceiling, ceilingDepth, controllingCompartment}
|
getFirstStopDepth(tissues, gfLow, stopIncrement=3, surfacePressure=1.01325, pressurePerMeter=0.1) |
580 | First mandatory stop rounded up to 3 m grid |
Exports — Gradient factors
| Signature | Description |
|---|---|
calculateInstantGF(Pt, ambient, compartment) |
(Pt − ambient) / (M − ambient), expressed as 0–1 |
calculateMaxGF(tissues, ambient) |
Returns {gfMax, leadingCompartment, allGFs} across supersaturated tissues; when all tissues satisfy gfMax: 0 and leadingCompartment: null while preserving raw negative values in allGFs
|
findFirstStopAtGFLow(tissues, depth, n2, gfLow, stopIncrement, ascentRate, gasSwitchPoints, surfacePressure, recordDecision, pressurePerMeter) |
The convention's first-stop search: shallowest stop-grid depth where the dive ceiling at GF_low is satisfied after simulated ascent. Returns {anchorDepth, pAnchor, tissuesAtAnchor}. The canonical pAnchor source used by generateDecoSchedule, calculateCeilingTimeSeriesDetailed, MValueChart, and GFChart. See Algo-03-First-Stop-Ramped-GF. |
interpolateGF(ambient, pAnchor, gfLow, gfHigh, surfacePressure=1.01325) |
Linear ramp from GF-low at pAnchor to GF-high at the selected surface pressure |
getFirstStopDepth(tissues, gfLow, stopIncrement=3) |
Static (no ascent simulation): rounds the current dive ceiling at GF_low up to the stop grid. Used for quick lookups; the deco scheduler uses findFirstStopAtGFLow instead. |
Exports — NDL & deco scheduling
| Signature | Line | Description |
|---|---|---|
DECO_MODES |
63 | Frozen mode names: standard, adaptive, and continuous. |
DECISION_AUDIT_VERSION |
69 | Schema version for structured scheduler decision events. |
getDecoMode(options={}) |
70 | Validates the explicit mode and migrates legacy continuousDeco; missing values default to standard. |
calculateNDL(depth, n2=0.7902, gfHigh=1.0, initialTissuePressures=null, surfacePressure=1.01325, pressurePerMeter=0.1) |
859 | Binary-searches the maximum bottom runtime whose direct ascent on bottom gas reaches the surface within GF High. Returns {ndl, ndlExact, ndlAtDepth, ndlAtDepthExact, controllingCompartment, descentTime}. |
generateDecoSchedule(tissues, depth, n2, gfLow, gfHigh, gases=null, options={}) |
966 | Attempts the same GF High direct ascent before creating a GF Low anchor. Standard mode uses Decotengu-style iterative first-stop discovery and a minimum minute at every active 3 m level. With alignRuntimeDepartures: true plus absolute runtimeStart, each recorded stop also has an integer departureRuntime; the alignment wait is tissue-simulated and the destination ceiling is rechecked. Returns {stops, gasSwitches, totalTime, totalAscentTime, pAnchor, anchorDepth, decisionAudit}. |
generateDecoSchedule options:
-
decoMode(defaultstandard) —standard,adaptive, orcontinuous -
continuousDeco— deprecated compatibility alias; onlytruehas an effect -
gasSwitchTime(default 0) — minutes held at switch depth -
switchPpO2(default 1.6) — cap used for deco gas MOD -
surfacePressure(default 1.01325 bar) — absolute atmospheric pressure at the dive site -
pressurePerMeter(default 0.1 bar/m) — EN, freshwater, or seawater hydrostatic conversion -
audit(defaultfalse) — emit versioned structured events for the direct-ascent test, anchor search, gas switches, and per-level decisions
Exports — Full-dive simulation
| Signature | Line | Description |
|---|---|---|
calculateTissueLoading(profile, surfaceInterval=60, options={}) |
1645 | Main entry: walks the waypoint array at CALC_INTERVAL resolution. options.surfacePressure controls atmospheric pressure and initial equilibrium; options.pressurePerMeter controls hydrostatic conversion. Both resolved values are returned on results. |
calculateCeilingTimeSeries(results, gfLow, gfHigh=gfLow, providedPAnchor=null) |
483 | Flat array of ceiling depths at each time point; a surface anchor means GF High applies throughout |
calculateCeilingTimeSeriesDetailed(results, gfLow, gfHigh, providedPAnchor=null) |
510 | Returns per-compartment ceiling series plus gfValues and pAnchor; performs the GF High direct-ascent decision when no anchor is supplied |
Implementation notes
- Haldane is coded as
Palv + (P0 − Palv) × e^(−k·t)atdeco/config.js:20. - Schreiner is coded as a three-term form at
deco/config.js:37–39: constant inert-gas source + exponential offset. -
pAnchoris an ambient-pressure value, not a depth.findFirstStopAtGFLowiterates the stop grid surface-up, simulating the ascent to each candidate and checking the dive ceiling atgfLow(getDiveCeiling). The first depth that passes is the anchor — also the first decompression stop. See Algo-03-First-Stop-Ramped-GF. - Ascent permission in the deco loop (
deco/schedule.js:572–591) checks the GF-adjusted ceiling at the destination stop depth against the destination depth, without Schreiner-crediting the short ascent segment. This matches decotengu's convention. -
gasKey()helper atdeco/ceiling.js:368normalises gases with or without anidfield, so the deco loop tolerates both library gases and custom mixes.
16 Bühlmann ZH-L16 compartment definitions, runtime-switchable between variants A / B / C.
Imports: none.
Imported by: decoModel.js, diveSetup.js, tissueEducation.js, every chart, every test file.
Exports
| Name | Signature | Description |
|---|---|---|
ZHL16_VARIANTS |
{A: 'ZH-L16A', B: 'ZH-L16B', C: 'ZH-L16C'} |
Enum |
COMPARTMENTS |
array of 16 objects | Current active compartments. Rebuilt in-place on variant switch. |
getZHL16Variant() |
() => string |
Current active variant name |
setZHL16Variant(variant) |
(string) => void |
Switch variant and rebuild COMPARTMENTS in place |
getCompartmentsForVariant(variant) |
(string) => Array |
Inspect a variant without mutating global state |
getRateConstant(halfTime) |
(number) => number |
ln(2) / halfTime |
getCompartmentCategory(halfTime) |
(number) => string |
Returns the educational grouping "Fast" (≤12.5 min), "Medium" (≤77 min), or "Slow" |
Implementation notes
- Each compartment object has
{id, halfTime, bN2, aN2, label, color}after build; seebuildCompartments()aroundtissueCompartments.js:140. - TC1's half-time and
bcoefficient swap as a pair between variants. ZH-L16A uses 4.0 min /b=0.5050; B and C use 5.0 min /b=0.5578. SeeCOMPARTMENT_1_HALFTIMEat line 46 andCOMPARTMENT_1_B_N2around line 55. - All variants share TC2–16 half-times; only the
acoefficient varies between A, B, C for compartments 5–15 (A_COEFFICIENTS_16A/B/C at lines 102, 115, 128). -
setZHL16Variant()clears and repushes the existingCOMPARTMENTSarray (tissueCompartments.js:190–191) rather than reassigning, so downstream code holding a reference (e.g.decoModel.js) picks up the change without reimporting. - Default active variant is ZH-L16C (
currentVariant = ZHL16_VARIANTS.Cat line 40). Matches the decotengu default. - All times in minutes, pressures in bar. See Model-01-Compartments for the full coefficient table.
Dive configuration, gas library, profile generation, and gas-switch waypoint insertion. The second-largest module (~1470 lines).
Imports: calculateNDL, generateDecoSchedule, simulateDepthTime, simulateDepthChange, getInitialTissueN2, and other helpers from decoModel.js; COMPARTMENTS from tissueCompartments.js; translate from i18n.js.
Imported by: every chart, DiveSetupEditor.js.
| Export | Line | Description |
|---|---|---|
BOTTOM_GASES |
40 | Air (O₂ 0.2098 / N₂ 0.7902), EAN32, EAN36 |
DECO_GASES |
50 | EAN50, EAN80, O₂ 100% |
PREDEFINED_GASES |
59 | Union of the two above |
BOTTOM_CYLINDERS, STAGE_CYLINDERS
|
65, 81 | Cylinder size presets (litres) |
getPredefinedGas(id), getBottomGas(id), getDecoGas(id)
|
119, 128, 137 | ID lookups |
Note: BOTTOM_GASES[0].n2 is 0.7902, matching N2_FRACTION in decoModel.js.
| Signature | Line | Description |
|---|---|---|
loadDiveSetup(path='data/dive-setup.json') |
146 | async; tries localStorage, falls back to JSON fetch, caches in module scope |
getDefaultSetup() |
177 | Hardcoded 40 m / 20 min air dive |
clearCache() |
1241 | Clears the module-level cache |
saveDiveSetup(setup, key='diveSetup') |
1250 | Persist to localStorage |
loadSavedSetup(key='diveSetup') |
1263 | Restore from localStorage |
extendDiveSetup(base, overrides) |
748 | Deep merge with validation |
| Signature | Line | Description |
|---|---|---|
generateSimpleProfile(maxDepth, bottomTime, safetyStop, options) |
240 | No-deco profile. Descent 20 m/min, ascent 10 m/min, optional 3 min @ 5 m. |
generateDecoProfile(maxDepth, bottomTime, gases, gfLow, gfHigh, safetyStop, options) |
336 | Runs the NDL check and builds the complete waypoint array. A fresh NDL dive carrying only bottom gas uses the simple-profile fast path; if deco gases are configured, the full scheduler still runs so their MOD switches, optional switch time, audit events, and tissue-loading gas IDs are preserved even with zero mandatory stops. Pure switch holds do not set requiresDeco, populate decoStops, or contribute to totalDecoTime; a coincident safety stop extends the hold to the configured safety-stop duration. options.surfacePressure and options.pressurePerMeter carry the selected environment through NDL, tissue loading, MOD, and scheduling. With options.alignRuntimeDepartures, it passes bottomTime as the default absolute runtimeStart. Returns {waypoints, ndl, requiresDeco, decoStops, totalDecoTime, controllingCompartment, pAnchor, anchorDepth}. Accepts optional options.initialTissuePressures — when provided, tissues are seeded from that map and the surface-based NDL early-return is bypassed so the scheduler always runs against the actual pre-saturated state. See repetitive-dive chaining. |
generateDecoProfileSync(...) |
567 | Variant accepting a pre-loaded compartments array. Does not support options.initialTissuePressures; callers needing a seeded profile must use generateDecoProfile. |
generateDecisionAudit(setup) |
832 | Reconstructs the end-of-bottom tissue state for a generated single-dive setup and returns the scheduler's opt-in structured decision audit. |
getNDLForDepth(depth, gas, gfHigh) |
720 | Convenience wrapper around calculateNDL. Returns the full result, including descentTime. |
getNDLStatus(ndl, bottomTime) |
729 | Classifies a dive against its NDL for UI display. Both arguments run from leaving the surface, so they are compared with no correction — subtracting the descent here would count it twice. Returns {state: 'unlimited'|'ok'|'nearLimit'|'deco', remaining}. nearLimit is the band below the limit (NDL_NEAR_LIMIT_FRACTION = 0.9), so a genuine deco obligation is never reported as merely "at limit". Callers pass ndlExact (not the floored ndl) so the badge cannot disagree with the generated profile. |
"Bottom time" means the absolute time at which ascent begins, not time spent at max depth. Descent duration is exact (not rounded); ascent time snaps to 0.1 min except in continuous study mode.
| Signature | Line | Description |
|---|---|---|
calculateMOD(o2Fraction, maxPpO2=1.4, surfacePressure=1, pressurePerMeter=0.1) |
1023 | MOD in metres; non-integers round down, while exact integer boundaries are preserved within floating-point tolerance |
calculateEND(depth, heFraction=0) |
874 | (depth + 10) × (1 − fHe) − 10 |
calculatePartialPressure(depth, gasFraction) |
886 | fraction × (1.01325 + depth/10) |
getGasCylinderVolume(gas), getCylinderVolume(setup)
|
896, 905 | Litres |
getGasStartPressure(gas) |
915 | bar |
computeGasConsumption(results, gases, sacRate, decoSacRate, reservePressure=50) |
1612 | Per-gas consumption over the profile; a gas with its own sacRate (l/min, set for recorded dives by js/import/recordedGas.js) uses it instead of sacRate/decoSacRate
|
| Signature | Line | Description |
|---|---|---|
NOAA_CNS_LIMITS |
1312 | Discrete ppO₂ / max-exposure lookup table |
getCNSPerMinute(ppO2) |
1336 | Percent per minute; 0 if ppO₂ < 0.5 |
calculateOTU(ppO2, timeMinutes) |
1358 |
t × ((ppO₂ − 0.5)/0.5)^0.83 (NOAA form) |
OTU_LIMITS |
1365 | Daily / series exposure limits |
Toxicity is informational; not fed back into the deco loop.
| Signature | Line | Description |
|---|---|---|
getDiveSetupWaypoints(setup) |
808 | Extracts dives[0].waypoints
|
getSurfaceInterval(setup), getGFLow(setup), getGFHigh(setup), getGradientFactors(setup)
|
821, 830, 839, 848 | Getters |
getGases(setup), getBottomGasFromSetup(setup), getDecoGasesFromSetup(setup)
|
938, 959, 969 | Gas collections |
getGasAtWaypoint(waypoint, gases), getGasAtTime(waypoints, gases, time)
|
981, 1003 | Active gas lookup |
getGasSwitchEvents(waypoints, gases) |
1028 | Returns [{time, depth, fromGas, toGas}]
|
insertGasSwitchWaypoints(waypoints, gases, ascentRate=10, maxPpO2=1.6, gasSwitchTime=0) |
1063 | Inserts switch waypoints at MOD depths on ascent; rounds to 3 m grid |
| Signature | Line | Description |
|---|---|---|
generateProfileName(setup) |
1279 | Short label for UI |
formatDiveSetupSummary(setup) |
1295 | Multi-line human summary |
renderDivePlanTableHTML(waypoints, gases, opts) |
1722 | Returns HTML for the dive-plan table. Both split tables mark the Runtime heading with a superscript asterisk tied to the shared definition of runtime. The optional departure convention adds its calculation details as a separate unmarked footnote and renders scheduler-aligned departure runtimes, while the legacy default remains stage-end. opts.runtimeConvention: 'practical' keeps the legacy whole-minute model stops, moves between stops at the planner's ASCENT_SPEED (10 m/min, i.e. 18 s per 3 m; override with opts.ascentRate) so the table matches the profile chart, and rounds ascent-table runtimes UP to whole minutes (never earlier than the model; descent/bottom rows round to nearest); intermediate stop-to-stop ascent rows are omitted while their time remains included. A gas switch is always billed against the OLD gas for the ascent leg leading into it; the instant Switch marker row that follows consumes no gas, so the new gas's Tank cell shows its untouched pressure. |
DEFAULT_GAS_SWITCH_TIME=0 (line 34), DEFAULT_START_PRESSURE=200, DEFAULT_RESERVE_PRESSURE=50 (lines 91, 96), DEFAULT_GF_LOW=100 / DEFAULT_GF_HIGH=100 as percentages (lines 101–102), DEFAULT_SAFETY_STOP={enabled:true, depth:5, time:3} (line 107).
Repetitive-dive trip planner (sub-project ①). Chains a sequence of square dives in chronological order: tissues off-gas at the surface between dives via simulateDepthTime, and each dive's deco profile is regenerated from the carried-in tissue state via the options.initialTissuePressures seam in generateDecoProfile.
Pure module — no DOM, no side effects.
Imports: generateDecoProfile from diveSetup.js; calculateTissueLoading, simulateDepthTime, N2_FRACTION from decoModel.js.
Imported by: js/components/TripCalendar.js (indirectly, via the sandbox trip-planner page).
Exports
| Signature | Line | Description |
|---|---|---|
planTrip(diveSetup) |
47 | Plans a sequence of dives; returns { dives, conflicts }
|
diveSetup shape:
{
gases: [...], // shared gas list (same format as single-dive setup)
gfLow: number, // as percentage (default 100)
gfHigh: number, // as percentage (default 100)
dives: [
{ id: string, startDateTime: number, maxDepth: number, bottomTime: number, gases?: Array },
...
]
}Per-dive gases field (optional, tripPlanner.js:55): when present on an individual dive, it overrides the shared diveSetup.gases for that dive's profile generation and tissue-loading simulation. Falls back to diveSetup.gases when absent. This allows each dive in a trip to use a different gas set (e.g. different deco mix) while keeping a single shared default.
Return value:
{
dives: [
{
id,
name, // string | undefined — echoed from the input dive (tripPlanner.js:78)
startDateTime, // epoch minutes (input, passed through)
endDateTime, // epoch minutes — accounts for deco extension
maxDepth, // metres (input, passed through — tripPlanner.js:80)
bottomTime, // minutes (input, passed through — tripPlanner.js:81)
surfaceIntervalBefore, // minutes; null for first dive
startingTissue, // { [compartmentId]: nitrogenPressureBar } at dive entry
endTissue, // { [compartmentId]: nitrogenPressureBar } at surfacing
profile, // full generateDecoProfile result for this dive
ndlLocked // boolean — echoed from the input dive (false when absent)
},
...
],
conflicts: [
{ diveId, type: 'overlap', overrunMinutes }
// emitted when a dive is scheduled to start before the previous dive's deco-extended end
]
}name, maxDepth, and bottomTime are echoed onto each result dive (tripPlanner.js:78–81) so callers such as TripCalendar can label blocks without re-parsing the waypoint array.
Implementation notes
- Dives are sorted by
startDateTimebefore processing (tripPlanner.js:32). - Surface off-gassing is computed by
simulateDepthTime(tissue, 0, gap, N2_FRACTION)wheregapis the actual surface interval in minutes (tripPlanner.js:51). - Per-dive gas selection:
const diveGases = dive.gases ?? gasesattripPlanner.js:55. - When an overlap conflict is detected, the overlapping dive is still planned using the previous dive's end tissue state, with no surface off-gassing applied (the conflict entry records the overrun minutes).
- For the first dive, tissues start at surface equilibrium (no seed is passed).
-
NDL-locked dives (
ndlLocked: trueon the input dive): instead of using the caller-suppliedbottomTime,planTripderivesbottomTimeas the pre-saturation-aware NDL for that position in the trip —calculateNDLseeded with the carried-in tissue (startingTissue). The result is capped at 99 min (NDL_LOCK_CAP) and checked against the descent time (maxDepth / 20, matchinggenerateDecoProfile's 20 m/min rate) so a dive with under a minute of real bottom phase is flagged invalid. This ensures a moved NDL-locked dive always shows the same number the add-dialog/ndlPreviewshowed at creation. -
Safety stops disabled for trip dives: all trip dives are generated with
generateDecoProfile(..., { safetyStop: { enabled: false } }). No-deco dives' runtime and TTS therefore reflect pure deco obligation without an appended 3 m safety stop. -
Invalid NDL-locked dives (
invalid: true,invalidReason: 'ndl-too-short'): when an NDL-locked dive's actual bottom phase —min(NDL, NDL_LOCK_CAP) − descentTime— is under 1 min, the dive is flaggedinvalid: truewithinvalidReason: 'ndl-too-short'. The profile is still generated at the floored bottom time to preserve tissue continuity for subsequent dives, but the UI renders an explanation instead of the (degenerate) dive profile. - See repetitive-dive chaining for the
initialTissuePressuresseam used internally.
Both calculateTissueLoading and generateDecoProfile accept an optional options.initialTissuePressures — a { [compartmentId]: nitrogenPressureBar } map.
When provided:
-
calculateTissueLoading(deco/profile.js:106–115): seeds each compartment from the map instead of callinggetInitialTissueN2. Useful for plotting the tissue trajectory of a repetitive dive starting from residual saturation. -
generateDecoProfile(diveSetup.js:351–373): seeds the bottom-phase tissues from the map and bypasses the surface-based NDL early-return. The NDL computed bycalculateNDLis a fresh-start figure — it is meaningless when the diver already carries residual nitrogen. By skipping the early-return and always running the full deco scheduler,generateDecoProfilecomputes the actual deco obligation against the pre-saturated tissue state (which may require stops even when bottom time is under the surface NDL).
generateDecoProfileSync intentionally does not support this option; its NDL early-return and surface-only tissue init assume a fresh surface start.
planTrip in tripPlanner.js is the only current consumer of this seam.
Pre-saturation at the start of a (repetitive) dive (sub-project ②).
"How loaded are the tissues vs a fresh surface-saturated diver, on a 0–100 scale toward the surfacing limit?" The baseline is the surface-saturation value — the inert-gas tension a fresh diver carries at the surface on air, getInitialTissueN2(N2_FRACTION) ≈ 0.751 bar — not ambient pressure. Per compartment: (P − surfaceSat) / (M0 − surfaceSat), clamped at 0, where M0 is the surfacing M-value. So 0 % = fresh/fully off-gassed, 100 % = the leading tissue is already at its surfacing M-value before the dive begins (> 100 % means over the surfacing limit at the surface, e.g. a too-soon repeat dive).
Anchoring at the surface-saturation baseline (not ambient, as the surfacing gradient factor would) is deliberate: residual loading that sits between the baseline (~0.751 bar) and ambient (~1.013 bar) — exactly the band that makes a repeat dive accrue more deco — reads 0 on a GF but registers here. (Earlier versions used the ambient-anchored surfacing GF and read 0 % for such dives despite their longer deco.)
Pure module — no DOM, no side effects.
Imports: getMValue, getAmbientPressure, getInitialTissueN2, N2_FRACTION from decoModel.js; COMPARTMENTS from tissueCompartments.js.
Imported by: the repetitive-dive selected-dive panel (sandbox).
Exports
| Signature | Line | Description |
|---|---|---|
preSaturation(tissuePressures) |
36 | Returns { controllingPct, controllingCompartmentId, perCompartmentPct }. controllingPct is the maximum pre-saturation across all 16 compartments (clamped at 0), as a percentage of the way from the surface-saturation baseline to the surfacing M-value. controllingCompartmentId is the numeric id of the leading compartment. perCompartmentPct is keyed by numeric compartment id. |
tissuePressures input shape: { [compartmentId]: nitrogenPressureBar } — the same shape produced by planTrip in tripPlanner.js (startingTissue / endTissue fields).
Implementation notes
- Calls
calculateMaxGF(tissuePressures, surfaceAmbient)(decoModel.js), wheresurfaceAmbient = getAmbientPressure(0)(preSaturation.js:26). - Clamp:
Math.max(0, g) * 100atpreSaturation.js:28. -
perCompartmentPctiteratesallGFsfromcalculateMaxGFand applies the same clamp per compartment (preSaturation.js:31–33).
Immutable reducer over a trip's dive list (sub-project ③). Every operation returns a new trip object; the original is never mutated. The dives array produced here is the direct input to planTrip.
Pure module — no DOM, no side effects.
Imports: none. Imported by: the trip-planner sandbox page.
Exports
| Signature | Line | Description |
|---|---|---|
addDive(trip, fields) |
22 | Appends a new dive with a stable max-based id ('d<n>'); returns new trip |
editDive(trip, id, patch) |
27 | Shallow-merges patch onto the matching dive; returns new trip |
removeDive(trip, id) |
34 | Filters the dive out; returns new trip |
rescheduleDive(trip, id, startDateTime) |
38 | Sugar for editDive that only updates startDateTime; returns new trip |
A trip dive shape: { id, startDateTime (epoch minutes), maxDepth, bottomTime, gases, name? }. The optional name field (e.g. 'Dive 1') carries a user-editable label; planTrip echoes it onto each result dive.
Implementation notes
-
addDivedelegates id assignment to the privatenextId(dives)helper (tripState.js:13), which scans existing ids for the highest numeric suffix and returns'd<max+1>'. This is max-based (not length-based) so ids never collide after a removal. - All four exports are pure:
tripin, newtripout, no mutation.
Position-aware NDL preview for a candidate dive at an arbitrary point in a trip. A dive's carried-in tissue load depends only on the dives before it, not on its own duration — so the candidate is inserted as a placeholder, planTrip is run to extract startingTissue, and that seed is passed to the new initialTissuePressures parameter of calculateNDL.
Pure module — no DOM, no side effects.
Imports: planTrip from tripPlanner.js; addDive from tripState.js; calculateNDL from decoModel.js.
Imported by: the trip-planner sandbox page (via AddDiveDialog).
Exports
| Signature | Line | Description |
|---|---|---|
previewNdl(trip, candidate, gfHigh = trip.gfHigh ?? 100) |
21 | Returns the pre-saturation-aware NDL in minutes for a candidate dive. candidate shape: { startDateTime, maxDepth, gases }. gfHigh is a percentage (0–100). |
Implementation notes
- Inserts a placeholder dive with
bottomTime: 1— the value is irrelevant becausestartingTissuedepends only on prior dives and the surface gap (ndlPreview.js:25). - Resolves N₂ fraction from
candidate.gases[0].n2; falls back to0.79when no gases are supplied (ndlPreview.js:32). - Passes
gfHigh / 100tocalculateNDLbecausecalculateNDLexpects a decimal fraction, while the trip config and callers use percentages (ndlPreview.js:33).
Pure calendar layout engine (sub-project ③). Converts a planTrip result into day columns and absolutely-positioned duration blocks. No DOM; the renderer (TripCalendar) maps the output percentages to pixels.
Pure module — no DOM, no side effects.
Imports: none.
Imported by: js/components/TripCalendar.js.
Exports
| Signature | Line | Description |
|---|---|---|
computeCalendarLayout(planResult, windowConfig) |
19 | Returns { dayCount, baseDay, blocks }
|
windowConfig shape: { dayStartMin, dayEndMin, dayCount } — visible window as minutes-of-day (e.g. { dayStartMin: 360, dayEndMin: 1200 } for 06:00–20:00) plus the caller-supplied column count. dayCount is not derived from the dives; it comes from the trip configuration.
planResult shape: planTrip() output — { dives: [{id, startDateTime, endDateTime}], conflicts: [{diveId}] }. startDateTime values are trip-relative minutes (day 0 = trip-start midnight).
Return value:
{
dayCount: number, // echoed from windowConfig.dayCount (caller-supplied)
baseDay: number, // always 0 — days are trip-relative, not epoch-absolute
blocks: [
{
diveId: string,
dayIndex: number, // floor(startDateTime / 1440) — trip-relative column index
topPct: number, // top edge as % of window span (clamped 0–100)
heightPct: number, // block height as % of window span (clamped 0–100)
conflict: boolean, // true if diveId appears in planResult.conflicts
startMinOfDay: number, // dive start as minutes-of-day (may be < dayStartMin)
endMinOfDay: number // dive end clamped to dayEndMin
},
...
]
}Implementation notes
-
baseDayis always0; trip-relative epoch minutes already count from day 0 so no offset is needed (calendarLayout.js:45). -
dayIndexisfloor(startDateTime / 1440)(calendarLayout.js:27). - Block top clips early dives:
visibleStart = Math.max(startMinOfDay, dayStartMin)(calendarLayout.js:31). - Dives crossing midnight are clamped to
dayEndMinfor v1 (documented limitation,calendarLayout.js:29). -
dayCountis taken directly fromwindowConfig.dayCount; the function no longer derives it from the dives (calendarLayout.js:20, 45).
Chart.js-driven educational animations for tissue-loading.html. Not imported as a library (no exports); executes on module load.
Constants used (duplicated from decoModel.js for page self-containment):
-
WATER_VAPOR_PRESSURE = 0.0627(line 7) -
SURFACE_PRESSURE = 1.01325(line 8) -
N2_FRACTION = 0.7902(line 9) -
SURFACE_ALVEOLAR_N2≈ 0.755 bar (line 10)
Internal functions: animateTissueBars() (line 129), calculateOngassing() (line 320), calculateOffgassing() (line 462). All use P(T) = target + (initial − target) × e^(−ln2·T) on a 0–6 half-time axis with 0.5 T steps.
Class component (~1600 lines) rendering the depth-vs-time chart with ceiling overlay, gas-switch markers, deco-stop annotations, and optional tissue-compartment overlays.
Exports: class DiveProfileChart (line 63), factory createDiveProfileChart(container, config) (line 1609).
Imports: COMPARTMENTS from tissueCompartments.js; calculateTissueLoading, calculateCeilingTimeSeries, getAmbientPressure, and others from decoModel.js; theme, applyChartTheme, formatAxis from chartTheme.js; validateDiveSetup, normalizeDiveSetup, mergeOptions, DEFAULT_DIVE_PROFILE_OPTIONS from chartTypes.js; translate from i18n.js; getGases, getGasSwitchEvents from diveSetup.js.
Key methods: constructor(container, config), _render(), update(diveSetup), destroy(), _buildTissueControls(). Accepts options.mode values for profile-only, tissue-overlay, or ppO₂/ppN₂ overlays. Listens for the global languagechange event.
Class component rendering the pressure-pressure (P-P) M-value diagram with time-slider playback and an optional horizontal intersection ruler.
Exports: class MValueChart, pure helper calculateMValueRulerIntersections({ tissuePressure, compartment, gfLow, gfHigh, surfacePressure, pAnchor }), and factory createMValueChart(container, config).
Imports same as DiveProfileChart.js plus chart-specific helpers. Uses createInteractionLockBtn from interactionLock.js.
Key methods: _setupKeyboardShortcuts() (arrow keys step time, shift+arrow jumps waypoint, space toggles play/pause, home/end jump to start/end, T toggles the ruler). The ruler panel starts with the current ambient pressure and depth, then shows the geometric intersections. An active ruler follows normal one-compartment selection instead of retaining a hidden stale compartment. It uses getCompartmentCeiling() for fixed GF Low/High references and solves the intersection with the interpolated adjusted-M-value ramp between pAnchor and the surface; at GF 100/100 the three identical GF results collapse into one M-value row. The component controls include the alveolar nitrogen-pressure visibility toggle, so it remains available in fullscreen. Playback loop redraws with chart.update('none') to keep the slider smooth.
Class component (~1300 lines) plotting instantaneous GF (%) per compartment against ambient pressure, with the GF corridor as a shaded band.
Exports: class GFChart (line 81), factory createGFChart(container, config) (line 1311).
Plots 100 × (P_tissue − P_amb) / (a + P_amb/b − P_amb) per compartment, the 100 % Bühlmann reference line, a shaded corridor from GF-low at pAnchor to GF-high at surface, and a vertical line at pAnchor. Current tissue markers contain their compartment number directly in the coloured circle. A responsive right-side table ranks visible supersaturated compartments by their current instantaneous GF; undersaturated compartments with negative GF remain plotted but are omitted from the table.
Chart.js theme glue (~200 lines). No state; idempotent.
| Export | Line | Description |
|---|---|---|
theme() |
32 | Reads CSS custom properties from :root, returns a palette object |
applyChartTheme() |
64 | Applies defaults to Chart.defaults globally |
depthGradient(ctx, area, strong, weak) |
158 | Canvas gradient helper |
formatAxis(v, decimals=0) |
175 | Axis-tick formatter |
watchThemeChanges(onChange) |
189 | Observes prefers-color-scheme / data-theme attribute |
Validation and normalisation of diveSetup objects for chart consumption (~300 lines).
| Export | Line | Description |
|---|---|---|
DEFAULT_ENVIRONMENT |
146 | Sea-level altitude and EN 13319 water mode |
DEFAULT_DIVE_PROFILE_OPTIONS |
156 | Chart display toggles |
DEFAULT_TISSUE_PRESSURE_OPTIONS |
184 | Tissue overlay defaults |
mergeOptions(defaults, user) |
210 | Shallow-per-key deep merge |
createLegendHelpIcon() |
— | Creates the accessible, click-isolated DOM help icon used next to chart legend items |
positionLegendHelpIcon(chart, icon, marker, text) |
— | Positions a DOM help icon next to the legend dataset carrying marker, or hides it when absent |
validateDiveSetup(setup) |
232 | Returns {valid, errors}
|
normalizeDiveSetup(setup) |
300 | Applies defaults, coerces types, returns a fresh object. Missing or invalid environment.waterType becomes standard. Preserves initialTissuePressures from the input setup, defaulting to null (surface equilibrium). When non-null this value is threaded into each chart's calculateTissueLoading call to seed tissues from a prior dive's residual state for repetitive-dive rendering. |
Small helper (~100 lines) that adds a toggle button to lock/unlock Chart.js zoom/pan on mobile.
| Export | Line | Description |
|---|---|---|
createInteractionLockBtn(getChart, container, opts) |
22 | Injects a button and wires the zoom plugin's enabled flag |
class BubbleModel (line 28). A visualisation-only toy model, not wired into the decompression algorithm.
class DiveSetupEditor extends EventTarget (line 115; ~1700 lines). Embeddable form editor that produces the diveSetup JSON consumed by the three chart components.
Default export at line 1674. Emits change events with detail.diveSetup when the form mutates (configurable via options.emitOnInput). options.autoGenerateProfile regenerates quick-setup waypoints after a debounced 250 ms delay; the main Sandbox enables it, while embedded editors keep the default false. Sections: gas management (library + custom), waypoint editor with drag-reorder, gradient-factor sliders with presets (Bühlmann, Conservative, Deco Planner), safety stop, SAC rates, import/export JSON textarea. Re-renders on languagechange.
Multi-dive toggle (showMultiDive) exists but only dives[0] is rendered by the chart components; see the note in CLAUDE.md.
class TripCalendar extends EventTarget (line 29). Renders a planTrip result as duration-spanning blocks across day columns, with a left-side hour ruler, per-column date headers, and hour gridlines. Owns no trip state — it reads a plan result and emits interaction events; the caller mutates state and re-renders.
Also exports the pure helpers snapClamp and diveBlockLabel.
Imports: computeCalendarLayout from ../calendarLayout.js.
Imported by: the trip-planner sandbox page.
Exported pure helpers
snapClamp(rawMin, dayStartMin, dayEndMin, snap)Rounds rawMin to the nearest snap-minute boundary, then clamps the result to [dayStartMin, dayEndMin]. Used internally during drag to produce the drop position; also importable by callers that need the same arithmetic.
diveBlockLabel(plannedDive)Returns the full block label string for a planTrip result dive in the form "{name} · {depth}m · {bottomTime}min". The number shown is bottom time (the block's height already conveys total runtime), followed by " · +{totalDecoTime} deco" for deco dives or " · NDL" for NDL-locked no-deco dives. When the dive is flagged invalid: true, returns "{name} · {depth}m · ⚠ no-deco N/A". Examples: "Dive 1 · 40m · 30min · +28 deco" (deco), "Dive 2 · 18m · 40min · NDL" (NDL-locked no-deco), "Dive 3 · 30m · 25min" (plain no-deco). Replaces the old decoLabelSuffix export (which only returned a stop/TTS suffix and had no NDL tag). Used by render to populate calendar block labels.
Constructor
new TripCalendar(container, config = {})
// config.window: { dayStartMin, dayEndMin, dayCount }
// defaults: { dayStartMin: 360, dayEndMin: 1200, dayCount: 3 } (06:00–20:00, 3 columns)
// config.startDate: ISO date string for the first column header (default '2026-06-15')The constructor wires two delegated listeners on the persistent container:
- A
clicklistener (TripCalendar.js:40) that handlescreateAtandselectDive(see_onClick, line 46). Because it lives on the container rather than on DOM nodes rebuilt byrender, it survives every calendar redraw. - A
pointerdownlistener (TripCalendar.js:43) that initiates drag tracking (see_onPointerDown, line 65).
_justDragged (initialised at line 41) is a one-shot flag set by _onPointerUp to swallow the trailing click event that the browser fires after a drag release, preventing an accidental selectDive emission.
Methods
| Signature | Line | Description |
|---|---|---|
configure({ startDate, dayCount }) |
149 | Updates this.startDate and/or this.window.dayCount without re-rendering; call before render
|
render(planResult, selectedDiveId = null) |
155 | Clears container.innerHTML; draws a left hour ruler, exactly dayCount day columns (each with a date header and hour gridlines), and dive blocks from the planTrip result. selectedDiveId marks the matching block with the tc-selected CSS class. Dives with invalid: true (e.g. invalidReason: 'ndl-too-short') render with the tc-invalid CSS class and a ⚠ no-deco N/A label instead of a normal depth/time annotation. Deco dives receive a two-tone background: render sets an inline linear-gradient(to bottom, …) whose colour stop sits at round(bottomTime / runtime * 100)%, so the lower bottom-phase slice is the solid block blue (#2980b9) and the ascent+deco portion above it is a lighter blue (#5dade2). The shading is skipped for no-deco, conflicting (tc-conflict), and invalid (tc-invalid) dives. |
toStartDateTime(dayIndex, minutesOfDay) |
229 | Converts a {dayIndex, minutesOfDay} pair to a trip-relative epoch-minute start (dayIndex * 1440 + minutesOfDay). Used by both createAt click handling and drag-drop. |
Events (CustomEvent dispatched on the instance)
| Event | detail |
Trigger |
|---|---|---|
createAt |
{ dayIndex, minutesOfDay } |
User clicks empty area in a day column |
selectDive |
{ diveId } |
User clicks a rendered dive block (plain click, no drag) |
reschedule |
{ diveId, startDateTime } |
User drags a dive block and releases it on a valid column position |
Drag-to-reschedule behaviour
A pointer-press on a .tc-block starts drag tracking in _onPointerDown (TripCalendar.js:65). The drag does not activate until the pointer has moved at least DRAG_THRESHOLD = 4 px (TripCalendar.js:14), so short taps still emit selectDive.
Once the threshold is crossed, _onPointerMove (TripCalendar.js:83):
- Adds
.tc-draggingto the block and setspointerEvents: nonesoelementFromPointsees the column behind it. - Identifies the
.tc-daycolumn under the pointer. - Converts the pointer's Y position to minutes-of-day and passes it through
snapClamp(..., SNAP_DRAG_MIN)whereSNAP_DRAG_MIN = 15(TripCalendar.js:15), snapping the ghost position to 15-minute boundaries and clamping it inside the visible window. - Moves the block DOM node into the target column and updates its
topstyle — the block visually follows the pointer.
On release, _onPointerUp (TripCalendar.js:106):
- Removes event listeners and the
.tc-draggingclass. - If the drag moved and a valid target position was recorded, sets
_justDragged = true(suppresses the trailing click, line 115) and dispatchesreschedulewith{ diveId, startDateTime }wherestartDateTime = toStartDateTime(targetDayIndex, targetMinutes)(TripCalendar.js:116–117).
pointercancel (e.g. scroll interruption) is handled by _onPointerCancel (TripCalendar.js:121), which cleans up listeners and the .tc-dragging class without emitting reschedule.
Page wiring (sandbox/repetitive-dives.html)
// repetitive-dives.html:402–404
calendar.addEventListener('reschedule', (e) => {
trip = rescheduleDive(trip, e.detail.diveId, e.detail.startDateTime);
rerender();
});rescheduleDive (from js/tripState.js:38) moves the dive to the new startDateTime, re-chains pre-saturation and deco across the trip, and updates conflict flags. rerender() calls calendar.render(...), which rebuilds the DOM with the updated plan.
Implementation notes
- Renders exactly
dayCountcolumns; no phantom extra column (TripCalendar.js:159). - Left hour ruler (
.tc-ruler) contains.tc-hour-labeldivs positioned bytoppercentage (TripCalendar.js:146–155). - Each column gets a
.tc-day-headerdiv showing the formatted date (formatDayHeaderat line 23) usingthis.startDateand the column index (TripCalendar.js:165–166). - Hour gridlines (
.tc-hour-line) are injected into each column at the same percentage positions as the ruler labels (TripCalendar.js:169–174). - Click position within a column is converted to
minutesOfDayand snapped toSNAP_MIN = 60minutes (TripCalendar.js:13, 60). - Each dive block is labelled using
diveBlockLabel(plannedDive), which returns the full label string including name, depth, bottom time, and a deco or NDL tag. Example output:"Dive 1 · 30m · 45min · +12 deco"(deco) or"Dive 1 · 30m · 40min · NDL"(NDL-locked no-deco).planTripmust echoname,maxDepth,bottomTime,totalDecoTime(inprofile), andndlLockedonto result dives for this label to render correctly. - Conflict blocks receive the
tc-conflictCSS class (TripCalendar.js:185); selected block receivestc-selected(TripCalendar.js:186). -
toStartDateTimecomputesdayIndex * 1440 + minutesOfDaydirectly (TripCalendar.js:203);_layout.baseDay(always 0) is not used.
class AddDiveDialog extends EventTarget (line 9). Modal dialog for adding a new dive at a given calendar position. Supports two modes:
- Custom — user enters depth and bottom time manually.
- No-deco — user enters depth; bottom time is computed as the NDL for that depth and position in the trip (read-only).
Physics is injected: the caller supplies a computeNdl(startDateTime, maxDepth, gases) → minutes callback so the dialog stays pure and testable without a full trip.
Imports: none (physics injected via callback). Imported by: the trip-planner sandbox page.
Constructor
new AddDiveDialog(container)Methods
| Signature | Line | Description |
|---|---|---|
open(opts) |
21 | Renders the dialog into container. opts shape: { startDateTime, gases, defaultDepth=18, defaultTime=40, defaultName='', computeNdl }. Shows a Name text input pre-filled from opts.defaultName (AddDiveDialog.js:26). Calls computeNdl on every depth change to update the NDL display and the No-deco bottom-time field. Shows a deco warning when Custom time exceeds NDL. |
close() |
75 | Clears container.innerHTML. |
Events (CustomEvent dispatched on the instance)
| Event | detail |
Trigger |
|---|---|---|
add |
{ name, startDateTime, maxDepth, bottomTime, gases } |
User clicks "Add" |
cancel |
— | User clicks "Cancel" |
Implementation notes
-
computeNdlis called with the dialog's currentstartDateTimeand the live depth value on every depth input and mode switch (AddDiveDialog.js:47). - In No-deco mode,
timeEl.disabled = trueandtimeEl.valueis overwritten with the NDL each refresh (AddDiveDialog.js:50–51). - The deco warning reads: "⚠ deco — exceeds NDL (N min) for this depth at this point in the trip" (
AddDiveDialog.js:54). -
namein theaddevent detail is the trimmed value of the Name input; falls back toopts.defaultNamewhen the field is left blank (AddDiveDialog.js:66).
class DiveEditPanel extends EventTarget (line 26). Per-dive edit panel combining a start date/time field with a stripped-down DiveSetupEditor (quick-setup depth/bottom-time + gas management). Emits apply and remove events; owns no trip state.
Imports: DiveSetupEditor from ./DiveSetupEditor.js.
Imported by: the trip-planner sandbox page.
Constructor
new DiveEditPanel(container)Methods
| Signature | Line | Description |
|---|---|---|
open(dive, startDate) |
22 | Renders the edit panel for dive into container. Shows an "Editing: {name}" header (DiveEditPanel.js:48) and a Name text input pre-filled from dive.name (DiveEditPanel.js:50). startDate is an ISO date string ('YYYY-MM-DD') for the trip start, used to re-base the epoch↔datetime-local conversion. Defaults to '2026-01-01' when not supplied. Wires change listeners. |
close() |
115 | Destroys the embedded editor and clears container
|
Events (CustomEvent dispatched on the instance)
| Event | detail |
Trigger |
|---|---|---|
apply |
{ id, patch: { startDateTime, maxDepth, bottomTime, gases, name, ndlLocked } } |
Any field changes (gas editor change, datetime input change, Name input change, quick depth/time change, NDL-lock checkbox change) |
remove |
{ id } |
"Remove dive" button clicked |
Implementation notes
- The embedded
DiveSetupEditoris opened withshowProfiles: false,showQuickSetup: true,showGradientFactors: false,showSacRate: false,showMultiDive: false,showSurfaceInterval: false,showDescription: false,showImportExport: false(DiveEditPanel.js:64–67). -
maxDepthandbottomTimeare read fromeditor.elements.quickDepth/editor.elements.quickTimerather than fromgetDiveSetup().dives[0].waypoints, because waypoints are only populated after "Generate Profile" is clicked (DiveEditPanel.js:86–87). -
namein the patch is the trimmed value of the.dep-nameinput; falls back todive.namewhen blank (DiveEditPanel.js:89). - Quick-setup depth/time inputs only fire
_updateNDLDisplayinternally, not the editor'schangeevent, soDiveEditPanelattaches its ownchangelisteners to those inputs (DiveEditPanel.js:102–105). - The Name input (
dep-name) firesemitApplyonchange(DiveEditPanel.js:98), so renaming a dive triggers anapplyevent that propagates to the page. - Epoch-minute ↔
<input type="datetime-local">conversion uses abasecomputed from thestartDateargument atopentime viaDate.UTC(y, m-1, d)(DiveEditPanel.js:44–45). UTC reads/writes ensure the displayed time is not shifted by the user's local UTC offset. The helper functionsepochMinToLocalInputandlocalInputToEpochMinare module-private (DiveEditPanel.js:16–27).
Runtime table for a single executed dive profile (sub-project ②). Splits pure row derivation from DOM rendering so the logic is unit-testable without a browser.
Imports: none. Imported by: the repetitive-dive detail view (sandbox).
Exports
| Signature | Line | Description |
|---|---|---|
buildRuntimeRows(profile, gases) |
21 | Pure function. Derives ordered runtime rows from an executed dive profile. Returns Array<{ phase, depth, segmentTime, runTime, gas, isStop }>. |
renderRuntimeTable(rows) |
73 | DOM-only. Accepts the output of buildRuntimeRows and returns an HTMLTableElement with class runtime-table. |
buildRuntimeRows row shape:
| Field | Type | Description |
|---|---|---|
phase |
'descent' | 'bottom' | 'ascent' | 'stop'
|
Phase label. Level segments at max depth are bottom; all other level segments are stop. |
depth |
number |
Depth (m) at end of segment (arrival depth for descent/ascent; held level for bottom/stop). |
segmentTime |
number |
Duration of this segment (minutes). |
runTime |
number |
Absolute run time at end of segment (minutes from dive start). |
gas |
string |
Human-readable name of the active gas for this segment. |
isStop |
boolean |
true when phase === 'stop' (convenience flag for table row styling). |
profile input: the result object from generateDecoProfile — expects { waypoints: [{ time, depth, gasId? }], … } with absolute times in minutes.
gases input: [{ id, name, … }]; gases[0] is the starting gas.
Implementation notes
- Zero-length segments (e.g. in-transit gas-switch marker waypoints) are skipped (
RuntimeTable.js:38). - Gas tracking follows
wp.gasIdon each waypoint; falls back togases[0]for unmarked segments (RuntimeTable.js:27,RuntimeTable.js:35).
class TissueSaturationSim (line 51). Pure UI controller for the tissue-saturation canvas on tissue-loading.html. No algorithm exports.
Small UI helpers:
| Export | Line | Description |
|---|---|---|
mountHeroMotion(root) |
82 in HeroMotion.js
|
Landing-page animated background |
initStickyTOC(options) |
26 in StickyTOC.js
|
Scroll-spy for theory-page TOCs |
initTooltipShortcut() |
tooltipShortcut.js |
T shortcut for the hovered or focused Chart.js tooltip |
resolveChartTooltipEnabled(defaultEnabled=true, canvas=null) |
tooltipShortcut.js |
Preserves each canvas's shortcut state when its chart is rebuilt |
Custom JSON-based i18n (no library). Loads locales/{lang}.json, applies translations to data-i18n DOM attributes, persists selection in localStorage['deco-theory-lang'].
Exports (all from line 333):
| Name | Description |
|---|---|
initI18n() |
Detects browser language, loads translations, wires languagechange
|
setLanguage(lang) |
Switches active language and fires languagechange
|
getCurrentLanguage() |
Returns the active code ('en', 'cs', 'es') |
createLanguageSwitcher(container, options) |
Renders the dropdown switcher |
translate(key, vars) |
Lookup by dot-notation key, supports {0}, {1} interpolation |
SUPPORTED_LANGS |
['en', 'cs', 'es'] |
Locale files are in locales/ (en.json, cs.json, es.json).
Centralised navigation menu for all HTML pages.
Exports (line 247): initNavigation, NAV_ITEMS. NAV_ITEMS is the authoritative menu structure (sandbox, theory, tests, about), and initNavigation() renders it into .nav-links, detects the active page, and handles subdirectory path prefixing for /sandbox/.
URL-based dive-setup share links.
| Export | Line | Description |
|---|---|---|
encodeDiveSetup(setup) |
28 | Compact base64-ish encoding for ?profile=…
|
decodeDiveSetup(encoded) |
140 | Inverse; sanitizes free-text fields before returning |
MAX_SHARED_TEXT_LENGTH |
65 | Length cap applied to shared free text |
getSandboxUrl(setup, options) |
179 | Produces an "Open in Sandbox" link |
getChartModeFromUrl() |
203 | Reads ?mode=…
|
getProfileFromUrl() |
213 | Reads ?profile=… and decodes |
updateUrlWithProfile(setup) |
225 |
history.replaceState without reloading |
getCompactProfileFromUrl() |
260 | Reads the numeric ?v=1&d=…&t=… form |
A decoded setup is untrusted input. decodeDiveSetup strips <, >, quotes,
backticks and control characters from name, description, id, every gas
name/id/presetId, dive name/id and waypoint gasId, and clamps free
text to MAX_SHARED_TEXT_LENGTH. This is a boundary shape check, not the
primary control — HTML escaping happens at each sink via js/utils/escHtml.js.
HTML escaping for values interpolated into innerHTML templates.
| Export | Line | Description |
|---|---|---|
escHtml(value) |
29 | Escapes & < > " '; null/undefined become ''
|
The single escaping helper in the project — three near-identical local copies
(DiveEditPanel.js, RuntimeTable.js, repetitive-dives.html) were folded into
it. A guard test in tests/run-tests.mjs fails if a local copy reappears, or if a
user-controlled value reaches an innerHTML template without passing through it.
Imported by: js/diveSetup.js, js/components/DiveSetupEditor.js,
js/components/DiveEditPanel.js, js/components/RuntimeTable.js,
sandbox/index.html, sandbox/repetitive-dives.html.
Encode/decode a repetitive-dive trip to/from a ?trip= URL param, making trips shareable and reload-surviving. Mirrors the single-dive scheme in urlParams.js.
encodeTrip and decodeTrip are pure (no DOM). The get… / update… / share… helpers read and write window.location / window.history.
Imports: none.
Imported by: sandbox/repetitive-dives.html (direct script import).
Exports
| Signature | Line | Description |
|---|---|---|
encodeTrip(trip) |
25 | Returns URL-safe base64 of a minimal trip JSON containing startDate, dayCount, gfLow, gfHigh, gases, and dives. Per-dive gases is omitted when it equals the trip-level gas array (keeps URLs short); gases are stored minimally as {id, name, o2, n2, he}. |
decodeTrip(str) |
54 | Inverse of encodeTrip. Reconstructs the trip: per-dive gas is refilled from the trip-level set when absent (fresh array copy). Re-mints sequential ids d1, d2, … regardless of the original ids. Returns null on any malformed input (parse error, missing dives or gases arrays, empty/null string). |
getTripFromUrl() |
84 | Reads the ?trip= param from window.location.search and passes it to decodeTrip; returns null if the param is absent or invalid. |
updateUrlWithTrip(trip) |
90 | Encodes the trip and writes ?trip= via history.replaceState — no new history entry, no reload. |
getTripShareUrl(trip) |
97 | Returns the absolute URL string with ?trip= set for the current page. |
Page integration (sandbox/repetitive-dives.html)
- On load (
repetitive-dives.html:131–139): callsgetTripFromUrl(); if non-null, it overrides the default trip and syncs the GF, start-date, and day-count form inputs to the restored values. - At the end of
rerender()(repetitive-dives.html:270): callsupdateUrlWithTrip(trip)so every state change is reflected in the URL automatically. - "Copy trip link" button (
repetitive-dives.html:447): callsgetTripShareUrl(trip)and writes the result to the clipboard.
Implementation notes
- Encoding uses
btoa(unescape(encodeURIComponent(json)))(tripUrl.js:46) to handle Unicode characters in dive names safely. - Decoding reverses with
decodeURIComponent(escape(atob(str)))(tripUrl.js:56). -
sameGases()attripUrl.js:15compares gases byid,o2,n2, andhe; thenamefield is intentionally excluded from the equality check so cosmetic renames do not force a per-dive gas copy into the URL. -
decodeTripvalidates by checkingArray.isArray(m.dives)andArray.isArray(m.gases)before constructing the result (tripUrl.js:58); any other structural issue is caught by the surroundingtry/catch.
Generic quiz engine for the seven CMAS / SPČR quiz pages. No exports — executes on DOM load; reads data/quiz-{name}.json based on a URL parameter, renders questions with category filtering, shuffling, and scoring. See Extending-DecoJS for the JSON format.
-
sandbox/m-values.html— Two-playground sandbox for the M-value formula and its derivation. Top playground evaluatesM = a + P_amb/b. Bottom playground exposes the analytical curvesa(t½)andb(t½)with 16 ZH-L16 compartments overlaid as dots (variants A/B/C lift dots off the curves selectively). -
sandbox/repetitive-dives.html— Trip planner page. WiresTripCalendar,AddDiveDialog,DiveEditPanel,tripState, andplanTriptogether using a master–detail layout: the calendar is always visible on the left; clicking a block updates the single#selectedpanel on the right, which is rebuilt byrenderSelected. The old all-cards overview, separate detail view, and separate edit strip are gone. Key wiring notes:- Tracks
selectedDiveIdin page scope; passes it to everycalendar.render(result, selectedDiveId)call so the selected block stays highlighted across rerenders. -
renderSelected()(no argument — reads the page-levelselectedDiveId, defaulting to the first dive) builds the#selectedpanel: a header with ✎ Edit / 🗑 Delete, a one-line summary (surface interval, depth, bottom time, runtime, deco/no-deco, first stop, TTS, pre-load %), the pre-saturation strip, an inlineDiveProfileChartwith mode toggles, an edit form (the sharedDiveEditPanel, shown when Edit is toggled), and a collapsed "Full analysis"<details>disclosure that lazily builds the M-value chart, GF chart, and runtime table. An invalid dive shows the explanation + pre-saturation strip instead of charts; an empty trip clears the panel. - Dive
nameis shown on the calendar block (viadiveBlockLabel), in the#selectedsummary header, and in theDiveEditPanelheader. -
Edit-triggered rerenders are debounced (
rerenderDeferred, 250 ms). A synchronous rerender onDiveEditPanel'sapplyevent would rebuild the calendar's DOM mid-click, causing the delegatedselectDivedispatch to fire on a stale block and losing the new selection. The 250 ms deferral ensures the click completes before the calendar rebuilds.
- Tracks