Snake Vector Field — D3 + GSAP Scroll
        
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
  <title>Snake Vector Field — D3 + GSAP Scroll</title>
  <style>
    body { margin:0; background:#0b0b0c; color:#ddd; font-family:system-ui,Segoe UI,Roboto,"Helvetica Neue",Arial; height:300vh; }
    /* make page tall so scrolling is possible */
    #body {
      height: 2000vh;
      width: 100vw;
      position: relative;
    }
    #canvas-wrap { position:sticky; top:0; height:100vh; width:100vw; }
    svg { display:block; width:100vw; height:100vh; }
    .field-line { stroke-linecap:round; stroke-linejoin:round; fill:none; }
  </style>
</head>
<body>

    <div id="body">
      <div id="canvas-wrap">
        <svg id="svg"></svg>
      </div>
    </div>

  <!-- D3 -->
  <script src="https://d3js.org/d3.v7.min.js"></script>
  <!-- GSAP + ScrollTrigger -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>

  <script>
  gsap.registerPlugin(ScrollTrigger);

  // Config
  const width = window.innerWidth;
  const height = window.innerHeight;
  const svg = d3.select('#svg').attr('viewBox', [0,0,width,height]);
  const nSnakes = 40;
  const segments = 26;
  const segLength = 10;
  const noiseAmp = 1.0;
  const baseHue = 190;
  const fieldScale = 0.006;
  const timeScale = 0.0018; // used to map scroll progress to field time
  const rngSeed = 12345;

  // seeded RNG
  function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; }; }

  // value noise (same simple impl)
  function valueNoise2D(nx, ny, seed = 0) {
    const ix=Math.floor(nx), iy=Math.floor(ny);
    function corner(xo, yo){
      const r = mulberry32(((ix+xo)*374761393 + (iy+yo)*668265263) ^ seed);
      return r();
    }
    const fx=nx-ix, fy=ny-iy;
    function s(t){ return t*t*(3-2*t); }
    const v00=corner(0,0), v10=corner(1,0), v01=corner(0,1), v11=corner(1,1);
    const ix0 = v00*(1-s(fx)) + v10*s(fx);
    const ix1 = v01*(1-s(fx)) + v11*s(fx);
    return ix0*(1-s(fy)) + ix1*s(fy);
  }

  // field function: returns [vx,vy] for a given position and time
  function field(x,y,t){
    const nx = x * fieldScale;
    const ny = y * fieldScale;
    const angle =
      Math.sin(nx * 2.1 + t * 1.2) * 1.2 +
      Math.cos(ny * 1.7 - t * 0.9) * 0.9 +
      (valueNoise2D(nx*1.5, ny*1.5, 42) - 0.5) * 4.0;
    const speed = 0.6 + 0.8 * (valueNoise2D(nx*0.7, ny*0.7, 99));
    const p = (valueNoise2D(nx*3 + t*0.2, ny*3 - t*0.3, 7) - 0.5) * noiseAmp;
    return [Math.cos(angle)*speed + p*0.6, Math.sin(angle)*speed + p*0.6];
  }

  // Initialize snakes
  const rng = mulberry32(rngSeed);
  const snakes = d3.range(nSnakes).map(i => {
    const headX = rng()*width;
    const headY = rng()*height;
    const hue = (baseHue + i*(120/nSnakes)) % 360;
    const points = d3.range(segments).map(j => ({ x: headX - j*segLength*0.5, y: headY - j*segLength*0.5 }));
    return { points, hue, speed: 0.5 + rng()*1.5 };
  });

  // SVG paths
  const group = svg.append('g');
  const paths = group.selectAll('path')
    .data(snakes)
    .enter()
    .append('path')
    .attr('class','field-line')
    .attr('stroke-width', 2.2)
    .attr('opacity', 0.95);

  // Catmull-Rom -> cubic bezier path
  function catmullRomPath(points){
    if(points.length<2) return '';
    let d = `M ${points[0].x} ${points[0].y}`;
    for(let i=0;i<points.length-1;i++){
      const p0 = points[i-1] || points[i];
      const p1 = points[i];
      const p2 = points[i+1];
      const p3 = points[i+2] || p2;
      const t = 0.5;
      const cp1x = p1.x + (p2.x - p0.x) * t / 6 * 2;
      const cp1y = p1.y + (p2.y - p0.y) * t / 6 * 2;
      const cp2x = p2.x - (p3.x - p1.x) * t / 6 * 2;
      const cp2y = p2.y - (p3.y - p1.y) * t / 6 * 2;
      d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
    }
    return d;
  }

  // Animation state driven by "fieldTime" which GSAP will update from scroll
  let lastRender = performance.now();
  const state = { fieldTime: 0 }; // in arbitrary units passed to field()

  // GSAP ScrollTrigger: map scroll progress to state.fieldTime
  gsap.to(state, {
    fieldTime: 4000, // total time range (tweak to change sensitivity)
    ease: "none",
    scrollTrigger: {
      trigger: "#body",
      start: "top top",
      end: "bottom bottom",
      scrub: 0.7,    // smooth scrubbing
      onUpdate: self => {
        // nothing else needed; RAF loop reads state.fieldTime
      }
    }
  });

  // RAF loop: update snakes based on current state.fieldTime progression
  function animate(now){
    const dt = (now - lastRender);
    lastRender = now;
    const t = state.fieldTime * timeScaleForField();

    // move snakes: head integrates field at its position using t
    for(const s of snakes){
      const head = s.points[0];
      const [vx,vy] = field(head.x, head.y, t);
      const sp = s.speed * (0.6 + 0.5 * Math.sin(state.fieldTime*0.002 + s.hue));
      const nhx = head.x + vx * sp * (dt * 0.06);
      const nhy = head.y + vy * sp * (dt * 0.06);

      const wrapX = x => (x < -20 ? width + 20 : (x > width + 20 ? -20 : x));
      const wrapY = y => (y < -20 ? height + 20 : (y > height + 20 ? -20 : y));
      const newHead = { x: wrapX(nhx), y: wrapY(nhy) };

      s.points.unshift(newHead);
      s.points.pop();

      // simple relaxation to maintain segment lengths
      for(let iter=0; iter<2; iter++){
        for(let i=1;i<s.points.length;i++){
          const a = s.points[i-1], b = s.points[i];
          const dx = b.x - a.x, dy = b.y - a.y;
          const dist = Math.hypot(dx,dy) || 1e-6;
          const diff = (dist - segLength) / dist;
          const moveFactor = 0.5;
          b.x -= dx * diff * moveFactor;
          b.y -= dy * diff * moveFactor;
        }
      }
    }

    // render
    paths
      .attr('d', d => catmullRomPath(d.points))
      .attr('stroke', d => `hsl(${d.hue},80%,60%)`)
      .attr('stroke-width', d => 1.2 + 1.8 * (1 - (d.points.length-1)/segments));

    requestAnimationFrame(animate);
  }

  // Map fieldTime to a scaled t for the field() function.
  // Keeps field evolution responsive to scroll by converting the large fieldTime range
  // to a smaller scale used by the procedural field.
  function timeScaleForField(){ return 0.0018; }

  requestAnimationFrame(animate);

  // Resize handling
  window.addEventListener('resize', () => {
    const w = window.innerWidth, h = window.innerHeight;
    svg.attr('viewBox', [0,0,w,h]);
  });
  </script>
</body>
</html>

<!--
This conversation was generated with Duck.ai (https://duck.ai) using OpenAI's GPT-5 mini Model. AI chats may display inaccurate or offensive information (see https://duckduckgo.com/duckai/privacy-terms for more info).

====================

User prompt 1 of 3 - 2/16/2026, 7:10:36 PM:
Create me a vector field visualization in d3js which looks like snake moving. Animate with request animation frame.

GPT-5 mini:
## D3.js animated "snake-like" vector field

Below is a complete HTML + JS example that draws a 2D vector field and animates streamlines that look like snakes moving across the field. It uses D3 for SVG setup and rendering and requestAnimationFrame for smooth animation. Copy into a single .html file and open in a browser.

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
  <title>Snake-like Vector Field (D3 + RAF)</title>
  <style>
    body { margin:0; background:#111; color:#ddd; font-family:system-ui,Segoe UI,Roboto,"Helvetica Neue",Arial; }
    svg { display:block; width:100vw; height:100vh; }
    .field-line { stroke-linecap:round; stroke-linejoin:round; fill:none; }
  </style>
</head>
<body>
  <svg id="svg"></svg>

  <script src="https://d3js.org/d3.v7.min.js"></script>
  <script>
  // Configuration
  const width = window.innerWidth;
  const height = window.innerHeight;
  const svg = d3.select('#svg').attr('viewBox', [0,0,width,height]);
  const nSnakes = 40;           // number of snakes/streamlines
  const segments = 26;          // segments per snake
  const segLength = 10;         // length in pixels between segments
  const maxSpeed = 2.0;
  const fieldScale = 0.006;     // controls spatial frequency of the vector field
  const timeScale = 0.0018;     // controls speed of field time evolution
  const noiseAmp = 1.0;         // amplitude for perturbations
  const baseHue = 180;          // base color hue

  // Simple pseudo-random seeded generator for deterministic seeds
  function mulberry32(a) {
    return function() {
      let t = a += 0x6D2B79F5;
      t = Math.imul(t ^ t >>> 15, t | 1);
      t ^= t + Math.imul(t ^ t >>> 7, t | 61);
      return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
  }

  // Perlin-ish noise (value noise with smooth interpolation)
  function valueNoise2D(nx, ny, seed = 0) {
    // integer grid
    const ix = Math.floor(nx), iy = Math.floor(ny);
    const fx = nx - ix, fy = ny - iy;
    const rnd = mulberry32((ix * 374761393 + iy * 668265263) ^ seed);
    function corner(xo, yo) {
      const r = mulberry32(( (ix+xo) * 374761393 + (iy+yo) * 668265263) ^ seed);
      return r();
    }
    // smoothstep interpolation
    function s(t){ return t*t*(3-2*t); }
    const v00 = corner(0,0), v10 = corner(1,0), v01 = corner(0,1), v11 = corner(1,1);
    const ix0 = v00*(1-s(fx)) + v10*s(fx);
    const ix1 = v01*(1-s(fx)) + v11*s(fx);
    return ix0*(1-s(fy)) + ix1*s(fy);
  }

  // Vector field: returns [vx, vy]
  function field(x, y, t) {
    // normalized coords
    const nx = x * fieldScale;
    const ny = y * fieldScale;
    // base swirling field with time evolution
    const angle =
      Math.sin(nx * 2.1 + t * 1.2) * 1.2 +
      Math.cos(ny * 1.7 - t * 0.9) * 0.9 +
      (valueNoise2D(nx*1.5, ny*1.5, 42) - 0.5) * 4.0;
    const speed = 0.6 + 0.8 * (valueNoise2D(nx*0.7, ny*0.7, 99));
    const vx = Math.cos(angle) * speed;
    const vy = Math.sin(angle) * speed;
    // small dynamic perturbation
    const p = (valueNoise2D(nx*3 + t*0.2, ny*3 - t*0.3, 7) - 0.5) * noiseAmp;
    return [vx + p*0.6, vy + p*0.6];
  }

  // Initialize snakes: each is an array of points {x,y}
  const rng = mulberry32(12345);
  const snakes = d3.range(nSnakes).map((i) => {
    const headX = rng() * width;
    const headY = rng() * height;
    const hue = (baseHue + i * (120 / nSnakes)) % 360;
    // create a short tail behind the head (straight line)
    const points = d3.range(segments).map(j => ({
      x: headX - j * segLength * 0.5,
      y: headY - j * segLength * 0.5
    }));
    return { points, hue, speed: 0.5 + rng()*1.5 };
  });

  // Create SVG groups and paths
  const group = svg.append('g');
  const paths = group.selectAll('path')
    .data(snakes)
    .enter()
    .append('path')
    .attr('class','field-line')
    .attr('stroke-width', 2.2)
    .attr('stroke-linecap','round')
    .attr('stroke-linejoin','round')
    .attr('opacity', 0.95);

  // Helper: catmull-rom through points to make a smooth path
  function catmullRomPath(points) {
    if (points.length < 2) return '';
    // For SVG path we convert to cubic beziers approximating Catmull-Rom
    // Implementation adapted for uniform parameterization.
    let d = `M ${points[0].x} ${points[0].y}`;
    for (let i = 0; i < points.length - 1; i++) {
      const p0 = points[i - 1] || points[i];
      const p1 = points[i];
      const p2 = points[i + 1];
      const p3 = points[i + 2] || p2;
      // tension alpha = 0.5 for centripetal Catmull-Rom approx
      const t = 0.5;
      const cp1x = p1.x + (p2.x - p0.x) * t / 6 * 2;
      const cp1y = p1.y + (p2.y - p0.y) * t / 6 * 2;
      const cp2x = p2.x - (p3.x - p1.x) * t / 6 * 2;
      const cp2y = p2.y - (p3.y - p1.y) * t / 6 * 2;
      d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
    }
    return d;
  }

  // Animation loop
  let last = performance.now();
  function animate(now) {
    const dt = (now - last);
    last = now;
    const t = now * timeScale;

    // Update each snake: move head according to field and shift tail
    for (let s of snakes) {
      // compute head velocity
      const head = s.points[0];
      const [vx, vy] = field(head.x, head.y, t);
      // head moves with scaled speed, with small oscillation to look alive
      const sp = s.speed * (0.6 + 0.5 * Math.sin(now * 0.002 + s.hue));
      const nhx = head.x + vx * sp * (dt * 0.06);
      const nhy = head.y + vy * sp * (dt * 0.06);

      // clamp or wrap edges: wrap to opposite side for continuous motion
      const wrapX = (x) => (x < -20 ? width + 20 : (x > width + 20 ? -20 : x));
      const wrapY = (y) => (y < -20 ? height + 20 : (y > height + 20 ? -20 : y));
      const newHead = { x: wrapX(nhx), y: wrapY(nhy) };

      // insert new head and remove last point to keep segment count
      s.points.unshift(newHead);
      s.points.pop();

      // relax segments so they stay close to segLength: simple constraint iteration
      for (let iter = 0; iter < 2; iter++) {
        for (let i = 1; i < s.points.length; i++) {
          const a = s.points[i-1];
          const b = s.points[i];
          const dx = b.x - a.x, dy = b.y - a.y;
          const dist = Math.hypot(dx, dy) || 1e-6;
          const diff = (dist - segLength) / dist;
          // pull point b toward/away from a
          const moveFactor = 0.5;
          b.x -= dx * diff * moveFactor;
          b.y -= dy * diff * moveFactor;
        }
      }
    }

    // Render paths
    paths
      .attr('d', d => catmullRomPath(d.points))
      .attr('stroke', d => `hsl(${d.hue}, 80%, 60%)`)
      .attr('stroke-width', d => 1.2 + 1.8 * (1 - (d.points.length-1)/segments));

    requestAnimationFrame(animate);
  }

  // Start animation
  requestAnimationFrame(animate);

  // Responsive
  window.addEventListener('resize', () => {
    const w = window.innerWidth, h = window.innerHeight;
    svg.attr('viewBox', [0,0,w,h]);
  });
  </script>
</body>
</html>
```

Notes:
- Tweak nSnakes, segments, segLength, fieldScale, timeScale, and baseHue to change look/behavior.
- The vector field is procedural (value-noise + trig) so no external libs beyond D3 are required.


User prompt 2 of 3 - 2/16/2026, 7:12:03 PM:
Animate it with GSAP scroll so when I scroll the snakes move.

GPT-5 mini:
## Updated D3.js "snake-like" vector field with GSAP scroll-driven animation

Below is a complete HTML file. It uses D3 to render snakes and GSAP + ScrollTrigger so scrolling controls the animation: scrolling forward advances the field time (snakes move forward), scrolling back reverses it. Copy into an .html file and open in a browser.

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
  <title>Snake Vector Field — D3 + GSAP Scroll</title>
  <style>
    body { margin:0; background:#0b0b0c; color:#ddd; font-family:system-ui,Segoe UI,Roboto,"Helvetica Neue",Arial; height:300vh; }
    /* make page tall so scrolling is possible */
    #canvas-wrap { position:sticky; top:0; height:100vh; width:100vw; }
    svg { display:block; width:100vw; height:100vh; }
    .field-line { stroke-linecap:round; stroke-linejoin:round; fill:none; }
  </style>
</head>
<body>
  <div id="canvas-wrap">
    <svg id="svg"></svg>
  </div>

  <script src="https://d3js.org/d3.v7.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>

  <script>
  gsap.registerPlugin(ScrollTrigger);

  // Config
  const width = window.innerWidth;
  const height = window.innerHeight;
  const svg = d3.select('#svg').attr('viewBox', [0,0,width,height]);
  const nSnakes = 40;
  const segments = 26;
  const segLength = 10;
  const noiseAmp = 1.0;
  const baseHue = 190;
  const fieldScale = 0.006;
  const timeScale = 0.0018; // used to map scroll progress to field time
  const rngSeed = 12345;

  // seeded RNG
  function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; }; }

  // value noise (same simple impl)
  function valueNoise2D(nx, ny, seed = 0) {
    const ix=Math.floor(nx), iy=Math.floor(ny);
    function corner(xo, yo){
      const r = mulberry32(((ix+xo)*374761393 + (iy+yo)*668265263) ^ seed);
      return r();
    }
    const fx=nx-ix, fy=ny-iy;
    function s(t){ return t*t*(3-2*t); }
    const v00=corner(0,0), v10=corner(1,0), v01=corner(0,1), v11=corner(1,1);
    const ix0 = v00*(1-s(fx)) + v10*s(fx);
    const ix1 = v01*(1-s(fx)) + v11*s(fx);
    return ix0*(1-s(fy)) + ix1*s(fy);
  }

  // field function: returns [vx,vy] for a given position and time
  function field(x,y,t){
    const nx = x * fieldScale;
    const ny = y * fieldScale;
    const angle =
      Math.sin(nx * 2.1 + t * 1.2) * 1.2 +
      Math.cos(ny * 1.7 - t * 0.9) * 0.9 +
      (valueNoise2D(nx*1.5, ny*1.5, 42) - 0.5) * 4.0;
    const speed = 0.6 + 0.8 * (valueNoise2D(nx*0.7, ny*0.7, 99));
    const p = (valueNoise2D(nx*3 + t*0.2, ny*3 - t*0.3, 7) - 0.5) * noiseAmp;
    return [Math.cos(angle)*speed + p*0.6, Math.sin(angle)*speed + p*0.6];
  }

  // Initialize snakes
  const rng = mulberry32(rngSeed);
  const snakes = d3.range(nSnakes).map(i => {
    const headX = rng()*width;
    const headY = rng()*height;
    const hue = (baseHue + i*(120/nSnakes)) % 360;
    const points = d3.range(segments).map(j => ({ x: headX - j*segLength*0.5, y: headY - j*segLength*0.5 }));
    return { points, hue, speed: 0.5 + rng()*1.5 };
  });

  // SVG paths
  const group = svg.append('g');
  const paths = group.selectAll('path')
    .data(snakes)
    .enter()
    .append('path')
    .attr('class','field-line')
    .attr('stroke-width', 2.2)
    .attr('opacity', 0.95);

  // Catmull-Rom -> cubic bezier path
  function catmullRomPath(points){
    if(points.length<2) return '';
    let d = `M ${points[0].x} ${points[0].y}`;
    for(let i=0;i<points.length-1;i++){
      const p0 = points[i-1] || points[i];
      const p1 = points[i];
      const p2 = points[i+1];
      const p3 = points[i+2] || p2;
      const t = 0.5;
      const cp1x = p1.x + (p2.x - p0.x) * t / 6 * 2;
      const cp1y = p1.y + (p2.y - p0.y) * t / 6 * 2;
      const cp2x = p2.x - (p3.x - p1.x) * t / 6 * 2;
      const cp2y = p2.y - (p3.y - p1.y) * t / 6 * 2;
      d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
    }
    return d;
  }

  // Animation state driven by "fieldTime" which GSAP will update from scroll
  let lastRender = performance.now();
  const state = { fieldTime: 0 }; // in arbitrary units passed to field()

  // GSAP ScrollTrigger: map scroll progress to state.fieldTime
  gsap.to(state, {
    fieldTime: 4000, // total time range (tweak to change sensitivity)
    ease: "none",
    scrollTrigger: {
      trigger: document.body,
      start: "top top",
      end: "bottom bottom",
      scrub: 0.7,    // smooth scrubbing
      onUpdate: self => {
        // nothing else needed; RAF loop reads state.fieldTime
      }
    }
  });

  // RAF loop: update snakes based on current state.fieldTime progression
  function animate(now){
    const dt = (now - lastRender);
    lastRender = now;
    const t = state.fieldTime * timeScaleForField();

    // move snakes: head integrates field at its position using t
    for(const s of snakes){
      const head = s.points[0];
      const [vx,vy] = field(head.x, head.y, t);
      const sp = s.speed * (0.6 + 0.5 * Math.sin(state.fieldTime*0.002 + s.hue));
      const nhx = head.x + vx * sp * (dt * 0.06);
      const nhy = head.y + vy * sp * (dt * 0.06);

      const wrapX = x => (x < -20 ? width + 20 : (x > width + 20 ? -20 : x));
      const wrapY = y => (y < -20 ? height + 20 : (y > height + 20 ? -20 : y));
      const newHead = { x: wrapX(nhx), y: wrapY(nhy) };

      s.points.unshift(newHead);
      s.points.pop();

      // simple relaxation to maintain segment lengths
      for(let iter=0; iter<2; iter++){
        for(let i=1;i<s.points.length;i++){
          const a = s.points[i-1], b = s.points[i];
          const dx = b.x - a.x, dy = b.y - a.y;
          const dist = Math.hypot(dx,dy) || 1e-6;
          const diff = (dist - segLength) / dist;
          const moveFactor = 0.5;
          b.x -= dx * diff * moveFactor;
          b.y -= dy * diff * moveFactor;
        }
      }
    }

    // render
    paths
      .attr('d', d => catmullRomPath(d.points))
      .attr('stroke', d => `hsl(${d.hue},80%,60%)`)
      .attr('stroke-width', d => 1.2 + 1.8 * (1 - (d.points.length-1)/segments));

    requestAnimationFrame(animate);
  }

  // Map fieldTime to a scaled t for the field() function.
  // Keeps field evolution responsive to scroll by converting the large fieldTime range
  // to a smaller scale used by the procedural field.
  function timeScaleForField(){ return 0.0018; }

  requestAnimationFrame(animate);

  // Resize handling
  window.addEventListener('resize', () => {
    const w = window.innerWidth, h = window.innerHeight;
    svg.attr('viewBox', [0,0,w,h]);
  });
  </script>
</body>
</html>
```

Notes:
- The page is made tall (height:300vh) so scrolling triggers GSAP ScrollTrigger; the SVG is sticky to keep the canvas visible while scrolling.
- Adjust the GSAP target value (fieldTime: 4000) to make scroll speed produce faster/slower snake motion.
- Scrub smoothing is controlled by ScrollTrigger's scrub value.


User prompt 3 of 3 - 2/16/2026, 7:14:08 PM:
They should not move till I scroll

GPT-5 mini:
Update the animation so snakes only move in response to scroll: set RAF to render but don't advance field time unless GSAP changes it; start fieldTime at 0 and remove any autonomous updates (no internal time-based head motion). Replace sin/time-based speed modulation with dependence on state.fieldTime. Here's a minimal patch to apply to the previous file — integrate these changes:

- Initialize state.fieldTime = 0 (already).
- Remove any autonomous time increments; use only state.fieldTime for field() time parameter.
- Remove dependence on performance.now() or lastRender for advancing head based on time; compute t = state.fieldTime * timeScaleForField().
- Replace head movement scale to use a fixed delta based on scroll delta: multiply vx,vy by (scrollDelta * sensitivity) where scrollDelta = state.fieldTime - prevFieldTime. Track prevFieldTime and only when scrollDelta != 0 update heads.
- Keep RAF running to render frames and relax segments, but if scrollDelta === 0 skip moving heads.

Patch example (insert/replace in the script):

```js
// Add near state
let prevFieldTime = state.fieldTime;

// Replace animate loop with:
function animate(now){
  const dt = (now - lastRender);
  lastRender = now;

  // compute how much scroll-driven time changed since last frame
  const scrollDelta = state.fieldTime - prevFieldTime;
  const t = state.fieldTime * timeScaleForField();

  if (Math.abs(scrollDelta) > 1e-6) {
    // move heads proportionally to scrollDelta (treat as "distance" command)
    const moveFactor = 0.06;           // same scale used previously
    for (const s of snakes) {
      const head = s.points[0];
      const [vx, vy] = field(head.x, head.y, t);
      const sp = s.speed * (0.6 + 0.5 * Math.sin(state.fieldTime*0.002 + s.hue)); // optional subtle variation
      const nhx = head.x + vx * sp * (scrollDelta * moveFactor);
      const nhy = head.y + vy * sp * (scrollDelta * moveFactor);

      const wrapX = x => (x < -20 ? width + 20 : (x > width + 20 ? -20 : x));
      const wrapY = y => (y < -20 ? height + 20 : (y > height + 20 ? -20 : y));
      const newHead = { x: wrapX(nhx), y: wrapY(nhy) };

      s.points.unshift(newHead);
      s.points.pop();

      // relax segments
      for (let iter=0; iter<2; iter++){
        for (let i=1;i<s.points.length;i++){
          const a = s.points[i-1], b = s.points[i];
          const dx = b.x - a.x, dy = b.y - a.y;
          const dist = Math.hypot(dx,dy) || 1e-6;
          const diff = (dist - segLength) / dist;
          const moveF = 0.5;
          b.x -= dx * diff * moveF;
          b.y -= dy * diff * moveF;
        }
      }
    }
    prevFieldTime = state.fieldTime;
  }

  // render paths (always)
  paths
    .attr('d', d => catmullRomPath(d.points))
    .attr('stroke', d => `hsl(${d.hue},80%,60%)`)
    .attr('stroke-width', d => 1.2 + 1.8 * (1 - (d.points.length-1)/segments));

  requestAnimationFrame(animate);
}
```

With this change the snakes remain static until you scroll (GSAP updates state.fieldTime), and they move only in direct response to scroll input. Adjust moveFactor and GSAP fieldTime range to tune responsiveness.
-->