Where ideas percolate and thoughts brew

The Nuance Trap

About This Sketch

Two particles navigate two branching decision trees. On the left, a particle picks a path through the branches and converges to a single resolution point — nuance that commits. On the right, a particle endlessly oscillates between the leaf nodes of a wider, deeper tree — nuance that accumulates without resolving. Accompanies the post "The Nuance Trap."

Algorithm

Two branching trees side-by-side, each with a particle navigating them. The left tree has a particle that descends one path and converges to a single resolution point at the bottom — representing nuance that resolves into commitment. The right tree has a particle that endlessly oscillates between leaf nodes, never settling — representing nuance as permanent identity, producing paralysis. The trees are drawn with warm accent colors; the committed particle is brighter and more saturated than the trapped one.

Pseudocode

SETUP:
  Build left tree (4 levels, moderate spread)
  Build right tree (5 levels, wider spread)
  Collect leaf nodes from each tree
  Create committed particle at left root
  Create trapped particle oscillating between right leaves

DRAW (every frame):
  Get current theme colors
  Clear background
  Draw left tree branches (warm, resolved color)
  Draw convergence lines from left leaves to resolution point
  Draw resolution point with pulsing glow
  Draw right tree branches (muted color)
  Advance committed particle along chosen path toward resolution
    When at leaf: smoothly converge to resolution point
    When arrived: pause briefly, restart from root
  Advance trapped particle between random leaf nodes (eased motion)
  Draw both particles
  Draw labels and dividing line

Source Code

let sketch = function(p) {
    let tree = [];
    let committed = null;
    let trapped = null;
    let time = 0;

    function buildTree(x, y, depth, angle, spread, maxDepth) {
        let node = { x, y, children: [], depth };
        if (depth < maxDepth) {
            let leftAngle = angle - spread;
            let rightAngle = angle + spread;
            let len = p.map(depth, 0, maxDepth, 38, 18);
            let lx = x + Math.cos(leftAngle) * len;
            let ly = y + Math.sin(leftAngle) * len;
            let rx = x + Math.cos(rightAngle) * len;
            let ry = y + Math.sin(rightAngle) * len;
            let newSpread = spread * 0.72;
            node.children.push(buildTree(lx, ly, depth + 1, leftAngle, newSpread, maxDepth));
            node.children.push(buildTree(rx, ry, depth + 1, rightAngle, newSpread, maxDepth));
        }
        return node;
    }

    function getLeaves(node, leaves) {
        if (node.children.length === 0) {
            leaves.push(node);
        } else {
            for (let c of node.children) getLeaves(c, leaves);
        }
    }

    function drawTree(node, colors, resolvedTree) {
        for (let c of node.children) {
            let depthFrac = node.depth / 5;
            let alpha = resolvedTree ? p.lerp(120, 50, depthFrac) : p.lerp(80, 30, depthFrac);
            let w = resolvedTree ? p.lerp(1.4, 0.6, depthFrac) : p.lerp(1.0, 0.4, depthFrac);
            p.stroke(
                resolvedTree ? colors.accent2[0] : colors.accent3[0],
                resolvedTree ? colors.accent2[1] : colors.accent3[1],
                resolvedTree ? colors.accent2[2] : colors.accent3[2],
                alpha
            );
            p.strokeWeight(w);
            p.line(node.x, node.y, c.x, c.y);
            drawTree(c, colors, resolvedTree);
        }
    }

    let leftRoot, rightRoot;
    let leftLeaves = [], rightLeaves = [];
    let resolutionY = 268;

    p.setup = function() {
        p.createCanvas(400, 300);
        p.colorMode(p.RGB);

        leftRoot = buildTree(100, 42, 0, p.HALF_PI, 0.52, 4);
        getLeaves(leftRoot, leftLeaves);

        rightRoot = buildTree(300, 42, 0, p.HALF_PI, 0.58, 5);
        getLeaves(rightRoot, rightLeaves);

        committed = {
            x: leftRoot.x, y: leftRoot.y,
            path: buildPath(leftRoot),
            pathIdx: 0,
            done: false
        };

        let rl = rightLeaves;
        trapped = {
            x: rl[0].x, y: rl[0].y,
            targets: rl,
            targetIdx: 1,
            progress: 0,
            speed: 0.008,
            fromX: rl[0].x, fromY: rl[0].y
        };
    };

    function buildPath(root) {
        let path = [{ x: root.x, y: root.y }];
        let node = root;
        while (node.children.length > 0) {
            node = node.children[0];
            path.push({ x: node.x, y: node.y });
        }
        return path;
    }

    p.draw = function() {
        const colors = getThemeColors();
        p.background(...colors.bg);
        time += 0.016;

        p.push();
        drawTree(leftRoot, colors, true);
        for (let lf of leftLeaves) {
            p.stroke(...colors.accent2, 35);
            p.strokeWeight(0.5);
            p.line(lf.x, lf.y, 100, resolutionY);
        }
        let rPulse = 0.85 + 0.15 * Math.sin(time * 2.1);
        p.noStroke();
        p.fill(...colors.accent2, 30 * rPulse);
        p.circle(100, resolutionY, 22 * rPulse);
        p.fill(...colors.accent2, 200 * rPulse);
        p.circle(100, resolutionY, 6 * rPulse);
        p.pop();

        p.push();
        drawTree(rightRoot, colors, false);
        p.pop();

        if (!committed.done) {
            let path = committed.path;
            if (committed.pathIdx < path.length - 1) {
                committed.pathIdx = Math.min(committed.pathIdx + 0.022, path.length - 1);
            }
            let idx = Math.floor(committed.pathIdx);
            let frac = committed.pathIdx - idx;
            let fromPt = path[Math.min(idx, path.length - 1)];
            let toPt = path[Math.min(idx + 1, path.length - 1)];
            committed.x = p.lerp(fromPt.x, toPt.x, frac);
            committed.y = p.lerp(fromPt.y, toPt.y, frac);

            if (committed.pathIdx >= path.length - 1) {
                committed.convergeProgress = (committed.convergeProgress || 0) + 0.018;
                committed.x = p.lerp(committed.x, 100, 0.04);
                committed.y = p.lerp(committed.y, resolutionY, 0.04);
                if (Math.abs(committed.x - 100) < 3 && Math.abs(committed.y - resolutionY) < 3) {
                    committed.done = true;
                }
            }
        } else {
            committed.doneTimer = (committed.doneTimer || 0) + 1;
            if (committed.doneTimer > 80) {
                committed.x = leftRoot.x; committed.y = leftRoot.y;
                committed.pathIdx = 0;
                committed.done = false;
                committed.doneTimer = 0;
                committed.convergeProgress = 0;
            }
        }

        p.noStroke();
        p.fill(...colors.accent2, 40);
        p.circle(committed.x, committed.y, 14);
        p.fill(...colors.accent2, 220);
        p.circle(committed.x, committed.y, 5);

        trapped.progress += trapped.speed;
        if (trapped.progress >= 1) {
            trapped.progress = 0;
            trapped.fromX = trapped.targets[trapped.targetIdx].x;
            trapped.fromY = trapped.targets[trapped.targetIdx].y;
            let next;
            do { next = Math.floor(p.random(trapped.targets.length)); }
            while (next === trapped.targetIdx && trapped.targets.length > 1);
            trapped.targetIdx = next;
        }
        let tx = trapped.targets[trapped.targetIdx].x;
        let ty = trapped.targets[trapped.targetIdx].y;
        let ease = trapped.progress < 0.5
            ? 2 * trapped.progress * trapped.progress
            : 1 - Math.pow(-2 * trapped.progress + 2, 2) / 2;
        trapped.x = p.lerp(trapped.fromX, tx, ease);
        trapped.y = p.lerp(trapped.fromY, ty, ease);

        p.noStroke();
        p.fill(...colors.accent3, 35);
        p.circle(trapped.x, trapped.y, 14);
        p.fill(...colors.accent3, 160);
        p.circle(trapped.x, trapped.y, 5);

        p.noStroke();
        p.fill(...colors.accent2, 100);
        p.textSize(8.5);
        p.textAlign(p.CENTER);
        p.text('commits', 100, 290);
        p.fill(...colors.accent3, 90);
        p.text('oscillates', 300, 290);

        p.stroke(...colors.accent3, 18);
        p.strokeWeight(0.7);
        p.line(200, 20, 200, 285);

        p.noStroke();
        p.fill(...colors.accent3, 65);
        p.textAlign(p.CENTER);
        p.textSize(9);
        p.text('nuance should resolve, not accumulate', 200, 14);
    };
};