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.
Mountscripts/preview_svg.py
#!/usr/bin/env python3
"""A small watercolor-like SVG of stacked data, for previews where a PNG is too heavy.
Usage:
python3 preview_svg.py DATA.json OUT.svg [--seed 20261010] [--size 600x210] [--cell 28] [--layers 3]
It reads the same stacked data as render.mjs ({q?, groups|series: [{name, v, kind?, color?}],
palette?}) and draws the grid style in miniature: series stacked from the floor with cosine
easing, one square per grid cell inside a band, each square a few translucent layers of a
polygon deformed by recursive midpoint displacement (Tyler Hobbs' method, without the masks, which
SVG cannot afford at this size). No paper and no ghosts. The background is transparent, and the
colours are CSS custom properties: the paper pigments by default, lifted pigments (as render.mjs
--background dark) and denser layers under prefers-color-scheme: dark. Same data and seed give the same bytes.
Standard library only.
"""
import argparse
import colorsys
import json
import math
KIND = {"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"]}
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"]}
class Rng:
"""The same linear congruential generator as art.html."""
def __init__(self, seed):
self.s = seed & 0xFFFFFFFF
def __call__(self):
self.s = (self.s * 1664525 + 1013904223) & 0xFFFFFFFF
return self.s / 4294967296
def gauss(self):
u = v = 0.0
while not u:
u = self()
while not v:
v = self()
return math.sqrt(-2 * math.log(u)) * math.cos(2 * math.pi * v)
def to_rgb(c):
c = str(c).strip()
if c.startswith("#"):
h = c[1:] if len(c) == 7 else "".join(x * 2 for x in c[1:4])
return [int(h[i:i + 2], 16) for i in (0, 2, 4)]
return [int(float(x)) for x in c.split(",")]
def lift(rgb):
"""render.mjs's dark-ground pigment: lightness at least 0.62, saturation x1.15, same hue."""
h, l, s = colorsys.rgb_to_hls(*[v / 255 for v in rgb])
return [round(v * 255) for v in colorsys.hls_to_rgb(h, max(l, 0.62), min(1.0, s * 1.15))]
def colours(D):
"""One colour per series, by art.html's rules: own colour, else a shade of its kind, else
the next colour of the named palette or list."""
lst = D.get("groups") or D.get("series")
pal = D.get("palette") or ("kind" if any(g.get("kind") for g in lst) else "earth")
ci, free, out = {"fall": 0, "rise": 0, "steady": 0}, 0, []
for g in lst:
k = g.get("kind")
kind = k if k in ("rise", "fall") else ("steady" if k else None)
if g.get("color"):
out.append(to_rgb(g["color"]))
elif pal == "kind" and kind:
out.append(to_rgb(KIND[kind][ci[kind] % 5]))
ci[kind] += 1
else:
p = pal if isinstance(pal, list) else NAMED.get("earth" if pal == "kind" else pal, NAMED["earth"])
out.append(to_rgb(p[free % len(p)]))
free += 1
return out
def deform(poly, rounds, rnd):
for _ in range(rounds):
out = []
for i, (x, y, v) in enumerate(poly):
bx, by, _ = poly[(i + 1) % len(poly)]
s = math.hypot(bx - x, by - y) * v * 0.5
v2 = v * (0.8 + rnd() * 0.15)
out += [(x, y, v2), ((x + bx) / 2 + rnd.gauss() * s, (y + by) / 2 + rnd.gauss() * s, v2 * (0.8 + rnd() * 0.4))]
poly = out
return poly
def path(poly):
"""A closed path in whole pixels, relative moves after the first point: small files."""
pts = [(round(x), round(y)) for x, y, _ in poly]
d = [f"M{pts[0][0]} {pts[0][1]}l"]
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
d.append(f"{x1 - x0} {y1 - y0}")
return " ".join(d).replace(" -", "-") + "z"
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("data")
ap.add_argument("out")
ap.add_argument("--seed", type=int, default=20261010)
ap.add_argument("--size", default="600x210")
ap.add_argument("--cell", type=float, default=28)
ap.add_argument("--layers", type=int, default=3)
a = ap.parse_args()
D = json.load(open(a.data, encoding="utf-8"))
groups = D.get("groups") or D.get("series")
if not groups:
raise SystemExit(f"{a.data}: needs stacked data, a \"groups\" or \"series\" list")
W, H = (int(n) for n in a.size.split("x"))
rnd = Rng(a.seed)
cols = colours(D)
X0, X1, FLOOR = 16, W - 16, H - 10
Q = len(groups[0]["v"])
tot = max(sum(g["v"][i] or 0 for g in groups) for i in range(Q))
S = (FLOOR - 8) / (tot or 1)
def at(v, x):
if Q == 1:
return v[0] or 0
f = max(0.0, min(Q - 1, (x - X0) / (X1 - X0) * (Q - 1)))
i = min(Q - 2, int(f))
s = (1 - math.cos((f - i) * math.pi)) / 2
return (v[i] or 0) * (1 - s) + (v[i + 1] or 0) * s
def band(x, y):
top = FLOOR
for k, g in enumerate(groups):
h = at(g["v"], x) * S
if top - h < y <= top:
return k
top -= h
return -1
C = a.cell
x0 = X0 + ((X1 - X0) % C) / 2
shapes = []
cx = x0 + C / 2
while cx < X1:
cy = FLOOR - C / 2
while cy > 0:
k = band(cx, cy)
if k >= 0:
h = C * 0.4
base = deform([(x, y, 0.1 + rnd() * 0.25) for x, y in
((cx - h, cy - h), (cx + h, cy - h), (cx + h, cy + h), (cx - h, cy + h))], 1, rnd)
op = 0.26 + rnd() * 0.1 # a little variation, as art.html's colour jitter
shapes.append((k, op, [path(deform(base, 1, rnd)) for _ in range(a.layers)]))
cy -= C
cx += C
light = ";".join(f"--wc{k}:rgb({','.join(map(str, c))})" for k, c in enumerate(cols))
dark = ";".join(f"--wc{k}:rgb({','.join(map(str, lift(c)))})" for k, c in enumerate(cols))
css = (f".wc{{{light}}}@media (prefers-color-scheme:dark){{.wc{{{dark}}}}}"
+ ".wc g{fill-opacity:var(--o)}@media (prefers-color-scheme:dark){.wc g{fill-opacity:calc(var(--o)*1.6)}}"
+ "".join(f".wc .s{k}{{fill:var(--wc{k})}}" for k in range(len(cols))))
names = ", ".join(str(g.get("name", k)) for k, g in enumerate(groups))
o = [f'<svg xmlns="http://www.w3.org/2000/svg" class="wc" width="{W}" height="{H}" viewBox="0 0 {W} {H}" role="img" '
f'aria-label="Watercolor squares of stacked data: {names}"><style>{css}</style>']
for k, op, layers in shapes:
o.append(f'<g class="s{k}" style="--o:{op:.2f}">' + "".join(f'<path d="{d}"/>' for d in layers) + "</g>")
o.append("</svg>")
svg = "\n".join(o) + "\n"
open(a.out, "w", encoding="utf-8").write(svg)
print(f"wrote {a.out} ({W}x{H}, {len(shapes)} squares, {a.layers} layers, seed {a.seed}, {len(svg.encode()):,} bytes)")
if __name__ == "__main__":
main()

