[object Object]

← back to Generative Art Canvas

auto-save: 2026-07-24T23:30:18 (1 files) — index.html

fb4427a671896d04a48bd0cca33c0f47c2fdd60b · 2026-07-24 23:31:43 -0700 · Steve Abrams

Files touched

Diff

commit fb4427a671896d04a48bd0cca33c0f47c2fdd60b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:31:43 2026 -0700

    auto-save: 2026-07-24T23:30:18 (1 files) — index.html
---
 index.html | 207 +++++++++++++++++++++++++++++++++++++++----------------------
 1 file changed, 132 insertions(+), 75 deletions(-)

diff --git a/index.html b/index.html
index a87e176..fbc7163 100644
--- a/index.html
+++ b/index.html
@@ -13,7 +13,7 @@
     font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
     -webkit-font-smoothing: antialiased;
   }
-  canvas { display: block; position: fixed; inset: 0; }
+  canvas { display: block; position: fixed; inset: 0; cursor: crosshair; outline: none; }
 
   #ui {
     position: fixed; top: 16px; left: 16px; z-index: 10;
@@ -28,7 +28,7 @@
     box-shadow: 0 10px 40px rgba(0,0,0,0.45);
     user-select: none;
     transition: opacity .4s ease;
-    max-width: 260px;
+    max-width: 264px;
   }
   #ui.dim { opacity: 0.12; }
   #ui:hover { opacity: 1; }
@@ -46,6 +46,7 @@
   }
   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; }
@@ -61,16 +62,16 @@
   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; }
+  .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;
+    border-radius: 4px; padding: 0 4px; margin: 0 1px; white-space: nowrap;
   }
 </style>
 </head>
 <body>
-  <canvas id="canvas"></canvas>
+  <canvas id="canvas" tabindex="0"></canvas>
 
   <div id="ui">
     <div id="title">Flow Field</div>
@@ -84,6 +85,9 @@
       <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">
@@ -95,7 +99,11 @@
       <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 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>
@@ -121,7 +129,7 @@ function makeNoise(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
+  for (let i = 255; i > 0; i--) {
     const j = (rng() * (i + 1)) | 0;
     const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
   }
@@ -129,7 +137,7 @@ function makeNoise(seed) {
 
   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
+  function grad(h, x, y) {
     switch (h & 3) {
       case 0: return  x + y;
       case 1: return -x + y;
@@ -137,7 +145,7 @@ function makeNoise(seed) {
       default:return -x - y;
     }
   }
-  return function (x, y) {                   // returns ~[-1, 1]
+  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);
@@ -164,23 +172,22 @@ const MOODS = ['Dawn','Ember','Aurora','Nocturne','Coral','Verdant','Cobalt','So
 function generatePalette(rng) {
   const scheme = SCHEMES[(rng() * SCHEMES.length) | 0];
   const baseHue = rng() * 360;
-  const baseSat = 55 + rng() * 40;          // 55–95%
+  const baseSat = 55 + rng() * 40;
   const mono = scheme.name === 'Monochrome';
-  const shadesPerHue = 5;                    // lightness variants for smooth banding
+  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 ? 30 + (off === 0 ? 0 : 0) + 12 + k * 12   // spread mono over lightness
-                         : 42 + k * 9 + (rng() - 0.5) * 6;          // 42–78%-ish
+      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) + '%)');
     }
   }
-  // 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 };
@@ -189,45 +196,56 @@ function generatePalette(rng) {
 // ---------- State ----------
 let W = 0, H = 0, dpr = 1;
 let noise, palette, seed;
-let particles = null;          // flat typed arrays for speed
+let particles = null;
 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
+let SCALE   = 14 / 10000;
+let SPEED   = 1.8;
 const LIFE_MIN = 40, LIFE_MAX = 220;
-const TURN = Math.PI * 2 * 1.4; // how strongly noise maps to angle
+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),
-    px: new Float32Array(n),
-    py: new Float32Array(n),
     life: new Float32Array(n),
-    col: new Uint16Array(n),   // index into palette.colors
+    col: new Uint16Array(n),
     n
   };
-  for (let i = 0; i < n; i++) respawn(i, true);
+  for (let i = 0; i < n; i++) respawn(i);
 }
 
-let _rng = Math.random;
-function respawn(i, initial) {
+function respawn(i) {
   const P = particles;
-  P.x[i] = P.px[i] = _rng() * W;
-  P.y[i] = P.py[i] = _rng() * H;
+  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;
 
-  // Snapshot existing pixels so a resize doesn't wipe the painting.
   let snap = null;
   if (canvas.width) {
     snap = document.createElement('canvas');
@@ -240,6 +258,7 @@ function resize() {
   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';
@@ -269,51 +288,78 @@ function reseed() {
   _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, true);
+  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;
-
-  // 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;
 
+  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 * SCALE, py * SCALE) * TURN;
-    const nx = px + Math.cos(a) * SPEED;
-    const ny = py + Math.sin(a) * SPEED;
+    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 (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;
-    }
+    if (!off) buckets[P.col[i]].push(px, py, nx, ny);
 
-    const b = buckets[P.col[i]];
-    b.push(px, py, nx, ny);
+    if (life <= 0 || off) { respawn(i); continue; }
 
-    P.px[i] = px; P.py[i] = py;
-    P.x[i] = nx;  P.y[i] = ny;
-    P.life[i] = life;
+    P.x[i] = nx; P.y[i] = ny; P.life[i] = life;
   }
 
   ctx.globalAlpha = 0.5;
@@ -332,12 +378,6 @@ function step() {
   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);
@@ -358,6 +398,24 @@ function savePNG() {
   }, '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'),
@@ -367,47 +425,50 @@ const els = {
   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.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();
-});
+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;
@@ -418,18 +479,14 @@ function scheduleDim() {
 }
 ['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();
+resize();
+reseed();
 syncLabels();
 scheduleDim();
+canvas.focus();
 loop();
 </script>
 </body>

← 018621f Generative flow-field art canvas: rebuild Qwen3 artifact int  ·  back to Generative Art Canvas  ·  (newest)