{
 "genart": "1.2",
 "id": "soundings-of-a-bank",
 "title": "Soundings of a Bank",
 "created": "2026-09-11T00:00:00Z",
 "modified": "2026-09-11T18:18:29.837Z",
 "renderer": {
  "type": "canvas2d",
  "version": "1.x"
 },
 "canvas": {
  "width": 1400,
  "height": 1000
 },
 "parameters": [],
 "colors": [],
 "state": {
  "seed": 2,
  "params": {},
  "colorPalette": [
   "#1d2327",
   "#3b474f",
   "#6c7a82",
   "#b3bab8",
   "#ebe8e0"
  ]
 },
 "algorithm": "// Soundings of a Bank. A chart of a bank of sand lying off a coast: the sketch\n// builds the sea floor and the land behind the shore and engraves the whole\n// sheet, the hachured hills and their streams, the coastline and its\n// water-lining, the drying heads of the bank, the depth curves, and above all\n// the soundings, which are the survey itself.\n//\n// The sea is made of figures. The boat runs lines across the bank and a depth\n// is set down at every cast, so the figures lie in rows, and where the bank\n// needs developing the lines are run closer together. The shape of the bank\n// shows twice: in the depth curves drawn through the figures, and in how\n// thickly the figures lie. The land is the one dark mass on the sheet.\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 ground -------------------------------------------------\n  // The coast lies along one edge of the plate and the bank off it, roughly in\n  // its line, as the banks off an open coast lie in the run of the tide. s runs\n  // from the coast out to sea, t along the coast.\n  var th = Math.floor(rand() * 4) * Math.PI / 2 + (rand() * 2 - 1) * 0.3;\n  var nx0 = Math.cos(th), ny0 = Math.sin(th);          // from the sea toward the land\n  var px0 = -ny0, py0 = nx0;                           // along the coast\n  var cx = ASPECT / 2, cy = 0.5;\n  var halfExt = Math.abs(nx0) * ASPECT / 2 + Math.abs(ny0) * 0.5;\n  // How far in the land reaches: a fifth to three tenths of the plate, so the\n  // hachured land is a mass large enough to tell one seed from another.\n  var landW = (0.20 + rand() * 0.10) * 2 * halfExt;\n  var S = 2 * halfExt - landW;                         // the breadth of sea across the plate\n  var sOrg = halfExt - landW;\n  function toS(x, y) { return sOrg - ((x - cx) * nx0 + (y - cy) * ny0); }\n  function toT(x, y) { return (x - cx) * px0 + (y - cy) * py0; }\n  function toX(s, t) { return cx + (sOrg - s) * nx0 + t * px0; }\n  function toY(s, t) { return cy + (sOrg - s) * ny0 + t * py0; }\n  var ph = [];\n  for (var i = 0; i < 8; i++) ph.push(rand() * Math.PI * 2);\n  function coastOff(t) { return 0.022 * Math.sin(2.1 * t + ph[0]) + 0.011 * Math.sin(5.3 * t + ph[1]) + 0.004 * Math.sin(12.7 * t + ph[2]); }\n\n  // The sea floor shelves off the coast, quickly at first and then slowly,\n  // into water too deep to trouble anyone. Depths are in fathoms throughout;\n  // negative is above the datum, ground that dries at low water.\n  var DM = 17 + rand() * 9, LS = 0.42 * S;\n  function base(s) { return -0.7 + DM * (1 - Math.exp(-Math.max(0, s) / LS)); }\n\n  // --- The banks --------------------------------------------------------------\n  // A tidal bank is long and narrow, lies a little across the line of the\n  // coast, bends gently, and is steeper on one side than the other. Its crest\n  // rises and falls along its length, so only the highest parts of it dry at\n  // low water, as heads of sand, and a swatchway, a shallow channel, is cut\n  // across it somewhere.\n  var al0 = (rand() * 2 - 1) * 0.32;\n  function mkBank(sc, tc, len, w, top, al) {\n    var steep = rand() < 0.5;\n    return {\n      sc: sc, tc: tc, len: len, w: w, al: al,\n      amp: Math.max(1.2, base(sc) - top),\n      kp: steep ? 0.6 : 1.5, kn: steep ? 1.5 : 0.6,\n      bend: (rand() * 2 - 1) * 0.05, bph: rand() * Math.PI * 2,\n      // 🔴 The swatchway is cut off the middle of the bank. At the middle it\n      // went through the highest part of the crest, and on some seeds nothing\n      // was left to dry.\n      crest: lattice(10, 2), sw: 0.35 + rand() * 0.35,\n      swA: (rand() < 0.5 ? -1 : 1) * (0.16 + rand() * 0.16) * len,\n    };\n  }\n  /** How high a bank's crest stands along its length (z from -1 to 1), unnormalised. */\n  function envAt(b, z) {\n    var a = z * b.len / 2;\n    return Math.pow(1 - z * z, 1.4) * (1 + 0.3 * b.crest(0.5 + z / 2, 0.5))\n      * (1 - b.sw * Math.exp(-Math.pow((a - b.swA) / 0.035, 2)));\n  }\n  var sB = (0.40 + rand() * 0.14) * S, tB = (rand() * 2 - 1) * 0.1;\n  var banks = [mkBank(sB, tB, 1.0 + rand() * 0.5, 0.042 + rand() * 0.02, -(1.0 + rand() * 1.2), al0)];\n  var nSec = rand() < 0.3 ? 0 : rand() < 0.65 ? 1 : 2, side = rand() < 0.5 ? -1 : 1;\n  for (var k = 0; k < nSec; k++, side = -side) {\n    var s2 = sB + side * (0.15 + rand() * 0.07) * S;\n    if (s2 < 0.2 * S || s2 > 0.9 * S) s2 = 2 * sB - s2;\n    banks.push(mkBank(s2, tB + (rand() * 2 - 1) * 0.35, 0.6 + rand() * 0.5, 0.03 + rand() * 0.02,\n      1.2 + rand() * 3.5, al0 + (rand() * 2 - 1) * 0.15));\n  }\n  // Each crest is scaled so its highest point stands exactly at the height the\n  // bank was given. Unscaled, the crest's own rise and fall put the main bank\n  // anywhere from not drying at all to drying 26 feet.\n  banks.forEach(function (b) {\n    b.eMax = 1e-6;\n    for (var z = -0.99; z < 0.99; z += 0.005) b.eMax = Math.max(b.eMax, envAt(b, z));\n  });\n  /** How far a bank stands up off the floor beneath it, in fathoms. */\n  function bankAt(b, s, t) {\n    var ds = s - b.sc, dt = t - b.tc;\n    var z = 2 * (ds * Math.sin(b.al) + dt * Math.cos(b.al)) / b.len;\n    if (z <= -1 || z >= 1) return 0;\n    var q = ds * Math.cos(b.al) - dt * Math.sin(b.al) - b.bend * Math.sin(Math.PI * z * 0.9 + b.bph);\n    var wq = b.w * (q > 0 ? b.kp : b.kn);\n    // 🔴 A flat-topped profile. Peaked, only a hair of the crest came above\n    // the datum, and the heads dried as slivers a few cells wide.\n    return b.amp * envAt(b, z) / b.eMax * Math.exp(-Math.pow(Math.abs(q) / wq, 2.6));\n  }\n\n  // --- The land -------------------------------------------------------------\n  // The ground of Relief of a Coast: a short rise from the shore, a plain that\n  // climbs gently, an escarpment and a second rise inland, with spurs coming\n  // down off the escarpment toward the sea. It climbs all the way inland, so\n  // no summit is left to be ringed by its own courses. Hachured, it is the one\n  // large dark mass on the sheet, and it moves with the coast.\n  var E1 = landW * (0.30 + rand() * 0.15), E2 = E1 + landW * (0.45 + rand() * 0.25);\n  function escOff(t) { return 0.03 * Math.sin(1.6 * t + ph[3]) + 0.014 * Math.sin(3.9 * t + ph[4]); }\n  var spurs = [], nSp = 5 + Math.floor(rand() * 3);\n  for (var ks = 0; ks < nSp; ks++) {\n    spurs.push({\n      t: -1 + (ks + 0.25 + rand() * 0.5) * (2 / nSp), w: 0.03 + rand() * 0.03,\n      // Below the escarpment's own 0.20, so a spur's junction with it can\n      // never become a summit.\n      hgt: 0.10 + rand() * 0.08, bend: (rand() * 2 - 1) * 0.3, reach: 0.005 + rand() * 0.03,\n    });\n  }\n  // 🔴 Two or three broad valleys come down across the land to the sea. On a\n  // uniform fall every gully ran straight down beside the next, and a narrow\n  // strip of land read as a comb of parallel streams, as Mouths of a River's\n  // upland did. A fall toward a valley gathers them into branching courses.\n  var valleys = [], nV = 2 + Math.floor(rand() * 2);\n  for (var kv = 0; kv < nV; kv++) {\n    valleys.push({ t: -0.9 + (kv + 0.3 + rand() * 0.4) * (1.8 / nV), w: 0.10 + rand() * 0.06,\n      d: 0.30 + rand() * 0.15, lean: (rand() * 2 - 1) * 0.3 });\n  }\n  var n1 = lattice(7, 5), n2 = lattice(15, 11), n3 = lattice(33, 25);\n  /** Height of the land at a point `s` out from the coast (negative inland). */\n  function landHeight(s, t, u, v) {\n    var lc = -(s + coastOff(t)), se = -s + escOff(t);\n    var h = 0.05 * Math.tanh(lc * 7) + 0.09 * Math.max(0, se);\n    var escS = smooth(E1 - 0.06, E1 + 0.06, se);\n    h += (0.20 * escS + 0.15 * smooth(E2 - 0.12, E2 + 0.12, se)) * smooth(0, 0.03, lc);\n    for (var q = 0; q < spurs.length; q++) {\n      var sp = spurs[q], tc = sp.t + sp.bend * (E1 - se), down = smooth(sp.reach, E1, se);\n      var A = smooth(sp.reach, sp.reach + 0.035, lc) * (0.55 + 0.45 * down);\n      // 🔴 A spur is a shoulder coming DOWN off the escarpment, so it fades as\n      // the escarpment rises (1 - escS); added on top, each junction stood up\n      // as a summit.\n      h += sp.hgt * A * (1 - escS) * Math.exp(-Math.pow((t - tc) / (sp.w * (1.35 - 0.35 * down)), 2));\n    }\n    for (var qv = 0; qv < valleys.length; qv++) {\n      var vl = valleys[qv];\n      // 🔴 A share of the height, not a depth taken off it. Subtracted, the\n      // valley drove the low ground by the shore to the floor, pit-filling\n      // levelled it, and the streams crossed it as ruled parallel lines.\n      h *= 1 - vl.d * Math.exp(-Math.pow((t - vl.t - vl.lean * se) / vl.w, 2));\n    }\n    // Kept faint: every lump is a summit, and a hachured summit is a white\n    // spot ringed by strokes.\n    h += 0.018 * n1(u, v) + 0.008 * n2(u, v) + 0.003 * n3(u, v);\n    return Math.max(0.002, h);\n  }\n\n  var nb1 = lattice(6, 5), nb2 = lattice(17, 13), w1x = lattice(6, 5), w1y = lattice(6, 5);\n  var dep = new Float32Array(N), land = new Uint8Array(N), bankH = new Float32Array(N);\n  var hmap = new Float32Array(N).fill(-1);             // the land's height; the sea is -1\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 floor, so no line on the sheet is ruled.\n    var xw = x + 0.015 * w1x(u, v), yw = y + 0.015 * w1y(u, v);\n    var s = toS(xw, yw), t = toT(xw, yw), sc = s + coastOff(t);\n    if (sc < 0) { land[p] = 1; dep[p] = -3; hmap[p] = landHeight(s, t, u, v); continue; }\n    var bh = 0;\n    // 🔴 The highest bank, not the sum. Where two banks overlapped their\n    // heights added, and a head dried twenty feet.\n    for (var bi = 0; bi < banks.length; bi++) bh = Math.max(bh, bankAt(banks[bi], s, t));\n    bankH[p] = bh;\n    dep[p] = base(sc) + (0.7 * nb1(u, v) + 0.25 * nb2(u, v)) * smooth(0.02, 0.15, sc) - bh;\n  }\n\n  /** Flood the 4-connected components that satisfy `pred`; hand each to `act`. */\n  var stk = new Int32Array(N);\n  function components(pred, act) {\n    var seenC = new Uint8Array(N);\n    for (var p0 = 0; p0 < N; p0++) {\n      if (seenC[p0] || !pred(p0)) continue;\n      var n = 0, mem = [];\n      seenC[p0] = 1; stk[n++] = p0;\n      while (n) {\n        var cq = stk[--n], cc = cq % COLS;\n        mem.push(cq);\n        var nbs = [cc > 0 ? cq - 1 : -1, cc < COLS - 1 ? cq + 1 : -1, cq - COLS, cq + COLS];\n        for (var j = 0; j < 4; j++) {\n          var q = nbs[j];\n          if (q < 0 || q >= N || seenC[q] || !pred(q)) continue;\n          seenC[q] = 1; stk[n++] = q;\n        }\n      }\n      act(mem);\n    }\n  }\n  // A speck of drying sand a few cells across printed as a fleck of dirt, and\n  // a pinhole of water in a head as a spot: both are put back.\n  components(function (p) { return !land[p] && dep[p] < 0; }, function (m) {\n    if (m.length < 30) m.forEach(function (q) { dep[q] = 0.25; });\n  });\n  components(function (p) { return !land[p] && dep[p] >= 0; }, function (m) {\n    if (m.length < 20) m.forEach(function (q) { dep[q] = -0.2; });\n  });\n\n  // The foreshore: ground that dries and is joined to the land. The drying\n  // heads of the bank are not, and the coast's water-lining starts outside it.\n  var coastDry = new Uint8Array(N), qn = 0;\n  for (var pl = 0; pl < N; pl++) if (land[pl]) stk[qn++] = pl;\n  while (qn) {\n    var cur = stk[--qn], ccur = cur % COLS;\n    var nb4 = [ccur > 0 ? cur - 1 : -1, ccur < COLS - 1 ? cur + 1 : -1, cur - COLS, cur + COLS];\n    for (var j4 = 0; j4 < 4; j4++) {\n      var q4 = nb4[j4];\n      if (q4 < 0 || q4 >= N || land[q4] || coastDry[q4] || dep[q4] > 0) continue;\n      coastDry[q4] = 1; stk[qn++] = q4;\n    }\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  // --- The land, eroded -------------------------------------------------------\n  // As on Relief of a Coast, the valleys are not drawn on: the ground is\n  // eroded. Each round, hollows are filled to their spill point, water runs\n  // from every cell to its steepest lower neighbour, and each cell is cut toward\n  // its receiver by the square root of the ground draining through it (stream\n  // power), so the streams find their own way down to the sea.\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  var KF = 0.045, 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. Only water gathered into a channel cuts:\n    // cutting from the first cell down, every hillside grew a gully.\n    for (var li2 = landIdx.length - 1; li2 >= 0; li2--) {\n      var pe = landIdx[li2], rq = rcv[pe];\n      if (rq < 0) continue;\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 as\n  // a ruled line. The pen lifts over it (Mouths of a River).\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 this much water is drawn as a stream; below it the\n  // hachures would run together into each gully as a dark feather.\n  var A1 = Math.round(N * 0.0011);\n  var isStream = new Uint8Array(N);\n  for (var ps = 0; ps < N; ps++) if (hmap[ps] > 0 && 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      // Inland of the shore only: the step down to the sea is not a slope.\n      if (hmap[n] > 0 && at(hmap, c2 - 2, r2) > 0 && at(hmap, c2 + 2, r2) > 0 &&\n        at(hmap, c2, r2 - 2) > 0 && at(hmap, c2, r2 + 2) > 0 && (r2 * 7 + c2) % 11 === 0) samples.push(g);\n    }\n  }\n  samples.sort(function (a, b) { return a - b; });\n  // Steepness is read against this coast's own steep ground, so every seed\n  // spends the whole tonal range whatever its relief happens to be.\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  // The fall line a hachure follows is read off a lightly smoothed copy of the\n  // ground. Off the raw ground, neighbouring strokes ran together into every\n  // shallow gully and each course read as a row of arrowheads.\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  var hmax = 0;\n  for (var ph0 = 0; ph0 < N; ph0++) if (hmap[ph0] > hmax) hmax = hmap[ph0];\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  var fromLow = edt(function (p) { return land[p] === 1 || coastDry[p] === 1; });\n  // The coast's water-lining, traced on a smoothed copy of the distance from\n  // the low-water line so it does not carry every notch of the shore out.\n  var smB = blur(fromLow.d, 3, 60), sm = new Float32Array(N);\n  for (var pm = 0; pm < N; pm++) {\n    var wb = land[pm] ? 0 : smooth(3, 9, fromLow.d[pm]);\n    sm[pm] = fromLow.d[pm] + (smB[pm] - fromLow.d[pm]) * wb;\n  }\n  var BANDS = [1.5];\n  for (var k2 = 1; k2 <= 6; k2++) BANDS.push(1.3 + 1.9 * Math.pow(k2, 1.3));\n  var LINE_END = BANDS[BANDS.length - 1];\n  var landM = new Float32Array(N), dryS = new Float32Array(N);\n  for (var pz = 0; pz < N; pz++) {\n    landM[pz] = land[pz];\n    if (!land[pz] && dep[pz] < 0) dryS[pz] = 0.62 + 0.28 * smooth(0, 0.6, -dep[pz]);\n    // 🔴 The bank itself is stippled lightly inside three fathoms, as the old\n    // engraved charts stippled their sands. Figures alone left the sheet one\n    // even grey at a glance: at 32px every pair of seeds measured 0.017-0.027\n    // apart, and no seed row could honestly claim its structure moved.\n    else if (!land[pz] && dep[pz] < 3 && bankH[pz] > 0.5) dryS[pz] = 0.12 + 0.26 * (1 - dep[pz] / 3);\n  }\n\n  // --- The sheet ----------------------------------------------------------\n  // Nothing is left to a layer but the border and the grain, so the sheet is\n  // laid here: the paper colour with a faint, slow mottle.\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  ctx.save();\n  ctx.beginPath();\n  ctx.rect(PX, PY, PW, PH);\n  ctx.clip();\n\n  // --- The survey -------------------------------------------------------------\n  // The lines are run across the main bank, square to its length. A line is\n  // sounded all the way in deep water only one time in four; one in two where\n  // it is under nine fathoms or near the bank's face, and every line over the\n  // bank itself, so the\n  // figures crowd where the ground needs developing. Figures under eleven\n  // fathoms are given in fathoms and feet, the feet set small beside them;\n  // on the drying heads the figure is the height the sand dries, in feet,\n  // underlined. Like all lettering on a chart the figures stand square to the\n  // page, whatever way the line runs.\n  var FS = 8.2 * K, FSS = FS * 0.66;\n  var fontM = 'italic ' + FS.toFixed(2) + 'px Georgia, \"Times New Roman\", serif';\n  var fontS = 'italic ' + FSS.toFixed(2) + 'px Georgia, \"Times New Roman\", serif';\n  var widthCache = {};\n  function textW(str, font, size) {\n    var key = font + '|' + str;\n    if (widthCache[key] !== undefined) return widthCache[key];\n    var w = str.length * size * 0.52;\n    if (ctx.measureText) {\n      ctx.font = font;\n      var mt = ctx.measureText(str);\n      if (mt && mt.width > 0) w = mt.width;\n    }\n    return (widthCache[key] = w);\n  }\n  function label(d) {\n    if (d < 0) {\n      var ftd = Math.round(-d * 6);\n      return ftd < 1 ? null : { main: String(ftd), sub: '', under: true };\n    }\n    if (d < 11) {\n      var f = Math.floor(d), ft = Math.round((d - f) * 6);\n      if (ft === 6) { f++; ft = 0; }\n      return { main: String(f), sub: ft ? String(ft) : '', under: false };\n    }\n    return { main: String(Math.round(d)), sub: '', under: false };\n  }\n  var BK = 24 * K, bucket = {}, boxes = [];\n  function overlaps(b, pad) {\n    var i0 = Math.floor((b[0] - pad) / BK), i1 = Math.floor((b[2] + pad) / BK);\n    var j0 = Math.floor((b[1] - pad) / BK), j1 = Math.floor((b[3] + pad) / BK);\n    for (var i = i0; i <= i1; i++) for (var j = j0; j <= j1; j++) {\n      var list = bucket[i + ',' + j];\n      if (!list) continue;\n      for (var n = 0; n < list.length; n++) {\n        var o = boxes[list[n]];\n        if (b[0] - pad < o[2] && b[2] + pad > o[0] && b[1] - pad < o[3] && b[3] + pad > o[1]) return true;\n      }\n    }\n    return false;\n  }\n  function addBox(b) {\n    var id = boxes.length;\n    boxes.push(b);\n    for (var i = Math.floor(b[0] / BK); i <= Math.floor(b[2] / BK); i++)\n      for (var j = Math.floor(b[1] / BK); j <= Math.floor(b[3] / BK); j++)\n        (bucket[i + ',' + j] = bucket[i + ',' + j] || []).push(id);\n  }\n  var fromBank = chamfer(function (p) { return bankH[p] > 1.2; });\n  var mb = banks[0];\n  var sDx = -nx0, sDy = -ny0;                           // the way s increases, on the plate\n  var dLx = Math.cos(mb.al) * sDx - Math.sin(mb.al) * px0, dLy = Math.cos(mb.al) * sDy - Math.sin(mb.al) * py0;\n  var aLx = Math.sin(mb.al) * sDx + Math.cos(mb.al) * px0, aLy = Math.sin(mb.al) * sDy + Math.cos(mb.al) * py0;\n  var Cx = PX + toX(mb.sc, mb.tc) * PH, Cy = PY + toY(mb.sc, mb.tc) * PH;\n  var U = 12.5 * K, DIAG = Math.hypot(PW, PH), M = Math.ceil(DIAG / U);\n  var wl = lattice(70, 50), figs = [];\n  for (var li = -M; li <= M; li++) {\n    var o = li * U, next = -DIAG;\n    for (var lam = -DIAG; lam <= DIAG; lam += 1.5 * K) {\n      if (lam < next) continue;\n      // The boat does not hold its line exactly.\n      var wd = 1.8 * K * wl(0.5 + lam / (2 * DIAG), (li + M) / (2 * M));\n      var fx = Cx + (o + wd) * aLx + lam * dLx, fy = Cy + (o + wd) * aLy + lam * dLy;\n      if (fx < PX + 7 * K || fy < PY + 7 * K || fx > PX + PW - 7 * K || fy > PY + PH - 7 * K) continue;\n      var gc = (fx - PX) / CW, gr = (fy - PY) / CH;\n      if (bil(fromLow.d, gc, gr) < LINE_END + 2.5) continue;\n      var d = bil(dep, gc, gr);\n      // 🔴 By the bank, not by depth alone: read off depth, the shallow water\n      // along the coast was sounded as closely as the bank and drew the eye.\n      // 🔴 The bank's face is developed: one line in two is run on past it, each\n      // line as far as it settles for itself. Where the face is steep the\n      // water goes from the bank's figures straight to eleven fathoms, and read\n      // off depth alone the one-in-four began at the foot of the face as a\n      // straight wall down the sheet (seed 11).\n      var every = bil(bankH, gc, gr) > 1.2 || d < 2.5 ? 1 :\n        d < 9 || bil(fromBank.d, gc, gr) < 10 + 40 * hash(li * 131 + 17) ? 2 : 4;\n      if (li % every !== 0) continue;\n      // Drying heights are few on a chart: only on every fourth line.\n      if (d < 0 && li % 4 !== 0) continue;\n      var lb = label(d);\n      if (!lb) continue;\n      var wM = textW(lb.main, fontM, FS), wS = lb.sub ? textW(lb.sub, fontS, FSS) : 0;\n      var bw = wM + (wS ? wS + 0.4 * K : 0), bh = FS * 0.78 + (wS ? FSS * 0.3 : 0);\n      var box = [fx - bw / 2, fy - FS * 0.39, fx + bw / 2, fy - FS * 0.39 + bh];\n      if (overlaps(box, 1.6 * K)) continue;\n      addBox(box);\n      figs.push([fx, fy, lb, wM, bw]);\n      // Casts are spaced wider as the water deepens.\n      var gap = d < 0 ? 11 * K : 3.2 * K + 0.45 * K * Math.min(Math.max(d, 0), 22);\n      var sx = Math.abs(dLx) > 1e-3 ? (bw + gap) / Math.abs(dLx) : 1e9;\n      var sy = Math.abs(dLy) > 1e-3 ? (bh + gap) / Math.abs(dLy) : 1e9;\n      next = lam + Math.min(sx, sy);\n    }\n  }\n  /** Is a point (px) inside any figure's box, padded? */\n  function inFig(x, y, pad) {\n    var list = bucket[Math.floor(x / BK) + ',' + Math.floor(y / BK)];\n    if (!list) return false;\n    for (var n = 0; n < list.length; n++) {\n      var b = boxes[list[n]];\n      if (x > b[0] - pad && x < b[2] + pad && y > b[1] - pad && y < b[3] + pad) return true;\n    }\n    return false;\n  }\n  /** Stroke a traced line, broken wherever it would run through a figure. */\n  function strokeBroken(pts, pad, keep) {\n    var run = false;\n    for (var j = 0; j < pts.length; j += 2) {\n      var x = X(pts[j]), y = Y(pts[j + 1]);\n      var ok = !inFig(x, y, pad) && (!keep || keep(pts[j], pts[j + 1]));\n      if (ok) { if (run) ctx.lineTo(x, y); else ctx.moveTo(x, y); }\n      run = ok;\n    }\n  }\n\n  // --- The drying heads -------------------------------------------------------\n  // Stippled dot by dot on a jittered grid, as on the other sheets.\n  var sg = rngFrom(seed * 6151 + 5), SG = 2.6 * K, dotsN = 0;\n  ctx.save();\n  ctx.globalAlpha = 0.9;\n  ctx.fillStyle = pal[0];\n  ctx.beginPath();\n  for (var gy = PY + SG / 2; gy < PY + PH; gy += SG) for (var gx = PX + SG / 2; gx < PX + PW; gx += SG) {\n    var jx = gx + (sg() - 0.5) * SG, jy = gy + (sg() - 0.5) * SG, roll = sg(), rs = sg();\n    if (roll >= bil(dryS, (jx - PX) / CW, (jy - PY) / CH) || inFig(jx, jy, 1.2 * K)) continue;\n    var rad = 0.66 * 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  ctx.restore();\n\n  // --- Hachures on the land ---------------------------------------------------\n  // As on Relief of a Coast: along each contour of height the strokes are set\n  // at an even spacing, closer and heavier on steep ground and in shadow, and\n  // each is run down the fall line to just short of the next contour below,\n  // as a wedge that lifts to a point the way a burin stroke does.\n  var TIER = smax * (5.5 * K) / PH;\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          next += (2.1 + 5.2 * (1 - T)) * K * (0.9 + 0.2 * hr());\n          if (T < 0.14) continue;\n          var wd = (0.24 + 1.05 * Math.pow(T, 1.35)) * K * (0.9 + 0.2 * hr());\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          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  // --- Streams ----------------------------------------------------------------\n  // Each stream is traced from its head down to where it meets a larger one or\n  // the sea, a hairline at the head that swells a little with what it carries.\n  var hasUp = new Uint8Array(N), done = new Uint8Array(N), drawn = 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  // Largest first, so a tributary always stops against a stream already drawn.\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 = [];\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]) break;\n      // A stream that reaches the edge of the plate leaves it there: the edge\n      // cells are outlets, and one ran along the border as a ruled line.\n      if (c9 < 2 || r9 < 2 || c9 > COLS - 3 || r9 > ROWS - 3) break;\n      done[cur] = 1; visited.push(cur);\n      cur = rcv[cur];\n    }\n    // 🔴 Too short to draw, a stream head printed as a stray tick (Mouths of a\n    // River). Its cells are handed back, so a stream from higher up runs on.\n    if (visited.length < 12) { visited.forEach(function (vc) { done[vc] = 0; }); return; }\n    // 🔴 In a broad valley several streams ran side by side to the shore and\n    // read as a ruled comb. One that spends most of its course close beside a\n    // stream already drawn, without yet joining it, is left out the same way.\n    var RN = 8, near = 0, span = visited.length - RN - 1;\n    for (var vi = 0; vi < span; vi++) {\n      var vc0 = visited[vi] % COLS, vr0 = Math.floor(visited[vi] / COLS), hit = false;\n      for (var dr = -RN; dr <= RN && !hit; dr++) for (var dc = -RN; dc <= RN; dc++) {\n        var c0 = vc0 + dc, r0 = vr0 + dr;\n        if (c0 >= 0 && r0 >= 0 && c0 < COLS && r0 < ROWS && drawn[r0 * COLS + c0]) { hit = true; break; }\n      }\n      if (hit) near++;\n    }\n    if (near > 0.5 * span) { visited.forEach(function (vc) { done[vc] = 0; }); return; }\n    visited.forEach(function (vc) { drawn[vc] = 1; });\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      // Lighter than on Relief of a Coast: here the hachures are the mass, and\n      // at full weight the streams outweighed them and ran across the plain\n      // as ruled lines.\n      ctx.lineWidth = Math.min(1.0, 0.25 + 0.16 * 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  // --- Water-lining along the coast -------------------------------------------\n  // Only the coast is lined. Where a line would run onto a bank it stops.\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.globalAlpha = 0.82;\n  var liningKeep = function (c, r) { return bil(bankH, c, r) < 0.5; };\n  for (var bb = 0; bb < BANDS.length; bb++) {\n    ctx.lineWidth = (0.95 - 0.07 * bb) * K;\n    ctx.beginPath();\n    contour(sm, BANDS[bb]).forEach(function (ln) {\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      strokeBroken(chaikin(ln, 2), -1e9, liningKeep);\n    });\n    ctx.stroke();\n  }\n  ctx.restore();\n\n  // --- Depth curves -------------------------------------------------------------\n  // Each depth has its own pattern of dash, as the old charts drew them, and\n  // each curve is broken where it passes through a figure.\n  var CURVES = [\n    [3, [3.2 * K, 2.4 * K]],\n    [5, [7 * K, 2.6 * K, 0.01, 2.6 * K]],\n    [10, [10 * K, 3 * K, 0.01, 3 * K, 0.01, 3 * K]],\n    [20, [16 * K, 4 * K]],\n  ];\n  var curveN = 0;\n  ctx.save();\n  ctx.strokeStyle = pal[0];\n  ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  ctx.lineWidth = 1.0 * K;\n  ctx.globalAlpha = 0.88;\n  CURVES.forEach(function (cv) {\n    if (ctx.setLineDash) ctx.setLineDash(cv[1]);\n    ctx.beginPath();\n    contour(dep, cv[0]).forEach(function (ln) {\n      if (lineLen(ln) < 40 * K) return;\n      strokeBroken(chaikin(ln, 2), 1.4 * K);\n      curveN++;\n    });\n    ctx.stroke();\n  });\n  // The low-water line: the edge of the drying ground, dotted.\n  if (ctx.setLineDash) ctx.setLineDash([0.01, 2.4 * K]);\n  ctx.lineWidth = 1.1 * K;\n  ctx.globalAlpha = 0.85;\n  ctx.beginPath();\n  contour(dep, 0).forEach(function (ln) { strokeBroken(chaikin(ln, 2), 1.0 * K); });\n  ctx.stroke();\n  if (ctx.setLineDash) ctx.setLineDash([]);\n  ctx.restore();\n\n  // --- The coastline --------------------------------------------------------\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(landM, 0.5).forEach(function (ln) { strokeLine(chaikin(ln, 2)); });\n  ctx.stroke();\n  ctx.restore();\n\n  // --- The figures ----------------------------------------------------------\n  var dryN = 0;\n  ctx.save();\n  ctx.fillStyle = pal[0];\n  ctx.strokeStyle = pal[0];\n  ctx.globalAlpha = 0.9;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n  ctx.lineWidth = 0.55 * K;\n  figs.forEach(function (f) {\n    var x0 = f[0] - f[4] / 2, yb = f[1] + FS * 0.34, lb = f[2];\n    ctx.font = fontM;\n    ctx.fillText(lb.main, x0, yb);\n    if (lb.sub) { ctx.font = fontS; ctx.fillText(lb.sub, x0 + f[3] + 0.4 * K, yb + FSS * 0.3); }\n    if (lb.under) {\n      ctx.beginPath();\n      ctx.moveTo(x0, yb + 1.5 * K); ctx.lineTo(x0 + f[4], yb + 1.5 * K);\n      ctx.stroke();\n      dryN++;\n    }\n  });\n  ctx.restore();\n\n  ctx.restore();   // the plate clip\n\n  var gl = (typeof globalThis !== 'undefined') ? globalThis : window;\n  gl.__genart_data = gl.__genart_data || {};\n  var landN = 0, dryC = 0;\n  for (var pq = 0; pq < N; pq++) { if (land[pq]) landN++; else if (dep[pq] < 0 && !coastDry[pq]) dryC++; }\n  gl.__genart_data.debug = {\n    banks: banks.length, land: +(landN / N).toFixed(3), dryHeads: +(dryC / N).toFixed(4),\n    figures: figs.length, strokes: strokes, streams: streamLines, dryFigures: dryN, curves: curveN, dots: dotsN, DM: +DM.toFixed(1),\n  };\n}\n",
 "layers": [
  {
   "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": 2,
    "monochrome": true
   }
  }
 ]
}