• 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

004 - 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>Hexagonal 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>
        let isLightMode = false;
        // ---------- 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');
                    isLightMode = true;
                } else {
                    document.body.classList.remove('light-mode');
                    isLightMode = false;
                }
            } 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', { alpha: true });

        const CONFIG = {
            speed: 22,
            density: 40,
            hexSize: 45,
            hexSpacing: 1.5,
            colorTransitionDuration: 4000,
            backgroundWaveSpeed: 0.00015
        };

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

        let points = [];

        let colorSets = [];
        let currentColorSet = 0;
        let targetColorSet = 1;
        let colorTransitionStartTime = 0;
        let isTransitioning = false;
        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, 95, 60),
                    hslToRgb(hue2, 100, 55),
                    hslToRgb(hue3, 90, 65)
                ]);
            }
        }

        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;
            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);
                }
            }

            return color1;
        }

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

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

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

            for (let i = 0; i < pointCount; i++) {
                points.push({
                    x: rand(0, width),
                    y: rand(0, height),
                    vx: rand(-CONFIG.speed/15, CONFIG.speed/15),
                    vy: rand(-CONFIG.speed/15, CONFIG.speed/15)
                });
            }
        }

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

                pt.vx += rand(-0.03, 0.03);
                pt.vy += rand(-0.03, 0.03);

                const maxSpeed = CONFIG.speed / 12;
                const speed = Math.sqrt(pt.vx * pt.vx + pt.vy * pt.vy);
                if (speed > maxSpeed) {
                    pt.vx = (pt.vx / speed) * maxSpeed;
                    pt.vy = (pt.vy / speed) * maxSpeed;
                }

                pt.x += pt.vx;
                pt.y += pt.vy;

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

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

            time += deltaTime * 0.06;

            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 pixelStep = 1;
            const alphaValue = isLightMode ? 38 : 100; // 15% alpha in light mode, ~40% in dark mode

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

                    let closestDist = Infinity;
                    let secondClosestDist = Infinity;
                    let closestPoint = null;

                    for (let h = 0; h < points.length; h++) {
                        const pt = points[h];
                        const dx = x - pt.x;
                        const dy = y - pt.y;
                        const dist = dx * dx + dy * dy;

                        if (dist < closestDist) {
                            secondClosestDist = closestDist;
                            closestDist = dist;
                            closestPoint = pt;
                        } else if (dist < secondClosestDist) {
                            secondClosestDist = dist;
                        }
                    }

                    if (closestPoint) {
                        const distDiff = Math.sqrt(secondClosestDist) - Math.sqrt(closestDist);
                        const isEdge = distDiff < 2;

                        if (isEdge) {
                            data[i] = isLightMode ? 0 : 255;
                            data[i + 1] = isLightMode ? 0 : 255;
                            data[i + 2] = isLightMode ? 0 : 255;
                            data[i + 3] = alphaValue;
                        } else {
                            const pointColor = getGradientColor(closestPoint.x, closestPoint.y, renderWidth, renderHeight, time);
                            data[i] = pointColor.r;
                            data[i + 1] = pointColor.g;
                            data[i + 2] = pointColor.b;
                            data[i + 3] = alphaValue;
                        }
                    } else {
                        data[i] = isLightMode ? 255 : 8;
                        data[i + 1] = isLightMode ? 255 : 8;
                        data[i + 2] = isLightMode ? 255 : 10;
                        data[i + 3] = alphaValue;
                    }
                }
            }

            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.