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.

Core algorithm

js/decoModel.js

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 $P_t \le P_{amb}$, returns 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 (default standard) — standard, adaptive, or continuous
  • continuousDeco — deprecated compatibility alias; only true has 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 (default false) — 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) at deco/config.js:20.
  • Schreiner is coded as a three-term form at deco/config.js:37–39: constant inert-gas source + exponential offset.
  • pAnchor is an ambient-pressure value, not a depth. findFirstStopAtGFLow iterates the stop grid surface-up, simulating the ascent to each candidate and checking the dive ceiling at gfLow (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 at deco/ceiling.js:368 normalises gases with or without an id field, so the deco loop tolerates both library gases and custom mixes.

js/tissueCompartments.js

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; see buildCompartments() around tissueCompartments.js:140.
  • TC1's half-time and b coefficient 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. See COMPARTMENT_1_HALFTIME at line 46 and COMPARTMENT_1_B_N2 around line 55.
  • All variants share TC2–16 half-times; only the a coefficient varies between A, B, C for compartments 5–15 (A_COEFFICIENTS_16A/B/C at lines 102, 115, 128).
  • setZHL16Variant() clears and repushes the existing COMPARTMENTS array (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.C at line 40). Matches the decotengu default.
  • All times in minutes, pressures in bar. See Model-01-Compartments for the full coefficient table.

js/diveSetup.js

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.

Gas definitions

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.

Setup loading

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

Profile generation

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.

Gas calculations

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

Oxygen toxicity

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.

Waypoint & gas-switch helpers

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

Presentation helpers

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.

Defaults

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).

js/tripPlanner.js

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 startDateTime before processing (tripPlanner.js:32).
  • Surface off-gassing is computed by simulateDepthTime(tissue, 0, gap, N2_FRACTION) where gap is the actual surface interval in minutes (tripPlanner.js:51).
  • Per-dive gas selection: const diveGases = dive.gases ?? gases at tripPlanner.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: true on the input dive): instead of using the caller-supplied bottomTime, planTrip derives bottomTime as the pre-saturation-aware NDL for that position in the trip — calculateNDL seeded with the carried-in tissue (startingTissue). The result is capped at 99 min (NDL_LOCK_CAP) and checked against the descent time (maxDepth / 20, matching generateDecoProfile'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/ndlPreview showed 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 flagged invalid: true with invalidReason: '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 initialTissuePressures seam used internally.

Repetitive-dive chaining (initialTissuePressures)

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 calling getInitialTissueN2. 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 by calculateNDL is 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, generateDecoProfile computes 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.

js/preSaturation.js

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), where surfaceAmbient = getAmbientPressure(0) (preSaturation.js:26).
  • Clamp: Math.max(0, g) * 100 at preSaturation.js:28.
  • perCompartmentPct iterates allGFs from calculateMaxGF and applies the same clamp per compartment (preSaturation.js:31–33).

js/tripState.js

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

  • addDive delegates id assignment to the private nextId(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: trip in, new trip out, no mutation.

js/ndlPreview.js

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 because startingTissue depends only on prior dives and the surface gap (ndlPreview.js:25).
  • Resolves N₂ fraction from candidate.gases[0].n2; falls back to 0.79 when no gases are supplied (ndlPreview.js:32).
  • Passes gfHigh / 100 to calculateNDL because calculateNDL expects a decimal fraction, while the trip config and callers use percentages (ndlPreview.js:33).

js/calendarLayout.js

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

  • baseDay is always 0; trip-relative epoch minutes already count from day 0 so no offset is needed (calendarLayout.js:45).
  • dayIndex is floor(startDateTime / 1440) (calendarLayout.js:27).
  • Block top clips early dives: visibleStart = Math.max(startMinOfDay, dayStartMin) (calendarLayout.js:31).
  • Dives crossing midnight are clamped to dayEndMin for v1 (documented limitation, calendarLayout.js:29).
  • dayCount is taken directly from windowConfig.dayCount; the function no longer derives it from the dives (calendarLayout.js:20, 45).

js/tissueEducation.js

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.

Charts (js/charts/)

DiveProfileChart.js

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.

MValueChart.js

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.

GFChart.js

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.

chartTheme.js

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

chartTypes.js

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.

interactionLock.js

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

BubbleModel.js

class BubbleModel (line 28). A visualisation-only toy model, not wired into the decompression algorithm.

Components (js/components/)

DiveSetupEditor.js

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.

TripCalendar.js

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 click listener (TripCalendar.js:40) that handles createAt and selectDive (see _onClick, line 46). Because it lives on the container rather than on DOM nodes rebuilt by render, it survives every calendar redraw.
  • A pointerdown listener (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):

  1. Adds .tc-dragging to the block and sets pointerEvents: none so elementFromPoint sees the column behind it.
  2. Identifies the .tc-day column under the pointer.
  3. Converts the pointer's Y position to minutes-of-day and passes it through snapClamp(..., SNAP_DRAG_MIN) where SNAP_DRAG_MIN = 15 (TripCalendar.js:15), snapping the ghost position to 15-minute boundaries and clamping it inside the visible window.
  4. Moves the block DOM node into the target column and updates its top style — the block visually follows the pointer.

On release, _onPointerUp (TripCalendar.js:106):

  • Removes event listeners and the .tc-dragging class.
  • If the drag moved and a valid target position was recorded, sets _justDragged = true (suppresses the trailing click, line 115) and dispatches reschedule with { diveId, startDateTime } where startDateTime = 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 dayCount columns; no phantom extra column (TripCalendar.js:159).
  • Left hour ruler (.tc-ruler) contains .tc-hour-label divs positioned by top percentage (TripCalendar.js:146–155).
  • Each column gets a .tc-day-header div showing the formatted date (formatDayHeader at line 23) using this.startDate and 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 minutesOfDay and snapped to SNAP_MIN = 60 minutes (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). planTrip must echo name, maxDepth, bottomTime, totalDecoTime (in profile), and ndlLocked onto result dives for this label to render correctly.
  • Conflict blocks receive the tc-conflict CSS class (TripCalendar.js:185); selected block receives tc-selected (TripCalendar.js:186).
  • toStartDateTime computes dayIndex * 1440 + minutesOfDay directly (TripCalendar.js:203); _layout.baseDay (always 0) is not used.

AddDiveDialog.js

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

  • computeNdl is called with the dialog's current startDateTime and the live depth value on every depth input and mode switch (AddDiveDialog.js:47).
  • In No-deco mode, timeEl.disabled = true and timeEl.value is 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).
  • name in the add event detail is the trimmed value of the Name input; falls back to opts.defaultName when the field is left blank (AddDiveDialog.js:66).

DiveEditPanel.js

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 DiveSetupEditor is opened with showProfiles: false, showQuickSetup: true, showGradientFactors: false, showSacRate: false, showMultiDive: false, showSurfaceInterval: false, showDescription: false, showImportExport: false (DiveEditPanel.js:64–67).
  • maxDepth and bottomTime are read from editor.elements.quickDepth / editor.elements.quickTime rather than from getDiveSetup().dives[0].waypoints, because waypoints are only populated after "Generate Profile" is clicked (DiveEditPanel.js:86–87).
  • name in the patch is the trimmed value of the .dep-name input; falls back to dive.name when blank (DiveEditPanel.js:89).
  • Quick-setup depth/time inputs only fire _updateNDLDisplay internally, not the editor's change event, so DiveEditPanel attaches its own change listeners to those inputs (DiveEditPanel.js:102–105).
  • The Name input (dep-name) fires emitApply on change (DiveEditPanel.js:98), so renaming a dive triggers an apply event that propagates to the page.
  • Epoch-minute ↔ <input type="datetime-local"> conversion uses a base computed from the startDate argument at open time via Date.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 functions epochMinToLocalInput and localInputToEpochMin are module-private (DiveEditPanel.js:16–27).

RuntimeTable.js

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.gasId on each waypoint; falls back to gases[0] for unmarked segments (RuntimeTable.js:27, RuntimeTable.js:35).

TissueSaturationSim.js

class TissueSaturationSim (line 51). Pure UI controller for the tissue-saturation canvas on tissue-loading.html. No algorithm exports.

HeroMotion.js / StickyTOC.js / tooltipShortcut.js

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

Utilities

js/i18n.js

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).

js/nav.js

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/.

js/urlParams.js

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.

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.

js/tripUrl.js

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): calls getTripFromUrl(); 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): calls updateUrlWithTrip(trip) so every state change is reflected in the URL automatically.
  • "Copy trip link" button (repetitive-dives.html:447): calls getTripShareUrl(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() at tripUrl.js:15 compares gases by id, o2, n2, and he; the name field is intentionally excluded from the equality check so cosmetic renames do not force a per-dive gas copy into the URL.
  • decodeTrip validates by checking Array.isArray(m.dives) and Array.isArray(m.gases) before constructing the result (tripUrl.js:58); any other structural issue is caught by the surrounding try/catch.

js/quiz.js

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 pages

  • sandbox/m-values.html — Two-playground sandbox for the M-value formula and its derivation. Top playground evaluates M = a + P_amb/b. Bottom playground exposes the analytical curves a(t½) and b(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. Wires TripCalendar, AddDiveDialog, DiveEditPanel, tripState, and planTrip together using a master–detail layout: the calendar is always visible on the left; clicking a block updates the single #selected panel on the right, which is rebuilt by renderSelected. The old all-cards overview, separate detail view, and separate edit strip are gone. Key wiring notes:

    • Tracks selectedDiveId in page scope; passes it to every calendar.render(result, selectedDiveId) call so the selected block stays highlighted across rerenders.
    • renderSelected() (no argument — reads the page-level selectedDiveId, defaulting to the first dive) builds the #selected panel: 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 inline DiveProfileChart with mode toggles, an edit form (the shared DiveEditPanel, 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 name is shown on the calendar block (via diveBlockLabel), in the #selected summary header, and in the DiveEditPanel header.
    • Edit-triggered rerenders are debounced (rerenderDeferred, 250 ms). A synchronous rerender on DiveEditPanel's apply event would rebuild the calendar's DOM mid-click, causing the delegated selectDive dispatch to fire on a stale block and losing the new selection. The 250 ms deferral ensures the click completes before the calendar rebuilds.
⚠️ **GitHub.com Fallback** ⚠️