[object Object]

← back to Generative Art Canvas

Generative flow-field art canvas: rebuild Qwen3 artifact into a working single-file page

018621f41731c38d538d9b44e89a9305f03873e2 · 2026-07-24 23:26:20 -0700 · Steve

Fixed: all-white trails (HSL/HSV misuse), palette index overflow, static field
on reseed, dot-based rendering. Added seeded Perlin noise, line-trails,
harmonious palettes, HiDPI, color-batched draw, controls, keyboard shortcuts,
resize-preserving snapshot, robust PNG export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 018621f41731c38d538d9b44e89a9305f03873e2
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:26:20 2026 -0700

    Generative flow-field art canvas: rebuild Qwen3 artifact into a working single-file page
    
    Fixed: all-white trails (HSL/HSV misuse), palette index overflow, static field
    on reseed, dot-based rendering. Added seeded Perlin noise, line-trails,
    harmonious palettes, HiDPI, color-batched draw, controls, keyboard shortcuts,
    resize-preserving snapshot, robust PNG export.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore |   8 ++
 index.html | 436 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 444 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bf06ffb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+preview.png
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..a87e176
--- /dev/null
+++ b/index.html
@@ -0,0 +1,436 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>Flow Field — Generative Art Canvas</title>
+<style>
+  :root { --panel-bg: rgba(14,14,18,0.55); }
+  * { box-sizing: border-box; }
+  html, body {
+    margin: 0; padding: 0; height: 100%;
+    overflow: hidden; background: #000;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+    -webkit-font-smoothing: antialiased;
+  }
+  canvas { display: block; position: fixed; inset: 0; }
+
+  #ui {
+    position: fixed; top: 16px; left: 16px; z-index: 10;
+    display: flex; flex-direction: column; gap: 10px;
+    padding: 14px 14px 12px;
+    background: var(--panel-bg);
+    backdrop-filter: blur(14px) saturate(1.2);
+    -webkit-backdrop-filter: blur(14px) saturate(1.2);
+    border: 1px solid rgba(255,255,255,0.10);
+    border-radius: 14px;
+    color: #f4f4f6;
+    box-shadow: 0 10px 40px rgba(0,0,0,0.45);
+    user-select: none;
+    transition: opacity .4s ease;
+    max-width: 260px;
+  }
+  #ui.dim { opacity: 0.12; }
+  #ui:hover { opacity: 1; }
+
+  #title { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; opacity: .65; margin-bottom: 2px; }
+  #palette-name { font-size: 13px; font-weight: 600; opacity: .95; min-height: 16px; }
+
+  .row { display: flex; gap: 8px; }
+  button {
+    flex: 1; padding: 9px 12px; font-size: 13px; font-weight: 600;
+    color: #f4f4f6; background: rgba(255,255,255,0.07);
+    border: 1px solid rgba(255,255,255,0.14); border-radius: 9px;
+    cursor: pointer; transition: background .15s, transform .05s;
+    -webkit-tap-highlight-color: transparent;
+  }
+  button:hover { background: rgba(255,255,255,0.16); }
+  button:active { transform: translateY(1px); }
+
+  label.control { display: flex; flex-direction: column; gap: 5px; font-size: 11px; letter-spacing: .05em; opacity: .8; }
+  label.control .val { opacity: .6; }
+  input[type=range] {
+    -webkit-appearance: none; appearance: none; width: 100%; height: 4px;
+    background: rgba(255,255,255,0.18); border-radius: 3px; outline: none;
+  }
+  input[type=range]::-webkit-slider-thumb {
+    -webkit-appearance: none; appearance: none; width: 15px; height: 15px;
+    border-radius: 50%; background: #fff; cursor: pointer; border: none;
+    box-shadow: 0 1px 4px rgba(0,0,0,.4);
+  }
+  input[type=range]::-moz-range-thumb {
+    width: 15px; height: 15px; border-radius: 50%; background: #fff; cursor: pointer; border: none;
+  }
+  .hint { font-size: 10.5px; opacity: .45; line-height: 1.5; margin-top: 2px; }
+  kbd {
+    font-family: ui-monospace, Menlo, monospace; font-size: 10px;
+    background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.15);
+    border-radius: 4px; padding: 0 4px; margin: 0 1px;
+  }
+</style>
+</head>
+<body>
+  <canvas id="canvas"></canvas>
+
+  <div id="ui">
+    <div id="title">Flow Field</div>
+    <div id="palette-name">—</div>
+
+    <div class="row">
+      <button id="btn-reseed" title="New palette + new flow field (Space)">New Field</button>
+      <button id="btn-save" title="Download a PNG (S)">Save PNG</button>
+    </div>
+    <div class="row">
+      <button id="btn-pause" title="Pause / resume (P)">Pause</button>
+      <button id="btn-clear" title="Repaint background, keep field (C)">Clear</button>
+    </div>
+
+    <label class="control">Particles <span class="val" id="v-count"></span>
+      <input id="s-count" type="range" min="500" max="8000" step="250" value="4000">
+    </label>
+    <label class="control">Flow scale <span class="val" id="v-scale"></span>
+      <input id="s-scale" type="range" min="4" max="40" step="1" value="14">
+    </label>
+    <label class="control">Speed <span class="val" id="v-speed"></span>
+      <input id="s-speed" type="range" min="5" max="45" step="1" value="18">
+    </label>
+
+    <div class="hint"><kbd>Space</kbd> new · <kbd>S</kbd> save · <kbd>P</kbd> pause · <kbd>C</kbd> clear</div>
+  </div>
+
+<script>
+"use strict";
+
+const canvas = document.getElementById('canvas');
+const ctx = canvas.getContext('2d', { alpha: false });
+
+// ---------- Seeded PRNG (mulberry32) ----------
+function makeRng(seed) {
+  let s = seed >>> 0;
+  return function () {
+    s |= 0; s = (s + 0x6D2B79F5) | 0;
+    let t = Math.imul(s ^ (s >>> 15), 1 | s);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+
+// ---------- Seeded 2D Perlin noise ----------
+function makeNoise(seed) {
+  const rng = makeRng(seed);
+  const perm = new Uint8Array(512);
+  const p = new Uint8Array(256);
+  for (let i = 0; i < 256; i++) p[i] = i;
+  for (let i = 255; i > 0; i--) {          // Fisher–Yates shuffle
+    const j = (rng() * (i + 1)) | 0;
+    const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
+  }
+  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
+
+  const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
+  const lerp = (a, b, t) => a + t * (b - a);
+  function grad(h, x, y) {                  // 4 diagonal gradient directions
+    switch (h & 3) {
+      case 0: return  x + y;
+      case 1: return -x + y;
+      case 2: return  x - y;
+      default:return -x - y;
+    }
+  }
+  return function (x, y) {                   // returns ~[-1, 1]
+    const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
+    x -= Math.floor(x); y -= Math.floor(y);
+    const u = fade(x), v = fade(y);
+    const aa = perm[perm[X] + Y],     ab = perm[perm[X] + Y + 1];
+    const ba = perm[perm[X + 1] + Y], bb = perm[perm[X + 1] + Y + 1];
+    return lerp(
+      lerp(grad(aa, x, y),     grad(ba, x - 1, y),     u),
+      lerp(grad(ab, x, y - 1), grad(bb, x - 1, y - 1), u),
+      v
+    );
+  };
+}
+
+// ---------- Harmonious palette generation ----------
+const SCHEMES = [
+  { name: 'Analogous',      offs: [-32, -16, 0, 16, 32] },
+  { name: 'Complementary',  offs: [-18, 0, 18, 180, 198] },
+  { name: 'Triadic',        offs: [0, 20, 120, 140, 240] },
+  { name: 'Split',          offs: [0, 15, 150, 165, 210] },
+  { name: 'Monochrome',     offs: [0, 0, 0, 0, 0] },
+];
+const MOODS = ['Dawn','Ember','Aurora','Nocturne','Coral','Verdant','Cobalt','Sorbet','Ink','Meadow','Dusk','Lagoon'];
+
+function generatePalette(rng) {
+  const scheme = SCHEMES[(rng() * SCHEMES.length) | 0];
+  const baseHue = rng() * 360;
+  const baseSat = 55 + rng() * 40;          // 55–95%
+  const mono = scheme.name === 'Monochrome';
+  const shadesPerHue = 5;                    // lightness variants for smooth banding
+  const colors = [];
+
+  for (const off of scheme.offs) {
+    const hue = (baseHue + off + 360) % 360;
+    for (let k = 0; k < shadesPerHue; k++) {
+      const light = mono ? 30 + (off === 0 ? 0 : 0) + 12 + k * 12   // spread mono over lightness
+                         : 42 + k * 9 + (rng() - 0.5) * 6;          // 42–78%-ish
+      const sat = mono ? baseSat - k * 4 : baseSat + (rng() - 0.5) * 10;
+      colors.push('hsl(' + hue.toFixed(1) + ',' +
+                  Math.max(20, Math.min(100, sat)).toFixed(0) + '%,' +
+                  Math.max(20, Math.min(88, light)).toFixed(0) + '%)');
+    }
+  }
+  // Dark, tinted background derived from base hue.
+  const bg = 'hsl(' + baseHue.toFixed(1) + ',' + (18 + rng() * 22).toFixed(0) + '%,' + (5 + rng() * 5).toFixed(0) + '%)';
+  const name = MOODS[(rng() * MOODS.length) | 0] + ' · ' + scheme.name;
+  return { colors, bg, name };
+}
+
+// ---------- State ----------
+let W = 0, H = 0, dpr = 1;
+let noise, palette, seed;
+let particles = null;          // flat typed arrays for speed
+let running = true;
+let rafId = null;
+
+// Tunables (driven by sliders)
+let COUNT   = 4000;
+let SCALE   = 14 / 10000;      // noise sampling scale
+let SPEED   = 1.8;             // px per step
+const LIFE_MIN = 40, LIFE_MAX = 220;
+const TURN = Math.PI * 2 * 1.4; // how strongly noise maps to angle
+
+function allocParticles(n) {
+  particles = {
+    x:  new Float32Array(n),
+    y:  new Float32Array(n),
+    px: new Float32Array(n),
+    py: new Float32Array(n),
+    life: new Float32Array(n),
+    col: new Uint16Array(n),   // index into palette.colors
+    n
+  };
+  for (let i = 0; i < n; i++) respawn(i, true);
+}
+
+let _rng = Math.random;
+function respawn(i, initial) {
+  const P = particles;
+  P.x[i] = P.px[i] = _rng() * W;
+  P.y[i] = P.py[i] = _rng() * H;
+  P.life[i] = LIFE_MIN + _rng() * (LIFE_MAX - LIFE_MIN);
+  P.col[i] = (_rng() * palette.colors.length) | 0;
+}
+
+// ---------- Canvas sizing (preserve art on resize) ----------
+function resize() {
+  dpr = Math.min(window.devicePixelRatio || 1, 2);
+  const cw = window.innerWidth, ch = window.innerHeight;
+
+  // Snapshot existing pixels so a resize doesn't wipe the painting.
+  let snap = null;
+  if (canvas.width) {
+    snap = document.createElement('canvas');
+    snap.width = canvas.width; snap.height = canvas.height;
+    snap.getContext('2d').drawImage(canvas, 0, 0);
+  }
+
+  canvas.width = Math.floor(cw * dpr);
+  canvas.height = Math.floor(ch * dpr);
+  canvas.style.width = cw + 'px';
+  canvas.style.height = ch + 'px';
+  W = cw; H = ch;
+
+  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+  ctx.lineCap = 'round';
+  ctx.lineJoin = 'round';
+
+  if (snap) {
+    ctx.save();
+    ctx.setTransform(1, 0, 0, 1, 0, 0);
+    ctx.drawImage(snap, 0, 0, snap.width, snap.height, 0, 0, canvas.width, canvas.height);
+    ctx.restore();
+  } else if (palette) {
+    paintBg();
+  }
+}
+
+function paintBg() {
+  ctx.save();
+  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+  ctx.fillStyle = palette.bg;
+  ctx.fillRect(0, 0, W, H);
+  ctx.restore();
+}
+
+// ---------- Reseed / clear ----------
+function reseed() {
+  seed = (Math.random() * 0xffffffff) >>> 0;
+  _rng = makeRng(seed);
+  noise = makeNoise(seed);
+  palette = generatePalette(_rng);
+  document.getElementById('palette-name').textContent = palette.name;
+  paintBg();
+  allocParticles(COUNT);
+}
+
+function clearKeepField() {
+  paintBg();
+  if (particles) for (let i = 0; i < particles.n; i++) respawn(i, true);
+}
+
+// ---------- Animation ----------
+function step() {
+  const P = particles;
+  const n = P.n;
+  const nColors = palette.colors.length;
+
+  // Group segments by colour → one path + one stroke per colour (fast).
+  // Build path strings via canvas path API grouped by colour index.
+  // We iterate colours in an outer loop but that's O(colors * n); cheaper to
+  // bucket first.
+  const buckets = _buckets;
+  for (let c = 0; c < nColors; c++) buckets[c].length = 0;
+
+  for (let i = 0; i < n; i++) {
+    const px = P.x[i], py = P.y[i];
+    const a = noise(px * SCALE, py * SCALE) * TURN;
+    const nx = px + Math.cos(a) * SPEED;
+    const ny = py + Math.sin(a) * SPEED;
+
+    let life = P.life[i] - 1;
+    const off = nx < -2 || nx > W + 2 || ny < -2 || ny > H + 2;
+
+    if (life <= 0 || off) {
+      // draw the last valid segment before respawn (if on-screen)
+      if (!off) { const b = buckets[P.col[i]]; b.push(px, py, nx, ny); }
+      respawn(i, false);
+      continue;
+    }
+
+    const b = buckets[P.col[i]];
+    b.push(px, py, nx, ny);
+
+    P.px[i] = px; P.py[i] = py;
+    P.x[i] = nx;  P.y[i] = ny;
+    P.life[i] = life;
+  }
+
+  ctx.globalAlpha = 0.5;
+  ctx.lineWidth = 1.15;
+  for (let c = 0; c < nColors; c++) {
+    const b = buckets[c];
+    if (!b.length) continue;
+    ctx.strokeStyle = palette.colors[c];
+    ctx.beginPath();
+    for (let k = 0; k < b.length; k += 4) {
+      ctx.moveTo(b[k], b[k + 1]);
+      ctx.lineTo(b[k + 2], b[k + 3]);
+    }
+    ctx.stroke();
+  }
+  ctx.globalAlpha = 1;
+}
+
+let _buckets = [];
+function rebuildBuckets() {
+  _buckets = [];
+  for (let c = 0; c < palette.colors.length; c++) _buckets.push([]);
+}
+
+function loop() {
+  if (running) step();
+  rafId = requestAnimationFrame(loop);
+}
+
+// ---------- Save PNG ----------
+function savePNG() {
+  canvas.toBlob(function (blob) {
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+    a.download = 'flow-field-' + ts + '.png';
+    a.href = url;
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+    setTimeout(() => URL.revokeObjectURL(url), 1000);
+  }, 'image/png');
+}
+
+// ---------- UI wiring ----------
+const els = {
+  count: document.getElementById('s-count'),
+  scale: document.getElementById('s-scale'),
+  speed: document.getElementById('s-speed'),
+  vcount: document.getElementById('v-count'),
+  vscale: document.getElementById('v-scale'),
+  vspeed: document.getElementById('v-speed'),
+  pause: document.getElementById('btn-pause'),
+  ui: document.getElementById('ui'),
+};
+
+function syncLabels() {
+  els.vcount.textContent = COUNT.toLocaleString();
+  els.vscale.textContent = els.scale.value;
+  els.vspeed.textContent = (SPEED).toFixed(1);
+}
+
+els.count.addEventListener('input', () => {
+  COUNT = +els.count.value;
+  syncLabels();
+  allocParticles(COUNT);
+});
+els.scale.addEventListener('input', () => {
+  SCALE = (+els.scale.value) / 10000;
+  syncLabels();
+});
+els.speed.addEventListener('input', () => {
+  SPEED = (+els.speed.value) / 10;
+  syncLabels();
+});
+
+document.getElementById('btn-reseed').addEventListener('click', reseed);
+document.getElementById('btn-save').addEventListener('click', savePNG);
+document.getElementById('btn-clear').addEventListener('click', clearKeepField);
+els.pause.addEventListener('click', togglePause);
+
+function togglePause() {
+  running = !running;
+  els.pause.textContent = running ? 'Pause' : 'Resume';
+}
+
+window.addEventListener('keydown', (e) => {
+  if (e.target.tagName === 'INPUT') return;
+  const k = e.key.toLowerCase();
+  if (k === ' ') { e.preventDefault(); reseed(); }
+  else if (k === 's') { e.preventDefault(); savePNG(); }
+  else if (k === 'p') togglePause();
+  else if (k === 'c') clearKeepField();
+});
+
+// Auto-dim the panel when idle so the art breathes.
+let dimTimer = null;
+function scheduleDim() {
+  els.ui.classList.remove('dim');
+  clearTimeout(dimTimer);
+  dimTimer = setTimeout(() => els.ui.classList.add('dim'), 3500);
+}
+['mousemove', 'touchstart', 'keydown'].forEach(ev => window.addEventListener(ev, scheduleDim, { passive: true }));
+
+// Reseed on resize needs fresh buckets; patch reseed to rebuild them.
+const _origReseed = reseed;
+reseed = function () { _origReseed(); rebuildBuckets(); };
+
+window.addEventListener('resize', resize);
+
+// ---------- Boot ----------
+resize();          // sets W/H (no palette yet → no bg paint)
+reseed();          // palette + noise + particles + bg
+rebuildBuckets();
+syncLabels();
+scheduleDim();
+loop();
+</script>
+</body>
+</html>

(oldest)  ·  back to Generative Art Canvas  ·  auto-save: 2026-07-24T23:30:18 (1 files) — index.html fb4427a →