{
 "genart": "1.2",
 "id": "mouths-of-a-river",
 "title": "Mouths of a River",
 "created": "2026-09-11T00:00:00Z",
 "modified": "2026-09-11T16:58:03.939Z",
 "renderer": {
  "type": "canvas2d",
  "version": "1.x"
 },
 "canvas": {
  "width": 1400,
  "height": 1000
 },
 "parameters": [],
 "colors": [],
 "dataChannels": [
  {
   "name": "drying",
   "type": "vector",
   "cols": 480,
   "rows": 360
  }
 ],
 "state": {
  "seed": 29,
  "params": {},
  "colorPalette": [
   "#1d2327",
   "#3b474f",
   "#6c7a82",
   "#b3bab8",
   "#ebe8e0"
  ]
 },
 "algorithm": "// Mouths of a River. The sketch builds the ground, runs the river down its\n// valley and out across the delta it has built into the sea, and engraves the\n// chart: channel banks, water-lining, marsh, beach ridges, grass, the stipple\n// of sand and flats, hachures on the bluffs, streams, depth curves and\n// soundings. Only the dotted low-water line is left to a plugin layer, reading\n// a map the sketch publishes on the ADR 062 data bridge.\n//\n// The river comes out between bluffs, meanders across its valley floor and\n// divides at the old coast into channels that each divide again, carrying the\n// land out into the sea on their banks. Between the channels the ground is too\n// low to drain and is marsh; where the sea works the delta front it throws up\n// ridges of sand parallel to the shore. The seed decides how far the river\n// builds against the waves: a long-fingered delta whose channels run far out\n// between their own banks, a rounded one, or a smooth arc with ridges across\n// it and few mouths.\nfunction sketch(ctx, state) {\n  var W = state.canvas.width, H = state.canvas.height;\n  var seed = state.seed || 0;\n  var pal = state.colorPalette;\n  var K = W / 1400;\n\n  var COLS = Math.round(240 * W / 700), ROWS = Math.round(180 * H / 500);\n  var N = COLS * ROWS;\n  // The field maps onto the inset plate: the build script uses the same box.\n  var PX = W * 0.075, PY = H * 0.075, PW = W * 0.85, PH = H * 0.81;\n  var ASPECT = PW / PH;\n  var DX = ASPECT / (COLS - 1), DY = 1 / (ROWS - 1);   // cell size, plate heights\n  var CW = PW / (COLS - 1), CH = PH / (ROWS - 1);      // cell size, px\n\n  function rngFrom(a) {\n    return function () {\n      a |= 0; a = a + 0x6D2B79F5 | 0;\n      var t = Math.imul(a ^ a >>> 15, 1 | a);\n      t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;\n      return ((t ^ t >>> 14) >>> 0) / 4294967296;\n    };\n  }\n  var rand = rngFrom(seed * 2654435761 + 104729);\n\n  /** A lattice of random values, bilinearly sampled over [0,1]. */\n  function lattice(nx, ny) {\n    var v = new Float32Array(nx * ny);\n    for (var k = 0; k < nx * ny; k++) v[k] = rand() * 2 - 1;\n    return function (u, t) {\n      // 🔴 Clamp: an out-of-range sample becomes a NaN in a published channel\n      // and takes the render down on a frame the CLI still exits 0 for.\n      if (u < 0) u = 0; else if (u > 1) u = 1;\n      if (t < 0) t = 0; else if (t > 1) t = 1;\n      var x = u * (nx - 1), y = t * (ny - 1);\n      var x0 = Math.floor(x), y0 = Math.floor(y);\n      var x1 = x0 + 1 > nx - 1 ? nx - 1 : x0 + 1, y1 = y0 + 1 > ny - 1 ? ny - 1 : y0 + 1;\n      var fx = x - x0, fy = y - y0;\n      fx = fx * fx * (3 - 2 * fx); fy = fy * fy * (3 - 2 * fy);\n      var a = v[y0 * nx + x0], b = v[y0 * nx + x1], c = v[y1 * nx + x0], d = v[y1 * nx + x1];\n      return (a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy;\n    };\n  }\n  function smooth(e0, e1, x) {\n    var t = (x - e0) / (e1 - e0);\n    if (t < 0) t = 0; else if (t > 1) t = 1;\n    return t * t * (3 - 2 * t);\n  }\n  /** A fixed random value per cell, for marks that must not move between passes. */\n  function hash(i) {\n    var h = Math.imul(i ^ (seed * 374761393), 668265263);\n    h = Math.imul(h ^ h >>> 13, 1274126177);\n    return ((h ^ h >>> 16) >>> 0) / 4294967296;\n  }\n\n  // --- The lie of the land ------------------------------------------------\n  // s runs from the sea toward the land and t along the coast. The old coast,\n  // before the river built out from it, is s = 0; the plate is placed so it\n  // lies well back and the delta has open sea in front of it.\n  var th = (rand() < 0.6 ? -Math.PI / 2 : Math.PI / 2) + (rand() * 2 - 1) * 0.3;\n  var nx0 = Math.cos(th), ny0 = Math.sin(th);          // from sea toward land\n  var px0 = -ny0, py0 = nx0;                           // along the coast\n  var cx = ASPECT / 2, cy = 0.5;\n  var off = -(0.20 + rand() * 0.06);\n  function toS(x, y) { return (x - cx) * nx0 + (y - cy) * ny0 + off; }\n  function toT(x, y) { return (x - cx) * px0 + (y - cy) * py0; }\n  function toX(s, t) { return cx + (s - off) * nx0 + t * px0; }\n  function toY(s, t) { return cy + (s - off) * ny0 + t * py0; }\n  var sMaxPlate = Math.max(toS(0, 0), toS(ASPECT, 0), toS(0, 1), toS(ASPECT, 1));\n  var ph = [];\n  for (var i = 0; i < 12; i++) ph.push(rand() * Math.PI * 2);\n\n  // How far the river wins against the waves. Low: few long channels, each\n  // run far out between its own banks. High: a broad smooth front, ridged by\n  // the sea, with few mouths.\n  var wv = rand();\n  var maxGen = wv > 0.72 ? 2 : wv < 0.3 ? 3 : 4;\n  var E1 = 0.10 + rand() * 0.04;                       // the foot of the bluffs\n  var tA = (rand() * 2 - 1) * 0.26;                    // where the valley comes down\n  function axis(s) { return tA + 0.05 * Math.sin(3.3 * s + ph[7]) + 0.02 * Math.sin(7.1 * s + ph[8]); }\n  function coastOff(t) { return 0.03 * Math.sin(2.3 * t + ph[0]) + 0.016 * Math.sin(5.9 * t + ph[1]) + 0.006 * Math.sin(13.1 * t + ph[4]); }\n  function escOff(t) { return 0.05 * Math.sin(1.7 * t + ph[2]) + 0.02 * Math.sin(4.3 * t + ph[3]); }\n  // The valley's width wanders, so its sides are not ruled.\n  var lvw = lattice(14, 2);\n  function valW(s) { return 0.06 * (1 + 0.3 * lvw(Math.max(0, Math.min(1, s / 0.6)), 0.5)); }\n\n  // The envelope of the delta: how far out in front of the old coast the land\n  // has been built, at each point along it. Longshore drift carries the lobe a\n  // little to one side of the river.\n  var R = 0.14 + 0.18 * wv + rand() * 0.06;\n  var Wd = 1.25 * (0.30 + 0.25 * wv);\n  var tAp = axis(0);\n  var tc = tAp + (rand() * 2 - 1) * 0.08 * wv;\n  function front(t) {\n    var q = (t - tc) / Wd;\n    if (q <= -1 || q >= 1) return 0;\n    // 🔴 An exponent above one, so the front leaves the old coast at a\n    // tangent. Below it, the delta met the coast in a hard corner.\n    return R * Math.pow(1 - q * q, 1.3) * (1 + 0.09 * Math.sin(3.1 * q + ph[5]) + 0.05 * Math.sin(7.3 * q + ph[6]));\n  }\n\n  // --- The river ----------------------------------------------------------\n  // The trunk meanders as a sine-generated curve (Langbein and Leopold): its\n  // heading swings back and forth with distance along the channel, which is\n  // the shape of a real meander rather than a sine wave laid on the ground.\n  // A pull toward the valley's axis keeps it on the valley floor.\n  var ST = 0.003;\n  var HW0 = 0.0095 + rand() * 0.002;      // the trunk's half-width, plate heights\n  var HWC = 0.0027;                       // narrower than this, drawn as a line\n  var chans = [];                         // {p: [x, y, hw, ...], ext, parent, mouth, gen}\n  var lm = lattice(30, 3);\n  var LAM = 0.10 + rand() * 0.04, OM = 1.5 + rand() * 0.3, phm = rand() * Math.PI * 2;\n  var sAp = 0.012;\n  var s = sMaxPlate + 0.04, t = axis(s), a = 0, phase = 0, bends = [];\n  var trunk = { p: [], ext: [], parent: -1, mouth: false, gen: 0 }, prevTh = 0, prevD = 0;\n  chans.push(trunk);\n  for (var it = 0; it < 4000 && s > sAp; it++) {\n    phase += ST / LAM * (1 + 0.35 * lm(Math.min(1, it / 900), 0.5));\n    var thm = OM * (0.4 + 0.6 * smooth(E1 + 0.03, E1 - 0.05, s)) * Math.sin(2 * Math.PI * phase + phm);\n    var corr = Math.max(-0.5, Math.min(0.5, (axis(s) - t) * 5));\n    a = thm + corr;\n    s -= Math.cos(a) * ST; t += Math.sin(a) * ST;\n    trunk.p.push(toX(s, t), toY(s, t), HW0);\n    // The apex of each bend, where an old loop may have been cut off.\n    var dth = thm - prevTh;\n    if (prevD > 0 && dth <= 0 || prevD < 0 && dth >= 0) bends.push(trunk.p.length / 3 - 1);\n    prevD = dth; prevTh = thm;\n  }\n\n  // The distributaries. Each channel runs out from the apex, turning slowly\n  // toward its own radial bearing so the channels fan, and divides after a\n  // run; each branch takes a share of the water and is narrower for it. A\n  // channel that runs into another ends there. One that passes the envelope\n  // of the delta carries its banks out with it as a finger of land, until it\n  // opens into the sea.\n  var OCC = 0.01, occ = {};\n  function occKey(x, y) { return Math.floor(x / OCC) + ',' + Math.floor(y / OCC); }\n  function finger(hw) { return 0.006 + Math.pow(1 - wv, 1.5) * 0.20 * Math.sqrt(hw / HW0); }\n  var bars = [];\n  // Each channel steers toward a bearing of its own and hands half of its\n  // span of bearings to each branch, so the channels fan across the delta\n  // instead of running together down one side of it.\n  var queue = [{ s: s, t: t, a: Math.max(-0.9, Math.min(0.9, a)), hw: HW0, gen: 0, parent: 0,\n    tb: 0.6 * Math.atan2(tc - tAp, R), span: 1.3 + 0.5 * wv }];\n  // The trunk's cells are occupied too, so no distributary turns back into it.\n  for (var q0 = 0; q0 < trunk.p.length; q0 += 3) occ[occKey(trunk.p[q0], trunk.p[q0 + 1])] = 0;\n  function grow(b) {\n    var id = chans.length;\n    var ch = { p: [], ext: [], parent: b.parent, mouth: false, gen: b.gen };\n    chans.push(ch);\n    var s = b.s, t = b.t, a = b.a, hw = b.hw, since = 0, tot = 0;\n    var Lb = (0.03 + rand() * 0.05) * (1 + 0.6 * (1 - wv)) * (b.gen === 0 ? 1.2 : 1);\n    var mph = rand() * Math.PI * 2, mlam = 0.05 + rand() * 0.04, wnd = lattice(16, 1);\n    ch.p.push(toX(s, t), toY(s, t), hw);\n    for (var it = 0; it < 1500; it++) {\n      // The bearing a channel holds wanders with distance, so no channel runs\n      // out across the delta as a straight spoke.\n      a += 0.08 * (b.tb + 0.6 * wnd(Math.min(1, tot / 0.5), 0) - a);\n      if (a > 1.25) a = 1.25; else if (a < -1.25) a = -1.25;\n      var am = a + 0.32 * Math.sin(2 * Math.PI * tot / mlam + mph);\n      s -= Math.cos(am) * ST; t += Math.sin(am) * ST; tot += ST; since += ST;\n      var x = toX(s, t), y = toY(s, t);\n      ch.p.push(x, y, hw);\n      if (x < -0.03 || y < -0.03 || x > ASPECT + 0.03 || y > 1.03) return;\n      var beyond = -(s + coastOff(t)) - front(t);\n      if (beyond > finger(hw)) {\n        // The mouth. The channel runs on a little way across the flats it\n        // has laid down, and a bar of sand builds in front of it.\n        ch.mouth = true;\n        var ex = x, ey = y, dxm = toX(s - Math.cos(am), t + Math.sin(am)) - toX(s, t), dym = toY(s - Math.cos(am), t + Math.sin(am)) - toY(s, t);\n        // The line across the mouth: past it the channel raises no banks.\n        ch.cut = [ex, ey, dxm, dym];\n        ch.ext.push(ex, ey, hw);\n        for (var e = 1; e <= 8; e++) ch.ext.push(ex + dxm * e * 0.003, ey + dym * e * 0.003, hw * (1 - 0.06 * e));\n        bars.push([ex + dxm * 0.03, ey + dym * 0.03, 0.010 + hw * 1.6]);\n        return;\n      }\n      if (tot > 0.02) {\n        var n = ch.p.length, hx = x + (x - ch.p[n - 6]) / (2 * ST) * 0.016, hy = y + (y - ch.p[n - 5]) / (2 * ST) * 0.016;\n        var o = occ[occKey(hx, hy)];\n        if (o !== undefined && o !== id && !(tot < 0.05 && (o === b.parent || chans[o].parent === b.parent))) return;\n      }\n      occ[occKey(x, y)] = id;\n      if (since > Lb && b.gen < maxGen && hw > HWC * 1.15 && beyond < -0.02) {\n        var r = 0.35 + rand() * 0.3;\n        queue.push({ s: s, t: t, a: a - 0.15 - rand() * 0.2, hw: hw * Math.sqrt(r) * 1.08, gen: b.gen + 1, parent: id, tb: b.tb - b.span / 4, span: b.span / 2 });\n        queue.push({ s: s, t: t, a: a + 0.15 + rand() * 0.2, hw: hw * Math.sqrt(1 - r) * 1.08, gen: b.gen + 1, parent: id, tb: b.tb + b.span / 4, span: b.span / 2 });\n        return;\n      }\n    }\n  }\n  for (var qi = 0; qi < queue.length && qi < 80; qi++) grow(queue[qi]);\n  // A branch that ran into another within a step or two of its split left a\n  // scrap of bank beside the channel, read as a stray tick. None of them can\n  // have branches of their own: a split needs a run of Lb first.\n  chans = chans.filter(function (ch, ci) { return ci === 0 || ch.mouth || ch.p.length / 3 * ST >= 0.015; });\n\n  // Cut-off meanders: the loop of an old bend, left standing beside the river\n  // on the valley floor as a crescent of still water.\n  var oxbows = [], kb = Math.round(0.32 * LAM / ST);\n  for (var bi = 0; bi < bends.length && oxbows.length < 3; bi++) {\n    var ib = bends[bi], tp = trunk.p;\n    if (ib - kb < 0 || ib + kb >= tp.length / 3) continue;\n    var sb = toS(tp[ib * 3], tp[ib * 3 + 1]);\n    if (sb < 0.012 || rand() > 0.7) continue;\n    // Out from the bend, on the side away from its chord.\n    var mx = (tp[(ib - kb) * 3] + tp[(ib + kb) * 3]) / 2, my = (tp[(ib - kb) * 3 + 1] + tp[(ib + kb) * 3 + 1]) / 2;\n    var ox = tp[ib * 3] - mx, oy = tp[ib * 3 + 1] - my, ol = Math.hypot(ox, oy) || 1;\n    var sh = 0.022 + rand() * 0.01, pts = [], ok = true;\n    for (var j = -kb; j <= kb; j++) {\n      var px = tp[(ib + j) * 3] + ox / ol * sh, py = tp[(ib + j) * 3 + 1] + oy / ol * sh;\n      for (var j2 = 0; j2 < tp.length && ok; j2 += 9) if (Math.hypot(tp[j2] - px, tp[j2 + 1] - py) < HW0 + 0.004) ok = false;\n      var spx = toS(px, py);\n      if (spx + coastOff(toT(px, py)) < 0.008 || spx > E1 && Math.abs(toT(px, py) - axis(spx)) > valW(spx) * 0.55) ok = false;\n      pts.push(px, py, 0.0034 * Math.pow(Math.sin(Math.PI * (j + kb) / (2 * kb)), 0.5));\n    }\n    if (ok) oxbows.push({ p: pts, ext: [], parent: -1, mouth: false, gen: 9, ox: true });\n  }\n  chans = chans.concat(oxbows);\n\n  // --- The channels on the grid ---------------------------------------------\n  // For every cell: how far it lies outside the nearest carved bank (negative\n  // inside the water), that channel's half-width, and the distance to the\n  // nearest creek too narrow to carve and to the runs across the flats.\n  var bankD = new Float32Array(N).fill(1), bankHW = new Float32Array(N), bankOx = new Uint8Array(N);\n  var bankCut = new Float32Array(N).fill(-1);   // how far behind its mouth, for a channel that has one\n  var creekD = new Float32Array(N).fill(1), extD = new Float32Array(N).fill(1);\n  function raster(x0, y0, h0, x1, y1, h1, reach, kind, ox, cut) {\n    var c0 = Math.max(0, Math.floor((Math.min(x0, x1) - reach) / DX)), c1 = Math.min(COLS - 1, Math.ceil((Math.max(x0, x1) + reach) / DX));\n    var r0 = Math.max(0, Math.floor((Math.min(y0, y1) - reach) / DY)), r1 = Math.min(ROWS - 1, Math.ceil((Math.max(y0, y1) + reach) / DY));\n    var vx = x1 - x0, vy = y1 - y0, L2 = vx * vx + vy * vy || 1e-12;\n    for (var r = r0; r <= r1; r++) for (var c = c0; c <= c1; c++) {\n      var px = c * DX, py = r * DY;\n      // 🔴 Past the mouth. The levee is a ring round the channel's end cap,\n      // and it closed every mouth: the fingers ended in sealed loops.\n      var behind = cut ? (px - cut[0]) * cut[2] + (py - cut[1]) * cut[3] : -1;\n      if (behind > 0) continue;\n      var u = ((px - x0) * vx + (py - y0) * vy) / L2;\n      if (u < 0) u = 0; else if (u > 1) u = 1;\n      var hw = h0 + (h1 - h0) * u;\n      var d = Math.hypot(px - x0 - vx * u, py - y0 - vy * u), p = r * COLS + c;\n      if (kind === 0) { if (d - hw < bankD[p]) { bankD[p] = d - hw; bankHW[p] = hw; bankOx[p] = ox ? 1 : 0; bankCut[p] = behind; } }\n      else if (kind === 1) { if (d < creekD[p]) creekD[p] = d; }\n      else if (d - hw < extD[p]) extD[p] = d - hw;\n    }\n  }\n  chans.forEach(function (ch) {\n    var p = ch.p;\n    for (var j = 0; j + 5 < p.length; j += 3) {\n      var carved = p[j + 2] >= HWC;\n      raster(p[j], p[j + 1], p[j + 2], p[j + 3], p[j + 4], p[j + 5], p[j + 2] * 2 + 0.03, carved ? 0 : 1, ch.ox, ch.cut);\n    }\n    for (var k = 0; k + 5 < ch.ext.length; k += 3) raster(ch.ext[k], ch.ext[k + 1], ch.ext[k + 2], ch.ext[k + 3], ch.ext[k + 4], ch.ext[k + 5], 0.02, 2, false);\n  });\n  var barF = new Float32Array(N);\n  bars.forEach(function (b) {\n    var c0 = Math.max(0, Math.floor((b[0] - 3 * b[2]) / DX)), c1 = Math.min(COLS - 1, Math.ceil((b[0] + 3 * b[2]) / DX));\n    var r0 = Math.max(0, Math.floor((b[1] - 3 * b[2]) / DY)), r1 = Math.min(ROWS - 1, Math.ceil((b[1] + 3 * b[2]) / DY));\n    for (var r = r0; r <= r1; r++) for (var c = c0; c <= c1; c++) {\n      var d = Math.hypot(c * DX - b[0], r * DY - b[1]) / b[2];\n      barF[r * COLS + c] = Math.max(barF[r * COLS + c], Math.exp(-d * d));\n    }\n  });\n\n  // --- The ground -----------------------------------------------------------\n  var w1x = lattice(6, 5), w1y = lattice(6, 5);\n  var n1 = lattice(7, 5), n2 = lattice(15, 11), n3 = lattice(33, 25), nE = lattice(24, 18);\n  // 🔴 Ponds and lagoons are drawn out along the coast, as the low ground\n  // between old shorelines lies. Off a round lattice every one was a blob.\n  var lagA = lattice(7, 18), lag2 = lattice(29, 21);\n  function pondF(sp, tp, u, v, x, y) {\n    var edge = Math.min(x, ASPECT - x, y, 1 - y);\n    return lagA(0.5 + tp / 2.2, (sp + 0.8) / 2.2) + 0.4 * lag2(u, v) + 0.12 * n3(u, v) - 0.9 * (1 - smooth(0.02, 0.07, edge));\n  }\n  /**\n   * The mainland: a plain barely above the sea behind the old coast, then a\n   * short steep rise of bluffs to an upland that climbs gently away inland.\n   * The plain is kept level so it takes marsh and grass and no hachures.\n   */\n  function mainland(s, t, u, v) {\n    var sc = s + coastOff(t), se = s + escOff(t);\n    var plain = 0.003 + 0.005 * smooth(0, E1, sc);\n    var up = smooth(E1 - 0.014, E1 + 0.014, se);\n    var vw = valW(s), dv = Math.abs(t - axis(s));\n    // The upland falls toward the valley as well as the sea, and rolls, so its\n    // streams gather into branching courses. On a plane they ran side by side.\n    var h = plain + up * (0.11 + 0.07 * Math.max(0, se - E1) + 0.016 * n1(u, v) + 0.003 * n2(u, v) - 0.03 * Math.exp(-dv / 0.12));\n    // The valley: a flat floor between steep sides, cut down to the plain.\n    var V = smooth(vw * 1.3, vw * 0.6, dv);\n    var floor = plain + 0.012 * Math.max(0, se - E1);\n    return floor + (h - floor) * (1 - V) + 0.0008 * n3(u, v) * (1 - up);\n  }\n  var hmap = new Float32Array(N), kind = new Uint8Array(N);   // kind: 0 land, 1 sea, 2 channel, 3 still water\n  for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n    var p = r * COLS + c, x = c * DX, y = r * DY, u = x / ASPECT, v = y;\n    // A gentle warp of the whole ground, so no line on the sheet is ruled.\n    var xw = x + 0.02 * w1x(u, v), yw = y + 0.02 * w1y(u, v);\n    var sp = toS(xw, yw), tp2 = toT(xw, yw), sc = sp + coastOff(tp2);\n    var h;\n    if (sc >= 0) {\n      h = mainland(sp, tp2, u, v);\n      // Ponds on the plain behind the old coast.\n      if (pondF(sp, tp2, u, v, x, y) - 0.7 * (1 - smooth(0.012, 0.03, bankD[p])) - 0.7 * smooth(E1 * 0.4, E1 * 0.8, sc) - 0.7 * (1 - smooth(0.004, 0.02, sc)) > 0.66) { h = -0.002; kind[p] = 3; }\n    } else {\n      var F = front(tp2) * (1 + 0.07 * nE(u, v));\n      // The banks thin to a point at the mouth. Cut off square, each finger\n      // ended in a flat-topped stub.\n      var inEnv = -sc < F, levee = !bankOx[p] && bankD[p] < (bankHW[p] + 0.004) * smooth(0, 0.022, -bankCut[p]);\n      if (inEnv || levee) {\n        // The banks stand a little above the ground between the channels.\n        h = 0.0014 + 0.0026 * Math.exp(-Math.max(0, bankD[p]) / 0.007) + 0.0005 * n3(u, v);\n        // Lagoons, in the low ground well away from the channels.\n        var lsc = pondF(sp, tp2, u, v, x, y) - 0.7 * (1 - smooth(0.012, 0.03, bankD[p])) - 0.7 * smooth(0.55, 0.85, -sc / F) - 0.7 * (1 - smooth(0.004, 0.02, -sc));\n        if (inEnv && lsc > 0.64) { h = -0.002; kind[p] = 3; }\n      } else { h = -0.01; kind[p] = 1; }\n    }\n    if (bankD[p] < 0) { h = -0.003; kind[p] = bankOx[p] ? 3 : 2; }\n    hmap[p] = h;\n  }\n  // Specks of land left inside the water, where two limbs of a meander ran\n  // together or two banks nearly met, are drowned: each read as a fleck of\n  // dirt in the channel.\n  var comp = new Int32Array(N).fill(-1), stackQ = new Int32Array(N);\n  for (var p0 = 0; p0 < N; p0++) {\n    if (hmap[p0] <= 0 || comp[p0] >= 0) continue;\n    var qn = 0, members = [], edge = false, wetK = 1;\n    comp[p0] = p0; stackQ[qn++] = p0;\n    while (qn) {\n      var cq = stackQ[--qn], ccq = cq % COLS, rcq = (cq - ccq) / COLS;\n      members.push(cq);\n      if (ccq === 0 || rcq === 0 || ccq === COLS - 1 || rcq === ROWS - 1) edge = true;\n      var nbs = [cq - 1, cq + 1, cq - COLS, cq + COLS];\n      for (var nq = 0; nq < 4; nq++) {\n        var q = nbs[nq];\n        if ((nq === 0 && ccq === 0) || (nq === 1 && ccq === COLS - 1) || q < 0 || q >= N) continue;\n        if (hmap[q] <= 0) { if (kind[q] !== 1) wetK = kind[q]; continue; }\n        if (comp[q] < 0) { comp[q] = p0; stackQ[qn++] = q; }\n      }\n    }\n    if (!edge && members.length < 20) members.forEach(function (m) { hmap[m] = -0.003; kind[m] = wetK; });\n  }\n  // And the other way: a pond of a few cells took a shoreline loop so tight it\n  // printed as a black speck. It is filled back to the ground round it.\n  var compW = new Int32Array(N).fill(-1);\n  for (var w0 = 0; w0 < N; w0++) {\n    if (kind[w0] !== 3 || bankOx[w0] || compW[w0] >= 0) continue;\n    var wn = 0, wm = [];\n    compW[w0] = w0; stackQ[wn++] = w0;\n    while (wn) {\n      var cw = stackQ[--wn], ccw = cw % COLS;\n      wm.push(cw);\n      var nbw = [ccw > 0 ? cw - 1 : -1, ccw < COLS - 1 ? cw + 1 : -1, cw - COLS, cw + COLS];\n      for (var nw = 0; nw < 4; nw++) {\n        var qw = nbw[nw];\n        if (qw < 0 || qw >= N || kind[qw] !== 3 || compW[qw] >= 0) continue;\n        compW[qw] = w0; stackQ[wn++] = qw;\n      }\n    }\n    if (wm.length < 25) wm.forEach(function (m) { hmap[m] = 0.0016; kind[m] = 0; });\n  }\n\n  function at(a, c, r) {\n    if (c < 0) c = 0; else if (c > COLS - 1) c = COLS - 1;\n    if (r < 0) r = 0; else if (r > ROWS - 1) r = ROWS - 1;\n    return a[r * COLS + c];\n  }\n  /** Bilinear sample of a map at fractional cell coordinates. */\n  function bil(a, x, y) {\n    if (x < 0) x = 0; else if (x > COLS - 1.001) x = COLS - 1.001;\n    if (y < 0) y = 0; else if (y > ROWS - 1.001) y = ROWS - 1.001;\n    var x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0, p = y0 * COLS + x0;\n    return (a[p] * (1 - fx) + a[p + 1] * fx) * (1 - fy) + (a[p + COLS] * (1 - fx) + a[p + COLS + 1] * fx) * fy;\n  }\n  var NB = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]];\n\n  /** Two-pass chamfer distance (in cells) from every source cell, with the nearest source. */\n  function chamfer(isSrc) {\n    var d = new Float32Array(N), src = new Int32Array(N);\n    for (var p = 0; p < N; p++) { d[p] = isSrc(p) ? 0 : 1e6; src[p] = d[p] === 0 ? p : -1; }\n    var D2 = Math.SQRT2;\n    function relax(p, q, w) { if (d[q] + w < d[p]) { d[p] = d[q] + w; src[p] = src[q]; } }\n    for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n      var p = r * COLS + c;\n      if (c > 0) relax(p, p - 1, 1);\n      if (r > 0) {\n        relax(p, p - COLS, 1);\n        if (c > 0) relax(p, p - COLS - 1, D2);\n        if (c < COLS - 1) relax(p, p - COLS + 1, D2);\n      }\n    }\n    for (var r2 = ROWS - 1; r2 >= 0; r2--) for (var c2 = COLS - 1; c2 >= 0; c2--) {\n      var p2 = r2 * COLS + c2;\n      if (c2 < COLS - 1) relax(p2, p2 + 1, 1);\n      if (r2 < ROWS - 1) {\n        relax(p2, p2 + COLS, 1);\n        if (c2 < COLS - 1) relax(p2, p2 + COLS + 1, D2);\n        if (c2 > 0) relax(p2, p2 + COLS - 1, D2);\n      }\n    }\n    return { d: d, src: src };\n  }\n  /** Box-blur a copy of a map, `passes` times, radius 2. */\n  function blur(a, passes, cap) {\n    var s = new Float32Array(N), tmp = new Float32Array(N);\n    for (var p = 0; p < N; p++) s[p] = cap !== undefined ? Math.min(a[p], cap) : a[p];\n    for (var pass = 0; pass < passes; pass++) {\n      for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n        var acc = 0, cnt = 0;\n        for (var oy = -2; oy <= 2; oy++) for (var ox = -2; ox <= 2; ox++) {\n          var cc = c + ox, rr = r + oy;\n          if (cc < 0 || rr < 0 || cc >= COLS || rr >= ROWS) continue;\n          acc += s[rr * COLS + cc]; cnt++;\n        }\n        tmp[r * COLS + c] = acc / cnt;\n      }\n      var sw = s; s = tmp; tmp = sw;\n    }\n    return s;\n  }\n  /**\n   * Exact Euclidean distance (in cells) from every source cell, carrying the\n   * index of the nearest source (Felzenszwalb and Huttenlocher's two-pass\n   * lower envelope). 🔴 A chamfer's distance steps in eighths of a turn, and\n   * water-lining laid on its bands came out octagonal round Relief of a Coast's stacks.\n   */\n  function edt(isSrc) {\n    var INF = 1e12, M = Math.max(COLS, ROWS);\n    var colD = new Float64Array(N), colS = new Int32Array(N);\n    var f = new Float64Array(M), out = new Float64Array(M), arg = new Int32Array(M);\n    var v = new Int32Array(M), z = new Float64Array(M + 1);\n    function pass1(n) {\n      var k = 0; v[0] = 0; z[0] = -INF; z[1] = INF;\n      for (var q = 1; q < n; q++) {\n        var s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);\n        while (s <= z[k]) { k--; s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); }\n        k++; v[k] = q; z[k] = s; z[k + 1] = INF;\n      }\n      k = 0;\n      for (var q2 = 0; q2 < n; q2++) {\n        while (z[k + 1] < q2) k++;\n        out[q2] = (q2 - v[k]) * (q2 - v[k]) + f[v[k]]; arg[q2] = v[k];\n      }\n    }\n    for (var c = 0; c < COLS; c++) {\n      for (var r = 0; r < ROWS; r++) f[r] = isSrc(r * COLS + c) ? 0 : INF;\n      pass1(ROWS);\n      for (var r2 = 0; r2 < ROWS; r2++) { colD[r2 * COLS + c] = out[r2]; colS[r2 * COLS + c] = arg[r2]; }\n    }\n    var d = new Float32Array(N), src = new Int32Array(N);\n    for (var r3 = 0; r3 < ROWS; r3++) {\n      for (var c2 = 0; c2 < COLS; c2++) f[c2] = colD[r3 * COLS + c2];\n      pass1(COLS);\n      for (var c3 = 0; c3 < COLS; c3++) {\n        var p = r3 * COLS + c3;\n        d[p] = out[c3] >= INF * 0.5 ? 1e6 : Math.sqrt(out[c3]);\n        src[p] = out[c3] >= INF * 0.5 ? -1 : colS[r3 * COLS + arg[c3]] * COLS + arg[c3];\n      }\n    }\n    return { d: d, src: src };\n  }\n\n  // --- Drainage and erosion -------------------------------------------------\n  // The bluffs are eroded as Relief of a Coast's escarpment is: hollows filled\n  // to their spill point, water routed downhill, and each cell cut toward its\n  // receiver by the square root of the water passing through it. The river's\n  // channels are already water, so the gullies find their way down to them.\n  var heapK = new Float64Array(N), heapV = new Int32Array(N), heapN = 0;\n  function hpush(key, val) {\n    var i = heapN++;\n    while (i > 0) {\n      var pa = (i - 1) >> 1;\n      if (heapK[pa] <= key) break;\n      heapK[i] = heapK[pa]; heapV[i] = heapV[pa]; i = pa;\n    }\n    heapK[i] = key; heapV[i] = val;\n  }\n  function hpop() {\n    var top = heapV[0], key = heapK[--heapN], val = heapV[heapN], i = 0;\n    for (;;) {\n      var l = 2 * i + 1;\n      if (l >= heapN) break;\n      if (l + 1 < heapN && heapK[l + 1] < heapK[l]) l++;\n      if (heapK[l] >= key) break;\n      heapK[i] = heapK[l]; heapV[i] = heapV[l]; i = l;\n    }\n    heapK[i] = key; heapV[i] = val;\n    return top;\n  }\n  var seen = new Uint8Array(N);\n  /** Fill every hollow on the land to its spill point, with a hair of fall. */\n  function fill() {\n    seen.fill(0); heapN = 0;\n    for (var p = 0; p < N; p++) {\n      var c = p % COLS, r = (p - c) / COLS;\n      if (hmap[p] <= 0 || c === 0 || r === 0 || c === COLS - 1 || r === ROWS - 1) { seen[p] = 1; hpush(hmap[p], p); }\n    }\n    while (heapN) {\n      var cur = hpop(), cc = cur % COLS, rc = (cur - cc) / COLS;\n      for (var nb = 0; nb < 8; nb++) {\n        var c2 = cc + NB[nb][0], r2 = rc + NB[nb][1];\n        if (c2 < 0 || r2 < 0 || c2 >= COLS || r2 >= ROWS) continue;\n        var q = r2 * COLS + c2;\n        if (seen[q]) continue;\n        seen[q] = 1;\n        if (hmap[q] <= hmap[cur] + 1e-6) hmap[q] = hmap[cur] + 1e-6;\n        hpush(hmap[q], q);\n      }\n    }\n  }\n  var rcv = new Int32Array(N), acc = new Float32Array(N), landIdx = [];\n  /**\n   * Route water downhill and accumulate it. The diagonal fall is weighed by a\n   * fixed random per cell (Fairfield and Leymarie's rho-8), because a plain\n   * steepest-of-eight rule runs every stream on a plane slope as a ruled\n   * line at a multiple of 45 degrees.\n   */\n  function route() {\n    rcv.fill(-1); acc.fill(0); landIdx = [];\n    for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n      var p = r * COLS + c;\n      if (hmap[p] <= 0) continue;\n      landIdx.push(p);\n      var best = 0, diag = 2 - hash(p * 7 + 3);\n      for (var nb = 0; nb < 8; nb++) {\n        var c2 = c + NB[nb][0], r2 = r + NB[nb][1];\n        if (c2 < 0 || r2 < 0 || c2 >= COLS || r2 >= ROWS) continue;\n        var q = r2 * COLS + c2;\n        var drop = (hmap[p] - hmap[q]) / (NB[nb][0] && NB[nb][1] ? diag : 1);\n        if (drop > best) { best = drop; rcv[p] = q; }\n      }\n    }\n    landIdx.sort(function (a, b) { return hmap[b] - hmap[a]; });\n    for (var li = 0; li < landIdx.length; li++) {\n      var pl = landIdx[li];\n      acc[pl] += 1;\n      if (rcv[pl] >= 0) acc[rcv[pl]] += acc[pl];\n    }\n  }\n  // Gentler than Relief of a Coast's. On a bluff this short the deep gullies\n  // were too small to draw as streams, and the hachures ran into each one.\n  var KF = 0.03, ROUNDS = 10;\n  var lap = new Float32Array(N);\n  for (var round = 0; round < ROUNDS; round++) {\n    fill(); route();\n    // Implicit stream-power step, receivers first (Braun and Willett), so it\n    // is stable however hard it cuts.\n    for (var li2 = landIdx.length - 1; li2 >= 0; li2--) {\n      var pe = landIdx[li2], rq = rcv[pe];\n      if (rq < 0) continue;\n      // Only water gathered into a channel cuts. Cutting from the first cell\n      // down, every hillside grew a gully, and the hachures drew a feather\n      // down each one so Relief of a Coast's escarpment read as streaks.\n      var F = KF * Math.sqrt(acc[pe]) * smooth(15, 70, acc[pe]);\n      var hn = (hmap[pe] + F * Math.max(hmap[rq], 0)) / (1 + F);\n      hmap[pe] = Math.max(0.001, Math.min(hmap[pe], hn));\n    }\n    // A little hillslope creep, so valley sides are slopes and not steps.\n    for (var pd = 0; pd < N; pd++) {\n      lap[pd] = 0;\n      if (hmap[pd] <= 0) continue;\n      var cd = pd % COLS, rd = (pd - cd) / COLS, sumL = 0, nL = 0;\n      if (cd > 0 && hmap[pd - 1] > 0) { sumL += hmap[pd - 1]; nL++; }\n      if (cd < COLS - 1 && hmap[pd + 1] > 0) { sumL += hmap[pd + 1]; nL++; }\n      if (rd > 0 && hmap[pd - COLS] > 0) { sumL += hmap[pd - COLS]; nL++; }\n      if (rd < ROWS - 1 && hmap[pd + COLS] > 0) { sumL += hmap[pd + COLS]; nL++; }\n      if (nL) lap[pd] = sumL / nL - hmap[pd];\n    }\n    for (var pd2 = 0; pd2 < N; pd2++) if (hmap[pd2] > 0) hmap[pd2] = Math.max(0.001, hmap[pd2] + 0.18 * lap[pd2]);\n  }\n  fill(); route();\n  // Ground the last fill raised to its spill point: a flat a stream crosses\n  // as a ruled line. The pen lifts over it.\n  var levelled = new Uint8Array(N), pre = Float32Array.from(hmap);\n  fill(); route();\n  for (var pv = 0; pv < N; pv++) if (hmap[pv] - pre[pv] > 1e-5) levelled[pv] = 1;\n  // A gully that gathers enough water is drawn as a stream, but only on ground\n  // that drains. Across the marsh the same routing runs dead straight over\n  // ground filled level, so a stream is traced until it reaches the marsh and\n  // stops there, as a stream coming off high ground loses itself in one.\n  var A1 = Math.round(N * 0.0014);\n  var isStream = new Uint8Array(N);\n  for (var ps = 0; ps < N; ps++) if (hmap[ps] > 0.006 && acc[ps] >= A1) isStream[ps] = 1;\n  var toStream = chamfer(function (p) { return isStream[p] === 1; });\n\n  // --- Slope and light ------------------------------------------------------\n  var slope = new Float32Array(N), facing = new Float32Array(N);\n  var LX = -Math.SQRT1_2, LY = -Math.SQRT1_2;          // toward the light, north-west\n  var samples = [];\n  for (var r2 = 0; r2 < ROWS; r2++) {\n    for (var c2 = 0; c2 < COLS; c2++) {\n      var n = r2 * COLS + c2;\n      var gx = (at(hmap, c2 + 1, r2) - at(hmap, c2 - 1, r2)) / (2 * DX);\n      var gy = (at(hmap, c2, r2 + 1) - at(hmap, c2, r2 - 1)) / (2 * DY);\n      var g = Math.sqrt(gx * gx + gy * gy);\n      slope[n] = g;\n      facing[n] = g > 1e-6 ? (-gx * LX - gy * LY) / g : 0;   // +1 faces the light\n      // 🔴 Read off the high ground only. Most of this sheet is level, and a\n      // percentile taken over all of it set the scale by the flat ground, so\n      // the banks of the channels took hachures.\n      if (hmap[n] > 0.03 && (r2 * 7 + c2) % 11 === 0) samples.push(g);\n    }\n  }\n  samples.sort(function (a, b) { return a - b; });\n  var smax = samples.length ? samples[Math.floor(samples.length * 0.92)] : 1;\n  /** The weight of engraving a piece of ground takes: steepness, then the light. */\n  function toneOf(sl, f) {\n    var sn = Math.min(1.25, sl / smax);\n    var T = 0.04 + 0.46 * Math.pow(sn, 0.85) + 0.38 * sn * (f < 0 ? -f : 0) - 0.14 * sn * (f > 0 ? f : 0);\n    return T < 0 ? 0 : T > 1 ? 1 : T;\n  }\n  var tone = new Float32Array(N);\n  for (var m = 0; m < N; m++) if (hmap[m] > 0) tone[m] = toneOf(slope[m], facing[m]);\n  var hsm = blur(hmap, 1), gxT = new Float32Array(N), gyT = new Float32Array(N);\n  for (var r8 = 0; r8 < ROWS; r8++) for (var c8 = 0; c8 < COLS; c8++) {\n    gxT[r8 * COLS + c8] = at(hsm, c8 + 1, r8) - at(hsm, c8 - 1, r8);\n    gyT[r8 * COLS + c8] = at(hsm, c8, r8 + 1) - at(hsm, c8, r8 - 1);\n  }\n\n  var fromLand = edt(function (p) { return hmap[p] > 0; });   // distance out into the water\n  var fromSea = edt(function (p) { return hmap[p] <= 0; });   // distance inland\n  var fromOpen = edt(function (p) { return kind[p] === 1; }); // distance from the open sea only\n\n  // The sea bed shelves away from whatever land is nearest, so the depth\n  // curves swing out round the delta front.\n  for (var pb = 0; pb < N; pb++) {\n    if (kind[pb] !== 1) continue;\n    var cb = pb % COLS, rb = (pb - cb) / COLS, dp0 = fromLand.d[pb] * DY;\n    hmap[pb] = -Math.min(0.6, (0.004 + 0.6 * Math.pow(dp0, 1.5)) * (1 + 0.22 * n1(cb / (COLS - 1), rb / (ROWS - 1))));\n  }\n\n  // How gentle the shore is, read off the ground just inside it.\n  var gentle = new Float32Array(N);\n  for (var pg = 0; pg < N; pg++) {\n    if (hmap[pg] <= 0 || fromSea.d[pg] > 3) continue;\n    var sum = 0, cnt = 0, cg = pg % COLS, rg = Math.floor(pg / COLS);\n    for (var oy = -3; oy <= 3; oy++) for (var ox2 = -3; ox2 <= 3; ox2++) {\n      var cc2 = cg + ox2, rr2 = rg + oy;\n      if (cc2 < 0 || rr2 < 0 || cc2 >= COLS || rr2 >= ROWS) continue;\n      var q2 = rr2 * COLS + cc2;\n      if (hmap[q2] <= 0) continue;\n      sum += Math.min(1.5, slope[q2] / smax); cnt++;\n    }\n    gentle[pg] = 1 - smooth(0.22, 0.65, cnt ? sum / cnt : 1);\n  }\n  // The flats in front of the shore, in cells, wider off the delta and wider\n  // again over the bar at each mouth.\n  var FLAT = 5 + 3 * wv, BAR = 9;\n  var flatW = new Float32Array(N);\n  for (var pf = 0; pf < N; pf++) {\n    if (kind[pf] !== 1) continue;\n    var sl = fromLand.src[pf];\n    flatW[pf] = (sl >= 0 ? FLAT * gentle[sl] : 0) + BAR * barF[pf];\n  }\n\n  // --- The water ------------------------------------------------------------\n  // The sea is water-lined from the low-water line outward. In a channel or a\n  // lagoon the lining is a single line inside each bank: the distance is\n  // capped short of the second band. Still water keeps only its bank; lined,\n  // every pond read as a hole cut in the sheet.\n  var fromLow = new Float32Array(N);\n  for (var pl2 = 0; pl2 < N; pl2++) {\n    if (hmap[pl2] > 0) fromLow[pl2] = 0;\n    else if (kind[pl2] === 1) fromLow[pl2] = fromLand.d[pl2] - flatW[pl2];\n    else if (kind[pl2] === 2) fromLow[pl2] = Math.min(fromLand.d[pl2], 2.9);\n    else fromLow[pl2] = 0;\n  }\n  // Traced on a smoothed copy in the open sea, so the lining does not carry\n  // every notch of the shore out with it; raw in the channels, which a blur\n  // would close up.\n  var smB = blur(fromLow, 3, 70), sm = new Float32Array(N);\n  for (var pm = 0; pm < N; pm++) {\n    var wb = kind[pm] === 1 ? smooth(3, 9, fromLand.d[pm]) : 0;\n    sm[pm] = fromLow[pm] + (smB[pm] - fromLow[pm]) * wb;\n  }\n  var BANDS = [1.5];\n  for (var k2 = 1; k2 <= 14; k2++) BANDS.push(1.3 + 1.9 * Math.pow(k2, 1.3));\n  function nearBand(d, tol) {\n    for (var b = 0; b < BANDS.length; b++) if (Math.abs(d - BANDS[b]) < tol) return true;\n    return false;\n  }\n\n  // --- The ground's cover -----------------------------------------------------\n  // Behind the open shore the sea has thrown up ridges of sand, as deep a belt\n  // of them as the waves have the better of the river. Behind them, and\n  // between the channels, the ground is too low to drain and is marsh. The\n  // banks of the channels stand a little higher and carry grass, as do the\n  // valley floor and the high ground wherever it is too gentle for hachures.\n  var RD = 3 + 15 * wv;\n  var shoreDir = new Float32Array(N), drying = new Float32Array(N), stipple = new Float32Array(N);\n  var sand = new Float32Array(N), marsh = new Float32Array(N), grass = new Float32Array(N), ridgeM = new Uint8Array(N);\n  var dune = lattice(26, 20), mpatch = lattice(14, 10), meadow = lattice(18, 13);\n  for (var r6 = 0; r6 < ROWS; r6++) for (var c6 = 0; c6 < COLS; c6++) {\n    var i6 = r6 * COLS + c6, u6 = c6 / (COLS - 1), v6 = r6 / (ROWS - 1);\n    var gx6 = (at(sm, c6 + 1, r6) - at(sm, c6 - 1, r6)) / 2;\n    var gy6 = (at(sm, c6, r6 + 1) - at(sm, c6, r6 - 1)) / 2;\n    shoreDir[i6] = Math.atan2(gy6, gx6) + Math.PI / 2;\n    var h6 = hmap[i6];\n    if (h6 > 0) {\n      var rz = h6 < 0.009 && bankD[i6] > 0.002 ? 1 - smooth(RD - 2, RD, fromOpen.d[i6]) : 0;\n      if (rz > 0.5 && fromOpen.d[i6] > 1.2) ridgeM[i6] = 1;\n      sand[i6] = rz * 0.42 * (0.7 + 0.3 * dune(u6, v6));\n      var offLev = smooth(0.003, 0.009, bankD[i6]) * smooth(0.002, 0.005, creekD[i6]);\n      marsh[i6] = (1 - smooth(0.0042, 0.0065, h6)) * offLev * (1 - rz) * (0.3 + 0.7 * smooth(-0.4, 0.4, mpatch(u6, v6)));\n      var flatT = 1 - smooth(0.10, 0.30, tone[i6]);\n      var lev = (1 - smooth(0.004, 0.008, Math.max(0, bankD[i6]))) * 0.9;\n      var gz = Math.max(lev, smooth(0.0062, 0.009, h6) * flatT * 0.6, smooth(0.03, 0.05, h6) * flatT * 0.5);\n      var patch = 0.3 + 0.7 * smooth(-0.35, 0.45, meadow(u6, v6));\n      if (toStream.d[i6] > 2.5) grass[i6] = gz * (1 - rz) * patch;\n      continue;\n    }\n    if (kind[i6] !== 1) continue;\n    var d6 = fromLand.d[i6], fw = flatW[i6];\n    // Flats, stippled, thinning toward the low-water line and parted where a\n    // channel runs on across them.\n    if (d6 < fw) stipple[i6] = 0.9 * Math.pow(1 - d6 / fw, 0.7) * smooth(0, 0.004, extD[i6]);\n    if (fw > 2.5 && Math.abs(fromLow[i6]) < 0.6 && extD[i6] > 0.002) drying[i6] = 1;\n  }\n\n  // --- Publish ------------------------------------------------------------\n  var gl = (typeof globalThis !== 'undefined') ? globalThis : window;\n  gl.__genart_data = gl.__genart_data || {};\n  gl.__genart_data.cols = COLS;\n  gl.__genart_data.rows = ROWS;\n  function pack(name, mag, ang) {\n    var f32 = new Float32Array(N * 3);\n    for (var p = 0; p < N; p++) {\n      var an = ang[p], mg = mag[p];\n      if (!isFinite(an)) an = 0;\n      if (!isFinite(mg)) mg = 0;\n      f32[p * 3] = Math.cos(an);\n      f32[p * 3 + 1] = Math.sin(an);\n      f32[p * 3 + 2] = mg;\n    }\n    gl.__genart_data[name] = f32;\n  }\n  pack('drying', drying, shoreDir);\n\n  // --- Tracing ------------------------------------------------------------\n  // Contour lines of a map at one level, by marching squares, chained into\n  // polylines (flat arrays of cell coordinates). Edges are numbered: the\n  // horizontal edge right of cell p is p, the vertical edge below it N + p.\n  var eA = new Int32Array(2 * N), eB = new Int32Array(2 * N), eStamp = new Int32Array(2 * N);\n  var ex = new Float32Array(2 * N), ey = new Float32Array(2 * N), eUsed = new Uint8Array(2 * N);\n  var stamp = 0;\n  function contour(a, L) {\n    stamp++;\n    var touched = [], e = [0, 0, 0, 0], ne;\n    function cross(va, vb, id, xa, ya, xb, yb) {\n      if ((va < L) === (vb < L)) return;\n      if (eStamp[id] !== stamp) {\n        eStamp[id] = stamp; eA[id] = -1; eB[id] = -1; eUsed[id] = 0;\n        var t = (L - va) / (vb - va);\n        ex[id] = xa + (xb - xa) * t; ey[id] = ya + (yb - ya) * t;\n        touched.push(id);\n      }\n      e[ne++] = id;\n    }\n    function link(i, j) {\n      if (eA[i] === -1) eA[i] = j; else eB[i] = j;\n      if (eA[j] === -1) eA[j] = i; else eB[j] = i;\n    }\n    for (var r = 0; r < ROWS - 1; r++) for (var c = 0; c < COLS - 1; c++) {\n      var p = r * COLS + c;\n      var v0 = a[p], v1 = a[p + 1], v2 = a[p + COLS + 1], v3 = a[p + COLS];\n      var b0 = v0 < L, b1 = v1 < L, b2 = v2 < L, b3 = v3 < L;\n      if (b0 === b1 && b1 === b2 && b2 === b3) continue;\n      ne = 0;\n      cross(v0, v1, p, c, r, c + 1, r);\n      cross(v1, v2, N + p + 1, c + 1, r, c + 1, r + 1);\n      cross(v3, v2, p + COLS, c, r + 1, c + 1, r + 1);\n      cross(v0, v3, N + p, c, r, c, r + 1);\n      if (ne === 2) link(e[0], e[1]);\n      else if (ne === 4) { link(e[0], e[1]); link(e[2], e[3]); }\n    }\n    var lines = [];\n    for (var ti = 0; ti < touched.length; ti++) {\n      var id0 = touched[ti];\n      if (eUsed[id0]) continue;\n      // Walk back to an end if the line has one, so it is traced in one piece.\n      var s0 = id0, prev = -1, guard = 0;\n      while (eB[s0] !== -1 && guard++ < touched.length) {\n        var nx = eA[s0] === prev ? eB[s0] : eA[s0];\n        if (nx === id0) break;\n        prev = s0; s0 = nx;\n      }\n      var pts = [], cur = s0, pv = -1;\n      while (cur !== -1 && !eUsed[cur]) {\n        eUsed[cur] = 1; pts.push(ex[cur], ey[cur]);\n        var l1 = eA[cur], l2 = eB[cur];\n        var nxt = (l1 !== -1 && l1 !== pv && !eUsed[l1]) ? l1 : (l2 !== -1 && l2 !== pv && !eUsed[l2]) ? l2 : -1;\n        pv = cur; cur = nxt;\n      }\n      if (pts.length >= 4) lines.push(pts);\n    }\n    return lines;\n  }\n  /** Corner-cutting, so a traced line does not show the grid it came from. */\n  function chaikin(p, rounds) {\n    for (var it = 0; it < rounds; it++) {\n      var q = [p[0], p[1]];\n      for (var j = 0; j < p.length - 2; j += 2) {\n        q.push(0.75 * p[j] + 0.25 * p[j + 2], 0.75 * p[j + 1] + 0.25 * p[j + 3],\n          0.25 * p[j] + 0.75 * p[j + 2], 0.25 * p[j + 1] + 0.75 * p[j + 3]);\n      }\n      q.push(p[p.length - 2], p[p.length - 1]);\n      p = q;\n    }\n    return p;\n  }\n  function X(c) { return PX + c * CW; }\n  function Y(r) { return PY + r * CH; }\n  function lineLen(p) {\n    var s = 0;\n    for (var j = 2; j < p.length; j += 2) s += Math.hypot((p[j] - p[j - 2]) * CW, (p[j + 1] - p[j - 1]) * CH);\n    return s;\n  }\n  function strokeLine(p) {\n    ctx.moveTo(X(p[0]), Y(p[1]));\n    for (var j = 2; j < p.length; j += 2) ctx.lineTo(X(p[j]), Y(p[j + 1]));\n  }\n  /** Cell coordinates of a point given in plate heights. */\n  function CX(x) { return PX + x / DX * CW; }\n  function CY(y) { return PY + y / DY * CH; }\n\n  // --- The sheet ----------------------------------------------------------\n  // The layers draw over this, so the sheet is laid here: the paper colour\n  // with a faint, slow mottle, as a hand-made sheet has.\n  ctx.fillStyle = pal[4];\n  ctx.fillRect(0, 0, W, H);\n  var m1 = lattice(44, 32), m2 = lattice(140, 100);\n  var MS = 3 * K;\n  for (var my = 0; my < H; my += MS) for (var mx = 0; mx < W; mx += MS) {\n    var mv = 0.5 + 0.35 * m1(mx / W, my / H) + 0.25 * m2(mx / W, my / H);\n    if (mv <= 0.45) continue;\n    ctx.fillStyle = 'rgba(60,70,76,' + (0.05 * (mv - 0.45)).toFixed(3) + ')';\n    ctx.fillRect(mx, my, MS + 0.5, MS + 0.5);\n  }\n\n  // Everything engraved from here on is held inside the plate. 🔴 Unclipped,\n  // hachures begun on the plate's edge ran on past it and hung below the\n  // neat line as black wedges.\n  ctx.save();\n  ctx.beginPath();\n  ctx.rect(PX, PY, PW, PH);\n  ctx.clip();\n\n  // --- Sand and flats -------------------------------------------------------\n  // Stipple, dot by dot on a jittered grid so it lies as evenly as a\n  // roulette's, with the chance of a dot set by how sandy the ground is. The\n  // flats in front of the shore are stippled darker than the sand behind it.\n  // 🔴 Drawn by flow-line layers at two or three steps each, the \"dots\" were\n  // two-pixel blobs that bunched inside each cell and read as dirt.\n  var sg = rngFrom(seed * 6151 + 5), SG = 2.6 * K, dotsN = 0;\n  ctx.save();\n  ctx.globalAlpha = 0.9;\n  [[sand, pal[1], 0.6], [stipple, pal[0], 0.58]].forEach(function (z) {\n    ctx.fillStyle = z[1];\n    ctx.beginPath();\n    for (var y = PY + SG / 2; y < PY + PH; y += SG) for (var x = PX + SG / 2; x < PX + PW; x += SG) {\n      var jx = x + (sg() - 0.5) * SG, jy = y + (sg() - 0.5) * SG, roll = sg(), rs = sg();\n      if (roll >= bil(z[0], (jx - PX) / CW, (jy - PY) / CH)) continue;\n      var rad = z[2] * K * (0.8 + 0.4 * rs);\n      ctx.moveTo(jx + rad, jy);\n      ctx.arc(jx, jy, rad, 0, Math.PI * 2);\n      dotsN++;\n    }\n    ctx.fill();\n  });\n  ctx.restore();\n\n  // --- Marsh ----------------------------------------------------------------\n  // The engraver's marsh sign: a short rule laid level, with a few fine\n  // strokes standing up from it, set in loose staggered rows. Like every\n  // symbol on a map it is square to the page, not to the ground.\n  var mr = rngFrom(seed * 7717 + 3), MSX = 13 * K, MSY = 7.5 * K, marshN = 0;\n  ctx.save();\n  ctx.strokeStyle = pal[1];\n  ctx.lineCap = 'round';\n  ctx.lineWidth = 0.6 * K;\n  ctx.globalAlpha = 0.85;\n  ctx.beginPath();\n  for (var my2 = PY + MSY * 0.5, row = 0; my2 < PY + PH - 3 * K; my2 += MSY, row++) {\n    for (var mx2 = PX + MSX * (row % 2 ? 1 : 0.5); mx2 < PX + PW - 3 * K; mx2 += MSX) {\n      var jx2 = mx2 + (mr() - 0.5) * MSX * 0.45, jy2 = my2 + (mr() - 0.5) * MSY * 0.3;\n      var roll2 = mr(), len2 = (5.5 + mr() * 3.5) * K, nt = 2 + Math.floor(mr() * 3);\n      var mc = (jx2 - PX) / CW, mrr = (jy2 - PY) / CH;\n      if (mc < 1 || mrr < 1 || mc > COLS - 2 || mrr > ROWS - 2) continue;\n      if (roll2 > 0.95 * bil(marsh, mc, mrr)) continue;\n      // Clear of the water on either side, so no sign sits on a bank.\n      if (bil(fromSea.d, mc - len2 / CW * 0.5, mrr) < 1.2 || bil(fromSea.d, mc + len2 / CW * 0.5, mrr) < 1.2) continue;\n      ctx.moveTo(jx2 - len2 / 2, jy2);\n      ctx.lineTo(jx2 + len2 / 2, jy2);\n      for (var j6 = 0; j6 < nt; j6++) {\n        var bx6 = jx2 + (j6 - (nt - 1) / 2) * 1.5 * K + (mr() - 0.5) * 0.6 * K;\n        var tall = (2.2 + mr() * 1.6) * K, lean = (j6 - (nt - 1) / 2) * 0.5 * K;\n        ctx.moveTo(bx6, jy2 - 0.9 * K);\n        ctx.lineTo(bx6 + lean, jy2 - 0.9 * K - tall);\n      }\n      marshN++;\n    }\n  }\n  ctx.stroke();\n  ctx.restore();\n\n  // --- Grass ----------------------------------------------------------------\n  var gr = rngFrom(seed * 104723 + 7), GS = 15 * K, tufts = 0;\n  ctx.save();\n  ctx.strokeStyle = pal[1];\n  ctx.lineCap = 'round';\n  ctx.lineWidth = 0.6 * K;\n  ctx.globalAlpha = 0.85;\n  ctx.beginPath();\n  for (var ty = PY + GS * 0.5; ty < PY + PH - 4 * K; ty += GS * 0.72) {\n    for (var tx = PX + GS * 0.5; tx < PX + PW - 4 * K; tx += GS) {\n      var gx3 = tx + (gr() - 0.5) * GS * 0.9, gy3 = ty + (gr() - 0.5) * GS * 0.6;\n      var roll = gr(), nb3 = 3 + Math.floor(gr() * 3), sz = (4.2 + gr() * 2.2) * K;\n      var gc = (gx3 - PX) / CW, grr = (gy3 - PY) / CH;\n      if (gc < 1 || grr < 1 || gc > COLS - 2 || grr > ROWS - 2) continue;\n      if (bil(hmap, gc, grr) <= 0 || bil(fromSea.d, gc, grr) < 1.3 || roll > 0.95 * bil(grass, gc, grr)) continue;\n      for (var j3 = 0; j3 < nb3; j3++) {\n        var fan = (j3 / (nb3 - 1) - 0.5) * 1.15 + (hash(tufts * 7 + j3) - 0.5) * 0.2;\n        var len = sz * (1 - 0.4 * Math.abs(fan));\n        var bx = gx3 + (j3 - (nb3 - 1) / 2) * 0.45 * K;\n        ctx.moveTo(bx, gy3);\n        ctx.quadraticCurveTo(bx + Math.sin(fan) * len * 0.4, gy3 - len * 0.55, bx + Math.sin(fan) * len, gy3 - Math.cos(fan) * len);\n      }\n      tufts++;\n    }\n  }\n  ctx.stroke();\n  ctx.restore();\n\n  // --- Hachures on the bluffs -------------------------------------------------\n  // As on Relief of a Coast: strokes set along each contour at an even\n  // spacing, each run down the fall line to the next contour below.\n  var hs = [];\n  for (var ph0 = 0; ph0 < N; ph0 += 3) if (hmap[ph0] > 0) hs.push(hmap[ph0]);\n  hs.sort(function (a, b) { return a - b; });\n  var TIER = smax * (5.5 * K) / PH;\n  var hmax = hs.length ? hs[hs.length - 1] : 0;\n  var hr = rngFrom(seed * 31337 + 11);\n  var STEP = 0.35, MAXLEN = 34 * K, strokes = 0;\n  ctx.save();\n  ctx.fillStyle = pal[0];\n  ctx.globalAlpha = 0.9;\n  for (var lv = 1; lv * TIER < hmax; lv++) {\n    var L = lv * TIER, floorH = L - 0.86 * TIER;\n    var lines = contour(hmap, L);\n    for (var li3 = 0; li3 < lines.length; li3++) {\n      var pl = lines[li3];\n      var next = hr() * 4 * K, run = 0;\n      for (var j4 = 0; j4 < pl.length - 2; j4 += 2) {\n        var ax = pl[j4], ay = pl[j4 + 1], bx2 = pl[j4 + 2], by2 = pl[j4 + 3];\n        var seg = Math.hypot((bx2 - ax) * CW, (by2 - ay) * CH);\n        while (run + seg >= next) {\n          var tt2 = (next - run) / seg;\n          var sx = ax + (bx2 - ax) * tt2, sy = ay + (by2 - ay) * tt2;\n          var T = toneOf(bil(slope, sx, sy), bil(facing, sx, sy));\n          // Closer on steep ground and in shadow, as well as heavier.\n          next += (2.1 + 5.2 * (1 - T)) * K * (0.9 + 0.2 * hr());\n          // Only the face of the bluffs is cut. The shoulders above and below\n          // it took long faint strokes, and the band read as a fringe of grass.\n          if (T < 0.22) continue;\n          var wd = (0.24 + 1.05 * Math.pow(T, 1.35)) * K * (0.9 + 0.2 * hr());\n          // Trace down the fall line.\n          var path = [sx, sy], x = sx, y = sy, lenPx = 0;\n          for (var stp = 0; stp < 120; stp++) {\n            var gxs = bil(gxT, x, y), gys = bil(gyT, x, y), gm = Math.hypot(gxs, gys);\n            if (gm < 1e-5) break;\n            x -= gxs / gm * STEP; y -= gys / gm * STEP;\n            lenPx += STEP * CW;\n            var hh = bil(hmap, x, y);\n            if (hh < floorH || hh <= 0 || lenPx > MAXLEN || bil(toStream.d, x, y) < 1.0) break;\n            if (x < 1 || y < 1 || x > COLS - 2 || y > ROWS - 2) break;\n            path.push(x, y);\n          }\n          if (path.length < 6) continue;\n          // A wedge: full width at the top of the course, lifting to a point\n          // at the bottom, as a burin stroke does.\n          var npt = path.length / 2, left = [], right = [];\n          for (var q3 = 0; q3 < npt; q3++) {\n            var qa = Math.max(0, q3 - 1), qb = Math.min(npt - 1, q3 + 1);\n            var tx2 = X(path[qb * 2]) - X(path[qa * 2]), ty2 = Y(path[qb * 2 + 1]) - Y(path[qa * 2 + 1]);\n            var tl = Math.hypot(tx2, ty2) || 1;\n            var hw = wd * 0.5 * (1 - 0.5 * q3 / (npt - 1));\n            left.push(X(path[q3 * 2]) - ty2 / tl * hw, Y(path[q3 * 2 + 1]) + tx2 / tl * hw);\n            right.push(X(path[q3 * 2]) + ty2 / tl * hw, Y(path[q3 * 2 + 1]) - tx2 / tl * hw);\n          }\n          ctx.beginPath();\n          ctx.moveTo(left[0], left[1]);\n          for (var q4 = 2; q4 < left.length; q4 += 2) ctx.lineTo(left[q4], left[q4 + 1]);\n          for (var q5 = right.length - 2; q5 >= 0; q5 -= 2) ctx.lineTo(right[q5], right[q5 + 1]);\n          ctx.closePath();\n          ctx.fill();\n          strokes++;\n        }\n        run += seg;\n      }\n    }\n  }\n  ctx.restore();\n\n  // --- Beach ridges -----------------------------------------------------------\n  // Old shorelines, one behind another, each left inland as the delta built\n  // out past it: fine lines laid parallel to the open shore, broken where a\n  // channel or a lagoon cuts through.\n  var foS = blur(fromOpen.d, 2, 40), ridges = 0;\n  ctx.save();\n  ctx.strokeStyle = pal[1];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  for (var lvR = 2.2; lvR < RD - 0.5; lvR += 2.0) {\n    ctx.lineWidth = 0.55 * K;\n    ctx.globalAlpha = 0.7 * (1 - 0.5 * lvR / RD);\n    ctx.beginPath();\n    contour(foS, lvR).forEach(function (ln) {\n      var p = chaikin(ln, 2), run = [];\n      for (var j = 0; j <= p.length; j += 2) {\n        var ok = j < p.length && bil(ridgeM, p[j], p[j + 1]) > 0.75;\n        if (ok) run.push(p[j], p[j + 1]);\n        if ((!ok || j === p.length) && run.length) {\n          if (run.length >= 12) { strokeLine(run); ridges++; }\n          run = [];\n        }\n      }\n    });\n    ctx.stroke();\n  }\n  ctx.restore();\n\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.globalAlpha = 0.82;\n  for (var bb = 0; bb < BANDS.length; bb++) {\n    var lk = contour(sm, BANDS[bb]);\n    ctx.lineWidth = (0.95 - 0.045 * bb) * K;\n    ctx.beginPath();\n    lk.forEach(function (ln) {\n      // A small closed ring round a speck of land read as an eye. The land\n      // keeps its own shoreline and the lining passes it by.\n      var n = ln.length, closed = Math.hypot(ln[0] - ln[n - 2], ln[1] - ln[n - 1]) < 1.5;\n      if (closed && lineLen(ln) < 90 * K) return;\n      strokeLine(chaikin(ln, 2));\n    });\n    ctx.stroke();\n  }\n  ctx.restore();\n\n  // --- Still water ------------------------------------------------------------\n  // Ponds, lagoons and cut-off loops are ruled across with fine level lines,\n  // square to the page, as an engraved map rules a lake. Left blank, each one\n  // read as a hole cut in the sheet.\n  var still = new Float32Array(N);\n  for (var pk = 0; pk < N; pk++) still[pk] = kind[pk] === 3 ? 1 : 0;\n  var RS = 2.8 * K, RX = 1.5 * K;\n  ctx.save();\n  ctx.strokeStyle = pal[1];\n  ctx.lineWidth = 0.4 * K;\n  ctx.globalAlpha = 0.62;\n  ctx.beginPath();\n  for (var ry = PY + RS / 2; ry < PY + PH; ry += RS) {\n    var runX = -1;\n    for (var rx = PX; rx <= PX + PW + RX; rx += RX) {\n      var rc2 = (rx - PX) / CW, rr4 = (ry - PY) / CH;\n      var wet = rx <= PX + PW && bil(still, rc2, rr4) > 0.5 && bil(fromLand.d, rc2, rr4) > 0.9;\n      if (wet && runX < 0) runX = rx;\n      else if (!wet && runX >= 0) {\n        if (rx - runX > 2 * RX) { ctx.moveTo(runX, ry); ctx.lineTo(rx - RX, ry); }\n        runX = -1;\n      }\n    }\n  }\n  ctx.stroke();\n  ctx.restore();\n\n  // Depth curves at five, ten and twenty fathoms, dotted, as a survey chart draws them.\n  var dep = new Float32Array(N);\n  for (var pd3 = 0; pd3 < N; pd3++) dep[pd3] = -hmap[pd3];\n  function fathoms(dp) { return Math.max(1, Math.round(46 * Math.pow(Math.max(0, dp) / 0.30, 1.6))); }\n  var CURVES = [5, 10, 20], curveN = 0;\n  ctx.save();\n  ctx.strokeStyle = pal[1];\n  ctx.lineCap = 'round';\n  ctx.lineWidth = 1.15 * K;\n  ctx.globalAlpha = 0.75;\n  if (ctx.setLineDash) ctx.setLineDash([0.01, 3.6 * K]);\n  CURVES.forEach(function (fm) {\n    var lines = contour(dep, 0.30 * Math.pow(fm / 46, 1 / 1.6));\n    ctx.beginPath();\n    lines.forEach(function (ln) { if (ln.length > 24) { strokeLine(chaikin(ln, 2)); curveN++; } });\n    ctx.stroke();\n  });\n  ctx.restore();\n\n  // --- The shoreline --------------------------------------------------------\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.lineWidth = 1.25 * K;\n  ctx.globalAlpha = 0.92;\n  ctx.beginPath();\n  contour(hmap, 0).forEach(function (ln) { strokeLine(chaikin(ln, 2)); });\n  ctx.stroke();\n  ctx.restore();\n\n  // --- Creeks -----------------------------------------------------------------\n  // A channel too narrow to draw with two banks is drawn as one line, tapering\n  // as it goes.\n  var creeks = 0;\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.lineCap = 'round';\n  ctx.globalAlpha = 0.9;\n  chans.forEach(function (ch) {\n    var p = ch.p, drew = false;\n    for (var j = 0; j + 5 < p.length; j += 3) {\n      if (p[j + 2] >= HWC) continue;\n      var cc = p[j] / DX, rr = p[j + 1] / DY;\n      if (bil(hmap, cc, rr) <= 0 && bil(fromLand.d, cc, rr) > 1) continue;\n      ctx.lineWidth = (0.35 + 0.8 * p[j + 2] / HWC) * K;\n      ctx.beginPath();\n      ctx.moveTo(CX(p[j]), CY(p[j + 1]));\n      ctx.lineTo(CX(p[j + 3]), CY(p[j + 4]));\n      ctx.stroke();\n      drew = true;\n    }\n    if (drew) creeks++;\n  });\n  ctx.restore();\n\n  // --- Streams ----------------------------------------------------------------\n  var hasUp = new Uint8Array(N), done = new Uint8Array(N), streamLines = 0;\n  for (var pu = 0; pu < N; pu++) if (isStream[pu] && rcv[pu] >= 0) hasUp[rcv[pu]] = 1;\n  var heads = [];\n  for (var ph2 = 0; ph2 < N; ph2++) if (isStream[ph2] && !hasUp[ph2]) heads.push(ph2);\n  heads.sort(function (a, b) { return hmap[a] - hmap[b]; });\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.globalAlpha = 0.9;\n  var wbx = lattice(60, 45), wby = lattice(60, 45);\n  heads.forEach(function (hd) {\n    var pts = [], ac = [], cur = hd, guard = 0, visited = [], inked = 0;\n    while (cur >= 0 && guard++ < 4000) {\n      var c9 = cur % COLS, r9 = Math.floor(cur / COLS), u9 = c9 / (COLS - 1), v9 = r9 / (ROWS - 1);\n      pts.push(c9 + 0.8 * wbx(u9, v9), r9 + 0.8 * wby(u9, v9)); ac.push(acc[cur]);\n      if (hmap[cur] <= 0 || done[cur] || marsh[cur] > 0.3) break;\n      if (c9 < 2 || r9 < 2 || c9 > COLS - 3 || r9 > ROWS - 3) break;\n      done[cur] = 1; visited.push(cur);\n      if (!levelled[cur]) inked++;\n      cur = rcv[cur];\n    }\n    // 🔴 A gully head at the foot of the bluffs, a few cells long, printed as\n    // a stray tick. Too short to draw: its cells are handed back, so a stream\n    // from higher up that reaches them runs on through instead of stopping.\n    if (inked < 12) { visited.forEach(function (v) { done[v] = 0; }); return; }\n    var sp2 = chaikin(pts, 2), n2 = sp2.length / 2;\n    for (var j5 = 0; j5 < n2 - 1; j5++) {\n      var a5 = ac[Math.min(ac.length - 1, Math.floor(j5 / (n2 - 1) * (ac.length - 1)))];\n      if (levelled[Math.round(sp2[j5 * 2 + 1]) * COLS + Math.round(sp2[j5 * 2])]) continue;\n      ctx.lineWidth = Math.min(1.35, 0.3 + 0.2 * Math.log2(a5 / A1 + 1)) * K;\n      ctx.beginPath();\n      ctx.moveTo(X(sp2[j5 * 2]), Y(sp2[j5 * 2 + 1]));\n      ctx.lineTo(X(sp2[j5 * 2 + 2]), Y(sp2[j5 * 2 + 3]));\n      ctx.stroke();\n    }\n    streamLines++;\n  });\n  ctx.restore();\n\n  // --- Soundings --------------------------------------------------------------\n  // Depth figures in fathoms on an open grid over the sea, only in the gaps\n  // between water-lines, clear of the depth curves and the runs of the\n  // channels across the flats.\n  var SP = 18, sound = [];\n  var sr = rngFrom(seed * 7919 + 31);\n  for (var gy2 = SP * 0.6; gy2 < ROWS - 4; gy2 += SP * 0.87) {\n    var shift = (Math.round(gy2 / (SP * 0.87)) % 2) * SP * 0.5;\n    for (var gx2 = SP * 0.4 + shift; gx2 < COLS - 4; gx2 += SP) {\n      var sc2 = Math.round(gx2 + (sr() - 0.5) * SP * 0.5), sr2 = Math.round(gy2 + (sr() - 0.5) * SP * 0.5);\n      if (sc2 < 5 || sr2 < 4 || sc2 > COLS - 6 || sr2 > ROWS - 5) continue;\n      var ps2 = sr2 * COLS + sc2;\n      if (kind[ps2] !== 1 || fromLand.d[ps2] < 5 || sm[ps2] < 3 || extD[ps2] < 0.01) continue;\n      if (sm[ps2] < BANDS[BANDS.length - 1] + 2 && nearBand(sm[ps2], 1.7)) continue;\n      var dp = dep[ps2];\n      var gdx = (dep[ps2 + 1] - dep[ps2 - 1]) / 2, gdy = (dep[ps2 + COLS] - dep[ps2 - COLS]) / 2;\n      var gm2 = Math.sqrt(gdx * gdx + gdy * gdy) || 1e-6;\n      if (CURVES.some(function (fm) { return Math.abs(dp - 0.30 * Math.pow(fm / 46, 1 / 1.6)) / gm2 < 2.4; })) continue;\n      sound.push([sc2, sr2, fathoms(dp)]);\n    }\n  }\n  ctx.save();\n  ctx.fillStyle = pal[1];\n  ctx.globalAlpha = 0.92;\n  ctx.font = 'italic ' + (8.8 * K).toFixed(2) + 'px Georgia, \"Times New Roman\", serif';\n  ctx.textAlign = 'center';\n  ctx.textBaseline = 'middle';\n  sound.forEach(function (s) { ctx.fillText(String(s[2]), X(s[0]), Y(s[1])); });\n  ctx.restore();\n\n  ctx.restore();   // the plate clip\n\n  var landN = 0; for (var pz = 0; pz < N; pz++) if (hmap[pz] > 0) landN++;\n  var mouths = 0; chans.forEach(function (ch) { if (ch.mouth) mouths++; });\n  gl.__genart_data.debug = {\n    wv: +wv.toFixed(2), land: +(landN / N).toFixed(3), chans: chans.length, mouths: mouths, oxbows: oxbows.length,\n    creeks: creeks, streams: streamLines, marsh: marshN, tufts: tufts, ridges: ridges, strokes: strokes,\n    soundings: sound.length, curves: curveN, smax: +smax.toFixed(2), tier: +TIER.toFixed(4),\n  };\n}\n",
 "layers": [
  {
   "id": "drying",
   "type": "painting:flow-lines",
   "name": "Low-Water Line — Dotted",
   "visible": true,
   "locked": false,
   "opacity": 1,
   "blendMode": "normal",
   "transform": {
    "x": 105,
    "y": 75,
    "width": 1190,
    "height": 810,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "field": "algorithm:drying",
    "fieldCols": 480,
    "fieldRows": 360,
    "minMagnitude": 0.5,
    "seed": 42,
    "lineCount": 5000,
    "seedDistribution": "grid-jittered",
    "lineLength": 3,
    "stepSize": 1.2,
    "lineWeight": 1.24,
    "lineWeightVariation": 0.35,
    "taper": "none",
    "color": "#3b474f",
    "colorVariation": 0.05,
    "opacity": 0.6,
    "paintMode": "multiply",
    "depthScale": false,
    "horizonY": 0,
    "depthWeightRange": "[1, 1]",
    "depthOpacityRange": "[1, 1]",
    "maskCenterY": -1,
    "maskSpread": 0.25
   }
  },
  {
   "id": "graduation",
   "type": "shapes:path",
   "name": "Neat Line — Graduated Border",
   "visible": true,
   "locked": false,
   "opacity": 0.8,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "fillColor": "#1d2327",
    "fillEnabled": true,
    "strokeColor": "#000000",
    "strokeWidth": 0,
    "strokeEnabled": false,
    "d": "M 105.00 69.00 L 149.07 69.00 L 149.07 75.00 L 105.00 75.00 Z M 193.15 69.00 L 237.22 69.00 L 237.22 75.00 L 193.15 75.00 Z M 281.30 69.00 L 325.37 69.00 L 325.37 75.00 L 281.30 75.00 Z M 369.44 69.00 L 413.52 69.00 L 413.52 75.00 L 369.44 75.00 Z M 457.59 69.00 L 501.67 69.00 L 501.67 75.00 L 457.59 75.00 Z M 545.74 69.00 L 589.81 69.00 L 589.81 75.00 L 545.74 75.00 Z M 633.89 69.00 L 677.96 69.00 L 677.96 75.00 L 633.89 75.00 Z M 722.04 69.00 L 766.11 69.00 L 766.11 75.00 L 722.04 75.00 Z M 810.19 69.00 L 854.26 69.00 L 854.26 75.00 L 810.19 75.00 Z M 898.33 69.00 L 942.41 69.00 L 942.41 75.00 L 898.33 75.00 Z M 986.48 69.00 L 1030.56 69.00 L 1030.56 75.00 L 986.48 75.00 Z M 1074.63 69.00 L 1118.70 69.00 L 1118.70 75.00 L 1074.63 75.00 Z M 1162.78 69.00 L 1206.85 69.00 L 1206.85 75.00 L 1162.78 75.00 Z M 1250.93 69.00 L 1295.00 69.00 L 1295.00 75.00 L 1250.93 75.00 Z M 105.00 885.00 L 149.07 885.00 L 149.07 891.00 L 105.00 891.00 Z M 193.15 885.00 L 237.22 885.00 L 237.22 891.00 L 193.15 891.00 Z M 281.30 885.00 L 325.37 885.00 L 325.37 891.00 L 281.30 891.00 Z M 369.44 885.00 L 413.52 885.00 L 413.52 891.00 L 369.44 891.00 Z M 457.59 885.00 L 501.67 885.00 L 501.67 891.00 L 457.59 891.00 Z M 545.74 885.00 L 589.81 885.00 L 589.81 891.00 L 545.74 891.00 Z M 633.89 885.00 L 677.96 885.00 L 677.96 891.00 L 633.89 891.00 Z M 722.04 885.00 L 766.11 885.00 L 766.11 891.00 L 722.04 891.00 Z M 810.19 885.00 L 854.26 885.00 L 854.26 891.00 L 810.19 891.00 Z M 898.33 885.00 L 942.41 885.00 L 942.41 891.00 L 898.33 891.00 Z M 986.48 885.00 L 1030.56 885.00 L 1030.56 891.00 L 986.48 891.00 Z M 1074.63 885.00 L 1118.70 885.00 L 1118.70 891.00 L 1074.63 891.00 Z M 1162.78 885.00 L 1206.85 885.00 L 1206.85 891.00 L 1162.78 891.00 Z M 1250.93 885.00 L 1295.00 885.00 L 1295.00 891.00 L 1250.93 891.00 Z M 99.00 75.00 L 105.00 75.00 L 105.00 120.00 L 99.00 120.00 Z M 99.00 165.00 L 105.00 165.00 L 105.00 210.00 L 99.00 210.00 Z M 99.00 255.00 L 105.00 255.00 L 105.00 300.00 L 99.00 300.00 Z M 99.00 345.00 L 105.00 345.00 L 105.00 390.00 L 99.00 390.00 Z M 99.00 435.00 L 105.00 435.00 L 105.00 480.00 L 99.00 480.00 Z M 99.00 525.00 L 105.00 525.00 L 105.00 570.00 L 99.00 570.00 Z M 99.00 615.00 L 105.00 615.00 L 105.00 660.00 L 99.00 660.00 Z M 99.00 705.00 L 105.00 705.00 L 105.00 750.00 L 99.00 750.00 Z M 99.00 795.00 L 105.00 795.00 L 105.00 840.00 L 99.00 840.00 Z M 1295.00 75.00 L 1301.00 75.00 L 1301.00 120.00 L 1295.00 120.00 Z M 1295.00 165.00 L 1301.00 165.00 L 1301.00 210.00 L 1295.00 210.00 Z M 1295.00 255.00 L 1301.00 255.00 L 1301.00 300.00 L 1295.00 300.00 Z M 1295.00 345.00 L 1301.00 345.00 L 1301.00 390.00 L 1295.00 390.00 Z M 1295.00 435.00 L 1301.00 435.00 L 1301.00 480.00 L 1295.00 480.00 Z M 1295.00 525.00 L 1301.00 525.00 L 1301.00 570.00 L 1295.00 570.00 Z M 1295.00 615.00 L 1301.00 615.00 L 1301.00 660.00 L 1295.00 660.00 Z M 1295.00 705.00 L 1301.00 705.00 L 1301.00 750.00 L 1295.00 750.00 Z M 1295.00 795.00 L 1301.00 795.00 L 1301.00 840.00 L 1295.00 840.00 Z M 99.00 69.00 L 105.00 69.00 L 105.00 75.00 L 99.00 75.00 Z M 1295.00 69.00 L 1301.00 69.00 L 1301.00 75.00 L 1295.00 75.00 Z M 99.00 885.00 L 105.00 885.00 L 105.00 891.00 L 99.00 891.00 Z M 1295.00 885.00 L 1301.00 885.00 L 1301.00 891.00 L 1295.00 891.00 Z",
    "scaleToFit": false
   }
  },
  {
   "id": "neat-outer",
   "type": "shapes:path",
   "name": "Neat Line — Outer Rule",
   "visible": true,
   "locked": false,
   "opacity": 0.85,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "fillColor": "#ffffff",
    "fillEnabled": false,
    "strokeColor": "#1d2327",
    "strokeWidth": 1.5,
    "strokeEnabled": true,
    "d": "M 99.00 69.00 L 1301.00 69.00 L 1301.00 891.00 L 99.00 891.00 Z",
    "scaleToFit": false
   }
  },
  {
   "id": "neat-inner",
   "type": "shapes:path",
   "name": "Neat Line — Inner Rule",
   "visible": true,
   "locked": false,
   "opacity": 0.8,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "fillColor": "#ffffff",
    "fillEnabled": false,
    "strokeColor": "#1d2327",
    "strokeWidth": 0.7,
    "strokeEnabled": true,
    "d": "M 105.00 75.00 L 1295.00 75.00 L 1295.00 885.00 L 105.00 885.00 Z",
    "scaleToFit": false
   }
  },
  {
   "id": "tone",
   "type": "filter:grain",
   "name": "Plate Tone",
   "visible": true,
   "locked": false,
   "opacity": 1,
   "blendMode": "normal",
   "transform": {
    "x": 0,
    "y": 0,
    "width": 1400,
    "height": 1000,
    "rotation": 0,
    "scaleX": 1,
    "scaleY": 1,
    "anchorX": 0,
    "anchorY": 0
   },
   "properties": {
    "intensity": 0.1,
    "size": 2,
    "seed": 29,
    "monochrome": true
   }
  }
 ]
}