// What a legend's numbers make, before anything is solved: the two angles per class that `terrain plan` // prints, ported from Tools/Terrain internal/planet/plan.go so the class table here says the same thing. // // Steady state for the stream-power law is S = U/(K·A^m). With channels allowed down to a single cell, A at a // drainage divide is one cell squared, so for n = 1 the uplift rate alone fixes the hillslope angle at the // divide - the most useful number in the whole legend, because it decides whether the ground is shaped by // rivers or by landsliding. But almost none of a map is divide: slope falls away downstream, and the median // over a class comes out at about a third of the divide angle in tangent. That ratio was measured on the Go // tool's 8 m grid over a factor of twenty in rate (0.34, 0.33, 0.33, 0.32), and it is the reason there are // two columns. An author who reads the divide angle as the landscape sets every rate two or three times too // hot. // // Pure functions of the legend and the bake's constants; no mesh, no DOM. The constants are the geology // grid's (cell size, K, m, angle of repose), which is what the numbers are *about*: the ground the bake makes, // not the globe here, whose relief is a scale. export const SOLVE_DEFAULTS = { k: 5e-5, m: 0.5, cellM: 8, talusDeg: 35 }; const RAD = Math.PI / 180; /** Hillslope angle at a divide, in degrees, for a rate in mm/yr and an erodibility multiplier on K. */ export function divideAngleDeg(rateMmYr, kMult, solve) { const s = solve || SOLVE_DEFAULTS; const k = (s.k || SOLVE_DEFAULTS.k) * (kMult > 0 ? kMult : 1); const cellM = s.cellM || SOLVE_DEFAULTS.cellM; const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m; if (k <= 0 || cellM <= 0) return 0; const slope = (rateMmYr / 1000) / (k * Math.pow(cellM * cellM, m)); return Math.atan(slope) / RAD; } // Fractions of the *tangent*, not of the angle, because the law is about slope. const TYPICAL_MEDIAN_FRAC = 0.33; const TYPICAL_P90_FRAC = 0.45; /** The median and 90th-percentile slope over a class whose divide angle is `deg`. */ export function typicalFromDivide(deg) { const t = Math.tan(deg * RAD); return { median: Math.atan(t * TYPICAL_MEDIAN_FRAC) / RAD, p90: Math.atan(t * TYPICAL_P90_FRAC) / RAD }; } /** The ground a median slope reads as. Boundaries are angles, not rates, deliberately. */ export function readsAs(deg) { if (deg < 3) return 'plain'; if (deg < 8) return 'rolling'; if (deg < 16) return 'hill country'; if (deg < 28) return 'mountain'; return 'alpine'; } /** The rate, in mm/yr, at which a divide at k = 1 reaches the angle of repose; above it the clamp shapes the ground. */ export function clampCeilingMmYr(solve) { const s = solve || SOLVE_DEFAULTS; const k = s.k || SOLVE_DEFAULTS.k; const cellM = s.cellM || SOLVE_DEFAULTS.cellM; const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m; const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg; return Math.tan(talus * RAD) * k * Math.pow(cellM * cellM, m) * 1000; } /** * Every angle the plan prints for one parsed class (see painted.js parseLegend): the divide, the typical * median and P90, what it reads as, whether the divide is past the angle of repose, and the same for the * massif floor when the class has one. Null for a class that is not land. */ export function classAngles(c, solve) { if (!c || c.sea || c.stroke || c.derived) return null; const s = solve || SOLVE_DEFAULTS; const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg; const divide = divideAngleDeg(c.upliftMmYr, c.kMult, s); const typ = typicalFromDivide(divide); const out = { divide, median: typ.median, p90: typ.p90, readsAs: readsAs(typ.median), clamped: divide >= talus, floor: null, }; if (c.massif && c.massif.fraction > 0) { const fd = divideAngleDeg(c.massif.floorMmYr, c.kMult, s); const ft = typicalFromDivide(fd); out.floor = { rateMmYr: c.massif.floorMmYr, divide: fd, median: ft.median, readsAs: readsAs(ft.median), fraction: c.massif.fraction }; } return out; }