← Natura·Docs/Modules/Source

ocean.js

114 lines · in natura-src-1.0.0.zip as natura/ocean.js · a module · its entry in the module reference · its test: ocean-test.js

// ocean.js -- Natura OCEAN / HYDROLOGY (SYSTEMS.md #4, ALGORITHMS.md): where the water GOES. The rain
// the weather drops does not just vanish -- it runs downhill along the terrain, gathering into RIVERS
// (flow accumulation on the D8 drainage network), pooling in the hollows as LAKES, and reaching the sea;
// and the SEA itself breathes with the moon -- celestial's tide raises and lowers sea level, so the
// coastline advances and retreats over the tidal flats. Heavy rain over a big catchment brings a FLOOD.
// This closes the water cycle (rain -> runoff -> river -> sea -> evaporation -> rain) and makes the
// celestial TIDES visible on the shore -- one system reading another's output, the coupling made real.
//
// A Natura system spec (init/step/fields/events), deterministic + node-testable. Reads an elevation
// sampler, weather.rain, and celestial.tide. UMD (node test / window.Ocean).
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Ocean = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;

  // make an Ocean system. config: { N, elevation(x,y)->e, seaLevel, weather, celestial, rainK, evap,
  //   tideAmp, floodQ }. Reads ctx.fields[weather].rain and ctx.fields[celestial].tide.
  function makeOcean(config) {
    config = config || {};
    const N = config.N || 64;
    const seaLevel = config.seaLevel != null ? config.seaLevel : 0;
    const weatherName = config.weather || 'weather';
    const celestialName = config.celestial || 'celestial';
    const rainK = config.rainK != null ? config.rainK : 8;       // rain -> surface water gain (lakes)
    const dischargeK = config.dischargeK != null ? config.dischargeK : 45;  // rain -> river discharge (flashy)
    const baseArea = config.baseArea != null ? config.baseArea : 0.05;      // per-cell base drainage area
    const evap = config.evap != null ? config.evap : 0.06;       // standing-water loss / day
    const tideAmp = config.tideAmp != null ? config.tideAmp : 0.012;  // sea-level swing with the tide
    const floodQ = config.floodQ != null ? config.floodQ : 12;   // discharge above which a cell floods (SCALE-DEPENDENT: scales with catchment size, so tune to the grid)
    const elevFn = config.elevation || ((x, y) => 0.2);
    const gx = (i) => (i / (N - 1)) * 2 - 1;

    return {
      name: config.name || 'ocean',
      domain: 'physical',
      scale: 'landscape',
      params: config,

      init() {
        const elev = new Float32Array(N * N), land = new Uint8Array(N * N);
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
          const e = elevFn(gx(i), gx(j)); elev[j * N + i] = e; land[j * N + i] = e > seaLevel ? 1 : 0;
        }
        // D8 downhill: each land cell's steepest-descent neighbour (-1 if a pit or the sea)
        const down = new Int32Array(N * N).fill(-1);
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
          const k = j * N + i; if (!land[k]) continue;
          let lowest = elev[k], best = -1;
          for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
            if (!dx && !dy) continue; const ni = i + dx, nj = j + dy;
            if (ni < 0 || ni >= N || nj < 0 || nj >= N) continue;
            const m = nj * N + ni; if (elev[m] < lowest) { lowest = elev[m]; best = m; }
          }
          down[k] = best;
        }
        // process order: high elevation first, so a cell drains before the cell below it (one-pass accum)
        const order = Array.from({ length: N * N }, (_, k) => k).filter(k => land[k]).sort((a, b) => elev[b] - elev[a]);
        return { N, elev, land, down, order,
                 water: new Float32Array(N * N), flow: new Float32Array(N * N), seaNow: seaLevel, floods: [] };
      },

      step(st, ctx, dt) {
        const s = clamp(dt / 86400, 0, 2); if (s <= 0) return st;
        const w = ctx.fields[weatherName], cel = ctx.fields[celestialName];
        const rain = w && w.rain ? w.rain : null;
        const tide = cel && cel.tide != null ? cel.tide : 0;
        const { N: n, elev, land, down, order, water, flow } = st;
        st.seaNow = seaLevel + tideAmp * tide;                    // the sea breathes with the moon

        // 1. rain adds surface water on land; standing water evaporates
        for (let k = 0; k < n * n; k++) {
          if (!land[k]) { water[k] = 0; continue; }
          if (rain) water[k] += rain[k] * rainK * s;
          water[k] = Math.max(0, water[k] - evap * s);
        }
        // 2. DISCHARGE = flow accumulation down the drainage network (one pass, high->low). The input is
        //    the rain PLUS a small base drainage-area per cell, so a persistent river NETWORK shows even
        //    between showers and SWELLS with rain; the trunk near the mouth carries the whole catchment.
        for (let k = 0; k < n * n; k++) flow[k] = land[k] ? (rain ? rain[k] * dischargeK : 0) + baseArea : 0;
        for (const k of order) { const d = down[k]; if (d >= 0 && land[d]) flow[d] += flow[k]; }
        // 3. move standing water downhill (rivers drain); water with nowhere lower to go POOLS (lakes).
        //    a fraction routes to the downhill neighbour each step; the rest stays (fills hollows).
        const route = clamp(0.5 * s * 6, 0, 0.9);
        const nw = new Float32Array(n * n);
        for (const k of order) {
          const d = down[k];
          if (d >= 0 && land[d]) { const move = water[k] * route; nw[d] += move; nw[k] += water[k] - move; }
          else nw[k] += water[k];                                 // a pit -> a lake fills here
        }
        for (let k = 0; k < n * n; k++) if (land[k]) water[k] = Math.min(nw[k], 3);   // cap ponding
        // 4. floods: cells whose discharge tops the bank
        st.floods = [];
        for (const k of order) if (flow[k] > floodQ) st.floods.push(k);
        return st;
      },

      // OUT: riverFlow (discharge, for the river network), water (standing/lakes), seaNow (tidal sea level)
      fields(st) {
        return { N: st.N, riverFlow: st.flow, water: st.water, seaNow: st.seaNow, land: st.land, elev: st.elev };
      },
      events(st) {
        // one flood event when a large catchment tops its banks (report the biggest)
        if (!st.floods.length) return [];
        let big = st.floods[0]; for (const k of st.floods) if (st.flow[k] > st.flow[big]) big = k;
        return [{ kind: 'flood', cells: st.floods.length, peak: +st.flow[big].toFixed(1) }];
      }
    };
  }

  return { makeOcean };
});