← Mike Higgins Home
Custom Code Experiment •

Not a Musical Instrument

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>Organic Cluster Tiles · click to activate</title>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700&display=swap');
        * { box-sizing: border-box; margin: 0; padding: 0; }
        :root { --bg: #0a0a0f; --tile-base: #1e3a5f; --text: #4a7ab5; }

        body {
            background: var(--bg);
            width: 100vw; height: 100vh;
            overflow: hidden;
            display: flex; align-items: center; justify-content: center;
            font-family: 'Syne', sans-serif;
            -webkit-tap-highlight-color: transparent;
        }
        body::before {
            content: '';
            position: fixed; inset: 0;
            background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
            pointer-events: none; z-index: 100; opacity: 0.35;
        }
        p.label {
            position: absolute; top: 1.5em; left: 1.5em;
            color: var(--text); font-size: clamp(0.75em, 2vw, 0.95em);
            letter-spacing: 0.2em; text-transform: uppercase; z-index: 10;
        }
        #scene {
            perspective: 1000px; width: 100%; height: 100%;
            display: flex; align-items: center; justify-content: center;
        }
        #cluster {
            position: relative; transform-style: preserve-3d;
            transform: rotateX(58deg) rotateZ(45deg);
            width: 620px; height: 620px;
        }
        .tile { position: absolute; transform-style: preserve-3d; cursor: pointer; }
        .tile svg { width: 100%; height: 100%; overflow: visible; display: block; }
        .tile .face {
            transition: transform 0.45s cubic-bezier(0.22, 0.61, 0.36, 1), filter 0.45s ease;
            transform: translateZ(0px);
            filter: drop-shadow(6px 8px 10px rgba(0,0,0,0.55));
            will-change: transform, filter;
        }
        .tile .face polygon { transition: fill 0.4s ease; fill: var(--tile-base); }
        .tile.hovered .face { transform: translateZ(48px); filter: drop-shadow(18px 22px 18px rgba(0,0,0,0.8)); }
        .tile.active  .face { transform: translateZ(32px); filter: drop-shadow(12px 16px 14px rgba(0,0,0,0.72)); }

        /* ——— minimal text prompt: no box, just words ——— */
        .audio-prompt {
            position: absolute;
            bottom: 5rem;          /* keep same placement */
            left: 50%;
            transform: translateX(-50%);
            color: #8bb3dc;         /* soft blue, fits palette */
            font-size: clamp(0.9rem, 3.5vw, 1.2rem);
            font-weight: 400;
            letter-spacing: 0.1em;
            text-transform: uppercase;
            z-index: 50;
            pointer-events: none;   /* clicks pass through to tiles */
            white-space: nowrap;
            background: transparent;
            border: none;
            padding: 0;
            box-shadow: none;
            text-shadow: 0 2px 5px rgba(0,0,0,0.5);
            opacity: 0.9;
        }

        .instructions {
            position: absolute; bottom: 1.5em; right: 1.5em;
            color: var(--text); font-size: clamp(0.6em, 1.8vw, 0.78em);
            letter-spacing: 0.12em; text-transform: uppercase;
            text-align: right; z-index: 10; opacity: 0.6;
        }
        @media (max-width: 560px) {
            .audio-prompt { font-size: 0.8rem; bottom: 4.5rem; }
        }
    </style>
</head>
<body>

    <div id="scene"><div id="cluster"></div></div>

    <!-- plain text message – exactly as requested: only the text, no button styling -->
    <div class="audio-prompt">Click to Activate Audio</div>

<script>
// ─────────────────────────────────────────────
//  AUDIO ENGINE
// ─────────────────────────────────────────────
let audioCtx = null;
let audioReady = false;

function getCtx() {
    if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    return audioCtx;
}

function unlockAudio() {
    if (audioReady) return;
    const ctx = getCtx();
    if (ctx.state === 'suspended') ctx.resume();
    audioReady = true;
}

['pointerdown', 'touchstart', 'keydown'].forEach(evt =>
    document.addEventListener(evt, unlockAudio, { once: false, passive: true })
);

function getMaster() {
    const ctx = getCtx();
    if (!getMaster._node) {
        const lim = ctx.createDynamicsCompressor();
        lim.threshold.value = -6;
        lim.knee.value = 3;
        lim.ratio.value = 12;
        lim.attack.value = 0.001;
        lim.release.value = 0.1;
        lim.connect(ctx.destination);
        getMaster._node = lim;
    }
    return getMaster._node;
}

const soundDefs = {
    '#c0392b': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.frequency.setValueAtTime(120, ctx.currentTime);
        osc.frequency.exponentialRampToValueAtTime(30, ctx.currentTime + 0.18);
        env.gain.setValueAtTime(0.7, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.3);
    },
    '#e74c3c': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sawtooth';
        osc.frequency.setValueAtTime(200, ctx.currentTime);
        osc.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + 0.12);
        env.gain.setValueAtTime(0.5, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.2);
    },
    '#e67e22': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'triangle';
        osc.frequency.value = 392;
        env.gain.setValueAtTime(0.5, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.55);
    },
    '#f39c12': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'triangle';
        osc.frequency.value = 440;
        env.gain.setValueAtTime(0.45, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.5);
    },
    '#f1c40f': (ctx, out) => {
        [523.25, 1046.5].forEach((freq, i) => {
            const osc = ctx.createOscillator();
            const env = ctx.createGain();
            osc.type = 'sine';
            osc.frequency.value = freq;
            env.gain.setValueAtTime(i === 0 ? 0.4 : 0.15, ctx.currentTime);
            env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8);
            osc.connect(env); env.connect(out);
            osc.start(); osc.stop(ctx.currentTime + 0.85);
        });
    },
    '#2ecc71': (ctx, out) => {
        const buf = ctx.createBuffer(1, ctx.sampleRate * 0.4, ctx.sampleRate);
        const d = buf.getChannelData(0);
        for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
        const src = ctx.createBufferSource();
        src.buffer = buf;
        const filter = ctx.createBiquadFilter();
        filter.type = 'bandpass';
        filter.frequency.setValueAtTime(400, ctx.currentTime);
        filter.frequency.exponentialRampToValueAtTime(2000, ctx.currentTime + 0.4);
        filter.Q.value = 3;
        const env = ctx.createGain();
        env.gain.setValueAtTime(0.3, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
        src.connect(filter); filter.connect(env); env.connect(out);
        src.start(); src.stop(ctx.currentTime + 0.45);
    },
    '#27ae60': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sine';
        osc.frequency.setValueAtTime(220, ctx.currentTime);
        osc.frequency.exponentialRampToValueAtTime(660, ctx.currentTime + 0.3);
        env.gain.setValueAtTime(0.35, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.4);
    },
    '#1abc9c': (ctx, out) => {
        [523.25, 659.25, 783.99].forEach((freq, i) => {
            const osc = ctx.createOscillator();
            const env = ctx.createGain();
            osc.type = 'sine';
            osc.frequency.value = freq;
            env.gain.setValueAtTime(0.18, ctx.currentTime + i * 0.04);
            env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.7);
            osc.connect(env); env.connect(out);
            osc.start(ctx.currentTime + i * 0.04);
            osc.stop(ctx.currentTime + 0.75);
        });
    },
    '#16a085': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sine';
        osc.frequency.value = 349.23;
        env.gain.setValueAtTime(0.4, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.65);
    },
    '#3498db': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        const filter = ctx.createBiquadFilter();
        filter.type = 'lowpass'; filter.frequency.value = 800; filter.Q.value = 8;
        osc.type = 'sawtooth';
        osc.frequency.value = 110;
        env.gain.setValueAtTime(0.5, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
        osc.connect(filter); filter.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.45);
    },
    '#2980b9': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'square';
        osc.frequency.value = 130.81;
        env.gain.setValueAtTime(0.25, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.4);
    },
    '#9b59b6': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sine';
        osc.frequency.setValueAtTime(880, ctx.currentTime);
        osc.frequency.exponentialRampToValueAtTime(110, ctx.currentTime + 0.5);
        env.gain.setValueAtTime(0.35, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.55);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.6);
    },
    '#8e44ad': (ctx, out) => {
        const carrier = ctx.createOscillator();
        const lfo    = ctx.createOscillator();
        const lfoGain = ctx.createGain();
        const env = ctx.createGain();
        lfo.frequency.value = 8;
        lfoGain.gain.value = 60;
        carrier.type = 'sine';
        carrier.frequency.value = 440;
        lfo.connect(lfoGain); lfoGain.connect(carrier.frequency);
        env.gain.setValueAtTime(0.3, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
        carrier.connect(env); env.connect(out);
        lfo.start(); carrier.start(); lfo.stop(ctx.currentTime + 0.55); carrier.stop(ctx.currentTime + 0.55);
    },
    '#e91e8c': (ctx, out) => {
        const buf = ctx.createBuffer(1, ctx.sampleRate * 0.08, ctx.sampleRate);
        const d = buf.getChannelData(0);
        for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
        const src = ctx.createBufferSource();
        src.buffer = buf;
        const filter = ctx.createBiquadFilter();
        filter.type = 'highpass'; filter.frequency.value = 6000;
        const env = ctx.createGain();
        env.gain.setValueAtTime(0.5, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
        src.connect(filter); filter.connect(env); env.connect(out);
        src.start(); src.stop(ctx.currentTime + 0.1);
    },
    '#c2185b': (ctx, out) => {
        const buf = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate);
        const d = buf.getChannelData(0);
        for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / 200);
        const src = ctx.createBufferSource(); src.buffer = buf;
        const env = ctx.createGain();
        env.gain.setValueAtTime(0.6, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.06);
        src.connect(env); env.connect(out);
        src.start(); src.stop(ctx.currentTime + 0.08);
    },
    '#00bcd4': (ctx, out) => {
        [523.25, 659.25, 783.99, 1046.5].forEach((freq, i) => {
            const osc = ctx.createOscillator();
            const env = ctx.createGain();
            osc.type = 'triangle';
            osc.frequency.value = freq;
            const t = ctx.currentTime + i * 0.06;
            env.gain.setValueAtTime(0.22, t);
            env.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
            osc.connect(env); env.connect(out);
            osc.start(t); osc.stop(t + 0.2);
        });
    },
    '#0097a7': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'triangle';
        osc.frequency.setValueAtTime(261.63, ctx.currentTime);
        osc.frequency.exponentialRampToValueAtTime(523.25, ctx.currentTime + 0.2);
        env.gain.setValueAtTime(0.35, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.35);
    },
    '#ff5722': (ctx, out) => {
        const osc = ctx.createOscillator();
        const wave = ctx.createWaveShaper();
        const env  = ctx.createGain();
        const curve = new Float32Array(256);
        for (let i = 0; i < 256; i++) {
            const x = (i * 2) / 256 - 1;
            curve[i] = (Math.PI + 200) * x / (Math.PI + 200 * Math.abs(x));
        }
        wave.curve = curve;
        osc.type = 'sawtooth'; osc.frequency.value = 80;
        env.gain.setValueAtTime(0.6, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
        osc.connect(wave); wave.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.2);
    },
    '#bf360c': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sawtooth';
        osc.frequency.setValueAtTime(160, ctx.currentTime);
        osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.2);
        env.gain.setValueAtTime(0.55, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.22);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.25);
    },
    '#76c442': (ctx, out) => {
        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sine'; osc.frequency.value = 1318.51;
        env.gain.setValueAtTime(0.3, ctx.currentTime);
        env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
        osc.connect(env); env.connect(out);
        osc.start(); osc.stop(ctx.currentTime + 0.65);
    },
    '#4caf50': (ctx, out) => {
        [659.25, 830.61].forEach((freq, i) => {
            const osc = ctx.createOscillator();
            const env = ctx.createGain();
            osc.type = 'sine'; osc.frequency.value = freq;
            env.gain.setValueAtTime(0.2, ctx.currentTime + i * 0.05);
            env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.55);
            osc.connect(env); env.connect(out);
            osc.start(ctx.currentTime + i * 0.05);
            osc.stop(ctx.currentTime + 0.6);
        });
    },
};

function playSound(colorKey) {
    if (!audioReady) return;
    const fn = soundDefs[colorKey];
    if (!fn) return;
    try {
        const ctx = getCtx();
        if (ctx.state === 'suspended') ctx.resume();
        fn(ctx, getMaster());
    } catch(e) { /* ignore */ }
}

// ─────────────────────────────────────────────
//  TILE DATA & RENDERING (identical to before)
// ─────────────────────────────────────────────
const palette = [
    '#c0392b','#e74c3c','#e67e22','#f39c12','#f1c40f',
    '#2ecc71','#27ae60','#1abc9c','#16a085','#3498db',
    '#2980b9','#9b59b6','#8e44ad','#e91e8c','#c2185b',
    '#00bcd4','#0097a7','#ff5722','#bf360c','#76c442','#4caf50',
];

const tiles = [
    { x:225, y:210, w:115, h:105, pts:[[0.1,0.05],[0.9,0],[1,0.2],[0.95,0.85],[0.8,1],[0.05,0.95],[0,0.5]] },
    { x:330, y:195, w:100, h:115, pts:[[0.05,0],[0.95,0.1],[1,0.4],[0.9,1],[0.1,0.95],[0,0.6],[0.05,0.2]] },
    { x:248, y:308, w:110, h:100, pts:[[0.15,0],[0.9,0.05],[1,0.5],[0.85,1],[0.1,0.95],[0,0.4]] },
    { x:348, y:298, w: 95, h:110, pts:[[0,0.1],[0.8,0],[1,0.3],[1,0.8],[0.7,1],[0.05,0.9]] },
    { x:235, y:115, w: 95, h: 98, pts:[[0.2,0],[0.85,0.05],[1,0.5],[0.8,1],[0.1,0.9],[0,0.3]] },
    { x:322, y: 98, w:110, h: 98, pts:[[0.05,0.1],[0.9,0],[1,0.35],[0.95,0.9],[0.5,1],[0,0.7]] },
    { x:145, y:130, w: 95, h: 88, pts:[[0.1,0.05],[0.9,0],[1,0.45],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:425, y:112, w: 82, h: 90, pts:[[0.05,0],[0.9,0.1],[1,0.55],[0.8,1],[0.1,0.95],[0,0.4]] },
    { x: 90, y:215, w:100, h: 95, pts:[[0.1,0],[0.95,0.05],[1,0.55],[0.85,1],[0.05,0.9],[0,0.4]] },
    { x: 82, y:305, w: 92, h:108, pts:[[0,0.05],[0.8,0],[1,0.4],[0.9,1],[0.15,0.95],[0.05,0.5]] },
    { x:155, y:220, w: 80, h: 92, pts:[[0.1,0.05],[0.85,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:440, y:210, w: 95, h:100, pts:[[0.1,0],[0.9,0.1],[1,0.5],[0.85,1],[0,0.9],[0.05,0.2]] },
    { x:432, y:305, w: 88, h: 95, pts:[[0.05,0],[0.95,0.05],[1,0.6],[0.7,1],[0.05,0.95],[0,0.35]] },
    { x:358, y:398, w: 92, h: 88, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:230, y:402, w:110, h: 90, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:332, y:392, w:100, h: 95, pts:[[0,0.1],[0.85,0],[1,0.45],[0.9,0.95],[0.2,1],[0.05,0.6]] },
    { x:135, y:392, w: 98, h: 88, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.75,1],[0,0.88],[0.05,0.3]] },
    { x:170, y:115, w: 72, h: 78, pts:[[0.15,0],[0.9,0.1],[1,0.6],[0.75,1],[0,0.85],[0.05,0.2]] },
    { x:430, y: 98, w: 68, h: 72, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:108, y:118, w: 65, h: 72, pts:[[0,0.15],[0.8,0],[1,0.4],[0.85,0.9],[0.2,1],[0.05,0.55]] },
    { x: 58, y:195, w: 68, h: 75, pts:[[0.1,0],[0.9,0.05],[1,0.55],[0.8,1],[0,0.9],[0.05,0.3]] },
    { x: 55, y:390, w: 78, h: 72, pts:[[0.05,0.1],[0.88,0],[1,0.5],[0.8,1],[0.1,0.95],[0,0.4]] },
    { x:450, y:390, w: 72, h: 75, pts:[[0,0.1],[0.85,0],[1,0.5],[0.8,1],[0.05,0.9],[0.05,0.3]] },
    { x:148, y:395, w: 88, h: 72, pts:[[0.1,0.05],[0.9,0],[1,0.45],[0.8,0.98],[0.05,0.9],[0,0.4]] },
    { x:160, y:308, w: 78, h: 88, pts:[[0.05,0],[0.9,0.1],[1,0.55],[0.8,1],[0.05,0.9],[0,0.35]] },
    { x:445, y:305, w: 68, h: 88, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.78,1],[0,0.88],[0.05,0.3]] },
    { x: 58, y:278, w: 62, h: 80, pts:[[0.05,0],[0.9,0.1],[1,0.5],[0.8,1],[0.05,0.9],[0,0.3]] },
    { x:505, y:250, w: 65, h: 78, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0,0.88],[0.05,0.25]] },
    { x:290, y: 42, w: 80, h: 68, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:200, y: 42, w: 72, h: 70, pts:[[0.05,0],[0.9,0.1],[1,0.5],[0.78,1],[0.05,0.9],[0,0.35]] },
    { x:382, y: 42, w: 70, h: 68, pts:[[0.1,0.05],[0.88,0],[1,0.45],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:260, y:488, w: 88, h: 65, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
    { x:348, y:482, w: 80, h: 68, pts:[[0.05,0.1],[0.88,0],[1,0.5],[0.8,1],[0.1,0.9],[0,0.4]] },
    { x:168, y:482, w: 78, h: 65, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.78,0.98],[0.05,0.9],[0,0.35]] },
];

const cluster = document.getElementById('cluster');
const tileColors = tiles.map((_, i) => palette[i % palette.length]);
for (let i = tileColors.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [tileColors[i], tileColors[j]] = [tileColors[j], tileColors[i]];
}

const leaveTimers = new Map();
const LEAVE_DELAY = 80;
const soundCooldowns = new Map();
const SOUND_COOLDOWN = 180;

function tryPlaySound(div, color) {
    const now = Date.now();
    const last = soundCooldowns.get(div) || 0;
    if (now - last < SOUND_COOLDOWN) return;
    soundCooldowns.set(div, now);
    playSound(color);
}

tiles.forEach((t, i) => {
    const div = document.createElement('div');
    div.className = 'tile';
    div.style.left   = t.x + 'px';
    div.style.top    = t.y + 'px';
    div.style.width  = t.w + 'px';
    div.style.height = t.h + 'px';
    div.setAttribute('tabindex', '0');

    const points = t.pts.map(([px, py]) => `${px * t.w},${py * t.h}`).join(' ');
    const hoverCol = tileColors[i];

    div.innerHTML = `
        <div class="face">
            <svg viewBox="0 0 ${t.w} ${t.h}" xmlns="http://www.w3.org/2000/svg">
                <polygon points="${points}" />
            </svg>
        </div>`;

    const poly = div.querySelector('polygon');
    const face = div.querySelector('.face');

    div.addEventListener('pointerenter', () => {
        if (leaveTimers.has(div)) { clearTimeout(leaveTimers.get(div)); leaveTimers.delete(div); }
        if (!div.classList.contains('hovered')) {
            div.classList.add('hovered');
            poly.style.fill = hoverCol;
            tryPlaySound(div, hoverCol);
        }
    });

    div.addEventListener('pointerleave', () => {
        const timer = setTimeout(() => {
            div.classList.remove('hovered');
            if (!div.classList.contains('active')) poly.style.fill = '';
            leaveTimers.delete(div);
        }, LEAVE_DELAY);
        leaveTimers.set(div, timer);
    });

    let isTouching = false;
    div.addEventListener('touchstart', e => {
        e.preventDefault();
        isTouching = true;
        div.classList.add('active');
        poly.style.fill = hoverCol;
        tryPlaySound(div, hoverCol);
    }, { passive: false });

    div.addEventListener('touchend', e => {
        e.preventDefault();
        isTouching = false;
        div.classList.remove('active');
        setTimeout(() => { poly.style.fill = ''; }, 420);
    }, { passive: false });

    div.addEventListener('touchcancel', () => {
        isTouching = false;
        div.classList.remove('active');
        poly.style.fill = '';
    });

    div.addEventListener('keydown', e => {
        if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            poly.style.fill = hoverCol;
            face.style.transform = 'translateZ(48px)';
            tryPlaySound(div, hoverCol);
            setTimeout(() => { poly.style.fill = ''; face.style.transform = ''; }, 440);
        }
    });

    cluster.appendChild(div);
});

let lastTouch = 0;
document.addEventListener('touchend', e => {
    const now = Date.now();
    if (now - lastTouch < 300) e.preventDefault();
    lastTouch = now;
}, false);
</script>
</body>
</html>