3D Vector Field — d3 + GSAP ScrollTrigger (Zoom Fix)
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>3D Vector Field — d3 + GSAP ScrollTrigger (Zoom Fix)</title>
<style>
html,body { height:200%; margin:0; background:#080808; color:#ddd; }
#canvas { display:block; background:#0b0b0b; position: sticky; top: 0; width: 100vw; height: 100vh; }
.overlay { position:fixed; left:10px; top:10px; color:#ccc; font-family:system-ui; font-size:13px; z-index:10; }
.hint { position:fixed; right:10px; bottom:10px; color:#999; font-family:system-ui; font-size:13px; z-index:10; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script src="https://unpkg.com/d3@7/dist/d3.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);
const cfg = {
width: Math.min(1200, window.innerWidth - 20),
height: Math.min(800, window.innerHeight - 20),
cols: 20,
rows: 14,
depthLayers: 10,
spacing: 48,
vectorScale: 14,
perspective: 800,
rotationSpeed: 0.6,
// NEW: single zoom factor (1 = normal, >1 = zoomed in)
zoom: 1
};
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function resize() {
cfg.width = Math.min(1200, window.innerWidth - 20);
cfg.height = Math.min(800, window.innerHeight - 20);
canvas.width = cfg.width;
canvas.height = cfg.height;
}
resize();
window.addEventListener('resize', resize);
const centerFn = () => ({ x: cfg.width/2, y: cfg.height/2 });
const xOffset = -((cfg.cols - 1) * cfg.spacing) / 2;
const yOffset = -((cfg.rows - 1) * cfg.spacing) / 2;
const zOffset = -((cfg.depthLayers - 1) * cfg.spacing) / 2;
const points = [];
for (let iz = 0; iz < cfg.depthLayers; iz++) {
for (let iy = 0; iy < cfg.rows; iy++) {
for (let ix = 0; ix < cfg.cols; ix++) {
const x = xOffset + ix * cfg.spacing;
const y = yOffset + iy * cfg.spacing;
const z = zOffset + iz * cfg.spacing;
points.push({x,y,z, ix, iy, iz});
}
}
}
const depthScale = d3.scaleLinear().domain([zOffset, -zOffset]).range([0,1]);
const colorScale = d3.scaleLinear().domain([0,1]).range([60,220]);
const widthScale = d3.scaleLinear().domain([0,1]).range([0.6,2.6]);
// Projection now uses zoom: we apply zoom by scaling rx, ry relative to center.
function project(pt, cameraAngle = 0) {
const cosA = Math.cos(cameraAngle), sinA = Math.sin(cameraAngle);
const rx = pt.x * cosA + pt.z * sinA;
const rz = -pt.x * sinA + pt.z * cosA;
// base perspective scale
const scale = cfg.perspective / (cfg.perspective - rz);
const c = centerFn();
// apply zoom as additional multiplier on projected coordinates around center
return {
x: c.x + (rx * scale - 0) * cfg.zoom,
y: c.y + (pt.y * scale - 0) * cfg.zoom,
s: scale * cfg.zoom,
rz
};
}
function circularField(x,y,z,t) {
const r = Math.hypot(x,y) + 1e-6;
const omega = cfg.rotationSpeed * (1 + 0.06 * z / cfg.spacing);
const vx = -y / r, vy = x / r;
const vz = 0.35 * Math.sin(t * 1.2 + z * 0.02 + x * 0.01);
const amp = cfg.vectorScale * (1 - 0.18 * Math.tanh(r / (cfg.cols*cfg.spacing*0.5)));
const ang = omega * t;
const cos = Math.cos(ang), sin = Math.sin(ang);
const rx = vx * cos - vy * sin;
const ry = vx * sin + vy * cos;
return { vx: rx * amp, vy: ry * amp, vz: vz * amp * 0.6 };
}
function drawArrow(x1,y1,x2,y2,lineWidth,color,alpha){
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
const headLen = 6 + lineWidth*1.5;
const angle = Math.atan2(y2-y1, x2-x1);
ctx.beginPath();
ctx.moveTo(x2,y2);
ctx.lineTo(x2 - headLen*Math.cos(angle-Math.PI/6), y2 - headLen*Math.sin(angle-Math.PI/6));
ctx.lineTo(x2 - headLen*Math.cos(angle+Math.PI/6), y2 - headLen*Math.sin(angle+Math.PI/6));
ctx.closePath(); ctx.fill();
ctx.restore();
}
d3.timer(function(elapsed){
const t = elapsed / 1000;
ctx.fillStyle = "rgba(8,8,10,0.20)";
ctx.fillRect(0,0,cfg.width,cfg.height);
const cameraAngle = 0.12 * Math.sin(t * 0.15);
const projected = points.map(p => ({p, proj: project(p, cameraAngle)}))
.sort((a,b) => a.proj.rz - b.proj.rz);
for (const item of projected){
const p = item.p, proj = item.proj, scale = proj.s;
const vec = circularField(p.x,p.y,p.z,t);
const sx = proj.x, sy = proj.y;
const end3 = { x: p.x + vec.vx, y: p.y + vec.vy, z: p.z + vec.vz };
const eproj = project(end3, cameraAngle);
const ex = eproj.x, ey = eproj.y;
const depthVal = depthScale(p.z);
const hue = colorScale(depthVal);
const brightness = 52 + 28*depthVal;
const color = `hsl(${hue},85%,${brightness}%)`;
const lineWidth = widthScale(depthVal) * scale;
const alpha = 0.28 + 0.72 * depthVal;
drawArrow(sx,sy,ex,ey,Math.max(0.6,lineWidth),color,alpha);
}
// subtle front points
ctx.save(); ctx.globalAlpha=0.06; ctx.fillStyle="#fff";
for (const item of projected.slice(-60)){ ctx.beginPath(); ctx.arc(item.proj.x, item.proj.y, 1.2*item.proj.s,0,Math.PI*2); ctx.fill(); }
ctx.restore();
});
// GSAP ScrollTrigger: animate cfg.zoom from 1 -> 2.8 (tune as desired)
gsap.to(cfg, {
zoom: 2.8,
ease: "none",
scrollTrigger: {
trigger: document.body,
start: "top top",
end: "bottom top",
scrub: 0.6,
// markers: true
}
});
</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:22:46 PM:
Generate me 3d vector field in d3js animated in circular motion. HTML + JS only
GPT-5 mini:
## 3D Vector Field (HTML + JS) — Animated Circular Motion (d3.js + Canvas)
Below is a single-file HTML+JS example that uses d3 for scales and animation timing, and an HTML canvas to render a pseudo-3D vector field where vectors circulate around the Z axis (circular motion). The 3D effect is achieved with simple perspective projection. Paste into a file (e.g., index.html) and open in a browser.
```html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>3D Circular Vector Field — d3.js</title>
<style>
html,body { height:100%; margin:0; background:#111; color:#ddd; }
#canvas { display:block; margin:0 auto; background:#0b0b0b; }
.overlay { position:fixed; left:10px; top:10px; color:#ccc; font-family:system-ui,Segoe UI,Roboto; font-size:13px; }
</style>
</head>
<body>
<div class="overlay">3D Circular Vector Field — d3.js</div>
<canvas id="canvas"></canvas>
<script src="https://unpkg.com/d3@7/dist/d3.min.js"></script>
<script>
// Config
const cfg = {
width: Math.min(1200, window.innerWidth - 20),
height: Math.min(800, window.innerHeight - 20),
cols: 20, // grid columns
rows: 14, // grid rows
depthLayers: 10, // number of z-slices (3D)
spacing: 48, // base spacing in x/y
vectorScale: 14, // arrow length multiplier
perspective: 800, // perspective distance
rotationSpeed: 0.6 // angular speed (radians per second)
};
// Canvas setup
const canvas = document.getElementById('canvas');
canvas.width = cfg.width;
canvas.height = cfg.height;
const ctx = canvas.getContext('2d');
// Derived layout
const centerX = cfg.width / 2;
const centerY = cfg.height / 2;
const xOffset = -((cfg.cols - 1) * cfg.spacing) / 2;
const yOffset = -((cfg.rows - 1) * cfg.spacing) / 2;
const zOffset = -((cfg.depthLayers - 1) * cfg.spacing) / 2;
// Build grid points in 3D
const points = [];
for (let iz = 0; iz < cfg.depthLayers; iz++) {
for (let iy = 0; iy < cfg.rows; iy++) {
for (let ix = 0; ix < cfg.cols; ix++) {
const x = xOffset + ix * cfg.spacing;
const y = yOffset + iy * cfg.spacing;
const z = zOffset + iz * cfg.spacing;
points.push({x,y,z, ix, iy, iz});
}
}
}
// d3 scales for color and thickness by depth (near = brighter)
const depthScale = d3.scaleLinear()
.domain([zOffset, -zOffset]) // far to near
.range([0,1]);
const colorScale = d3.scaleLinear()
.domain([0,1])
.range([60, 220]); // hue values for HSL
const widthScale = d3.scaleLinear()
.domain([0,1])
.range([0.6, 2.6]); // line width
// Projection: simple perspective onto canvas
function project(pt, cameraAngle = 0) {
// optionally rotate around Y (cameraAngle) to make it spin camera a bit
const cosA = Math.cos(cameraAngle), sinA = Math.sin(cameraAngle);
const rx = pt.x * cosA + pt.z * sinA;
const rz = -pt.x * sinA + pt.z * cosA; // rotated z
const scale = cfg.perspective / (cfg.perspective - rz);
return {
x: centerX + rx * scale,
y: centerY + pt.y * scale,
s: scale,
rz
};
}
// Vector field function: circular around Z axis with optional time
// returns vector {vx, vy, vz}
function circularField(x,y,z, t) {
// compute radius in XY plane from origin
const r = Math.hypot(x, y) + 1e-6;
// angular velocity might vary slightly with z for depth variation
const omega = cfg.rotationSpeed * (1 + 0.06 * z / cfg.spacing);
// direction tangent to circle: (-y/r, x/r) (counterclockwise)
const vx = -y / r;
const vy = x / r;
// add small vertical component that oscillates with time for 3D motion
const vz = 0.35 * Math.sin(t * 1.2 + z * 0.02 + x * 0.01);
// amplitude modulation by radius to keep vectors reasonable
const amp = cfg.vectorScale * (1 - 0.18 * Math.tanh(r / (cfg.cols*cfg.spacing*0.5)));
// also rotate vector slowly over time
const ang = omega * t;
const cos = Math.cos(ang), sin = Math.sin(ang);
const rx = vx * cos - vy * sin;
const ry = vx * sin + vy * cos;
return { vx: rx * amp, vy: ry * amp, vz: vz * amp * 0.6 };
}
// Arrow drawing helper
function drawArrow(x1,y1,x2,y2, lineWidth, color, alpha) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
// main shaft
ctx.beginPath();
ctx.moveTo(x1,y1);
ctx.lineTo(x2,y2);
ctx.stroke();
// arrowhead
const headLen = 6 + lineWidth * 1.5;
const angle = Math.atan2(y2 - y1, x2 - x1);
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI/6), y2 - headLen * Math.sin(angle - Math.PI/6));
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI/6), y2 - headLen * Math.sin(angle + Math.PI/6));
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Animation loop using d3.timer
const start = Date.now();
d3.timer(function(elapsed) {
const t = elapsed / 1000; // seconds
// clear with slight alpha to create motion trails
ctx.fillStyle = "rgba(8,8,10,0.20)";
ctx.fillRect(0,0,cfg.width,cfg.height);
// optional slow camera spin for nicer 3D perception
const cameraAngle = 0.12 * Math.sin(t * 0.15);
// sort points by depth (back to front) for painter's algorithm
const projected = points.map(p => {
const proj = project(p, cameraAngle);
return { p, proj };
}).sort((a,b) => a.proj.rz - b.proj.rz); // far (more negative rz) first
// draw grid points vectors
for (const item of projected) {
const p = item.p;
const proj = item.proj;
const scale = proj.s;
// compute vector at this 3D point
const vec = circularField(p.x, p.y, p.z, t);
// project start and end
const sx = proj.x;
const sy = proj.y;
// project 3D end by adding vector then projecting (approx)
const end3 = { x: p.x + vec.vx, y: p.y + vec.vy, z: p.z + vec.vz };
const eproj = project(end3, cameraAngle);
const ex = eproj.x;
const ey = eproj.y;
// color / width / alpha by depth (nearer => brighter / thicker)
const depthVal = depthScale(p.z);
const hue = colorScale(depthVal);
const brightness = 52 + 28 * depthVal;
const color = `hsl(${hue}, 85%, ${brightness}%)`;
const lineWidth = widthScale(depthVal) * scale;
// fade vectors that are very far behind
const alpha = 0.28 + 0.72 * depthVal;
drawArrow(sx, sy, ex, ey, Math.max(0.6, lineWidth), color, alpha);
}
// optional: draw subtle grid points near front
ctx.save();
ctx.globalAlpha = 0.06;
ctx.fillStyle = "#fff";
for (const item of projected.slice(-60)) {
ctx.beginPath();
ctx.arc(item.proj.x, item.proj.y, 1.2 * item.proj.s, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
// done; continue
});
// Resize handling
window.addEventListener('resize', () => {
cfg.width = Math.min(1200, window.innerWidth - 20);
cfg.height = Math.min(800, window.innerHeight - 20);
canvas.width = cfg.width;
canvas.height = cfg.height;
});
</script>
</body>
</html>
```
Usage notes (brief):
- Adjust cfg.cols/rows/depthLayers and spacing for density.
- Change cfg.rotationSpeed, vectorScale, perspective for different visual effects.
- The script uses d3.timer and d3 scales; no DOM-based SVG vectors to keep performance high.
--------------------
User prompt 2 of 3 - 2/16/2026, 7:23:57 PM:
Now add GSAP scroll trigger so when I scroll it zooms in into center of this field.
GPT-5 mini:
## Updated HTML + JS — Add GSAP ScrollTrigger Zoom
Below is the single-file HTML updated to include GSAP with ScrollTrigger. Scrolling the page will animate a smooth zoom into the center of the 3D vector field by adjusting the canvas perspective value and a camera focal offset. Paste into a file and open in a browser.
```html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>3D Circular Vector Field — d3.js + GSAP ScrollTrigger</title>
<style>
html,body { height:200%; margin:0; background:#111; color:#ddd; } /* taller page to allow scrolling */
#canvas { display:block; margin:40vh auto 0; background:#0b0b0b; } /* start canvas mid-page */
.overlay { position:fixed; left:10px; top:10px; color:#ccc; font-family:system-ui,Segoe UI,Roboto; font-size:13px; z-index:10; }
.hint { position:fixed; right:10px; bottom:10px; color:#999; font-family:system-ui,Segoe UI,Roboto; font-size:13px; z-index:10; }
</style>
</head>
<body>
<div class="overlay">3D Circular Vector Field — d3.js + GSAP ScrollTrigger</div>
<div class="hint">Scroll to zoom in</div>
<canvas id="canvas"></canvas>
<script src="https://unpkg.com/d3@7/dist/d3.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 (kept adjustable)
const cfg = {
width: Math.min(1200, window.innerWidth - 20),
height: Math.min(800, window.innerHeight - 20),
cols: 20,
rows: 14,
depthLayers: 10,
spacing: 48,
vectorScale: 14,
perspective: 800,
rotationSpeed: 0.6,
// additional camera params that ScrollTrigger will tween
camZOffset: 0, // shift along z for camera (used in projection)
perspectiveScale: 1 // multiplier applied to perspective for zooming
};
// Canvas setup
const canvas = document.getElementById('canvas');
canvas.width = cfg.width;
canvas.height = cfg.height;
const ctx = canvas.getContext('2d');
// Derived layout
const centerX = () => cfg.width / 2;
const centerY = () => cfg.height / 2;
const xOffset = -((cfg.cols - 1) * cfg.spacing) / 2;
const yOffset = -((cfg.rows - 1) * cfg.spacing) / 2;
const zOffset = -((cfg.depthLayers - 1) * cfg.spacing) / 2;
// Build grid points in 3D
const points = [];
for (let iz = 0; iz < cfg.depthLayers; iz++) {
for (let iy = 0; iy < cfg.rows; iy++) {
for (let ix = 0; ix < cfg.cols; ix++) {
const x = xOffset + ix * cfg.spacing;
const y = yOffset + iy * cfg.spacing;
const z = zOffset + iz * cfg.spacing;
points.push({x,y,z, ix, iy, iz});
}
}
}
// d3 scales for color and thickness by depth (near = brighter)
const depthScale = d3.scaleLinear()
.domain([zOffset, -zOffset])
.range([0,1]);
const colorScale = d3.scaleLinear()
.domain([0,1])
.range([60, 220]);
const widthScale = d3.scaleLinear()
.domain([0,1])
.range([0.6, 2.6]);
// Projection: uses cfg.perspective * cfg.perspectiveScale and camZOffset
function project(pt, cameraAngle = 0) {
const cosA = Math.cos(cameraAngle), sinA = Math.sin(cameraAngle);
const rx = pt.x * cosA + pt.z * sinA;
const rz = -pt.x * sinA + pt.z * cosA + cfg.camZOffset;
const perspectiveEffective = cfg.perspective * cfg.perspectiveScale;
const scale = perspectiveEffective / (perspectiveEffective - rz);
return {
x: centerX() + rx * scale,
y: centerY() + pt.y * scale,
s: scale,
rz
};
}
// Vector field function: circular around Z axis
function circularField(x,y,z, t) {
const r = Math.hypot(x, y) + 1e-6;
const omega = cfg.rotationSpeed * (1 + 0.06 * z / cfg.spacing);
const vx = -y / r;
const vy = x / r;
const vz = 0.35 * Math.sin(t * 1.2 + z * 0.02 + x * 0.01);
const amp = cfg.vectorScale * (1 - 0.18 * Math.tanh(r / (cfg.cols*cfg.spacing*0.5)));
const ang = omega * t;
const cos = Math.cos(ang), sin = Math.sin(ang);
const rx = vx * cos - vy * sin;
const ry = vx * sin + vy * cos;
return { vx: rx * amp, vy: ry * amp, vz: vz * amp * 0.6 };
}
function drawArrow(x1,y1,x2,y2, lineWidth, color, alpha) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x1,y1);
ctx.lineTo(x2,y2);
ctx.stroke();
const headLen = 6 + lineWidth * 1.5;
const angle = Math.atan2(y2 - y1, x2 - x1);
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI/6), y2 - headLen * Math.sin(angle - Math.PI/6));
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI/6), y2 - headLen * Math.sin(angle + Math.PI/6));
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Animation loop using d3.timer
d3.timer(function(elapsed) {
const t = elapsed / 1000;
ctx.fillStyle = "rgba(8,8,10,0.20)";
ctx.fillRect(0,0,cfg.width,cfg.height);
const cameraAngle = 0.12 * Math.sin(t * 0.15);
const projected = points.map(p => ({ p, proj: project(p, cameraAngle) }))
.sort((a,b) => a.proj.rz - b.proj.rz);
for (const item of projected) {
const p = item.p;
const proj = item.proj;
const scale = proj.s;
const vec = circularField(p.x, p.y, p.z, t);
const sx = proj.x;
const sy = proj.y;
const end3 = { x: p.x + vec.vx, y: p.y + vec.vy, z: p.z + vec.vz };
const eproj = project(end3, cameraAngle);
const ex = eproj.x;
const ey = eproj.y;
const depthVal = depthScale(p.z);
const hue = colorScale(depthVal);
const brightness = 52 + 28 * depthVal;
const color = `hsl(${hue}, 85%, ${brightness}%)`;
const lineWidth = widthScale(depthVal) * scale;
const alpha = 0.28 + 0.72 * depthVal;
drawArrow(sx, sy, ex, ey, Math.max(0.6, lineWidth), color, alpha);
}
ctx.save();
ctx.globalAlpha = 0.06;
ctx.fillStyle = "#fff";
for (const item of projected.slice(-60)) {
ctx.beginPath();
ctx.arc(item.proj.x, item.proj.y, 1.2 * item.proj.s, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
});
// GSAP ScrollTrigger: zoom into center while scrolling down the page
// We'll tween cfg.perspectiveScale (lower => zoom in) and cfg.camZOffset to pull camera forward.
gsap.to(cfg, {
perspectiveScale: 0.28, // smaller perspective => larger apparent zoom
camZOffset: -200, // shift camera forward along Z
ease: "none",
scrollTrigger: {
trigger: document.body,
start: "top top",
end: "bottom top",
scrub: 0.6,
// markers: true, // uncomment for debugging
},
onUpdate: () => {
// no-op; d3.timer reads cfg directly each frame
}
});
// Responsive handling
function resizeCanvas() {
cfg.width = Math.min(1200, window.innerWidth - 20);
cfg.height = Math.min(800, window.innerHeight - 20);
canvas.width = cfg.width;
canvas.height = cfg.height;
}
window.addEventListener('resize', resizeCanvas);
// Ensure initial layout (canvas vertically centered area)
resizeCanvas();
</script>
</body>
</html>
```
Notes:
- Adjust GSAP target values (perspectiveScale and camZOffset) to tune zoom intensity.
- Set ScrollTrigger markers: true for tuning the scroll range.
--------------------
User prompt 3 of 3 - 2/16/2026, 7:25:00 PM:
It does not zoom in
GPT-5 mini:
Likely cause: the canvas draw uses a fixed center and perspective read from cfg, but the projection math or GSAP target values don't produce a visibly larger scale. I'll give a minimal, corrected HTML+JS where ScrollTrigger clearly zooms by animating a single "zoom" parameter that multiplies projected coordinates (works robustly). Paste this file and open in a browser; scroll to see zoom.
```html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>3D Vector Field — d3 + GSAP ScrollTrigger (Zoom Fix)</title>
<style>
html,body { height:200%; margin:0; background:#080808; color:#ddd; }
#canvas { display:block; margin:30vh auto 0; background:#0b0b0b; }
.overlay { position:fixed; left:10px; top:10px; color:#ccc; font-family:system-ui; font-size:13px; z-index:10; }
.hint { position:fixed; right:10px; bottom:10px; color:#999; font-family:system-ui; font-size:13px; z-index:10; }
</style>
</head>
<body>
<div class="overlay">3D Vector Field — Scroll to zoom</div>
<div class="hint">Scroll to zoom in</div>
<canvas id="canvas"></canvas>
<script src="https://unpkg.com/d3@7/dist/d3.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);
const cfg = {
width: Math.min(1200, window.innerWidth - 20),
height: Math.min(800, window.innerHeight - 20),
cols: 20,
rows: 14,
depthLayers: 10,
spacing: 48,
vectorScale: 14,
perspective: 800,
rotationSpeed: 0.6,
// NEW: single zoom factor (1 = normal, >1 = zoomed in)
zoom: 1
};
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function resize() {
cfg.width = Math.min(1200, window.innerWidth - 20);
cfg.height = Math.min(800, window.innerHeight - 20);
canvas.width = cfg.width;
canvas.height = cfg.height;
}
resize();
window.addEventListener('resize', resize);
const centerFn = () => ({ x: cfg.width/2, y: cfg.height/2 });
const xOffset = -((cfg.cols - 1) * cfg.spacing) / 2;
const yOffset = -((cfg.rows - 1) * cfg.spacing) / 2;
const zOffset = -((cfg.depthLayers - 1) * cfg.spacing) / 2;
const points = [];
for (let iz = 0; iz < cfg.depthLayers; iz++) {
for (let iy = 0; iy < cfg.rows; iy++) {
for (let ix = 0; ix < cfg.cols; ix++) {
const x = xOffset + ix * cfg.spacing;
const y = yOffset + iy * cfg.spacing;
const z = zOffset + iz * cfg.spacing;
points.push({x,y,z, ix, iy, iz});
}
}
}
const depthScale = d3.scaleLinear().domain([zOffset, -zOffset]).range([0,1]);
const colorScale = d3.scaleLinear().domain([0,1]).range([60,220]);
const widthScale = d3.scaleLinear().domain([0,1]).range([0.6,2.6]);
// Projection now uses zoom: we apply zoom by scaling rx, ry relative to center.
function project(pt, cameraAngle = 0) {
const cosA = Math.cos(cameraAngle), sinA = Math.sin(cameraAngle);
const rx = pt.x * cosA + pt.z * sinA;
const rz = -pt.x * sinA + pt.z * cosA;
// base perspective scale
const scale = cfg.perspective / (cfg.perspective - rz);
const c = centerFn();
// apply zoom as additional multiplier on projected coordinates around center
return {
x: c.x + (rx * scale - 0) * cfg.zoom,
y: c.y + (pt.y * scale - 0) * cfg.zoom,
s: scale * cfg.zoom,
rz
};
}
function circularField(x,y,z,t) {
const r = Math.hypot(x,y) + 1e-6;
const omega = cfg.rotationSpeed * (1 + 0.06 * z / cfg.spacing);
const vx = -y / r, vy = x / r;
const vz = 0.35 * Math.sin(t * 1.2 + z * 0.02 + x * 0.01);
const amp = cfg.vectorScale * (1 - 0.18 * Math.tanh(r / (cfg.cols*cfg.spacing*0.5)));
const ang = omega * t;
const cos = Math.cos(ang), sin = Math.sin(ang);
const rx = vx * cos - vy * sin;
const ry = vx * sin + vy * cos;
return { vx: rx * amp, vy: ry * amp, vz: vz * amp * 0.6 };
}
function drawArrow(x1,y1,x2,y2,lineWidth,color,alpha){
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
const headLen = 6 + lineWidth*1.5;
const angle = Math.atan2(y2-y1, x2-x1);
ctx.beginPath();
ctx.moveTo(x2,y2);
ctx.lineTo(x2 - headLen*Math.cos(angle-Math.PI/6), y2 - headLen*Math.sin(angle-Math.PI/6));
ctx.lineTo(x2 - headLen*Math.cos(angle+Math.PI/6), y2 - headLen*Math.sin(angle+Math.PI/6));
ctx.closePath(); ctx.fill();
ctx.restore();
}
d3.timer(function(elapsed){
const t = elapsed / 1000;
ctx.fillStyle = "rgba(8,8,10,0.20)";
ctx.fillRect(0,0,cfg.width,cfg.height);
const cameraAngle = 0.12 * Math.sin(t * 0.15);
const projected = points.map(p => ({p, proj: project(p, cameraAngle)}))
.sort((a,b) => a.proj.rz - b.proj.rz);
for (const item of projected){
const p = item.p, proj = item.proj, scale = proj.s;
const vec = circularField(p.x,p.y,p.z,t);
const sx = proj.x, sy = proj.y;
const end3 = { x: p.x + vec.vx, y: p.y + vec.vy, z: p.z + vec.vz };
const eproj = project(end3, cameraAngle);
const ex = eproj.x, ey = eproj.y;
const depthVal = depthScale(p.z);
const hue = colorScale(depthVal);
const brightness = 52 + 28*depthVal;
const color = `hsl(${hue},85%,${brightness}%)`;
const lineWidth = widthScale(depthVal) * scale;
const alpha = 0.28 + 0.72 * depthVal;
drawArrow(sx,sy,ex,ey,Math.max(0.6,lineWidth),color,alpha);
}
// subtle front points
ctx.save(); ctx.globalAlpha=0.06; ctx.fillStyle="#fff";
for (const item of projected.slice(-60)){ ctx.beginPath(); ctx.arc(item.proj.x, item.proj.y, 1.2*item.proj.s,0,Math.PI*2); ctx.fill(); }
ctx.restore();
});
// GSAP ScrollTrigger: animate cfg.zoom from 1 -> 2.8 (tune as desired)
gsap.to(cfg, {
zoom: 2.8,
ease: "none",
scrollTrigger: {
trigger: document.body,
start: "top top",
end: "bottom top",
scrub: 0.6,
// markers: true
}
});
</script>
</body>
</html>
```
How this fixes it:
- Introduces a single cfg.zoom applied directly to projected x/y and scale so increasing zoom enlarges the field visually.
- ScrollTrigger tweens cfg.zoom from 1 to 2.8 (adjust that value if you want stronger/weaker zoom).
-->