← back to Generative Art Canvas

index.html

494 lines

<!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; cursor: crosshair; outline: none; }

  #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: 264px;
  }
  #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); }
  button.on { background: rgba(94,234,212,0.22); border-color: rgba(94,234,212,0.5); }

  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: .5; line-height: 1.65; 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; white-space: nowrap;
  }
</style>
</head>
<body>
  <canvas id="canvas" tabindex="0"></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>
    <div class="row">
      <button id="btn-mode" title="Toggle attract / repel for the pointer (Tab)">Pointer: Swirl</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>Move mouse</kbd> steer the flow · <kbd>Drag</kbd> stronger<br>
      <kbd>← ↑ → ↓</kbd> pan the field · <kbd>Tab</kbd> swirl/repel<br>
      <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--) {
    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) {
    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) {                   // ~[-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;
  const mono = scheme.name === 'Monochrome';
  const shadesPerHue = 5;
  const colors = [];

  for (const off of scheme.offs) {
    const hue = (baseHue + off + 360) % 360;
    for (let k = 0; k < shadesPerHue; k++) {
      const light = mono ? 12 + k * 15
                         : 42 + k * 9 + (rng() - 0.5) * 6;
      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) + '%)');
    }
  }
  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;
let running = true;
let rafId = null;

let COUNT   = 4000;
let SCALE   = 14 / 10000;
let SPEED   = 1.8;
const LIFE_MIN = 40, LIFE_MAX = 220;
const TURN = Math.PI * 2 * 1.4;

// Field panning (arrow keys) + pointer interaction (mouse/touch)
let fieldOX = 0, fieldOY = 0;          // noise-space offset, in px
const heldKeys = new Set();
const PAN_ACCEL = 0.6, PAN_MAX = 6, PAN_FRICTION = 0.86;
let panVX = 0, panVY = 0;

const pointer = { x: 0, y: 0, active: false, down: false, repel: false, strength: 0, radius: 150 };

let _rng = Math.random;
let _buckets = [];

function allocParticles(n) {
  particles = {
    x:  new Float32Array(n),
    y:  new Float32Array(n),
    life: new Float32Array(n),
    col: new Uint16Array(n),
    n
  };
  for (let i = 0; i < n; i++) respawn(i);
}

function respawn(i) {
  const P = particles;
  P.x[i] = _rng() * W;
  P.y[i] = _rng() * H;
  P.life[i] = LIFE_MIN + _rng() * (LIFE_MAX - LIFE_MIN);
  P.col[i] = (_rng() * palette.colors.length) | 0;
}

function rebuildBuckets() {
  _buckets = [];
  for (let c = 0; c < palette.colors.length; c++) _buckets.push([]);
}

// ---------- Canvas sizing (preserve art on resize) ----------
function resize() {
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  const cw = window.innerWidth, ch = window.innerHeight;

  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;
  pointer.radius = Math.max(120, Math.min(W, H) * 0.22);

  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);
  fieldOX = fieldOY = 0; panVX = panVY = 0;
  document.getElementById('palette-name').textContent = palette.name;
  rebuildBuckets();
  paintBg();
  allocParticles(COUNT);
}

function clearKeepField() {
  paintBg();
  if (particles) for (let i = 0; i < particles.n; i++) respawn(i);
}

// ---------- Animation ----------
function step() {
  // Apply arrow-key panning (momentum + friction).
  let ax = 0, ay = 0;
  if (heldKeys.has('ArrowLeft'))  ax -= 1;
  if (heldKeys.has('ArrowRight')) ax += 1;
  if (heldKeys.has('ArrowUp'))    ay -= 1;
  if (heldKeys.has('ArrowDown'))  ay += 1;
  panVX = Math.max(-PAN_MAX, Math.min(PAN_MAX, panVX + ax * PAN_ACCEL));
  panVY = Math.max(-PAN_MAX, Math.min(PAN_MAX, panVY + ay * PAN_ACCEL));
  if (ax === 0) panVX *= PAN_FRICTION;
  if (ay === 0) panVY *= PAN_FRICTION;
  fieldOX += panVX; fieldOY += panVY;

  // Ease pointer strength in/out so influence feels smooth.
  const target = pointer.active ? (pointer.down ? 1.6 : 0.9) : 0;
  pointer.strength += (target - pointer.strength) * 0.12;

  const P = particles;
  const n = P.n;
  const nColors = palette.colors.length;
  const buckets = _buckets;
  for (let c = 0; c < nColors; c++) buckets[c].length = 0;

  const R = pointer.radius, R2 = R * R;
  const pInf = pointer.strength;
  const pActive = pInf > 0.01;
  const pSign = pointer.repel ? -1 : 1;

  for (let i = 0; i < n; i++) {
    const px = P.x[i], py = P.y[i];
    const a = noise((px + fieldOX) * SCALE, (py + fieldOY) * SCALE) * TURN;
    let vx = Math.cos(a), vy = Math.sin(a);

    // Pointer interaction: swirl + gentle attract/repel within radius.
    if (pActive) {
      const dx = pointer.x - px, dy = pointer.y - py;
      const d2 = dx * dx + dy * dy;
      if (d2 < R2) {
        const d = Math.sqrt(d2) || 1;
        const inf = (1 - d / R) * pInf;
        const ux = dx / d, uy = dy / d;      // toward pointer
        const sx = -uy, sy = ux;             // perpendicular (swirl)
        vx += pSign * (ux * 0.55 + sx) * inf;
        vy += pSign * (uy * 0.55 + sy) * inf;
      }
    }

    const len = Math.hypot(vx, vy) || 1;
    const nx = px + (vx / len) * SPEED;
    const ny = py + (vy / len) * SPEED;

    let life = P.life[i] - 1;
    const off = nx < -2 || nx > W + 2 || ny < -2 || ny > H + 2;

    if (!off) buckets[P.col[i]].push(px, py, nx, ny);

    if (life <= 0 || off) { respawn(i); continue; }

    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;
}

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');
}

// ---------- Pointer input (mouse + touch) ----------
function setPointerFromEvent(e) {
  const t = (e.touches && e.touches[0]) || e;
  const rect = canvas.getBoundingClientRect();
  pointer.x = t.clientX - rect.left;
  pointer.y = t.clientY - rect.top;
  pointer.active = true;
}
canvas.addEventListener('mousemove', setPointerFromEvent);
canvas.addEventListener('mouseenter', setPointerFromEvent);
canvas.addEventListener('mouseleave', () => { pointer.active = false; pointer.down = false; });
canvas.addEventListener('mousedown', (e) => { canvas.focus(); setPointerFromEvent(e); pointer.down = true; });
window.addEventListener('mouseup', () => { pointer.down = false; });
canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // right-click reserved
canvas.addEventListener('touchstart', (e) => { canvas.focus(); setPointerFromEvent(e); pointer.down = true; }, { passive: true });
canvas.addEventListener('touchmove',  (e) => { setPointerFromEvent(e); e.preventDefault(); }, { passive: false });
canvas.addEventListener('touchend',   () => { pointer.down = false; pointer.active = false; });

// ---------- 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'),
  mode: document.getElementById('btn-mode'),
  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);
els.mode.addEventListener('click', toggleMode);

function togglePause() {
  running = !running;
  els.pause.textContent = running ? 'Pause' : 'Resume';
}
function toggleMode() {
  pointer.repel = !pointer.repel;
  els.mode.textContent = 'Pointer: ' + (pointer.repel ? 'Repel' : 'Swirl');
  els.mode.classList.toggle('on', pointer.repel);
}

// Keyboard: actions + arrow-key field panning. Works standalone and inside the hub iframe.
const ARROWS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
window.addEventListener('keydown', (e) => {
  if (e.target.tagName === 'INPUT') return;
  if (ARROWS.has(e.key)) { heldKeys.add(e.key); e.preventDefault(); 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();
  else if (e.key === 'Tab') { e.preventDefault(); toggleMode(); }
});
window.addEventListener('keyup', (e) => { if (ARROWS.has(e.key)) heldKeys.delete(e.key); });
window.addEventListener('blur', () => heldKeys.clear());

// 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 }));

window.addEventListener('resize', resize);

// ---------- Boot ----------
resize();
reseed();
syncLabels();
scheduleDim();
canvas.focus();
loop();
</script>
</body>
</html>