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 };
});