
Soundings of a Bank
A chart of a bank of sand lying off a coast, with the sea made of figures. The boat runs lines across the bank and sets down a depth at every cast, so the soundings lie in rows. Over the bank every line is sounded; in water under nine fathoms one line in two, and in deep water one in four, with the casts spaced wider as the water deepens. Where a bank falls steeply to deep water, one line in two is run on past the foot of the slope, each line for its own distance, so the steep side is surveyed more closely than the depth alone would ask. The bank shows twice: in the depth curves drawn through the figures, and in how thickly the figures lie. Depths under eleven fathoms are given in fathoms and feet, the feet set small beside the fathoms. The highest parts of the bank dry at low water. They are stippled inside a dotted low-water line, the bank round them is stippled more lightly out to three fathoms, and the few figures on the heads give the height the sand dries, in feet, underlined. The depth curves at three, five, ten and twenty fathoms each have their own pattern of dash, as the old charts drew them, and each is broken where it passes through a figure. Behind a water-lined coast the land rises to an escarpment, hachured, with spurs coming down toward the shore and streams in the valleys between them. It is the one dark mass on the sheet. The seed decides the bearing of the coast and so where the land lies, how many banks lie off it and where, the bearing of each, which flank is the steep one, where the crest rises into heads, and where a swatchway is cut across between them.
Technique
the sketch builds the sea floor and the land and engraves the whole sheet. The floor shelves away from the coast, quickly and then slowly. Each bank is long and narrow, bends gently, and has a flat-topped cross-section that is steeper on one side than the other; its crest rises and falls along its length and is scaled so its highest point stands exactly at the height the bank was given, and where banks overlap the higher one wins. The land is the ground of Relief of a Coast, with broad valleys that take a share of its height, eroded the same way: hollows filled, water routed downhill, and each cell cut by the square root of the water passing through it. Hachures are traced from marching-squares contours of the eroded ground and drawn as tapered wedges; the streams follow the drainage, and the pen lifts over ground the filling left level. A stream that would run most of its way close beside one already drawn, without joining it, is left out, so a broad valley holds a few streams instead of a row of parallel lines. The coast's water-lining is traced on an exact Euclidean distance from the low-water line, and the depth curves by marching squares. The soundings are set along the survey lines, square to the page, with a grid of their boxes keeping each figure clear of the others. The graduated neat line is a `shapes:path` layer, and the grain is a `filter:grain` 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.
2
7
11
Layer stack
In paint order. Every mark in the image comes from these; there is no handwritten drawing code.
shapes:pathNeat Line — Graduated Bordershapes:pathNeat Line — Outer Ruleshapes:pathNeat Line — Inner Rulefilter:grainPlate Tone
Source
The complete composition. Open it in GenArt to re-render, re-seed, or take it apart.
{
"genart": "1.2",
"id": "soundings-of-a-bank",
"title": "Soundings of a Bank",
"created": "2026-09-11T00:00:00Z",
"modified": "2026-09-11T18:18:29.837Z",
"renderer": {
"type": "canvas2d",
"version": "1.x"
},
"canvas": {
"width": 1400,
"height": 1000
},
"parameters": [],
"colors": [],
"state": {
"seed": 2,
"params": {},
"colorPalette": [
"#1d2327",
"#3b474f",
"#6c7a82",
"#b3bab8",
"#ebe8e0"
]
},
"algorithm": "// Soundings of a Bank. A chart of a bank of sand lying off a coast: the sketch\n// builds the sea floor and the land behind the shore and engraves the whole\n// sheet, the hachured hills and their streams, the coastline and its\n// water-lining, the drying heads of the bank, the depth curves, and above all\n// the soundings, which are the survey itself.\n//\n// The sea is made of figures. The boat runs lines across the bank and a depth\n// is set down at every cast, so the figures lie in rows, and where the bank\n// needs developing the lines are run closer together. The shape of the bank\n// shows twice: in the depth curves drawn through the figures, and in how\n// thickly the figures lie. The land is the one dark mass on the sheet.\nfunction sketch(ctx, state) {\n var W = state.canvas.width, H = state.canvas.height;\n var seed = state.seed || 0;\n var pal = state.colorPalette;\n var K = W / 1400;\n\n var COLS = Math.round(240 * W / 700), ROWS = Math.round(180 * H / 500);\n var N = COLS * ROWS;\n // The field maps onto the inset plate: the build script uses the same box.\n var PX = W * 0.075, PY = H * 0.075, PW = W * 0.85, PH = H * 0.81;\n var ASPECT = PW / PH;\n var DX = ASPECT / (COLS - 1), DY = 1 / (ROWS - 1); // cell size, plate heights\n var CW = PW / (COLS - 1), CH = PH / (ROWS - 1); // cell size, px\n\n function rngFrom(a) {\n return function () {\n a |= 0; a = a + 0x6D2B79F5 | 0;\n var t = Math.imul(a ^ a >>> 15, 1 | a);\n t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;\n return ((t ^ t >>> 14) >>> 0) / 4294967296;\n };\n }\n var rand = rngFrom(seed * 2654435761 + 104729);\n\n /** A lattice of random values, bilinearly sampled over [0,1]. */\n function lattice(nx, ny) {\n var v = new Float32Array(nx * ny);\n for (var k = 0; k < nx * ny; k++) v[k] = rand() * 2 - 1;\n return function (u, t) {\n // 🔴 Clamp: an out-of-range sample becomes a NaN in a published channel\n // and takes the render down on a frame the CLI still exits 0 for.\n if (u < 0) u = 0; else if (u > 1) u = 1;\n if (t < 0) t = 0; else if (t > 1) t = 1;\n var x = u * (nx - 1), y = t * (ny - 1);\n var x0 = Math.floor(x), y0 = Math.floor(y);\n var x1 = x0 + 1 > nx - 1 ? nx - 1 : x0 + 1, y1 = y0 + 1 > ny - 1 ? ny - 1 : y0 + 1;\n var fx = x - x0, fy = y - y0;\n fx = fx * fx * (3 - 2 * fx); fy = fy * fy * (3 - 2 * fy);\n var a = v[y0 * nx + x0], b = v[y0 * nx + x1], c = v[y1 * nx + x0], d = v[y1 * nx + x1];\n return (a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy;\n };\n }\n function smooth(e0, e1, x) {\n var t = (x - e0) / (e1 - e0);\n if (t < 0) t = 0; else if (t > 1) t = 1;\n return t * t * (3 - 2 * t);\n }\n /** A fixed random value per cell, for marks that must not move between passes. */\n function hash(i) {\n var h = Math.imul(i ^ (seed * 374761393), 668265263);\n h = Math.imul(h ^ h >>> 13, 1274126177);\n return ((h ^ h >>> 16) >>> 0) / 4294967296;\n }\n\n // --- The lie of the ground -------------------------------------------------\n // The coast lies along one edge of the plate and the bank off it, roughly in\n // its line, as the banks off an open coast lie in the run of the tide. s runs\n // from the coast out to sea, t along the coast.\n var th = Math.floor(rand() * 4) * Math.PI / 2 + (rand() * 2 - 1) * 0.3;\n var nx0 = Math.cos(th), ny0 = Math.sin(th); // from the sea toward the land\n var px0 = -ny0, py0 = nx0; // along the coast\n var cx = ASPECT / 2, cy = 0.5;\n var halfExt = Math.abs(nx0) * ASPECT / 2 + Math.abs(ny0) * 0.5;\n // How far in the land reaches: a fifth to three tenths of the plate, so the\n // hachured land is a mass large enough to tell one seed from another.\n var landW = (0.20 + rand() * 0.10) * 2 * halfExt;\n var S = 2 * halfExt - landW; // the breadth of sea across the plate\n var sOrg = halfExt - landW;\n function toS(x, y) { return sOrg - ((x - cx) * nx0 + (y - cy) * ny0); }\n function toT(x, y) { return (x - cx) * px0 + (y - cy) * py0; }\n function toX(s, t) { return cx + (sOrg - s) * nx0 + t * px0; }\n function toY(s, t) { return cy + (sOrg - s) * ny0 + t * py0; }\n var ph = [];\n for (var i = 0; i < 8; i++) ph.push(rand() * Math.PI * 2);\n function coastOff(t) { return 0.022 * Math.sin(2.1 * t + ph[0]) + 0.011 * Math.sin(5.3 * t + ph[1]) + 0.004 * Math.sin(12.7 * t + ph[2]); }\n\n // The sea floor shelves off the coast, quickly at first and then slowly,\n // into water too deep to trouble anyone. Depths are in fathoms throughout;\n // negative is above the datum, ground that dries at low water.\n var DM = 17 + rand() * 9, LS = 0.42 * S;\n function base(s) { return -0.7 + DM * (1 - Math.exp(-Math.max(0, s) / LS)); }\n\n // --- The banks --------------------------------------------------------------\n // A tidal bank is long and narrow, lies a little across the line of the\n // coast, bends gently, and is steeper on one side than the other. Its crest\n // rises and falls along its length, so only the highest parts of it dry at\n // low water, as heads of sand, and a swatchway, a shallow channel, is cut\n // across it somewhere.\n var al0 = (rand() * 2 - 1) * 0.32;\n function mkBank(sc, tc, len, w, top, al) {\n var steep = rand() < 0.5;\n return {\n sc: sc, tc: tc, len: len, w: w, al: al,\n amp: Math.max(1.2, base(sc) - top),\n kp: steep ? 0.6 : 1.5, kn: steep ? 1.5 : 0.6,\n bend: (rand() * 2 - 1) * 0.05, bph: rand() * Math.PI * 2,\n // 🔴 The swatchway is cut off the middle of the bank. At the middle it\n // went through the highest part of the crest, and on some seeds nothing\n // was left to dry.\n crest: lattice(10, 2), sw: 0.35 + rand() * 0.35,\n swA: (rand() < 0.5 ? -1 : 1) * (0.16 + rand() * 0.16) * len,\n };\n }\n /** How high a bank's crest stands along its length (z from -1 to 1), unnormalised. */\n function envAt(b, z) {\n var a = z * b.len / 2;\n return Math.pow(1 - z * z, 1.4) * (1 + 0.3 * b.crest(0.5 + z / 2, 0.5))\n * (1 - b.sw * Math.exp(-Math.pow((a - b.swA) / 0.035, 2)));\n }\n var sB = (0.40 + rand() * 0.14) * S, tB = (rand() * 2 - 1) * 0.1;\n var banks = [mkBank(sB, tB, 1.0 + rand() * 0.5, 0.042 + rand() * 0.02, -(1.0 + rand() * 1.2), al0)];\n var nSec = rand() < 0.3 ? 0 : rand() < 0.65 ? 1 : 2, side = rand() < 0.5 ? -1 : 1;\n for (var k = 0; k < nSec; k++, side = -side) {\n var s2 = sB + side * (0.15 + rand() * 0.07) * S;\n if (s2 < 0.2 * S || s2 > 0.9 * S) s2 = 2 * sB - s2;\n banks.push(mkBank(s2, tB + (rand() * 2 - 1) * 0.35, 0.6 + rand() * 0.5, 0.03 + rand() * 0.02,\n 1.2 + rand() * 3.5, al0 + (rand() * 2 - 1) * 0.15));\n }\n // Each crest is scaled so its highest point stands exactly at the height the\n // bank was given. Unscaled, the crest's own rise and fall put the main bank\n // anywhere from not drying at all to drying 26 feet.\n banks.forEach(function (b) {\n b.eMax = 1e-6;\n for (var z = -0.99; z < 0.99; z += 0.005) b.eMax = Math.max(b.eMax, envAt(b, z));\n });\n /** How far a bank stands up off the floor beneath it, in fathoms. */\n function bankAt(b, s, t) {\n var ds = s - b.sc, dt = t - b.tc;\n var z = 2 * (ds * Math.sin(b.al) + dt * Math.cos(b.al)) / b.len;\n if (z <= -1 || z >= 1) return 0;\n var q = ds * Math.cos(b.al) - dt * Math.sin(b.al) - b.bend * Math.sin(Math.PI * z * 0.9 + b.bph);\n var wq = b.w * (q > 0 ? b.kp : b.kn);\n // 🔴 A flat-topped profile. Peaked, only a hair of the crest came above\n // the datum, and the heads dried as slivers a few cells wide.\n return b.amp * envAt(b, z) / b.eMax * Math.exp(-Math.pow(Math.abs(q) / wq, 2.6));\n }\n\n // --- The land -------------------------------------------------------------\n // The ground of Relief of a Coast: a short rise from the shore, a plain that\n // climbs gently, an escarpment and a second rise inland, with spurs coming\n // down off the escarpment toward the sea. It climbs all the way inland, so\n // no summit is left to be ringed by its own courses. Hachured, it is the one\n // large dark mass on the sheet, and it moves with the coast.\n var E1 = landW * (0.30 + rand() * 0.15), E2 = E1 + landW * (0.45 + rand() * 0.25);\n function escOff(t) { return 0.03 * Math.sin(1.6 * t + ph[3]) + 0.014 * Math.sin(3.9 * t + ph[4]); }\n var spurs = [], nSp = 5 + Math.floor(rand() * 3);\n for (var ks = 0; ks < nSp; ks++) {\n spurs.push({\n t: -1 + (ks + 0.25 + rand() * 0.5) * (2 / nSp), w: 0.03 + rand() * 0.03,\n // Below the escarpment's own 0.20, so a spur's junction with it can\n // never become a summit.\n hgt: 0.10 + rand() * 0.08, bend: (rand() * 2 - 1) * 0.3, reach: 0.005 + rand() * 0.03,\n });\n }\n // 🔴 Two or three broad valleys come down across the land to the sea. On a\n // uniform fall every gully ran straight down beside the next, and a narrow\n // strip of land read as a comb of parallel streams, as Mouths of a River's\n // upland did. A fall toward a valley gathers them into branching courses.\n var valleys = [], nV = 2 + Math.floor(rand() * 2);\n for (var kv = 0; kv < nV; kv++) {\n valleys.push({ t: -0.9 + (kv + 0.3 + rand() * 0.4) * (1.8 / nV), w: 0.10 + rand() * 0.06,\n d: 0.30 + rand() * 0.15, lean: (rand() * 2 - 1) * 0.3 });\n }\n var n1 = lattice(7, 5), n2 = lattice(15, 11), n3 = lattice(33, 25);\n /** Height of the land at a point `s` out from the coast (negative inland). */\n function landHeight(s, t, u, v) {\n var lc = -(s + coastOff(t)), se = -s + escOff(t);\n var h = 0.05 * Math.tanh(lc * 7) + 0.09 * Math.max(0, se);\n var escS = smooth(E1 - 0.06, E1 + 0.06, se);\n h += (0.20 * escS + 0.15 * smooth(E2 - 0.12, E2 + 0.12, se)) * smooth(0, 0.03, lc);\n for (var q = 0; q < spurs.length; q++) {\n var sp = spurs[q], tc = sp.t + sp.bend * (E1 - se), down = smooth(sp.reach, E1, se);\n var A = smooth(sp.reach, sp.reach + 0.035, lc) * (0.55 + 0.45 * down);\n // 🔴 A spur is a shoulder coming DOWN off the escarpment, so it fades as\n // the escarpment rises (1 - escS); added on top, each junction stood up\n // as a summit.\n h += sp.hgt * A * (1 - escS) * Math.exp(-Math.pow((t - tc) / (sp.w * (1.35 - 0.35 * down)), 2));\n }\n for (var qv = 0; qv < valleys.length; qv++) {\n var vl = valleys[qv];\n // 🔴 A share of the height, not a depth taken off it. Subtracted, the\n // valley drove the low ground by the shore to the floor, pit-filling\n // levelled it, and the streams crossed it as ruled parallel lines.\n h *= 1 - vl.d * Math.exp(-Math.pow((t - vl.t - vl.lean * se) / vl.w, 2));\n }\n // Kept faint: every lump is a summit, and a hachured summit is a white\n // spot ringed by strokes.\n h += 0.018 * n1(u, v) + 0.008 * n2(u, v) + 0.003 * n3(u, v);\n return Math.max(0.002, h);\n }\n\n var nb1 = lattice(6, 5), nb2 = lattice(17, 13), w1x = lattice(6, 5), w1y = lattice(6, 5);\n var dep = new Float32Array(N), land = new Uint8Array(N), bankH = new Float32Array(N);\n var hmap = new Float32Array(N).fill(-1); // the land's height; the sea is -1\n for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n var p = r * COLS + c, x = c * DX, y = r * DY, u = x / ASPECT, v = y;\n // A gentle warp of the whole floor, so no line on the sheet is ruled.\n var xw = x + 0.015 * w1x(u, v), yw = y + 0.015 * w1y(u, v);\n var s = toS(xw, yw), t = toT(xw, yw), sc = s + coastOff(t);\n if (sc < 0) { land[p] = 1; dep[p] = -3; hmap[p] = landHeight(s, t, u, v); continue; }\n var bh = 0;\n // 🔴 The highest bank, not the sum. Where two banks overlapped their\n // heights added, and a head dried twenty feet.\n for (var bi = 0; bi < banks.length; bi++) bh = Math.max(bh, bankAt(banks[bi], s, t));\n bankH[p] = bh;\n dep[p] = base(sc) + (0.7 * nb1(u, v) + 0.25 * nb2(u, v)) * smooth(0.02, 0.15, sc) - bh;\n }\n\n /** Flood the 4-connected components that satisfy `pred`; hand each to `act`. */\n var stk = new Int32Array(N);\n function components(pred, act) {\n var seenC = new Uint8Array(N);\n for (var p0 = 0; p0 < N; p0++) {\n if (seenC[p0] || !pred(p0)) continue;\n var n = 0, mem = [];\n seenC[p0] = 1; stk[n++] = p0;\n while (n) {\n var cq = stk[--n], cc = cq % COLS;\n mem.push(cq);\n var nbs = [cc > 0 ? cq - 1 : -1, cc < COLS - 1 ? cq + 1 : -1, cq - COLS, cq + COLS];\n for (var j = 0; j < 4; j++) {\n var q = nbs[j];\n if (q < 0 || q >= N || seenC[q] || !pred(q)) continue;\n seenC[q] = 1; stk[n++] = q;\n }\n }\n act(mem);\n }\n }\n // A speck of drying sand a few cells across printed as a fleck of dirt, and\n // a pinhole of water in a head as a spot: both are put back.\n components(function (p) { return !land[p] && dep[p] < 0; }, function (m) {\n if (m.length < 30) m.forEach(function (q) { dep[q] = 0.25; });\n });\n components(function (p) { return !land[p] && dep[p] >= 0; }, function (m) {\n if (m.length < 20) m.forEach(function (q) { dep[q] = -0.2; });\n });\n\n // The foreshore: ground that dries and is joined to the land. The drying\n // heads of the bank are not, and the coast's water-lining starts outside it.\n var coastDry = new Uint8Array(N), qn = 0;\n for (var pl = 0; pl < N; pl++) if (land[pl]) stk[qn++] = pl;\n while (qn) {\n var cur = stk[--qn], ccur = cur % COLS;\n var nb4 = [ccur > 0 ? cur - 1 : -1, ccur < COLS - 1 ? cur + 1 : -1, cur - COLS, cur + COLS];\n for (var j4 = 0; j4 < 4; j4++) {\n var q4 = nb4[j4];\n if (q4 < 0 || q4 >= N || land[q4] || coastDry[q4] || dep[q4] > 0) continue;\n coastDry[q4] = 1; stk[qn++] = q4;\n }\n }\n\n function at(a, c, r) {\n if (c < 0) c = 0; else if (c > COLS - 1) c = COLS - 1;\n if (r < 0) r = 0; else if (r > ROWS - 1) r = ROWS - 1;\n return a[r * COLS + c];\n }\n /** Bilinear sample of a map at fractional cell coordinates. */\n function bil(a, x, y) {\n if (x < 0) x = 0; else if (x > COLS - 1.001) x = COLS - 1.001;\n if (y < 0) y = 0; else if (y > ROWS - 1.001) y = ROWS - 1.001;\n var x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0, p = y0 * COLS + x0;\n return (a[p] * (1 - fx) + a[p + 1] * fx) * (1 - fy) + (a[p + COLS] * (1 - fx) + a[p + COLS + 1] * fx) * fy;\n }\n var NB = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]];\n\n /** Two-pass chamfer distance (in cells) from every source cell, with the nearest source. */\n function chamfer(isSrc) {\n var d = new Float32Array(N), src = new Int32Array(N);\n for (var p = 0; p < N; p++) { d[p] = isSrc(p) ? 0 : 1e6; src[p] = d[p] === 0 ? p : -1; }\n var D2 = Math.SQRT2;\n function relax(p, q, w) { if (d[q] + w < d[p]) { d[p] = d[q] + w; src[p] = src[q]; } }\n for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n var p = r * COLS + c;\n if (c > 0) relax(p, p - 1, 1);\n if (r > 0) {\n relax(p, p - COLS, 1);\n if (c > 0) relax(p, p - COLS - 1, D2);\n if (c < COLS - 1) relax(p, p - COLS + 1, D2);\n }\n }\n for (var r2 = ROWS - 1; r2 >= 0; r2--) for (var c2 = COLS - 1; c2 >= 0; c2--) {\n var p2 = r2 * COLS + c2;\n if (c2 < COLS - 1) relax(p2, p2 + 1, 1);\n if (r2 < ROWS - 1) {\n relax(p2, p2 + COLS, 1);\n if (c2 < COLS - 1) relax(p2, p2 + COLS + 1, D2);\n if (c2 > 0) relax(p2, p2 + COLS - 1, D2);\n }\n }\n return { d: d, src: src };\n }\n /** Box-blur a copy of a map, `passes` times, radius 2. */\n function blur(a, passes, cap) {\n var s = new Float32Array(N), tmp = new Float32Array(N);\n for (var p = 0; p < N; p++) s[p] = cap !== undefined ? Math.min(a[p], cap) : a[p];\n for (var pass = 0; pass < passes; pass++) {\n for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n var acc = 0, cnt = 0;\n for (var oy = -2; oy <= 2; oy++) for (var ox = -2; ox <= 2; ox++) {\n var cc = c + ox, rr = r + oy;\n if (cc < 0 || rr < 0 || cc >= COLS || rr >= ROWS) continue;\n acc += s[rr * COLS + cc]; cnt++;\n }\n tmp[r * COLS + c] = acc / cnt;\n }\n var sw = s; s = tmp; tmp = sw;\n }\n return s;\n }\n /**\n * Exact Euclidean distance (in cells) from every source cell, carrying the\n * index of the nearest source (Felzenszwalb and Huttenlocher's two-pass\n * lower envelope). 🔴 A chamfer's distance steps in eighths of a turn, and\n * water-lining laid on its bands came out octagonal round Relief of a Coast's stacks.\n */\n function edt(isSrc) {\n var INF = 1e12, M = Math.max(COLS, ROWS);\n var colD = new Float64Array(N), colS = new Int32Array(N);\n var f = new Float64Array(M), out = new Float64Array(M), arg = new Int32Array(M);\n var v = new Int32Array(M), z = new Float64Array(M + 1);\n function pass1(n) {\n var k = 0; v[0] = 0; z[0] = -INF; z[1] = INF;\n for (var q = 1; q < n; q++) {\n var s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);\n while (s <= z[k]) { k--; s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); }\n k++; v[k] = q; z[k] = s; z[k + 1] = INF;\n }\n k = 0;\n for (var q2 = 0; q2 < n; q2++) {\n while (z[k + 1] < q2) k++;\n out[q2] = (q2 - v[k]) * (q2 - v[k]) + f[v[k]]; arg[q2] = v[k];\n }\n }\n for (var c = 0; c < COLS; c++) {\n for (var r = 0; r < ROWS; r++) f[r] = isSrc(r * COLS + c) ? 0 : INF;\n pass1(ROWS);\n for (var r2 = 0; r2 < ROWS; r2++) { colD[r2 * COLS + c] = out[r2]; colS[r2 * COLS + c] = arg[r2]; }\n }\n var d = new Float32Array(N), src = new Int32Array(N);\n for (var r3 = 0; r3 < ROWS; r3++) {\n for (var c2 = 0; c2 < COLS; c2++) f[c2] = colD[r3 * COLS + c2];\n pass1(COLS);\n for (var c3 = 0; c3 < COLS; c3++) {\n var p = r3 * COLS + c3;\n d[p] = out[c3] >= INF * 0.5 ? 1e6 : Math.sqrt(out[c3]);\n src[p] = out[c3] >= INF * 0.5 ? -1 : colS[r3 * COLS + arg[c3]] * COLS + arg[c3];\n }\n }\n return { d: d, src: src };\n }\n\n // --- The land, eroded -------------------------------------------------------\n // As on Relief of a Coast, the valleys are not drawn on: the ground is\n // eroded. Each round, hollows are filled to their spill point, water runs\n // from every cell to its steepest lower neighbour, and each cell is cut toward\n // its receiver by the square root of the ground draining through it (stream\n // power), so the streams find their own way down to the sea.\n var heapK = new Float64Array(N), heapV = new Int32Array(N), heapN = 0;\n function hpush(key, val) {\n var i = heapN++;\n while (i > 0) {\n var pa = (i - 1) >> 1;\n if (heapK[pa] <= key) break;\n heapK[i] = heapK[pa]; heapV[i] = heapV[pa]; i = pa;\n }\n heapK[i] = key; heapV[i] = val;\n }\n function hpop() {\n var top = heapV[0], key = heapK[--heapN], val = heapV[heapN], i = 0;\n for (;;) {\n var l = 2 * i + 1;\n if (l >= heapN) break;\n if (l + 1 < heapN && heapK[l + 1] < heapK[l]) l++;\n if (heapK[l] >= key) break;\n heapK[i] = heapK[l]; heapV[i] = heapV[l]; i = l;\n }\n heapK[i] = key; heapV[i] = val;\n return top;\n }\n var seen = new Uint8Array(N);\n /** Fill every hollow on the land to its spill point, with a hair of fall. */\n function fill() {\n seen.fill(0); heapN = 0;\n for (var p = 0; p < N; p++) {\n var c = p % COLS, r = (p - c) / COLS;\n if (hmap[p] <= 0 || c === 0 || r === 0 || c === COLS - 1 || r === ROWS - 1) { seen[p] = 1; hpush(hmap[p], p); }\n }\n while (heapN) {\n var cur = hpop(), cc = cur % COLS, rc = (cur - cc) / COLS;\n for (var nb = 0; nb < 8; nb++) {\n var c2 = cc + NB[nb][0], r2 = rc + NB[nb][1];\n if (c2 < 0 || r2 < 0 || c2 >= COLS || r2 >= ROWS) continue;\n var q = r2 * COLS + c2;\n if (seen[q]) continue;\n seen[q] = 1;\n if (hmap[q] <= hmap[cur] + 1e-6) hmap[q] = hmap[cur] + 1e-6;\n hpush(hmap[q], q);\n }\n }\n }\n var rcv = new Int32Array(N), acc = new Float32Array(N), landIdx = [];\n /**\n * Route water downhill and accumulate it. The diagonal fall is weighed by a\n * fixed random per cell (Fairfield and Leymarie's rho-8), because a plain\n * steepest-of-eight rule runs every stream on a plane slope as a ruled\n * line at a multiple of 45 degrees.\n */\n function route() {\n rcv.fill(-1); acc.fill(0); landIdx = [];\n for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {\n var p = r * COLS + c;\n if (hmap[p] <= 0) continue;\n landIdx.push(p);\n var best = 0, diag = 2 - hash(p * 7 + 3);\n for (var nb = 0; nb < 8; nb++) {\n var c2 = c + NB[nb][0], r2 = r + NB[nb][1];\n if (c2 < 0 || r2 < 0 || c2 >= COLS || r2 >= ROWS) continue;\n var q = r2 * COLS + c2;\n var drop = (hmap[p] - hmap[q]) / (NB[nb][0] && NB[nb][1] ? diag : 1);\n if (drop > best) { best = drop; rcv[p] = q; }\n }\n }\n landIdx.sort(function (a, b) { return hmap[b] - hmap[a]; });\n for (var li = 0; li < landIdx.length; li++) {\n var pl = landIdx[li];\n acc[pl] += 1;\n if (rcv[pl] >= 0) acc[rcv[pl]] += acc[pl];\n }\n }\n var KF = 0.045, ROUNDS = 10;\n var lap = new Float32Array(N);\n for (var round = 0; round < ROUNDS; round++) {\n fill(); route();\n // Implicit stream-power step, receivers first (Braun and Willett), so it\n // is stable however hard it cuts. Only water gathered into a channel cuts:\n // cutting from the first cell down, every hillside grew a gully.\n for (var li2 = landIdx.length - 1; li2 >= 0; li2--) {\n var pe = landIdx[li2], rq = rcv[pe];\n if (rq < 0) continue;\n var F = KF * Math.sqrt(acc[pe]) * smooth(15, 70, acc[pe]);\n var hn = (hmap[pe] + F * Math.max(hmap[rq], 0)) / (1 + F);\n hmap[pe] = Math.max(0.001, Math.min(hmap[pe], hn));\n }\n // A little hillslope creep, so valley sides are slopes and not steps.\n for (var pd = 0; pd < N; pd++) {\n lap[pd] = 0;\n if (hmap[pd] <= 0) continue;\n var cd = pd % COLS, rd = (pd - cd) / COLS, sumL = 0, nL = 0;\n if (cd > 0 && hmap[pd - 1] > 0) { sumL += hmap[pd - 1]; nL++; }\n if (cd < COLS - 1 && hmap[pd + 1] > 0) { sumL += hmap[pd + 1]; nL++; }\n if (rd > 0 && hmap[pd - COLS] > 0) { sumL += hmap[pd - COLS]; nL++; }\n if (rd < ROWS - 1 && hmap[pd + COLS] > 0) { sumL += hmap[pd + COLS]; nL++; }\n if (nL) lap[pd] = sumL / nL - hmap[pd];\n }\n for (var pd2 = 0; pd2 < N; pd2++) if (hmap[pd2] > 0) hmap[pd2] = Math.max(0.001, hmap[pd2] + 0.18 * lap[pd2]);\n }\n fill(); route();\n // Ground the last fill raised to its spill point: a flat a stream crosses as\n // a ruled line. The pen lifts over it (Mouths of a River).\n var levelled = new Uint8Array(N), pre = Float32Array.from(hmap);\n fill(); route();\n for (var pv = 0; pv < N; pv++) if (hmap[pv] - pre[pv] > 1e-5) levelled[pv] = 1;\n // A gully that gathers this much water is drawn as a stream; below it the\n // hachures would run together into each gully as a dark feather.\n var A1 = Math.round(N * 0.0011);\n var isStream = new Uint8Array(N);\n for (var ps = 0; ps < N; ps++) if (hmap[ps] > 0 && acc[ps] >= A1) isStream[ps] = 1;\n var toStream = chamfer(function (p) { return isStream[p] === 1; });\n\n // --- Slope and light ------------------------------------------------------\n var slope = new Float32Array(N), facing = new Float32Array(N);\n var LX = -Math.SQRT1_2, LY = -Math.SQRT1_2; // toward the light, north-west\n var samples = [];\n for (var r2 = 0; r2 < ROWS; r2++) {\n for (var c2 = 0; c2 < COLS; c2++) {\n var n = r2 * COLS + c2;\n var gx = (at(hmap, c2 + 1, r2) - at(hmap, c2 - 1, r2)) / (2 * DX);\n var gy = (at(hmap, c2, r2 + 1) - at(hmap, c2, r2 - 1)) / (2 * DY);\n var g = Math.sqrt(gx * gx + gy * gy);\n slope[n] = g;\n facing[n] = g > 1e-6 ? (-gx * LX - gy * LY) / g : 0; // +1 faces the light\n // Inland of the shore only: the step down to the sea is not a slope.\n if (hmap[n] > 0 && at(hmap, c2 - 2, r2) > 0 && at(hmap, c2 + 2, r2) > 0 &&\n at(hmap, c2, r2 - 2) > 0 && at(hmap, c2, r2 + 2) > 0 && (r2 * 7 + c2) % 11 === 0) samples.push(g);\n }\n }\n samples.sort(function (a, b) { return a - b; });\n // Steepness is read against this coast's own steep ground, so every seed\n // spends the whole tonal range whatever its relief happens to be.\n var smax = samples.length ? samples[Math.floor(samples.length * 0.92)] : 1;\n /** The weight of engraving a piece of ground takes: steepness, then the light. */\n function toneOf(sl, f) {\n var sn = Math.min(1.25, sl / smax);\n var T = 0.04 + 0.46 * Math.pow(sn, 0.85) + 0.38 * sn * (f < 0 ? -f : 0) - 0.14 * sn * (f > 0 ? f : 0);\n return T < 0 ? 0 : T > 1 ? 1 : T;\n }\n // The fall line a hachure follows is read off a lightly smoothed copy of the\n // ground. Off the raw ground, neighbouring strokes ran together into every\n // shallow gully and each course read as a row of arrowheads.\n var hsm = blur(hmap, 1), gxT = new Float32Array(N), gyT = new Float32Array(N);\n for (var r8 = 0; r8 < ROWS; r8++) for (var c8 = 0; c8 < COLS; c8++) {\n gxT[r8 * COLS + c8] = at(hsm, c8 + 1, r8) - at(hsm, c8 - 1, r8);\n gyT[r8 * COLS + c8] = at(hsm, c8, r8 + 1) - at(hsm, c8, r8 - 1);\n }\n var hmax = 0;\n for (var ph0 = 0; ph0 < N; ph0++) if (hmap[ph0] > hmax) hmax = hmap[ph0];\n\n // --- Tracing ------------------------------------------------------------\n // Contour lines of a map at one level, by marching squares, chained into\n // polylines (flat arrays of cell coordinates). Edges are numbered: the\n // horizontal edge right of cell p is p, the vertical edge below it N + p.\n var eA = new Int32Array(2 * N), eB = new Int32Array(2 * N), eStamp = new Int32Array(2 * N);\n var ex = new Float32Array(2 * N), ey = new Float32Array(2 * N), eUsed = new Uint8Array(2 * N);\n var stamp = 0;\n function contour(a, L) {\n stamp++;\n var touched = [], e = [0, 0, 0, 0], ne;\n function cross(va, vb, id, xa, ya, xb, yb) {\n if ((va < L) === (vb < L)) return;\n if (eStamp[id] !== stamp) {\n eStamp[id] = stamp; eA[id] = -1; eB[id] = -1; eUsed[id] = 0;\n var t = (L - va) / (vb - va);\n ex[id] = xa + (xb - xa) * t; ey[id] = ya + (yb - ya) * t;\n touched.push(id);\n }\n e[ne++] = id;\n }\n function link(i, j) {\n if (eA[i] === -1) eA[i] = j; else eB[i] = j;\n if (eA[j] === -1) eA[j] = i; else eB[j] = i;\n }\n for (var r = 0; r < ROWS - 1; r++) for (var c = 0; c < COLS - 1; c++) {\n var p = r * COLS + c;\n var v0 = a[p], v1 = a[p + 1], v2 = a[p + COLS + 1], v3 = a[p + COLS];\n var b0 = v0 < L, b1 = v1 < L, b2 = v2 < L, b3 = v3 < L;\n if (b0 === b1 && b1 === b2 && b2 === b3) continue;\n ne = 0;\n cross(v0, v1, p, c, r, c + 1, r);\n cross(v1, v2, N + p + 1, c + 1, r, c + 1, r + 1);\n cross(v3, v2, p + COLS, c, r + 1, c + 1, r + 1);\n cross(v0, v3, N + p, c, r, c, r + 1);\n if (ne === 2) link(e[0], e[1]);\n else if (ne === 4) { link(e[0], e[1]); link(e[2], e[3]); }\n }\n var lines = [];\n for (var ti = 0; ti < touched.length; ti++) {\n var id0 = touched[ti];\n if (eUsed[id0]) continue;\n // Walk back to an end if the line has one, so it is traced in one piece.\n var s0 = id0, prev = -1, guard = 0;\n while (eB[s0] !== -1 && guard++ < touched.length) {\n var nx = eA[s0] === prev ? eB[s0] : eA[s0];\n if (nx === id0) break;\n prev = s0; s0 = nx;\n }\n var pts = [], cur = s0, pv = -1;\n while (cur !== -1 && !eUsed[cur]) {\n eUsed[cur] = 1; pts.push(ex[cur], ey[cur]);\n var l1 = eA[cur], l2 = eB[cur];\n var nxt = (l1 !== -1 && l1 !== pv && !eUsed[l1]) ? l1 : (l2 !== -1 && l2 !== pv && !eUsed[l2]) ? l2 : -1;\n pv = cur; cur = nxt;\n }\n if (pts.length >= 4) lines.push(pts);\n }\n return lines;\n }\n /** Corner-cutting, so a traced line does not show the grid it came from. */\n function chaikin(p, rounds) {\n for (var it = 0; it < rounds; it++) {\n var q = [p[0], p[1]];\n for (var j = 0; j < p.length - 2; j += 2) {\n q.push(0.75 * p[j] + 0.25 * p[j + 2], 0.75 * p[j + 1] + 0.25 * p[j + 3],\n 0.25 * p[j] + 0.75 * p[j + 2], 0.25 * p[j + 1] + 0.75 * p[j + 3]);\n }\n q.push(p[p.length - 2], p[p.length - 1]);\n p = q;\n }\n return p;\n }\n function X(c) { return PX + c * CW; }\n function Y(r) { return PY + r * CH; }\n function lineLen(p) {\n var s = 0;\n for (var j = 2; j < p.length; j += 2) s += Math.hypot((p[j] - p[j - 2]) * CW, (p[j + 1] - p[j - 1]) * CH);\n return s;\n }\n function strokeLine(p) {\n ctx.moveTo(X(p[0]), Y(p[1]));\n for (var j = 2; j < p.length; j += 2) ctx.lineTo(X(p[j]), Y(p[j + 1]));\n }\n /** Cell coordinates of a point given in plate heights. */\n function CX(x) { return PX + x / DX * CW; }\n function CY(y) { return PY + y / DY * CH; }\n\n var fromLow = edt(function (p) { return land[p] === 1 || coastDry[p] === 1; });\n // The coast's water-lining, traced on a smoothed copy of the distance from\n // the low-water line so it does not carry every notch of the shore out.\n var smB = blur(fromLow.d, 3, 60), sm = new Float32Array(N);\n for (var pm = 0; pm < N; pm++) {\n var wb = land[pm] ? 0 : smooth(3, 9, fromLow.d[pm]);\n sm[pm] = fromLow.d[pm] + (smB[pm] - fromLow.d[pm]) * wb;\n }\n var BANDS = [1.5];\n for (var k2 = 1; k2 <= 6; k2++) BANDS.push(1.3 + 1.9 * Math.pow(k2, 1.3));\n var LINE_END = BANDS[BANDS.length - 1];\n var landM = new Float32Array(N), dryS = new Float32Array(N);\n for (var pz = 0; pz < N; pz++) {\n landM[pz] = land[pz];\n if (!land[pz] && dep[pz] < 0) dryS[pz] = 0.62 + 0.28 * smooth(0, 0.6, -dep[pz]);\n // 🔴 The bank itself is stippled lightly inside three fathoms, as the old\n // engraved charts stippled their sands. Figures alone left the sheet one\n // even grey at a glance: at 32px every pair of seeds measured 0.017-0.027\n // apart, and no seed row could honestly claim its structure moved.\n else if (!land[pz] && dep[pz] < 3 && bankH[pz] > 0.5) dryS[pz] = 0.12 + 0.26 * (1 - dep[pz] / 3);\n }\n\n // --- The sheet ----------------------------------------------------------\n // Nothing is left to a layer but the border and the grain, so the sheet is\n // laid here: the paper colour with a faint, slow mottle.\n ctx.fillStyle = pal[4];\n ctx.fillRect(0, 0, W, H);\n var m1 = lattice(44, 32), m2 = lattice(140, 100);\n var MS = 3 * K;\n for (var my = 0; my < H; my += MS) for (var mx = 0; mx < W; mx += MS) {\n var mv = 0.5 + 0.35 * m1(mx / W, my / H) + 0.25 * m2(mx / W, my / H);\n if (mv <= 0.45) continue;\n ctx.fillStyle = 'rgba(60,70,76,' + (0.05 * (mv - 0.45)).toFixed(3) + ')';\n ctx.fillRect(mx, my, MS + 0.5, MS + 0.5);\n }\n ctx.save();\n ctx.beginPath();\n ctx.rect(PX, PY, PW, PH);\n ctx.clip();\n\n // --- The survey -------------------------------------------------------------\n // The lines are run across the main bank, square to its length. A line is\n // sounded all the way in deep water only one time in four; one in two where\n // it is under nine fathoms or near the bank's face, and every line over the\n // bank itself, so the\n // figures crowd where the ground needs developing. Figures under eleven\n // fathoms are given in fathoms and feet, the feet set small beside them;\n // on the drying heads the figure is the height the sand dries, in feet,\n // underlined. Like all lettering on a chart the figures stand square to the\n // page, whatever way the line runs.\n var FS = 8.2 * K, FSS = FS * 0.66;\n var fontM = 'italic ' + FS.toFixed(2) + 'px Georgia, \"Times New Roman\", serif';\n var fontS = 'italic ' + FSS.toFixed(2) + 'px Georgia, \"Times New Roman\", serif';\n var widthCache = {};\n function textW(str, font, size) {\n var key = font + '|' + str;\n if (widthCache[key] !== undefined) return widthCache[key];\n var w = str.length * size * 0.52;\n if (ctx.measureText) {\n ctx.font = font;\n var mt = ctx.measureText(str);\n if (mt && mt.width > 0) w = mt.width;\n }\n return (widthCache[key] = w);\n }\n function label(d) {\n if (d < 0) {\n var ftd = Math.round(-d * 6);\n return ftd < 1 ? null : { main: String(ftd), sub: '', under: true };\n }\n if (d < 11) {\n var f = Math.floor(d), ft = Math.round((d - f) * 6);\n if (ft === 6) { f++; ft = 0; }\n return { main: String(f), sub: ft ? String(ft) : '', under: false };\n }\n return { main: String(Math.round(d)), sub: '', under: false };\n }\n var BK = 24 * K, bucket = {}, boxes = [];\n function overlaps(b, pad) {\n var i0 = Math.floor((b[0] - pad) / BK), i1 = Math.floor((b[2] + pad) / BK);\n var j0 = Math.floor((b[1] - pad) / BK), j1 = Math.floor((b[3] + pad) / BK);\n for (var i = i0; i <= i1; i++) for (var j = j0; j <= j1; j++) {\n var list = bucket[i + ',' + j];\n if (!list) continue;\n for (var n = 0; n < list.length; n++) {\n var o = boxes[list[n]];\n if (b[0] - pad < o[2] && b[2] + pad > o[0] && b[1] - pad < o[3] && b[3] + pad > o[1]) return true;\n }\n }\n return false;\n }\n function addBox(b) {\n var id = boxes.length;\n boxes.push(b);\n for (var i = Math.floor(b[0] / BK); i <= Math.floor(b[2] / BK); i++)\n for (var j = Math.floor(b[1] / BK); j <= Math.floor(b[3] / BK); j++)\n (bucket[i + ',' + j] = bucket[i + ',' + j] || []).push(id);\n }\n var fromBank = chamfer(function (p) { return bankH[p] > 1.2; });\n var mb = banks[0];\n var sDx = -nx0, sDy = -ny0; // the way s increases, on the plate\n var dLx = Math.cos(mb.al) * sDx - Math.sin(mb.al) * px0, dLy = Math.cos(mb.al) * sDy - Math.sin(mb.al) * py0;\n var aLx = Math.sin(mb.al) * sDx + Math.cos(mb.al) * px0, aLy = Math.sin(mb.al) * sDy + Math.cos(mb.al) * py0;\n var Cx = PX + toX(mb.sc, mb.tc) * PH, Cy = PY + toY(mb.sc, mb.tc) * PH;\n var U = 12.5 * K, DIAG = Math.hypot(PW, PH), M = Math.ceil(DIAG / U);\n var wl = lattice(70, 50), figs = [];\n for (var li = -M; li <= M; li++) {\n var o = li * U, next = -DIAG;\n for (var lam = -DIAG; lam <= DIAG; lam += 1.5 * K) {\n if (lam < next) continue;\n // The boat does not hold its line exactly.\n var wd = 1.8 * K * wl(0.5 + lam / (2 * DIAG), (li + M) / (2 * M));\n var fx = Cx + (o + wd) * aLx + lam * dLx, fy = Cy + (o + wd) * aLy + lam * dLy;\n if (fx < PX + 7 * K || fy < PY + 7 * K || fx > PX + PW - 7 * K || fy > PY + PH - 7 * K) continue;\n var gc = (fx - PX) / CW, gr = (fy - PY) / CH;\n if (bil(fromLow.d, gc, gr) < LINE_END + 2.5) continue;\n var d = bil(dep, gc, gr);\n // 🔴 By the bank, not by depth alone: read off depth, the shallow water\n // along the coast was sounded as closely as the bank and drew the eye.\n // 🔴 The bank's face is developed: one line in two is run on past it, each\n // line as far as it settles for itself. Where the face is steep the\n // water goes from the bank's figures straight to eleven fathoms, and read\n // off depth alone the one-in-four began at the foot of the face as a\n // straight wall down the sheet (seed 11).\n var every = bil(bankH, gc, gr) > 1.2 || d < 2.5 ? 1 :\n d < 9 || bil(fromBank.d, gc, gr) < 10 + 40 * hash(li * 131 + 17) ? 2 : 4;\n if (li % every !== 0) continue;\n // Drying heights are few on a chart: only on every fourth line.\n if (d < 0 && li % 4 !== 0) continue;\n var lb = label(d);\n if (!lb) continue;\n var wM = textW(lb.main, fontM, FS), wS = lb.sub ? textW(lb.sub, fontS, FSS) : 0;\n var bw = wM + (wS ? wS + 0.4 * K : 0), bh = FS * 0.78 + (wS ? FSS * 0.3 : 0);\n var box = [fx - bw / 2, fy - FS * 0.39, fx + bw / 2, fy - FS * 0.39 + bh];\n if (overlaps(box, 1.6 * K)) continue;\n addBox(box);\n figs.push([fx, fy, lb, wM, bw]);\n // Casts are spaced wider as the water deepens.\n var gap = d < 0 ? 11 * K : 3.2 * K + 0.45 * K * Math.min(Math.max(d, 0), 22);\n var sx = Math.abs(dLx) > 1e-3 ? (bw + gap) / Math.abs(dLx) : 1e9;\n var sy = Math.abs(dLy) > 1e-3 ? (bh + gap) / Math.abs(dLy) : 1e9;\n next = lam + Math.min(sx, sy);\n }\n }\n /** Is a point (px) inside any figure's box, padded? */\n function inFig(x, y, pad) {\n var list = bucket[Math.floor(x / BK) + ',' + Math.floor(y / BK)];\n if (!list) return false;\n for (var n = 0; n < list.length; n++) {\n var b = boxes[list[n]];\n if (x > b[0] - pad && x < b[2] + pad && y > b[1] - pad && y < b[3] + pad) return true;\n }\n return false;\n }\n /** Stroke a traced line, broken wherever it would run through a figure. */\n function strokeBroken(pts, pad, keep) {\n var run = false;\n for (var j = 0; j < pts.length; j += 2) {\n var x = X(pts[j]), y = Y(pts[j + 1]);\n var ok = !inFig(x, y, pad) && (!keep || keep(pts[j], pts[j + 1]));\n if (ok) { if (run) ctx.lineTo(x, y); else ctx.moveTo(x, y); }\n run = ok;\n }\n }\n\n // --- The drying heads -------------------------------------------------------\n // Stippled dot by dot on a jittered grid, as on the other sheets.\n var sg = rngFrom(seed * 6151 + 5), SG = 2.6 * K, dotsN = 0;\n ctx.save();\n ctx.globalAlpha = 0.9;\n ctx.fillStyle = pal[0];\n ctx.beginPath();\n for (var gy = PY + SG / 2; gy < PY + PH; gy += SG) for (var gx = PX + SG / 2; gx < PX + PW; gx += SG) {\n var jx = gx + (sg() - 0.5) * SG, jy = gy + (sg() - 0.5) * SG, roll = sg(), rs = sg();\n if (roll >= bil(dryS, (jx - PX) / CW, (jy - PY) / CH) || inFig(jx, jy, 1.2 * K)) continue;\n var rad = 0.66 * K * (0.8 + 0.4 * rs);\n ctx.moveTo(jx + rad, jy);\n ctx.arc(jx, jy, rad, 0, Math.PI * 2);\n dotsN++;\n }\n ctx.fill();\n ctx.restore();\n\n // --- Hachures on the land ---------------------------------------------------\n // As on Relief of a Coast: along each contour of height the strokes are set\n // at an even spacing, closer and heavier on steep ground and in shadow, and\n // each is run down the fall line to just short of the next contour below,\n // as a wedge that lifts to a point the way a burin stroke does.\n var TIER = smax * (5.5 * K) / PH;\n var hr = rngFrom(seed * 31337 + 11);\n var STEP = 0.35, MAXLEN = 34 * K, strokes = 0;\n ctx.save();\n ctx.fillStyle = pal[0];\n ctx.globalAlpha = 0.9;\n for (var lv = 1; lv * TIER < hmax; lv++) {\n var L = lv * TIER, floorH = L - 0.86 * TIER;\n var lines = contour(hmap, L);\n for (var li3 = 0; li3 < lines.length; li3++) {\n var pl = lines[li3];\n var next = hr() * 4 * K, run = 0;\n for (var j4 = 0; j4 < pl.length - 2; j4 += 2) {\n var ax = pl[j4], ay = pl[j4 + 1], bx2 = pl[j4 + 2], by2 = pl[j4 + 3];\n var seg = Math.hypot((bx2 - ax) * CW, (by2 - ay) * CH);\n while (run + seg >= next) {\n var tt2 = (next - run) / seg;\n var sx = ax + (bx2 - ax) * tt2, sy = ay + (by2 - ay) * tt2;\n var T = toneOf(bil(slope, sx, sy), bil(facing, sx, sy));\n next += (2.1 + 5.2 * (1 - T)) * K * (0.9 + 0.2 * hr());\n if (T < 0.14) continue;\n var wd = (0.24 + 1.05 * Math.pow(T, 1.35)) * K * (0.9 + 0.2 * hr());\n var path = [sx, sy], x = sx, y = sy, lenPx = 0;\n for (var stp = 0; stp < 120; stp++) {\n var gxs = bil(gxT, x, y), gys = bil(gyT, x, y), gm = Math.hypot(gxs, gys);\n if (gm < 1e-5) break;\n x -= gxs / gm * STEP; y -= gys / gm * STEP;\n lenPx += STEP * CW;\n var hh = bil(hmap, x, y);\n if (hh < floorH || hh <= 0 || lenPx > MAXLEN || bil(toStream.d, x, y) < 1.0) break;\n if (x < 1 || y < 1 || x > COLS - 2 || y > ROWS - 2) break;\n path.push(x, y);\n }\n if (path.length < 6) continue;\n var npt = path.length / 2, left = [], right = [];\n for (var q3 = 0; q3 < npt; q3++) {\n var qa = Math.max(0, q3 - 1), qb = Math.min(npt - 1, q3 + 1);\n var tx2 = X(path[qb * 2]) - X(path[qa * 2]), ty2 = Y(path[qb * 2 + 1]) - Y(path[qa * 2 + 1]);\n var tl = Math.hypot(tx2, ty2) || 1;\n var hw = wd * 0.5 * (1 - 0.5 * q3 / (npt - 1));\n left.push(X(path[q3 * 2]) - ty2 / tl * hw, Y(path[q3 * 2 + 1]) + tx2 / tl * hw);\n right.push(X(path[q3 * 2]) + ty2 / tl * hw, Y(path[q3 * 2 + 1]) - tx2 / tl * hw);\n }\n ctx.beginPath();\n ctx.moveTo(left[0], left[1]);\n for (var q4 = 2; q4 < left.length; q4 += 2) ctx.lineTo(left[q4], left[q4 + 1]);\n for (var q5 = right.length - 2; q5 >= 0; q5 -= 2) ctx.lineTo(right[q5], right[q5 + 1]);\n ctx.closePath();\n ctx.fill();\n strokes++;\n }\n run += seg;\n }\n }\n }\n ctx.restore();\n\n // --- Streams ----------------------------------------------------------------\n // Each stream is traced from its head down to where it meets a larger one or\n // the sea, a hairline at the head that swells a little with what it carries.\n var hasUp = new Uint8Array(N), done = new Uint8Array(N), drawn = new Uint8Array(N), streamLines = 0;\n for (var pu = 0; pu < N; pu++) if (isStream[pu] && rcv[pu] >= 0) hasUp[rcv[pu]] = 1;\n var heads = [];\n for (var ph2 = 0; ph2 < N; ph2++) if (isStream[ph2] && !hasUp[ph2]) heads.push(ph2);\n // Largest first, so a tributary always stops against a stream already drawn.\n heads.sort(function (a, b) { return hmap[a] - hmap[b]; });\n ctx.save();\n ctx.strokeStyle = pal[0];\n ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.globalAlpha = 0.9;\n var wbx = lattice(60, 45), wby = lattice(60, 45);\n heads.forEach(function (hd) {\n var pts = [], ac = [], cur = hd, guard = 0, visited = [];\n while (cur >= 0 && guard++ < 4000) {\n var c9 = cur % COLS, r9 = Math.floor(cur / COLS), u9 = c9 / (COLS - 1), v9 = r9 / (ROWS - 1);\n pts.push(c9 + 0.8 * wbx(u9, v9), r9 + 0.8 * wby(u9, v9)); ac.push(acc[cur]);\n if (hmap[cur] <= 0 || done[cur]) break;\n // A stream that reaches the edge of the plate leaves it there: the edge\n // cells are outlets, and one ran along the border as a ruled line.\n if (c9 < 2 || r9 < 2 || c9 > COLS - 3 || r9 > ROWS - 3) break;\n done[cur] = 1; visited.push(cur);\n cur = rcv[cur];\n }\n // 🔴 Too short to draw, a stream head printed as a stray tick (Mouths of a\n // River). Its cells are handed back, so a stream from higher up runs on.\n if (visited.length < 12) { visited.forEach(function (vc) { done[vc] = 0; }); return; }\n // 🔴 In a broad valley several streams ran side by side to the shore and\n // read as a ruled comb. One that spends most of its course close beside a\n // stream already drawn, without yet joining it, is left out the same way.\n var RN = 8, near = 0, span = visited.length - RN - 1;\n for (var vi = 0; vi < span; vi++) {\n var vc0 = visited[vi] % COLS, vr0 = Math.floor(visited[vi] / COLS), hit = false;\n for (var dr = -RN; dr <= RN && !hit; dr++) for (var dc = -RN; dc <= RN; dc++) {\n var c0 = vc0 + dc, r0 = vr0 + dr;\n if (c0 >= 0 && r0 >= 0 && c0 < COLS && r0 < ROWS && drawn[r0 * COLS + c0]) { hit = true; break; }\n }\n if (hit) near++;\n }\n if (near > 0.5 * span) { visited.forEach(function (vc) { done[vc] = 0; }); return; }\n visited.forEach(function (vc) { drawn[vc] = 1; });\n var sp2 = chaikin(pts, 2), n2 = sp2.length / 2;\n for (var j5 = 0; j5 < n2 - 1; j5++) {\n var a5 = ac[Math.min(ac.length - 1, Math.floor(j5 / (n2 - 1) * (ac.length - 1)))];\n if (levelled[Math.round(sp2[j5 * 2 + 1]) * COLS + Math.round(sp2[j5 * 2])]) continue;\n // Lighter than on Relief of a Coast: here the hachures are the mass, and\n // at full weight the streams outweighed them and ran across the plain\n // as ruled lines.\n ctx.lineWidth = Math.min(1.0, 0.25 + 0.16 * Math.log2(a5 / A1 + 1)) * K;\n ctx.beginPath();\n ctx.moveTo(X(sp2[j5 * 2]), Y(sp2[j5 * 2 + 1]));\n ctx.lineTo(X(sp2[j5 * 2 + 2]), Y(sp2[j5 * 2 + 3]));\n ctx.stroke();\n }\n streamLines++;\n });\n ctx.restore();\n\n // --- Water-lining along the coast -------------------------------------------\n // Only the coast is lined. Where a line would run onto a bank it stops.\n ctx.save();\n ctx.strokeStyle = pal[0];\n ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.globalAlpha = 0.82;\n var liningKeep = function (c, r) { return bil(bankH, c, r) < 0.5; };\n for (var bb = 0; bb < BANDS.length; bb++) {\n ctx.lineWidth = (0.95 - 0.07 * bb) * K;\n ctx.beginPath();\n contour(sm, BANDS[bb]).forEach(function (ln) {\n var n = ln.length, closed = Math.hypot(ln[0] - ln[n - 2], ln[1] - ln[n - 1]) < 1.5;\n if (closed && lineLen(ln) < 90 * K) return;\n strokeBroken(chaikin(ln, 2), -1e9, liningKeep);\n });\n ctx.stroke();\n }\n ctx.restore();\n\n // --- Depth curves -------------------------------------------------------------\n // Each depth has its own pattern of dash, as the old charts drew them, and\n // each curve is broken where it passes through a figure.\n var CURVES = [\n [3, [3.2 * K, 2.4 * K]],\n [5, [7 * K, 2.6 * K, 0.01, 2.6 * K]],\n [10, [10 * K, 3 * K, 0.01, 3 * K, 0.01, 3 * K]],\n [20, [16 * K, 4 * K]],\n ];\n var curveN = 0;\n ctx.save();\n ctx.strokeStyle = pal[0];\n ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.lineWidth = 1.0 * K;\n ctx.globalAlpha = 0.88;\n CURVES.forEach(function (cv) {\n if (ctx.setLineDash) ctx.setLineDash(cv[1]);\n ctx.beginPath();\n contour(dep, cv[0]).forEach(function (ln) {\n if (lineLen(ln) < 40 * K) return;\n strokeBroken(chaikin(ln, 2), 1.4 * K);\n curveN++;\n });\n ctx.stroke();\n });\n // The low-water line: the edge of the drying ground, dotted.\n if (ctx.setLineDash) ctx.setLineDash([0.01, 2.4 * K]);\n ctx.lineWidth = 1.1 * K;\n ctx.globalAlpha = 0.85;\n ctx.beginPath();\n contour(dep, 0).forEach(function (ln) { strokeBroken(chaikin(ln, 2), 1.0 * K); });\n ctx.stroke();\n if (ctx.setLineDash) ctx.setLineDash([]);\n ctx.restore();\n\n // --- The coastline --------------------------------------------------------\n ctx.save();\n ctx.strokeStyle = pal[0];\n ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.lineWidth = 1.25 * K;\n ctx.globalAlpha = 0.92;\n ctx.beginPath();\n contour(landM, 0.5).forEach(function (ln) { strokeLine(chaikin(ln, 2)); });\n ctx.stroke();\n ctx.restore();\n\n // --- The figures ----------------------------------------------------------\n var dryN = 0;\n ctx.save();\n ctx.fillStyle = pal[0];\n ctx.strokeStyle = pal[0];\n ctx.globalAlpha = 0.9;\n ctx.textAlign = 'left';\n ctx.textBaseline = 'alphabetic';\n ctx.lineWidth = 0.55 * K;\n figs.forEach(function (f) {\n var x0 = f[0] - f[4] / 2, yb = f[1] + FS * 0.34, lb = f[2];\n ctx.font = fontM;\n ctx.fillText(lb.main, x0, yb);\n if (lb.sub) { ctx.font = fontS; ctx.fillText(lb.sub, x0 + f[3] + 0.4 * K, yb + FSS * 0.3); }\n if (lb.under) {\n ctx.beginPath();\n ctx.moveTo(x0, yb + 1.5 * K); ctx.lineTo(x0 + f[4], yb + 1.5 * K);\n ctx.stroke();\n dryN++;\n }\n });\n ctx.restore();\n\n ctx.restore(); // the plate clip\n\n var gl = (typeof globalThis !== 'undefined') ? globalThis : window;\n gl.__genart_data = gl.__genart_data || {};\n var landN = 0, dryC = 0;\n for (var pq = 0; pq < N; pq++) { if (land[pq]) landN++; else if (dep[pq] < 0 && !coastDry[pq]) dryC++; }\n gl.__genart_data.debug = {\n banks: banks.length, land: +(landN / N).toFixed(3), dryHeads: +(dryC / N).toFixed(4),\n figures: figs.length, strokes: strokes, streams: streamLines, dryFigures: dryN, curves: curveN, dots: dotsN, DM: +DM.toFixed(1),\n };\n}\n",
"layers": [
{
"id": "graduation",
"type": "shapes:path",
"name": "Neat Line — Graduated Border",
"visible": true,
"locked": false,
"opacity": 0.8,
"blendMode": "normal",
"transform": {
"x": 0,
"y": 0,
"width": 1400,
"height": 1000,
"rotation": 0,
"scaleX": 1,
"scaleY": 1,
"anchorX": 0,
"anchorY": 0
},
"properties": {
"fillColor": "#1d2327",
"fillEnabled": true,
"strokeColor": "#000000",
"strokeWidth": 0,
"strokeEnabled": false,
"d": "M 105.00 69.00 L 149.07 69.00 L 149.07 75.00 L 105.00 75.00 Z M 193.15 69.00 L 237.22 69.00 L 237.22 75.00 L 193.15 75.00 Z M 281.30 69.00 L 325.37 69.00 L 325.37 75.00 L 281.30 75.00 Z M 369.44 69.00 L 413.52 69.00 L 413.52 75.00 L 369.44 75.00 Z M 457.59 69.00 L 501.67 69.00 L 501.67 75.00 L 457.59 75.00 Z M 545.74 69.00 L 589.81 69.00 L 589.81 75.00 L 545.74 75.00 Z M 633.89 69.00 L 677.96 69.00 L 677.96 75.00 L 633.89 75.00 Z M 722.04 69.00 L 766.11 69.00 L 766.11 75.00 L 722.04 75.00 Z M 810.19 69.00 L 854.26 69.00 L 854.26 75.00 L 810.19 75.00 Z M 898.33 69.00 L 942.41 69.00 L 942.41 75.00 L 898.33 75.00 Z M 986.48 69.00 L 1030.56 69.00 L 1030.56 75.00 L 986.48 75.00 Z M 1074.63 69.00 L 1118.70 69.00 L 1118.70 75.00 L 1074.63 75.00 Z M 1162.78 69.00 L 1206.85 69.00 L 1206.85 75.00 L 1162.78 75.00 Z M 1250.93 69.00 L 1295.00 69.00 L 1295.00 75.00 L 1250.93 75.00 Z M 105.00 885.00 L 149.07 885.00 L 149.07 891.00 L 105.00 891.00 Z M 193.15 885.00 L 237.22 885.00 L 237.22 891.00 L 193.15 891.00 Z M 281.30 885.00 L 325.37 885.00 L 325.37 891.00 L 281.30 891.00 Z M 369.44 885.00 L 413.52 885.00 L 413.52 891.00 L 369.44 891.00 Z M 457.59 885.00 L 501.67 885.00 L 501.67 891.00 L 457.59 891.00 Z M 545.74 885.00 L 589.81 885.00 L 589.81 891.00 L 545.74 891.00 Z M 633.89 885.00 L 677.96 885.00 L 677.96 891.00 L 633.89 891.00 Z M 722.04 885.00 L 766.11 885.00 L 766.11 891.00 L 722.04 891.00 Z M 810.19 885.00 L 854.26 885.00 L 854.26 891.00 L 810.19 891.00 Z M 898.33 885.00 L 942.41 885.00 L 942.41 891.00 L 898.33 891.00 Z M 986.48 885.00 L 1030.56 885.00 L 1030.56 891.00 L 986.48 891.00 Z M 1074.63 885.00 L 1118.70 885.00 L 1118.70 891.00 L 1074.63 891.00 Z M 1162.78 885.00 L 1206.85 885.00 L 1206.85 891.00 L 1162.78 891.00 Z M 1250.93 885.00 L 1295.00 885.00 L 1295.00 891.00 L 1250.93 891.00 Z M 99.00 75.00 L 105.00 75.00 L 105.00 120.00 L 99.00 120.00 Z M 99.00 165.00 L 105.00 165.00 L 105.00 210.00 L 99.00 210.00 Z M 99.00 255.00 L 105.00 255.00 L 105.00 300.00 L 99.00 300.00 Z M 99.00 345.00 L 105.00 345.00 L 105.00 390.00 L 99.00 390.00 Z M 99.00 435.00 L 105.00 435.00 L 105.00 480.00 L 99.00 480.00 Z M 99.00 525.00 L 105.00 525.00 L 105.00 570.00 L 99.00 570.00 Z M 99.00 615.00 L 105.00 615.00 L 105.00 660.00 L 99.00 660.00 Z M 99.00 705.00 L 105.00 705.00 L 105.00 750.00 L 99.00 750.00 Z M 99.00 795.00 L 105.00 795.00 L 105.00 840.00 L 99.00 840.00 Z M 1295.00 75.00 L 1301.00 75.00 L 1301.00 120.00 L 1295.00 120.00 Z M 1295.00 165.00 L 1301.00 165.00 L 1301.00 210.00 L 1295.00 210.00 Z M 1295.00 255.00 L 1301.00 255.00 L 1301.00 300.00 L 1295.00 300.00 Z M 1295.00 345.00 L 1301.00 345.00 L 1301.00 390.00 L 1295.00 390.00 Z M 1295.00 435.00 L 1301.00 435.00 L 1301.00 480.00 L 1295.00 480.00 Z M 1295.00 525.00 L 1301.00 525.00 L 1301.00 570.00 L 1295.00 570.00 Z M 1295.00 615.00 L 1301.00 615.00 L 1301.00 660.00 L 1295.00 660.00 Z M 1295.00 705.00 L 1301.00 705.00 L 1301.00 750.00 L 1295.00 750.00 Z M 1295.00 795.00 L 1301.00 795.00 L 1301.00 840.00 L 1295.00 840.00 Z M 99.00 69.00 L 105.00 69.00 L 105.00 75.00 L 99.00 75.00 Z M 1295.00 69.00 L 1301.00 69.00 L 1301.00 75.00 L 1295.00 75.00 Z M 99.00 885.00 L 105.00 885.00 L 105.00 891.00 L 99.00 891.00 Z M 1295.00 885.00 L 1301.00 885.00 L 1301.00 891.00 L 1295.00 891.00 Z",
"scaleToFit": false
}
},
{
"id": "neat-outer",
"type": "shapes:path",
"name": "Neat Line — Outer Rule",
"visible": true,
"locked": false,
"opacity": 0.85,
"blendMode": "normal",
"transform": {
"x": 0,
"y": 0,
"width": 1400,
"height": 1000,
"rotation": 0,
"scaleX": 1,
"scaleY": 1,
"anchorX": 0,
"anchorY": 0
},
"properties": {
"fillColor": "#ffffff",
"fillEnabled": false,
"strokeColor": "#1d2327",
"strokeWidth": 1.5,
"strokeEnabled": true,
"d": "M 99.00 69.00 L 1301.00 69.00 L 1301.00 891.00 L 99.00 891.00 Z",
"scaleToFit": false
}
},
{
"id": "neat-inner",
"type": "shapes:path",
"name": "Neat Line — Inner Rule",
"visible": true,
"locked": false,
"opacity": 0.8,
"blendMode": "normal",
"transform": {
"x": 0,
"y": 0,
"width": 1400,
"height": 1000,
"rotation": 0,
"scaleX": 1,
"scaleY": 1,
"anchorX": 0,
"anchorY": 0
},
"properties": {
"fillColor": "#ffffff",
"fillEnabled": false,
"strokeColor": "#1d2327",
"strokeWidth": 0.7,
"strokeEnabled": true,
"d": "M 105.00 75.00 L 1295.00 75.00 L 1295.00 885.00 L 105.00 885.00 Z",
"scaleToFit": false
}
},
{
"id": "tone",
"type": "filter:grain",
"name": "Plate Tone",
"visible": true,
"locked": false,
"opacity": 1,
"blendMode": "normal",
"transform": {
"x": 0,
"y": 0,
"width": 1400,
"height": 1000,
"rotation": 0,
"scaleX": 1,
"scaleY": 1,
"anchorX": 0,
"anchorY": 0
},
"properties": {
"intensity": 0.1,
"size": 2,
"seed": 2,
"monochrome": true
}
}
]
}