agentleFS
Registry

Watercolor data art

skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-12

Paint generative watercolor art from a chart's own data, after Tyler Hobbs' published recipe, on paper, transparent or dark.

Mount
scripts/art.html
<!doctype html>
<html><head><meta charset="utf-8"><style>body{margin:0;background:#f7f4ec}canvas{display:block}</style></head>
<body>
<canvas id="a"></canvas>
<script>
// Inputs, set before load by render.mjs:
//   window.DATA  stacked series {q?, groups|series: [{name, v: [...], kind?, color?}], palette?}
//                or a matrix {matrix: [[...]], rows?, cols?, palette?}
//   window.OPTS  {style: grid|rothko|squares|matrix, seed, size: [w, h], palette, ghosts, cell,
//                 background: paper|transparent|dark}
//
// Watercolor method after Tyler Hobbs, "A Generative Approach to Simulating Watercolor Paints" (2017),
// https://tylerxhobbs.com/essays/2017/a-generative-approach-to-simulating-watercolor-paints :
// recursive midpoint deformation with per-edge variance; a base polygon deformed once, then each
// layer deformed further; many layers at low opacity, each masked by ~1000 random circles; colours
// interleaved five layers at a time. Where this differs from the essay: the base polygon gets 1
// round here (the essay suggests about 7), and the mask circles are small (radius ~0.2-1% of the
// width), so that small squares keep their shape instead of dissolving into blobs.
const OPTS = window.OPTS || {};
let seed = (OPTS.seed ?? 20261010) >>> 0;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const gauss = () => { let u = 0, v = 0; while (!u) u = rnd(); while (!v) v = rnd(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); };

// a polygon is [{x, y, v}], v = the variance of the edge that starts at that vertex
function deform(poly, rounds) {
  for (let r = 0; r < rounds; r++) {
    const out = [];
    for (let i = 0; i < poly.length; i++) {
      const a = poly[i], b = poly[(i + 1) % poly.length];
      const s = Math.hypot(b.x - a.x, b.y - a.y) * a.v * 0.5;
      const v = a.v * (0.8 + rnd() * 0.15);                 // children inherit a little less, randomised
      out.push({ x: a.x, y: a.y, v }, { x: (a.x + b.x) / 2 + gauss() * s, y: (a.y + b.y) / 2 + gauss() * s, v: v * (0.8 + rnd() * 0.4) });
    }
    poly = out;
  }
  return poly;
}
const withVariance = (pts, lo, hi) => pts.map(p => ({ x: p[0], y: p[1], v: lo + rnd() * (hi - lo) }));

// background: "paper" (default) is warm paper with grain. "transparent" and "dark" have no grain
// (dark is a flat #111 ground), but draw the grain's random numbers anyway, so every background
// gets the same squares, edges and jitter and a light/dark pair matches shape for shape.
const BG = OPTS.background || "paper";
function paper(ctx, W, H) {
  if (BG !== "paper") {
    if (BG === "dark") { ctx.fillStyle = "#111"; ctx.fillRect(0, 0, W, H); }
    for (let i = 0; i < W * H; i++) rnd();
    return;
  }
  ctx.fillStyle = "#f7f4ec"; ctx.fillRect(0, 0, W, H);
  const img = ctx.getImageData(0, 0, W, H), d = img.data;
  for (let i = 0; i < d.length; i += 4) { const n = (rnd() - 0.5) * 9; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
  ctx.putImageData(img, 0, 0);
}

// on a dark ground the paper pigments (deep inks, earth reds) sink: lift each colour's lightness
// to at least 0.62 and its saturation a little, keeping the hue. Paper keeps the colours as given.
function lift(rgb) {
  if (BG === "paper") return rgb;
  let [r, g, b] = rgb.map(v => v / 255);
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2, d = mx - mn;
  let h = 0, s = d ? d / (1 - Math.abs(2 * l - 1)) : 0;
  if (d) h = mx === r ? ((g - b) / d + 6) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
  const L = Math.max(l, 0.62), S = Math.min(1, s * 1.15);
  const c = (1 - Math.abs(2 * L - 1)) * S, x = c * (1 - Math.abs((h % 2) - 1)), m = L - c / 2;
  const [r1, g1, b1] = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x] : h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
  return [r1, g1, b1].map(v => Math.round((v + m) * 255));
}

// shapes: [{poly (already base-deformed), rgb "r,g,b", group}]; groups interleave five layers at a time
function paint(ctx, W, H, shapes, { layers = 48, alpha = 0.045, perLayer = 3, circles = 1000 } = {}) {
  const tmp = document.createElement("canvas"); tmp.width = W; tmp.height = H;
  const t = tmp.getContext("2d");
  const groups = [...new Set(shapes.map(s => s.group))];
  for (let L = 0; L < layers; L += 5) {
    for (const g of groups) {
      for (let k = 0; k < 5 && L + k < layers; k++) {
        t.globalCompositeOperation = "source-over"; t.clearRect(0, 0, W, H);
        for (const s of shapes) {
          if (s.group !== g) continue;
          const p = deform(s.poly, perLayer);
          t.fillStyle = `rgb(${s.rgb})`; t.beginPath();
          p.forEach((q, i) => i ? t.lineTo(q.x, q.y) : t.moveTo(q.x, q.y)); t.closePath(); t.fill();
        }
        t.globalCompositeOperation = "destination-in"; t.fillStyle = "#000"; t.beginPath();
        for (let c = 0; c < circles; c++) {
          const x = rnd() * W, y = rnd() * H, r = Math.abs(gauss()) * W * 0.011 + W * 0.002;
          t.moveTo(x + r, y); t.arc(x, y, r, 0, Math.PI * 2);
        }
        t.fill();
        ctx.globalAlpha = alpha; ctx.drawImage(tmp, 0, 0); ctx.globalAlpha = 1;
      }
    }
  }
}

// by kind: what shrank in cool ink, what grew in warm earth, steady in sand
const KIND = {
  grid: { fall: ["74,104,138", "96,128,160", "60,86,118", "118,146,168", "84,114,144"],
          rise: ["168,70,50", "192,114,62", "156,90,70", "198,142,84", "146,58,46"],
          steady: ["194,184,164", "172,162,144", "208,196,170", "160,154,140", "184,170,146"] },
  rothko: { fall: ["70,98,132", "92,122,154", "58,82,114", "112,140,162", "80,108,138"],
            rise: ["160,62,46", "188,108,58", "150,84,64", "196,138,80", "138,52,42"],
            steady: ["190,178,156", "168,156,136", "204,190,164", "156,148,132", "180,164,140"] },
};
// named palettes for series without a kind, used in series order
const NAMED = {
  earth: ["168,70,50", "198,142,84", "122,128,84", "74,104,138", "146,58,46", "194,184,164", "96,128,160"],
  sea:   ["38,70,104", "74,104,138", "96,128,160", "118,146,168", "64,124,128", "150,170,180", "50,90,110"],
  dusk:  ["92,62,102", "146,58,46", "198,142,84", "168,112,140", "74,104,138", "214,184,140"],
  ink:   ["40,40,44", "84,84,90", "128,126,124", "170,166,160", "60,60,70"],
};
const toRgb = c => {
  if (Array.isArray(c)) return c.join(",");
  c = String(c).trim();
  if (c[0] === "#") { const h = c.length === 4 ? c.slice(1).split("").map(x => x + x).join("") : c.slice(1); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)).join(","); }
  return c.replace(/\s/g, "");
};

// series -> [{name, v, kind, key (paint group), rgb [r, g, b]}]
// A series' own color wins. With the "kind" palette, series with a kind take the next shade of
// their kind. Every other series takes the next colour of the named palette (or list), counted
// over those series only, so own colours and kinds never use up or repeat a palette colour.
function colourSeries(D, style) {
  const list = D.groups || D.series;
  const pal = OPTS.palette || D.palette || (list.some(g => g.kind) ? "kind" : "earth");
  const kindPal = KIND[style === "rothko" ? "rothko" : "grid"];
  const ci = { fall: 0, rise: 0, steady: 0 };
  let free = 0;
  return list.map((g, i) => {
    const kind = g.kind === "rise" || g.kind === "fall" ? g.kind : g.kind ? "steady" : null;   // "too few" paints as steady
    let rgb;
    if (g.color) rgb = toRgb(g.color);
    else if (pal === "kind" && kind) rgb = kindPal[kind][ci[kind]++ % kindPal[kind].length];
    else {
      const p = Array.isArray(pal) ? pal.map(toRgb) : NAMED[pal === "kind" ? "earth" : pal] || NAMED.earth;
      rgb = p[free++ % p.length];
    }
    return { ...g, kind, key: kind || `s${i % 5}`, rgb: lift(rgb.split(",").map(Number)) };
  });
}

// the stack: series stacked from the floor, time left to right, cosine-smoothed between periods.
// Scale: by default the tallest thing drawn (the stack, plus the ghosts above it in grid) fills
// the room. With OPTS.maxScale (--max-scale N) it is the older rule, min(N, room / tallest
// total), which images made before the auto-fit used (N = 54 for grid, 50 for rothko).
function stackGeometry(groups, X0, X1, FLOOR, room) {
  const Q = groups[0].v.length;
  const maxTot = Math.max(...groups[0].v.map((_, i) => groups.reduce((a, g) => a + (g.v[i] || 0), 0)));
  const at = (v, x) => {
    if (Q === 1) return v[0] || 0;
    const f = Math.max(0, Math.min(Q - 1, (x - X0) / (X1 - X0) * (Q - 1)));
    const i = Math.min(Q - 2, Math.floor(f)), u = f - i, s = (1 - Math.cos(u * Math.PI)) / 2;
    return (v[i] || 0) * (1 - s) + (v[i + 1] || 0) * s;
  };
  const scale = (heightAt) => {
    if (OPTS.maxScale) return Math.min(OPTS.maxScale, room / maxTot);
    let top = maxTot;
    if (heightAt) for (let x = X0; x <= X1; x += 4) top = Math.max(top, heightAt(x));
    return room / (top || 1);
  };
  return { Q, at, scale };
}

function grid(ctx, W, H, D, groups) {
  const X0 = 40, X1 = W - 40, FLOOR = H - 30;
  const { Q, at, scale } = stackGeometry(groups, X0, X1, FLOOR, FLOOR - 20);
  const band = (x, y) => {                                   // which series is under (x, y), or -1
    let top = FLOOR;
    for (let g = 0; g < groups.length; g++) {
      const h = at(groups[g].v, x) * S;
      if (y <= top && y > top - h) return g;
      top -= h;
    }
    return -1;
  };
  // ghosts, above the curve and stacked in the same order: what each series lost since its peak.
  // "running" (default): the peak so far, up to x, so nothing is lost before a series peaks.
  // "global": the series' overall peak at every x (how images before this option were made).
  // "none": no ghosts.
  const mode = OPTS.ghosts || "running";
  const peakAll = groups.map(g => Math.max(...g.v.map(v => v || 0)));
  const peakAt = (g, x) => {
    if (mode === "global") return peakAll[g];
    const v = groups[g].v, f = Math.max(0, Math.min(Q - 1, (x - X0) / (X1 - X0) * (Q - 1)));
    let m = at(v, x);                                        // the smoothing is monotone between periods,
    for (let i = 0; i <= Math.floor(f); i++) m = Math.max(m, v[i] || 0);   // so the running max is this
    return m;
  };
  const S = scale(mode === "none" ? null : x => groups.reduce((a, g, i) => a + peakAt(i, x), 0));
  const lost = (x, y) => {
    if (mode === "none") return -1;
    let top = FLOOR - groups.reduce((a, g) => a + at(g.v, x), 0) * S;
    for (let g = 0; g < groups.length; g++) {
      const h = (peakAt(g, x) - at(groups[g].v, x)) * S;
      if (y <= top && y > top - h) return g;
      top -= h;
    }
    return -1;
  };
  const CELL = OPTS.cell || 26, x0 = X0 + ((X1 - X0) % CELL) / 2;
  const live = [], ghost = [];
  for (let cx = x0 + CELL / 2; cx < X1; cx += CELL) {
    for (let cy = FLOOR - CELL / 2; cy > 0; cy -= CELL) {
      let g = band(cx, cy), list = live;
      if (g < 0) { g = lost(cx, cy); list = ghost; }
      if (g < 0) continue;
      const h = CELL * 0.4;
      const c = groups[g].rgb.map(v => Math.max(0, Math.min(255, Math.round(v + gauss() * 6))));
      list.push({ poly: deform(withVariance([[cx - h, cy - h], [cx + h, cy - h], [cx + h, cy + h], [cx - h, cy + h]], 0.3, 0.9), 1), rgb: c.join(","), group: groups[g].key });
    }
  }
  paint(ctx, W, H, ghost, { layers: 20, alpha: 0.012, perLayer: 3, circles: 1400 });
  paint(ctx, W, H, live, { layers: 50, alpha: 0.035, perLayer: 3, circles: 1400 });
}

function rothko(ctx, W, H, D, groups) {
  const X0 = 90, X1 = W - 90, FLOOR = H - 60;
  const { at, scale } = stackGeometry(groups, X0, X1, FLOOR, FLOOR - 60);
  const S = scale();
  // a warm ground under the field
  ctx.filter = "blur(40px)"; ctx.globalAlpha = 0.18; ctx.fillStyle = "rgb(214,196,170)";
  ctx.fillRect(X0 - 30, 40, X1 - X0 + 60, FLOOR - 10); ctx.filter = "none"; ctx.globalAlpha = 1;
  // each series a thin blurred wash laid many times, its edges breathing a little each layer
  const xs = []; for (let x = X0; x <= X1; x += 12) xs.push(x);
  const wob = () => { const a = rnd() * 6, f = 0.002 + rnd() * 0.004, p = rnd() * 6.28; return x => a * Math.sin(x * f + p); };
  for (let L = 0; L < 36; L++) {
    groups.forEach((g, gi) => {
      const wt = wob(), wb = wob(), inset = rnd() * 14;
      const base = x => { let y = FLOOR; for (let j = 0; j < gi; j++) y -= at(groups[j].v, x) * S; return y; };
      ctx.beginPath();
      xs.forEach((x, k) => { const y = base(x) - at(g.v, x) * S; k ? ctx.lineTo(x, y + wt(x)) : ctx.moveTo(x + inset, y + wt(x)); });
      for (let k = xs.length - 1; k >= 0; k--) {
        const x = xs[k];
        ctx.lineTo(k === 0 ? x + inset : k === xs.length - 1 ? x - inset : x, base(x) + wb(x));
      }
      ctx.closePath();
      ctx.filter = `blur(${8 + rnd() * 22}px)`;
      ctx.globalAlpha = 0.05; ctx.fillStyle = `rgb(${g.rgb.join(",")})`; ctx.fill();
    });
  }
  ctx.filter = "none"; ctx.globalAlpha = 1;
}

// random-size squares on a fine grid, big ones first, each wholly inside one series
function squares(ctx, W, H, D, groups) {
  const X0 = 40, X1 = W - 40, FLOOR = H - 30;
  const { at, scale } = stackGeometry(groups, X0, X1, FLOOR, FLOOR - 20);
  const S = scale();
  const band = (x, y) => {
    let top = FLOOR;
    for (let g = 0; g < groups.length; g++) {
      const h = at(groups[g].v, x) * S;
      if (y <= top && y > top - h) return g;
      top -= h;
    }
    return -1;
  };
  const U = Math.max(3, Math.round((OPTS.cell || 26) / 3.25)), GW = Math.floor((X1 - X0) / U), GH = Math.floor(FLOOR / U);
  const owner = new Int16Array(GW * GH), used = new Uint8Array(GW * GH);
  for (let j = 0; j < GH; j++) for (let i = 0; i < GW; i++) owner[j * GW + i] = band(X0 + (i + 0.5) * U, (j + 0.5) * U);
  const order = [...Array(GW * GH).keys()];
  for (let k = order.length - 1; k > 0; k--) { const r = Math.floor(rnd() * (k + 1)); [order[k], order[r]] = [order[r], order[k]]; }
  const sq = [];
  for (const [size, tries] of [[11, 0.4], [8, 0.5], [6, 0.6], [5, 0.7], [4, 0.8], [3, 1], [2, 1]]) {
    for (const idx of order) {
      if (rnd() > tries) continue;
      const i = idx % GW, j = (idx / GW) | 0;
      if (i + size > GW || j + size > GH) continue;
      const g = owner[idx]; if (g < 0) continue;
      let ok = true;
      for (let b = 0; b < size && ok; b++) for (let a = 0; a < size; a++) {
        const c = (j + b) * GW + i + a;
        if (used[c] || owner[c] !== g) { ok = false; break; }
      }
      if (!ok) continue;
      for (let b = 0; b < size; b++) for (let a = 0; a < size; a++) used[(j + b) * GW + i + a] = 1;
      sq.push({ x: X0 + i * U, y: j * U, s: size * U, g });
    }
  }
  const shapes = sq.map(q => {
    const gap = 2 + q.s * 0.06, x = q.x + gap, y = q.y + gap, s = q.s - 2 * gap;
    const c = groups[q.g].rgb.map(v => Math.max(0, Math.min(255, Math.round(v + gauss() * 7))));
    return { poly: deform(withVariance([[x, y], [x + s, y], [x + s, y + s], [x, y + s]], 0.03, 0.09), 1), rgb: c.join(","), group: groups[q.g].key };
  });
  paint(ctx, W, H, shapes, { layers: 45, alpha: 0.04, perLayer: 3, circles: 1400 });
}

// a matrix: one square per cell, its side growing with the square root of |value|, coloured by row
// (or by sign with "diverging"); empty cells (null) are faint ghost squares
function matrix(ctx, W, H, D) {
  const M = D.matrix, R = M.length, C = Math.max(...M.map(r => r.length));
  const cell = OPTS.cell || Math.floor(Math.min((W - 80) / C, (H - 60) / R));
  const ox = (W - cell * C) / 2, oy = (H - cell * R) / 2;
  const vals = M.flat().filter(v => v !== null && v !== undefined && !Number.isNaN(v)).map(Math.abs);
  const max = Math.max(...vals) || 1;
  const pal = OPTS.palette || D.palette || "earth";
  const P = (Array.isArray(pal) ? pal.map(toRgb) : NAMED[pal] || NAMED.earth).map(c => lift(c.split(",").map(Number)));
  const live = [], ghost = [];
  for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) {
    const v = M[r][c], cx = ox + (c + 0.5) * cell, cy = oy + (r + 0.5) * cell;
    const empty = v === null || v === undefined || Number.isNaN(v);
    if (!empty && v === 0) continue;
    const h = cell * 0.4 * (empty ? 1 : Math.max(0.18, Math.sqrt(Math.abs(v) / max)));
    const base = D.diverging ? P[v < 0 ? 1 % P.length : 0] : P[r % P.length];
    const col = base.map(x => Math.max(0, Math.min(255, Math.round(x + gauss() * 6))));
    const k = Math.min(1, 26 / cell);                         // big cells get calmer edges, so they stay square
    (empty ? ghost : live).push({ poly: deform(withVariance([[cx - h, cy - h], [cx + h, cy - h], [cx + h, cy + h], [cx - h, cy + h]], 0.3 * k, 0.9 * k), 1),
      rgb: col.join(","), group: D.diverging ? (v < 0 ? "neg" : "pos") : `r${r % 5}` });
  }
  paint(ctx, W, H, ghost, { layers: 20, alpha: 0.012, perLayer: 3, circles: 1400 });
  paint(ctx, W, H, live, { layers: 50, alpha: 0.035, perLayer: 3, circles: 1400 });
}

(function run() {
  const D = window.DATA, style = OPTS.style || (D.matrix ? "matrix" : "grid");
  const [W, H] = OPTS.size || [2400, 840];
  const cv = document.getElementById("a"); cv.width = W; cv.height = H;
  const ctx = cv.getContext("2d", { willReadFrequently: true });
  if (style === "matrix") {
    if (!D.matrix) throw new Error("style matrix needs {matrix: [[...]]}");
    paper(ctx, W, H); matrix(ctx, W, H, D);
  } else {
    if (!(D.groups || D.series)) throw new Error("this style needs stacked data: {groups: [{name, v}]} or {series: [...]}");
    const groups = colourSeries(D, style);
    paper(ctx, W, H);
    ({ grid, rothko, squares })[style](ctx, W, H, D, groups);
  }
  document.title = "done";
})();
</script>
</body></html>