{
 "genart": "1.2",
 "id": "relief-of-a-coast",
 "title": "Relief of a Coast",
 "created": "2026-09-10T00:00:00Z",
 "modified": "2026-09-10T23:07:06.749Z",
 "renderer": {
  "type": "canvas2d",
  "version": "1.x"
 },
 "canvas": {
  "width": 1400,
  "height": 1000
 },
 "parameters": [],
 "colors": [],
 "dataChannels": [
  {
   "name": "drying",
   "type": "vector",
   "cols": 480,
   "rows": 360
  }
 ],
 "state": {
  "seed": 58,
  "params": {},
  "colorPalette": [
   "#1d2327",
   "#3b474f",
   "#6c7a82",
   "#b3bab8",
   "#ebe8e0"
  ]
 },
 "algorithm": "// Relief of a Coast. The sketch builds the ground and the sea bed and engraves\n// the chart: hachures, streams, shoreline, water-lining, depth curves,\n// soundings, rocks, grass, and the stipple of the sand and the flats. Only the\n// dotted low-water line is left to a plugin layer, reading a map the sketch\n// publishes on the ADR 062 data bridge.\n//\n// A stretch of coast drawn the way an engraved survey chart draws it: in\n// plan, from directly above, with no view and no horizon. Relief is shown by\n// hachures cut in courses: along each contour of height the engraver sets\n// strokes at an even spacing and runs each one straight down the fall line to\n// the next contour, so a course is exactly as deep as the ground is steep.\n// The steeper the ground, the closer and heavier the strokes (Lehmann's rule),\n// so a cliff goes nearly black and gentle ground carries nothing. Streams part\n// the hachures. The low ground behind the shore is sand and the high ground\n// is grass. Offshore the sea is water-lined from the low-water line outward,\n// with the figures of soundings set in the gaps between the lines.\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  // The coast runs ACROSS the plate and the land comes in from its edges. An\n  // island set in the middle of the sheet is the map equivalent of a specimen\n  // floating in the middle of the paper: the most generic thing it can do.\n  var th = rand() * Math.PI * 2;\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.05 + rand() * 0.10;\n  var ph = [];\n  for (var i = 0; i < 10; i++) ph.push(rand() * Math.PI * 2);\n  // A low coastal plain, then an escarpment, then a second rise to the upland\n  // off the plate. There is no crest anywhere on the sheet: the ground climbs\n  // all the way inland, so every stream runs to the sea and no line of\n  // summits is left to be ringed by its own courses.\n  var E1 = 0.22 + rand() * 0.10;\n  var E2 = E1 + 0.36 + rand() * 0.14;\n  // One bay, where the plain is pushed back and a stream comes out.\n  var tb = (rand() * 2 - 1) * 0.45, bd = 0.09 + rand() * 0.07, bw = 0.09 + rand() * 0.07;\n  function coastOff(t) {\n    return 0.022 * Math.sin(2.1 * t + ph[0]) + 0.012 * Math.sin(5.3 * t + ph[1]) -\n      bd * Math.exp(-Math.pow((t - tb) / bw, 2));\n  }\n  function escOff(t) {\n    return 0.04 * Math.sin(1.6 * t + ph[2]) + 0.018 * Math.sin(3.9 * t + ph[3]);\n  }\n  function seaBed(sc, t) {\n    return -0.30 * (1 - Math.exp(sc / 0.20)) * (1 + 0.28 * Math.sin(1.3 * t + ph[4]));\n  }\n  // Spurs come down off the escarpment toward the sea. The ones that reach it\n  // make the headlands, and end in a cliff because a spur's height gives out\n  // over a short run at its tip; the ground between them makes the coves.\n  var spurs = [];\n  var nSp = 5 + Math.floor(rand() * 3);\n  for (var k = 0; k < nSp; k++) {\n    var st = -1 + (k + 0.25 + rand() * 0.5) * (2 / nSp);\n    var inBay = Math.abs(st - tb) < bw * 0.9;\n    spurs.push({\n      t: st,\n      w: 0.03 + rand() * 0.03,\n      // 🔴 Below the escarpment's own 0.20, so height climbs all the way up a\n      // spur and its junction with the escarpment can never become a summit.\n      hgt: 0.10 + rand() * 0.08,\n      bend: (rand() * 2 - 1) * 0.3,\n      // The tip, on the coast-relative axis: below zero is out in the sea.\n      reach: inBay ? 0.04 + rand() * 0.05 : (rand() < 0.75 ? -0.09 + rand() * 0.07 : 0.03 + rand() * 0.05),\n    });\n  }\n  // A few stacks off the headlands, left standing where the cliff has gone back.\n  var stacks = [];\n  spurs.forEach(function (sp) {\n    if (sp.reach > -0.03 || rand() > 0.65) return;\n    var n = 1 + Math.floor(rand() * 2);\n    for (var q = 0; q < n; q++) {\n      // Close in under the headland. Further out, the lining round a stack\n      // closed on itself as a target in open water.\n      var ss = sp.reach - 0.008 - rand() * 0.018;\n      var tt = sp.t + sp.bend * (E1 - ss) + (rand() * 2 - 1) * 0.02;\n      stacks.push({ s: ss, t: tt, r: 0.006 + rand() * 0.007, a: -seaBed(ss, tt) + 0.010 + rand() * 0.014 });\n    }\n  });\n  var w1x = lattice(6, 5), w1y = lattice(6, 5), w2x = lattice(14, 11), w2y = lattice(14, 11);\n  var n1 = lattice(7, 5), n2 = lattice(15, 11), n3 = lattice(33, 25), n4 = lattice(90, 64);\n\n  function height(x, y) {\n    var u = x / ASPECT, v = y;\n    // A gentle warp of the whole ground, so no line on the sheet is ruled.\n    x += 0.028 * w1x(u, v) + 0.009 * w2x(u, v);\n    y += 0.028 * w1y(u, v) + 0.009 * w2y(u, v);\n    var dx = x - cx, dy = y - cy;\n    var s = dx * nx0 + dy * ny0 + off;\n    var t = dx * px0 + dy * py0;\n    var sc = s + coastOff(t), se = s + escOff(t);\n    // Land keeps climbing gently all the way inland, so the upland has a fall\n    // for its streams to cut into.\n    var h = sc < 0 ? seaBed(sc, t) : 0.05 * Math.tanh(sc * 7) + 0.09 * Math.max(0, se);\n    var escS = smooth(E1 - 0.07, E1 + 0.07, se);\n    // The escarpment fades out just short of the shore: where it meets the sea\n    // it stands as a bluff rather than running out under the water.\n    h += (0.20 * escS + 0.15 * smooth(E2 - 0.15, E2 + 0.15, se)) * smooth(-0.015, 0.025, sc);\n    for (var q = 0; q < spurs.length; q++) {\n      var sp = spurs[q];\n      var tc = sp.t + sp.bend * (E1 - se);\n      var down = smooth(sp.reach, E1, se);\n      var A = smooth(sp.reach, sp.reach + 0.035, sc) * (0.55 + 0.45 * down);\n      var wv = sp.w * (1.35 - 0.35 * down);           // blunter toward the tip\n      // 🔴 A spur is a shoulder coming DOWN off the escarpment, so it fades\n      // out as the escarpment rises (1 - escS). Added on top instead, every\n      // junction stood higher than the ground either side: a summit, ringed\n      // by its own courses.\n      h += sp.hgt * A * (1 - escS) * Math.exp(-Math.pow((t - tc) / wv, 2));\n    }\n    for (var m = 0; m < stacks.length; m++) {\n      var sk = stacks[m];\n      h += sk.a * Math.exp(-(Math.pow(sc - sk.s, 2) + Math.pow(t - sk.t, 2)) / (sk.r * sk.r));\n    }\n    // 🔴 Kept faint and kept off the sea bed. Every lump is a summit, and a\n    // hachured summit is a white spot ringed by strokes.\n    h += (0.018 * n1(u, v) + 0.008 * n2(u, v) + 0.003 * n3(u, v)) * smooth(-0.02, 0.05, sc);\n    // Fine roughness only at the waterline, so the shore is broken without\n    // the ground behind it becoming lumpy.\n    h += 0.002 * n4(u, v) * Math.exp(-h * h / 0.0009);\n    return h;\n  }\n\n  var hmap = new Float32Array(N);\n  for (var r = 0; r < ROWS; r++)\n    for (var c = 0; c < COLS; c++)\n      hmap[r * COLS + c] = height(c * DX, r * DY);\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 every stack.\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 valleys are not drawn on: the ground is eroded. Each round, hollows\n  // are filled to their spill point, water runs from every cell to its\n  // steepest lower neighbour, and each cell is cut toward its receiver in\n  // proportion to the square root of the ground draining through it (stream\n  // power). Streams therefore find their own way to the sea, branch where\n  // they should, and cut deeper the more they carry.\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.\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 the 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  // A gully that gathers this much water is drawn as a stream. Set higher, the\n  // gullies below it were left undrawn, and the hachures ran together into\n  // each one as a dark feather.\n  var A1 = Math.round(N * 0.0011), A2 = Math.round(N * 0.012);\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 gxA = new Float32Array(N), gyA = 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      gxA[n] = gx; gyA[n] = gy;\n      slope[n] = g;\n      facing[n] = g > 1e-6 ? (-gx * LX - gy * LY) / g : 0;   // +1 faces the light\n      if (hmap[n] > 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  /**\n   * The weight of engraving a piece of ground takes. Steepness first, as the\n   * survey hachure is defined; then the oblique light, which is what makes\n   * the ground read as form rather than as a chart of gradients.\n   */\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  // 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\n  var fromLand = edt(function (p) { return hmap[p] > 0; });   // distance out to sea\n  var fromSea = edt(function (p) { return hmap[p] <= 0; });   // distance inland\n  // Ground heights, for the zones of sand and grass.\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 hq = function (f) { return hs.length ? hs[Math.floor(f * (hs.length - 1))] : 0; };\n\n  // How gentle the shore is, read off the ground just inside it: a steep shore\n  // is a cliff, a gentle one has a beach and flats in front of it.\n  var gentle = new Float32Array(N), cliff = new Uint8Array(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 ox = -3; ox <= 3; ox++) {\n      var cc2 = cg + ox, 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    if (fromSea.d[pg] <= 2.5 && slope[pg] / smax > 0.75 && gentle[pg] < 0.5) cliff[pg] = 1;\n  }\n  // The width of the flats in front of each piece of shore, in cells.\n  var FLAT = 7;\n  var flatW = new Float32Array(N);\n  for (var pf = 0; pf < N; pf++) {\n    if (hmap[pf] > 0) continue;\n    var sl = fromLand.src[pf];\n    flatW[pf] = sl >= 0 ? FLAT * gentle[sl] : 0;\n  }\n\n  // --- The sea ------------------------------------------------------------\n  // The water-lining is laid from the low-water line, not the shore: in front\n  // of a gentle shore the flats come first, stippled, and the lining begins\n  // beyond them. It is traced on a smoothed copy of that distance.\n  var fromLow = new Float32Array(N);\n  for (var pl2 = 0; pl2 < N; pl2++) fromLow[pl2] = hmap[pl2] > 0 ? 0 : fromLand.d[pl2] - flatW[pl2];\n  var sm = blur(fromLow, 3, 70);\n  var shoreDir = new Float32Array(N);\n  var drying = new Float32Array(N), stipple = new Float32Array(N), sand = new Float32Array(N);\n  // Each line a little further out than the last and each gap a little wider,\n  // which is how a water-lined chart makes the sea deepen away from the shore.\n  var BANDS = [];\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  // The low ground behind the shore is sand, stippled, thinning as the ground\n  // rises and giving out where the slope begins to carry hachures.\n  // Its density drifts slowly, heavier and lighter, as blown sand lies.\n  var H_SAND = hq(0.40), dune = lattice(26, 20);\n  for (var r6 = 0; r6 < ROWS; r6++) for (var c6 = 0; c6 < COLS; c6++) {\n    var i6 = r6 * COLS + c6;\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    if (hmap[i6] > 0) {\n      var low = 1 - smooth(H_SAND * 0.55, H_SAND, hmap[i6]);\n      var flat = 1 - smooth(0.12, 0.32, tone[i6]);\n      var drift = 0.7 + 0.3 * dune(c6 / (COLS - 1), r6 / (ROWS - 1));\n      if (toStream.d[i6] > 1.4 && !cliff[i6]) sand[i6] = low * flat * drift;\n      continue;\n    }\n    var d = fromLand.d[i6], fw = flatW[i6];\n    if (d < fw) {\n      // Flats: stipple, thinning toward the low-water line.\n      stipple[i6] = 0.9 * Math.pow(1 - d / fw, 0.7);\n    }\n    if (fw > 2.5 && Math.abs(fromLow[i6]) < 0.6) 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\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  // --- Grass --------------------------------------------------------------\n  // The engraver's tuft, a few short strokes fanned up from one root, set\n  // wherever the high ground is too gentle to carry hachures and thinning as\n  // the slope comes on. The strokes stand upright on the sheet, because a\n  // map's symbols are set square to the page, not to the ground.\n  var H_GRASS = hq(0.55);\n  var gr = rngFrom(seed * 104723 + 7);\n  // Set evenly, the tufts read as wallpaper; grass grows in patches.\n  var meadow = lattice(18, 13);\n  var 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      var hh3 = bil(hmap, gc, grr);\n      var hi = smooth(H_GRASS * 0.8, H_GRASS * 1.15, hh3);\n      var lev = 1 - smooth(0.10, 0.30, bil(tone, gc, grr));\n      var patch = 0.3 + 0.7 * smooth(-0.35, 0.45, meadow(gc / (COLS - 1), grr / (ROWS - 1)));\n      if (hh3 <= 0 || bil(toStream.d, gc, grr) < 2.5 || roll > 0.95 * hi * lev * patch) 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 -------------------------------------------------------------\n  // Along each contour of height the strokes are set at an even spacing and\n  // each is run straight down the fall line until it nearly meets the next\n  // contour below, leaving a hair of paper: the break between courses. So a\n  // stroke is as long as the course is deep, short on a steep face and long\n  // on a gentle one, and successive courses fall out of step with each other\n  // as they do under the graver. The interval puts the courses about five\n  // pixels apart on this coast's steepest ground. At ten, moderate slopes got\n  // courses so deep that each read as a row of long spikes.\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          if (T < 0.14) 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  // --- Water-lining ---------------------------------------------------------\n  // Traced lines, each a little finer than the one inside it, so the lining\n  // fades out into the open sea as the engraver's did.\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 stack read as an eye. The stack keeps its\n      // 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  // Depth curves at 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 = [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  // --- Streams ----------------------------------------------------------------\n  // Each stream is traced from its head down to where it meets a larger one\n  // or the sea, a hairline at the head that swells a little with what it\n  // carries.\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  // 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  // A slow wobble, the same everywhere so a tributary still meets its stream:\n  // across ground filled level the drainage alone runs dead straight.\n  var wbx = lattice(60, 45), wby = lattice(60, 45);\n  heads.forEach(function (hd) {\n    var pts = [], ac = [], cur = hd, guard = 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]) break;\n      // A stream that reaches the edge of the plate leaves it there. The cells\n      // along the edge are outlets, and without this one ran along the border\n      // as a ruled line.\n      if (c9 < 2 || r9 < 2 || c9 > COLS - 3 || r9 > ROWS - 3) break;\n      done[cur] = 1;\n      cur = rcv[cur];\n    }\n    if (pts.length < 8) 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      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  // --- Rocks ------------------------------------------------------------------\n  // Off the foot of the cliffs, the rock-awash sign: a cross with a dot in\n  // each angle.\n  var rocks = [], cand = [];\n  for (var pr2 = 0; pr2 < N; pr2++) {\n    if (hmap[pr2] > 0) continue;\n    var dd2 = fromLand.d[pr2], sl2 = fromLand.src[pr2];\n    if (dd2 > 2.5 && dd2 < 9 && sl2 >= 0 && cliff[sl2]) cand.push(pr2);\n  }\n  var nRock = 3 + Math.floor(rand() * 6);\n  for (var tries = 0; tries < 400 && rocks.length < nRock && cand.length; tries++) {\n    var pk = cand[Math.floor(rand() * cand.length)];\n    var rc = pk % COLS, rr3 = Math.floor(pk / COLS);\n    if (rocks.every(function (o) { return Math.hypot(o[0] - rc, o[1] - rr3) > 6; })) rocks.push([rc + rand() - 0.5, rr3 + rand() - 0.5]);\n  }\n  ctx.save();\n  ctx.strokeStyle = pal[0]; ctx.fillStyle = pal[0];\n  ctx.lineWidth = 0.9 * K;\n  rocks.forEach(function (o) {\n    var x = X(o[0]), y = Y(o[1]), a = 2.8 * K, dd3 = 1.6 * K;\n    ctx.beginPath();\n    ctx.moveTo(x - a, y); ctx.lineTo(x + a, y);\n    ctx.moveTo(x, y - a); ctx.lineTo(x, y + a);\n    ctx.stroke();\n    [[1, 1], [1, -1], [-1, 1], [-1, -1]].forEach(function (sg) {\n      ctx.beginPath(); ctx.arc(x + sg[0] * dd3, y + sg[1] * dd3, 0.55 * K, 0, Math.PI * 2); ctx.fill();\n    });\n  });\n  ctx.restore();\n\n  // --- Soundings --------------------------------------------------------------\n  // Depth figures in fathoms, set on an open grid over the sea and only in the\n  // gaps between water-lines, clear of the depth curves and the rocks.\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 (hmap[ps2] > 0 || fromLand.d[ps2] < 5 || sm[ps2] < 3) 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      if (rocks.some(function (o) { return Math.hypot(o[0] - sc2, o[1] - sr2) < 5; })) 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  gl.__genart_data.debug = {\n    land: +(landN / N).toFixed(3), streams: streamLines, spurs: spurs.length, stacks: stacks.length,\n    rocks: rocks.length, soundings: sound.length, curves: curveN, tufts: tufts, strokes: strokes,\n    courses: Math.floor(hmax / TIER),\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": 71,
    "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": 58,
    "monochrome": true
   }
  }
 ]
}