genart.dev
Download GenArt
Relief of a Coast

Relief of a Coast

Chart · 2026 · 1400×1000 · Canvas 2D

A stretch of coast in plan, engraved as a survey chart. Relief is drawn with hachures: short strokes set along each contour of height and run straight down the fall line to the next, so they stand in courses. Steeper ground takes closer and heavier strokes, and ground turned away from a light in the north-west takes more again, so the shadowed flank of a spur goes dark and a level bench is bare paper. Streams cut the land into valleys and part the hachures on either side. The low ground behind the shore is stippled as sand, and the high ground inland is set with tufts of grass. Offshore, flats are stippled in front of the gentle stretches of shore, out to a dotted low-water line. Beyond it the sea is water-lined: lines laid parallel to the low-water line, each finer than the one inside it and each gap wider, until the lining gives out. Soundings in fathoms are set in the gaps and across the open sea, with dotted depth curves at ten and twenty fathoms, and rocks awash lie off the foot of the cliffs. The coast runs across the sheet rather than round an island in the middle of it, so land comes in from the edges. The seed decides the bearing of the coast, the escarpment behind it, the spurs that come down to make the headlands, and the bay; every stream, cove and sounding follows from that ground.

Technique

the sketch builds a height field and erodes it. In each of ten rounds, hollows are filled, water is routed downhill, and every cell is cut toward its receiver by the square root of the water passing through it, so the streams find their own way to the sea. The sketch then engraves the chart itself. Hachures are traced from marching-squares contours of the eroded ground and drawn as tapered wedges. The water-lining is traced on an exact Euclidean distance from the low-water line, and the streams follow the drainage, widening with what they carry. The dotted low-water line is a `painting:flow-lines` layer reading a channel the sketch publishes on the algorithm data bridge, and the graduated neat line is a `shapes:path` layer.

Seeds

The same system at three seeds. The composition itself re-cuts: the geometry is derived from the seed, so masses, edges and placement all move, while the palette and the drawing language stay put.

  1. Relief of a Coast, seed 5858
  2. Relief of a Coast, seed 101101
  3. Relief of a Coast, seed 44104410

Layer stack

In paint order. Every mark in the image comes from these; there is no handwritten drawing code.

  1. painting:flow-linesLow-Water Line — Dotted
  2. shapes:pathNeat Line — Graduated Border
  3. shapes:pathNeat Line — Outer Rule
  4. shapes:pathNeat Line — Inner Rule
  5. filter:grainPlate Tone

Source

The complete composition. Open it in GenArt to re-render, re-seed, or take it apart.

relief-of-a-coast.genart
{
  "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
      }
    }
  ]
}