• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
07Games
└ Rockfall└ Speak & Math└ Space Invaders└ Apache└ Asteroids└ Molly Roger└ Carrom└ Chess└ Connect 4└ Anagrams
08Voronoi
└ 001└ 002└ 003└ 004└ 005
09Terrain
└ Simple Polygon└ Simple Flyover└ Infinite Flyover 01└ Infinite Flyover 02└ Hexagonal Plane└ Mountain Ridges
10Critter Models 01
└ Wireframe Spider└ Wireframe Fly└ Wireframe Scorpion└ Wireframe Mosquito└ Wireframe Bee└ Wireframe Butterfly└ Wireframe Locust
11Chromatophores
└ 01└ 02└ 03└ 04
12Conformal Maps
└ 01└ 02└ 03└ 04└ 05
02 / 03

001 - VORONOI FLOW FIELD SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Gradient Flow Fields</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            background-color: #000;
            overflow: hidden;
            transition: background-color 0.5s ease;
        }

        body.light-mode {
            background-color: #ffffff;
        }

        canvas {
            display: block;
            width: 100vw;
            height: 100vh;
        }
    </style>
</head>
<body>
    <canvas id="can"></canvas>

    <script>
        // ---------- THEME SYNC ----------
        function syncTheme() {
            try {
                let isDark = true;
                if (window.parent && window.parent.document) {
                    isDark = window.parent.document.documentElement.classList.contains('dark');
                } else {
                    const theme = localStorage.getItem('theme');
                    isDark = theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
                }
                
                if (!isDark) {
                    document.body.classList.add('light-mode');
                } else {
                    document.body.classList.remove('light-mode');
                }
            } catch (e) {
                console.warn("Could not sync theme", e);
            }
        }
        
        syncTheme();
        if (window.parent && window.parent.document) {
            try {
                const observer = new MutationObserver(syncTheme);
                observer.observe(window.parent.document.documentElement, { attributes: true, attributeFilter: ['class'] });
            } catch(e) {}
        }
        window.addEventListener('storage', syncTheme);

        const can = document.getElementById('can');
        const c = can.getContext('2d');

        const CONFIG = {
            speed: 22,
            density: 40,
            bandWidth: 6,
            bandGap: 45,
            colorTransitionDuration: 4000,
            backgroundWaveSpeed: 0.00015
        };

        let isAnimating = true;
        let animationId = null;
        let resolutionScale = 0.75;
        let time = 0;

        let points = [];
        let pointVelocities = [];

        let colorSets = [];
        let currentColorSet = 0;
        let targetColorSet = 1;
        let colorTransitionStartTime = 0;
        let isTransitioning = false;
        let colorMix = 0;
        let bgPhase = 0;
        let lastTimestamp = 0;

        function initColorSets() {
            colorSets = [];

            for (let i = 0; i < 8; i++) {
                const hue1 = (i * 60 + time * 0.001) % 360;
                const hue2 = (hue1 + 120) % 360;
                const hue3 = (hue2 + 120) % 360;

                colorSets.push([
                    hslToRgb(hue1, 75, 50),
                    hslToRgb(hue2, 80, 45),
                    hslToRgb(hue3, 65, 55)
                ]);
            }
        }

        function startColorTransition() {
            targetColorSet = (currentColorSet + 1) % colorSets.length;
            colorTransitionStartTime = time;
            isTransitioning = true;
        }

        function hslToRgb(h, s, l) {
            h /= 360;
            s /= 100;
            l /= 100;

            let r, g, b;

            if (s === 0) {
                r = g = b = l;
            } else {
                const hue2rgb = (p, q, t) => {
                    if (t < 0) t += 1;
                    if (t > 1) t -= 1;
                    if (t < 1/6) return p + (q - p) * 6 * t;
                    if (t < 1/2) return q;
                    if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
                    return p;
                };

                const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
                const p = 2 * l - q;

                r = hue2rgb(p, q, h + 1/3);
                g = hue2rgb(p, q, h);
                b = hue2rgb(p, q, h - 1/3);
            }

            return {
                r: Math.round(r * 255),
                g: Math.round(g * 255),
                b: Math.round(b * 255)
            };
        }

        function interpolateColor(color1, color2, t) {
            const easeT = 0.5 - Math.cos(t * Math.PI) * 0.5;
            return {
                r: color1.r * (1 - easeT) + color2.r * easeT,
                g: color1.g * (1 - easeT) + color2.g * easeT,
                b: color1.b * (1 - easeT) + color2.b * easeT
            };
        }

        function getGradientColor(x, y, width, height, timestamp) {
            const nx = x / width;
            const ny = y / height;

            const rotation = timestamp * 0.00001;
            const rotatedX = (nx - 0.5) * Math.cos(rotation) - (ny - 0.5) * Math.sin(rotation) + 0.5;
            const rotatedY = (nx - 0.5) * Math.sin(rotation) + (ny - 0.5) * Math.cos(rotation) + 0.5;

            const swirl = Math.sin(rotatedX * Math.PI * 3 + timestamp * 0.0001) *
                         Math.cos(rotatedY * Math.PI * 3 + timestamp * 0.00008) * 0.15;

            const diagonalMix = (rotatedX + rotatedY) * 0.5;
            const radialMix = Math.sqrt((rotatedX - 0.5) ** 2 + (rotatedY - 0.5) ** 2) * 1.4;

            const wave1 = Math.sin(timestamp * 0.00005 + rotatedX * 2) * 0.1;
            const wave2 = Math.cos(timestamp * 0.00004 + rotatedY * 2) * 0.1;

            const gradientPos = (diagonalMix * 0.4 + radialMix * 0.3 + swirl * 0.2 + (wave1 + wave2) * 0.1) % 1;

            let color1, color2;
            const currentSet = colorSets[currentColorSet];
            const targetSet = colorSets[targetColorSet];

            if (isTransitioning) {
                const transitionProgress = Math.min(1, (timestamp - colorTransitionStartTime) / CONFIG.colorTransitionDuration);

                if (transitionProgress >= 1) {
                    isTransitioning = false;
                    currentColorSet = targetColorSet;
                    setTimeout(startColorTransition, 1000);
                }

                if (gradientPos < 0.5) {
                    const t = gradientPos * 2;
                    const currentColor = interpolateColor(currentSet[0], currentSet[1], t);
                    const targetColor = interpolateColor(targetSet[0], targetSet[1], t);
                    color1 = interpolateColor(currentColor, targetColor, transitionProgress);
                } else {
                    const t = (gradientPos - 0.5) * 2;
                    const currentColor = interpolateColor(currentSet[1], currentSet[2], t);
                    const targetColor = interpolateColor(targetSet[1], targetSet[2], t);
                    color1 = interpolateColor(currentColor, targetColor, transitionProgress);
                }
            } else {
                if (gradientPos < 0.5) {
                    const t = gradientPos * 2;
                    color1 = interpolateColor(currentSet[0], currentSet[1], t);
                } else {
                    const t = (gradientPos - 0.5) * 2;
                    color1 = interpolateColor(currentSet[1], currentSet[2], t);
                }
            }

            const secondaryHueShift = Math.sin(timestamp * 0.00002) * 0.05;
            const secondaryColor = {
                r: color1.r * (0.9 + secondaryHueShift),
                g: color1.g * (0.95 + secondaryHueShift * 0.5),
                b: color1.b * (1.1 - secondaryHueShift)
            };

            const colorBlend = Math.sin(timestamp * 0.00003) * 0.3 + 0.7;
            return {
                r: color1.r * colorBlend + secondaryColor.r * (1 - colorBlend),
                g: color1.g * colorBlend + secondaryColor.g * (1 - colorBlend),
                b: color1.b * colorBlend + secondaryColor.b * (1 - colorBlend)
            };
        }

        function getBackgroundColor(x, y, width, height, timestamp) {
            const nx = x / width;
            const ny = y / height;

            const wave1 = Math.sin(nx * Math.PI * 2.5 + timestamp * CONFIG.backgroundWaveSpeed) * 0.04;
            const wave2 = Math.cos(ny * Math.PI * 2.5 + timestamp * CONFIG.backgroundWaveSpeed * 0.8) * 0.03;
            const wave3 = Math.sin((nx + ny) * Math.PI * 1.5 + timestamp * CONFIG.backgroundWaveSpeed * 1.2) * 0.02;

            const combinedWave = (wave1 + wave2 + wave3) * 0.8;

            if (document.body.classList.contains('light-mode')) {
                const baseLight = 245;
                const timeVariation = timestamp * 0.00003;
                
                const lightR = baseLight + Math.sin(timeVariation) * 5 + combinedWave * 10;
                const lightG = baseLight + Math.cos(timeVariation * 1.1) * 5 + combinedWave * 8;
                const lightB = baseLight + Math.sin(timeVariation * 1.2) * 5 + combinedWave * 12;

                return {
                    r: Math.max(230, Math.min(255, lightR)),
                    g: Math.max(230, Math.min(255, lightG)),
                    b: Math.max(230, Math.min(255, lightB))
                };
            }

            const baseDark = 8;
            const timeVariation = timestamp * 0.00003;

            const darkR = baseDark + Math.sin(timeVariation) * 0.5 + combinedWave * 2;
            const darkG = baseDark + Math.cos(timeVariation * 1.1) * 0.5 + combinedWave * 1.5;
            const darkB = baseDark + Math.sin(timeVariation * 1.2) * 0.5 + combinedWave * 2.5;

            return {
                r: Math.max(0, Math.min(20, darkR)),
                g: Math.max(0, Math.min(18, darkG)),
                b: Math.max(0, Math.min(25, darkB))
            };
        }

        function rand(min, max) {
            return Math.random() * (max - min) + min;
        }

        function initPoints(width, height) {
            points = [];
            pointVelocities = [];

            const pointCount = Math.floor((width * height) / (40000 / CONFIG.density));

            for (let i = 0; i < pointCount; i++) {
                points.push({
                    x: rand(0, width),
                    y: rand(0, height)
                });

                pointVelocities.push({
                    x: rand(-CONFIG.speed/15, CONFIG.speed/15),
                    y: rand(-CONFIG.speed/15, CONFIG.speed/15)
                });
            }
        }

        function updatePoints(width, height) {
            for (let i = 0; i < points.length; i++) {
                let p = points[i];
                let v = pointVelocities[i];

                v.x += rand(-0.03, 0.03);
                v.y += rand(-0.03, 0.03);

                const maxSpeed = CONFIG.speed / 12;
                const speed = Math.sqrt(v.x * v.x + v.y * v.y);
                if (speed > maxSpeed) {
                    v.x = (v.x / speed) * maxSpeed;
                    v.y = (v.y / speed) * maxSpeed;
                }

                p.x += v.x;
                p.y += v.y;

                if (p.x <= 0 || p.x >= width) {
                    v.x = -v.x * 0.95;
                    p.x = p.x <= 0 ? 1 : width - 1;
                }
                if (p.y <= 0 || p.y >= height) {
                    v.y = -v.y * 0.95;
                    p.y = p.y <= 0 ? 1 : height - 1;
                }
            }
        }

        function render(timestamp) {
            if (!lastTimestamp) lastTimestamp = timestamp;
            const deltaTime = timestamp - lastTimestamp;
            lastTimestamp = timestamp;

            time += deltaTime * 0.06;

            bgPhase = (bgPhase + deltaTime * 0.001) % 1;

            const width = window.innerWidth;
            const height = window.innerHeight;

            const renderWidth = Math.floor(width * resolutionScale);
            const renderHeight = Math.floor(height * resolutionScale);

            if (can.width !== renderWidth || can.height !== renderHeight) {
                can.width = renderWidth;
                can.height = renderHeight;
                can.style.width = width + 'px';
                can.style.height = height + 'px';
                initPoints(renderWidth, renderHeight);
            }

            updatePoints(renderWidth, renderHeight);

            const imageData = c.createImageData(renderWidth, renderHeight);
            const data = imageData.data;

            const bandGap = CONFIG.bandGap * (0.8 + Math.sin(time * 0.0001) * 0.2);
            const bandWidth = CONFIG.bandWidth * resolutionScale;

            const pixelStep = 2;
            const pointSkip = Math.max(1, Math.floor(points.length / 40));
            const isLightMode = document.body.classList.contains('light-mode');
            const alpha = isLightMode ? 40 : 255;

            for (let y = 0; y < renderHeight; y += pixelStep) {
                for (let x = 0; x < renderWidth; x += pixelStep) {
                    const i = (x + y * renderWidth) * 4;

                    let closestDist = 999999;

                    for (let pIdx = 0; pIdx < points.length; pIdx += pointSkip) {
                        const dx = x - points[pIdx].x;
                        const dy = y - points[pIdx].y;
                        const d = dx * dx + dy * dy;

                        if (d < closestDist) {
                            closestDist = d;
                        }
                    }

                    const d = Math.sqrt(closestDist);
                    const band = d % bandGap;
                    const inBand = d > bandWidth && band <= bandWidth;

                    if (inBand) {
                        const gradientColor = getGradientColor(x, y, renderWidth, renderHeight, time);
                        data[i] = gradientColor.r;
                        data[i + 1] = gradientColor.g;
                        data[i + 2] = gradientColor.b;
                        data[i + 3] = alpha;
                    } else {
                        const bgColor = getBackgroundColor(x, y, renderWidth, renderHeight, time);
                        data[i] = bgColor.r;
                        data[i + 1] = bgColor.g;
                        data[i + 2] = bgColor.b;
                        data[i + 3] = alpha;
                    }

                    if (pixelStep > 1) {
                        for (let dy = 0; dy < pixelStep && y + dy < renderHeight; dy++) {
                            for (let dx = 0; dx < pixelStep && x + dx < renderWidth; dx++) {
                                if (dx === 0 && dy === 0) continue;
                                const ni = ((x + dx) + (y + dy) * renderWidth) * 4;
                                data[ni] = data[i];
                                data[ni + 1] = data[i + 1];
                                data[ni + 2] = data[i + 2];
                                data[ni + 3] = data[i + 3];
                            }
                        }
                    }
                }
            }

            c.putImageData(imageData, 0, 0);

            if (isAnimating) {
                animationId = requestAnimationFrame(render);
            }
        }

        function handleResize() {
            initPoints(can.width, can.height);
        }

        function init() {
            initColorSets();
            startColorTransition();
            isAnimating = true;
            animationId = requestAnimationFrame(render);
            window.addEventListener('resize', handleResize);
        }

        window.addEventListener('load', init);

        document.addEventListener('visibilitychange', function() {
            if (document.hidden) {
                isAnimating = false;
                if (animationId) {
                    cancelAnimationFrame(animationId);
                    animationId = null;
                }
            } else {
                isAnimating = true;
                lastTimestamp = 0;
                if (!animationId) {
                    animationId = requestAnimationFrame(render);
                }
            }
        });
    </script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.