<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Canvas on Dan's Blog</title><link>https://blog.danfrevel.de/tags/canvas/</link><description>Recent content in Canvas on Dan's Blog</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 05 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.danfrevel.de/tags/canvas/index.xml" rel="self" type="application/rss+xml"/><item><title>Flow Fields: An Interactive Playground</title><link>https://blog.danfrevel.de/posts/flow-field-playground/</link><pubDate>Thu, 05 Feb 2026 00:00:00 +0000</pubDate><guid>https://blog.danfrevel.de/posts/flow-field-playground/</guid><description>&lt;p&gt;This was originally the background animation for this blog. I liked it too much to just delete it, so here it is as an interactive playground. Drag sliders to change the behavior in real time.&lt;/p&gt;
&lt;div style="position: relative; width: 100%; aspect-ratio: 16/9; border-radius: 0.75rem; overflow: hidden; background: #1e293b;"&gt;
 &lt;canvas id="flow-canvas" style="display: block; width: 100%; height: 100%;"&gt;&lt;/canvas&gt;
&lt;/div&gt;
&lt;div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1.5rem; font-size: 0.875rem;"&gt;
 &lt;label style="display: flex; flex-direction: column; gap: 0.25rem;"&gt;
 Particle Count: &lt;span id="val-particles"&gt;80&lt;/span&gt;
 &lt;input type="range" id="ctrl-particles" min="10" max="300" value="80" step="1"&gt;
 &lt;/label&gt;
 &lt;label style="display: flex; flex-direction: column; gap: 0.25rem;"&gt;
 Noise Scale: &lt;span id="val-noise-scale"&gt;0.005&lt;/span&gt;
 &lt;input type="range" id="ctrl-noise-scale" min="0.001" max="0.02" value="0.005" step="0.001"&gt;
 &lt;/label&gt;
 &lt;label style="display: flex; flex-direction: column; gap: 0.25rem;"&gt;
 Noise Strength: &lt;span id="val-noise-strength"&gt;0.3&lt;/span&gt;
 &lt;input type="range" id="ctrl-noise-strength" min="0.1" max="1.0" value="0.3" step="0.05"&gt;
 &lt;/label&gt;
 &lt;label style="display: flex; flex-direction: column; gap: 0.25rem;"&gt;
 Speed: &lt;span id="val-speed"&gt;2.0&lt;/span&gt;
 &lt;input type="range" id="ctrl-speed" min="0.5" max="4.0" value="2.0" step="0.1"&gt;
 &lt;/label&gt;
 &lt;label style="display: flex; flex-direction: column; gap: 0.25rem;"&gt;
 Mouse Radius: &lt;span id="val-mouse-radius"&gt;120&lt;/span&gt;
 &lt;input type="range" id="ctrl-mouse-radius" min="50" max="300" value="120" step="10"&gt;
 &lt;/label&gt;
 &lt;label style="display: flex; flex-direction: column; gap: 0.25rem;"&gt;
 &lt;span&gt;&amp;nbsp;&lt;/span&gt;
 &lt;button id="ctrl-reset" style="padding: 0.375rem 1rem; border-radius: 0.375rem; background: #f59e0b; color: #1e293b; font-weight: 600; cursor: pointer; border: none;"&gt;Reset&lt;/button&gt;
 &lt;/label&gt;
&lt;/div&gt;
&lt;script src="https://blog.danfrevel.de/js/perlin.js"&gt;&lt;/script&gt;
&lt;script&gt;
(function() {
 const canvas = document.getElementById('flow-canvas');
 if (!canvas) return;
 const ctx = canvas.getContext('2d');

 const defaults = {
 particleCount: 80,
 noiseScale: 0.005,
 noiseStrength: 0.3,
 maxSpeed: 2.0,
 mouseRadius: 120
 };

 const config = { ...defaults };

 let particles = [];
 let time = 0;
 let mouseX = -9999, mouseY = -9999;
 let lastTime = performance.now();
 let running = false;
 let rafId = null;
 let noiseGen = null;

 function resize() {
 const parent = canvas.parentElement;
 const rect = parent.getBoundingClientRect();
 const dpr = window.devicePixelRatio || 1;
 canvas.width = rect.width * dpr;
 canvas.height = rect.height * dpr;
 ctx.scale(dpr, dpr);
 initParticles();
 }

 function initParticles() {
 const rect = canvas.parentElement.getBoundingClientRect();
 particles = [];
 for (let i = 0; i &lt; config.particleCount; i++) {
 particles.push({
 x: Math.random() * rect.width,
 y: Math.random() * rect.height,
 vx: (Math.random() - 0.5) * 0.5,
 vy: (Math.random() - 0.5) * 0.5,
 age: Math.random() * 500
 });
 }
 }

 function isDark() {
 return document.documentElement.classList.contains('dark');
 }

 function animate() {
 if (!running) return;
 rafId = requestAnimationFrame(animate);

 const now = performance.now();
 const dt = Math.min((now - lastTime) / 16.67, 3);
 lastTime = now;
 time += 0.001 * dt;

 const rect = canvas.parentElement.getBoundingClientRect();
 const w = rect.width;
 const h = rect.height;

 ctx.globalAlpha = 1;
 ctx.fillStyle = isDark() ? 'rgba(15, 23, 42, 0.15)' : 'rgba(255, 255, 255, 0.15)';
 ctx.fillRect(0, 0, w, h);

 const particleColor = isDark() ? '#f59e0b' : '#6366f1';

 for (let i = 0; i &lt; particles.length; i++) {
 const p = particles[i];
 p.age += dt;

 if (p.age &gt; 500) {
 p.x = Math.random() * w;
 p.y = Math.random() * h;
 p.vx = (Math.random() - 0.5) * 0.5;
 p.vy = (Math.random() - 0.5) * 0.5;
 p.age = 0;
 continue;
 }

 const angle = noiseGen.simplex3(p.x * config.noiseScale, p.y * config.noiseScale, time) * Math.PI * 2;
 p.vx += Math.cos(angle) * config.noiseStrength * dt;
 p.vy += Math.sin(angle) * config.noiseStrength * dt;

 const dx = p.x - mouseX;
 const dy = p.y - mouseY;
 const distSq = dx * dx + dy * dy;
 const rSq = config.mouseRadius * config.mouseRadius;
 if (distSq &lt; rSq &amp;&amp; distSq &gt; 0) {
 const dist = Math.sqrt(distSq);
 const force = (1 - dist / config.mouseRadius) * 3;
 p.vx += (dx / dist) * force * dt;
 p.vy += (dy / dist) * force * dt;
 }

 const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
 if (speed &gt; config.maxSpeed) {
 p.vx = (p.vx / speed) * config.maxSpeed;
 p.vy = (p.vy / speed) * config.maxSpeed;
 }

 p.x += p.vx * dt;
 p.y += p.vy * dt;

 if (p.x &lt; 0) p.x = w;
 if (p.x &gt; w) p.x = 0;
 if (p.y &lt; 0) p.y = h;
 if (p.y &gt; h) p.y = 0;

 const friction = Math.pow(0.98, dt);
 p.vx *= friction;
 p.vy *= friction;

 ctx.fillStyle = particleColor;
 ctx.globalAlpha = 0.5;
 ctx.beginPath();
 ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
 ctx.fill();
 }
 }

 function start() {
 if (!running) {
 running = true;
 lastTime = performance.now();
 animate();
 }
 }

 function stop() {
 running = false;
 if (rafId) {
 cancelAnimationFrame(rafId);
 rafId = null;
 }
 }

 // Slider bindings
 const sliders = [
 { id: 'ctrl-particles', val: 'val-particles', key: 'particleCount', parse: v =&gt; parseInt(v), onChange: initParticles },
 { id: 'ctrl-noise-scale', val: 'val-noise-scale', key: 'noiseScale', parse: v =&gt; parseFloat(v) },
 { id: 'ctrl-noise-strength', val: 'val-noise-strength', key: 'noiseStrength', parse: v =&gt; parseFloat(v) },
 { id: 'ctrl-speed', val: 'val-speed', key: 'maxSpeed', parse: v =&gt; parseFloat(v) },
 { id: 'ctrl-mouse-radius', val: 'val-mouse-radius', key: 'mouseRadius', parse: v =&gt; parseInt(v) }
 ];

 sliders.forEach(s =&gt; {
 const input = document.getElementById(s.id);
 const display = document.getElementById(s.val);
 if (!input || !display) return;
 input.addEventListener('input', () =&gt; {
 const v = s.parse(input.value);
 config[s.key] = v;
 display.textContent = input.value;
 if (s.onChange) s.onChange();
 });
 });

 const resetBtn = document.getElementById('ctrl-reset');
 if (resetBtn) {
 resetBtn.addEventListener('click', () =&gt; {
 Object.assign(config, defaults);
 sliders.forEach(s =&gt; {
 const input = document.getElementById(s.id);
 const display = document.getElementById(s.val);
 if (!input || !display) return;
 input.value = defaults[s.key];
 display.textContent = defaults[s.key];
 });
 initParticles();
 });
 }

 // Mouse tracking relative to canvas
 canvas.style.pointerEvents = 'auto';
 canvas.addEventListener('mousemove', e =&gt; {
 const rect = canvas.getBoundingClientRect();
 mouseX = e.clientX - rect.left;
 mouseY = e.clientY - rect.top;
 });
 canvas.addEventListener('mouseleave', () =&gt; {
 mouseX = -9999;
 mouseY = -9999;
 });

 // Touch support
 canvas.addEventListener('touchmove', e =&gt; {
 e.preventDefault();
 const rect = canvas.getBoundingClientRect();
 const touch = e.touches[0];
 mouseX = touch.clientX - rect.left;
 mouseY = touch.clientY - rect.top;
 }, { passive: false });
 canvas.addEventListener('touchend', () =&gt; {
 mouseX = -9999;
 mouseY = -9999;
 });

 // Pause when not visible
 const observer = new IntersectionObserver(entries =&gt; {
 entries.forEach(entry =&gt; {
 if (entry.isIntersecting) start();
 else stop();
 });
 }, { threshold: 0.1 });
 observer.observe(canvas);

 // Dark mode changes
 const htmlEl = document.documentElement;
 const darkObserver = new MutationObserver(() =&gt; {
 // colors update automatically via isDark() in the render loop
 });
 darkObserver.observe(htmlEl, { attributes: true, attributeFilter: ['class'] });

 // Init
 function init() {
 if (typeof window.noise === 'undefined') {
 console.error('perlin.js not loaded');
 return;
 }
 noiseGen = window.noise;
 noiseGen.seed(Math.random());
 resize();
 window.addEventListener('resize', () =&gt; {
 clearTimeout(init._resizeTimer);
 init._resizeTimer = setTimeout(resize, 100);
 });
 }

 if (document.readyState === 'loading') {
 document.addEventListener('DOMContentLoaded', init);
 } else {
 init();
 }
})();
&lt;/script&gt;
&lt;h2 id="how-it-works"&gt;How It Works&lt;/h2&gt;
&lt;p&gt;Flow fields use &lt;strong&gt;simplex noise&lt;/strong&gt; to generate a smooth vector field across 2D space. Each point in the field has a direction, and particles follow that direction as they move.&lt;/p&gt;</description></item></channel></rss>