{"id":"study-contours","title":"Strata / Moving Earth","html":"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"description\" content=\"A living contour atlas with a movable survey lens.\"><title>Strata / Moving Earth — MotionVault</title><style>*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;background:#171b1c;color:#f3f0e7;font-family:Arial,sans-serif}main{position:relative;width:100%;height:100svh;min-height:560px;overflow:hidden}canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:pan-y}header{position:absolute;inset:28px 4vw auto;display:flex;justify-content:space-between;pointer-events:none;font:11px/1.4 monospace;letter-spacing:.12em}article{position:absolute;left:6vw;top:25%;max-width:55%;pointer-events:none;text-shadow:0 2px 18px #0008}article small{font:10px/1.4 monospace;letter-spacing:.15em;color:#e9b77b}h1{font:400 clamp(40px,6.4vw,100px)/.96 Georgia,serif;letter-spacing:-.045em;max-width:700px;margin:20px 0}article p{font:14px/1.7 Arial;max-width:280px;color:#d6dde3}.no-copy article{display:none}footer{position:absolute;bottom:24px;left:4vw;right:4vw;display:flex;gap:10px;flex-wrap:wrap;align-items:center}button{border:1px solid #dce8f040;background:#0c1422e8;color:#f2f5ee;border-radius:30px;padding:12px 16px;font:11px Arial;min-height:44px;cursor:pointer}button:hover{background:#273344}button:focus-visible,input:focus-visible{outline:2px solid #e9b77b;outline-offset:4px}label{display:flex;align-items:center;gap:8px;font:11px Arial;background:#0c1422e8;padding:10px 14px;border-radius:30px}input{accent-color:#e9b77b;width:80px;min-height:24px}output{width:30px}aside{position:absolute;right:4vw;bottom:100px;font:10px/1.5 monospace;color:#cbd3df;pointer-events:none}@media(max-width:600px){header{top:20px;font-size:9px}article{top:20%;max-width:85%}h1{font-size:52px}footer{gap:7px;bottom:16px}button{padding:10px 12px}aside{bottom:166px;font-size:9px}label{padding:6px 12px}main{min-height:660px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}</style></head><body><main><canvas aria-label=\"A living contour atlas with a movable survey lens.\"></canvas><header><span>MOTIONVAULT / GENERATIVE STUDIES</span><span>CONTOURS / 01</span></header><article><small>AN INTERACTIVE BACKGROUND</small><h1>Strata.</h1><p>A living contour atlas with a movable survey lens.</p></article><aside>MOVE YOUR POINTER TO EXPLORE<br>ORIGINAL GEOMETRY / ZERO EXTERNAL ASSETS</aside><footer aria-label=\"Background controls\"><button data-pause aria-pressed=\"false\">Pause motion</button><button data-variation>New variation</button><button data-overlay aria-pressed=\"false\">Hide typography</button><label>Density <input data-density type=\"range\" min=\"15\" max=\"100\" value=\"65\"><output>65%</output></label></footer></main><script>(function studyRuntime(config) {\n    const canvas = document.querySelector('canvas'), ctx = canvas.getContext('2d');\n    const pause = document.querySelector('[data-pause]'), density = document.querySelector('[data-density]');\n    const reduced = matchMedia('(prefers-reduced-motion: reduce)');\n    let stopped = false, localPause = reduced.matches, hostPause = false, raf = 0, last = 0, time = 1.8, seed = 1, d = 0.65, w = 1, h = 1, ratio = 1, speed = config.speed, accent = config.color;\n    const pointer = { x: 0.64, y: 0.45 }, tau = Math.PI * 2;\n    const wave = (v) => Math.sin(v), rnd = (n) => { const a = Math.sin(n * 127.1 + seed * 311.7) * 43758.5453; return a - Math.floor(a); };\n    const hex = (color, alpha) => { const v = color.replace('#', ''); return 'rgba(' + parseInt(v.slice(0, 2), 16) + ',' + parseInt(v.slice(2, 4), 16) + ',' + parseInt(v.slice(4, 6), 16) + ',' + alpha + ')'; };\n    function line(points, color, width = 1) { ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.stroke(); }\n    function glow(x, y, r, color) { const g = ctx.createRadialGradient(x, y, 0, x, y, r); g.addColorStop(0, hex(color, 0.22)); g.addColorStop(1, hex(color, 0)); ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2); }\n    function paint() {\n        ctx.setTransform(ratio, 0, 0, ratio, 0, 0);\n        ctx.fillStyle = config.background;\n        ctx.fillRect(0, 0, w, h);\n        const t = time, cx = w * pointer.x, cy = h * pointer.y, scale = Math.min(w, h);\n        if (config.kind === 'aurora') {\n            for (let i = 0; i < 120; i++) {\n                const x = rnd(i + 40) * w, y = rnd(i + 800) * h * .65;\n                ctx.fillStyle = hex('#c5d8ef', .12 + .4 * rnd(i + 70));\n                ctx.fillRect(x, y, 1.1, 1.1);\n            }\n            ctx.globalCompositeOperation = 'screen';\n            for (let band = 0; band < 4; band++) {\n                const points = Math.round(90 + 150 * d);\n                for (let i = 0; i < points; i++) {\n                    const x = i / (points - 1) * w, y = h * (.23 + band * .075) + wave(i * .034 + t * .28 + band) * h * .07 + wave(i * .07 - t * .16) * h * .025 + (pointer.y - .5) * 35;\n                    const g = ctx.createLinearGradient(x, y, x, y + h * .42);\n                    g.addColorStop(0, hex(accent, .035));\n                    g.addColorStop(.18, hex(band % 2 ? '#b5a1ff' : accent, .14));\n                    g.addColorStop(1, hex(accent, 0));\n                    ctx.fillStyle = g;\n                    ctx.fillRect(x, y, w / (points - 1) + .2, h * .42);\n                }\n            }\n            ctx.globalCompositeOperation = 'source-over';\n            for (let k = 0; k < 3; k++) {\n                ctx.beginPath();\n                ctx.moveTo(0, h);\n                for (let x = 0; x <= w + 10; x += 10)\n                    ctx.lineTo(x, h * (.82 + k * .06) + wave(x / w * 7 + k) * h * .045 + wave(x / w * 18 + 2 * k) * h * .02);\n                ctx.lineTo(w, h);\n                ctx.fillStyle = ['#081626', '#06111c', '#030b13'][k];\n                ctx.fill();\n            }\n        }\n        else if (config.kind === 'contours') {\n            glow(w * .7, h * .5, scale * .8, accent);\n            // Nested perturbed isolines form a topographic basin, with two moving height harmonics.\n            for (let j = 0; j < 22 + Math.round(d * 25); j++) {\n                const r = 16 + j * scale * .018, pts = [];\n                for (let i = 0; i <= 220; i++) {\n                    const a = i / 220 * tau, perturb = wave(a * 3 + t * .11 + seed) * .11 + wave(a * 5 - t * .08 + j * .045) * .055;\n                    pts.push([w * .58 + Math.cos(a) * (r * (1 + perturb)) * 1.5, h * .5 + Math.sin(a) * r * (1 + perturb)]);\n                }\n                line(pts, hex(accent, j % 5 === 0 ? .65 : .22), j % 5 === 0 ? 1.4 : .65);\n            }\n            ctx.setLineDash([3, 6]);\n            line([[cx - 40, cy], [cx + 40, cy]], hex('#fff4dd', .7));\n            line([[cx, cy - 40], [cx, cy + 40]], hex('#fff4dd', .7));\n            ctx.setLineDash([]);\n            ctx.strokeStyle = hex(accent, .6);\n            ctx.beginPath();\n            ctx.arc(cx, cy, 24, 0, tau);\n            ctx.stroke();\n        }\n        else if (config.kind === 'interference') {\n            const cell = w < 600 ? 7 : 8, a = { x: w * .38, y: h * .48 }, b = { x: cx, y: cy };\n            for (let y = 0; y < h; y += cell)\n                for (let x = 0; x < w; x += cell) {\n                    const da = Math.hypot(x - a.x, y - a.y) / scale, db = Math.hypot(x - b.x, y - b.y) / scale;\n                    const v = (Math.sin(da * (45 + 30 * d) - t * 1.8) + Math.sin(db * (45 + 30 * d) - t * 1.8)) * .5;\n                    ctx.fillStyle = hex(v > 0 ? accent : '#5865bc', Math.abs(v) * .7);\n                    ctx.fillRect(x, y, cell - 1, cell - 1);\n                }\n            for (const p of [a, b]) {\n                glow(p.x, p.y, scale * .18, accent);\n                ctx.fillStyle = '#e8feff';\n                ctx.beginPath();\n                ctx.arc(p.x, p.y, 3, 0, tau);\n                ctx.fill();\n            }\n        }\n        else if (config.kind === 'network') {\n            const pts = Array.from({ length: Math.round(38 + d * 75) }, (_, i) => { const baseX = rnd(i + 1) * w, baseY = rnd(i + 300) * h, dist = Math.hypot(baseX - cx, baseY - cy), force = Math.max(0, 1 - dist / (scale * .38)); return [baseX + wave(t * .18 + i) * 16 + (baseX - cx) * force * .28, baseY + Math.cos(t * .13 + i) * 16 + (baseY - cy) * force * .28]; });\n            glow(cx, cy, scale * .55, accent);\n            const limit = scale * .19;\n            pts.forEach((p, i) => { for (let j = i + 1; j < pts.length; j++) {\n                const dist = Math.hypot(p[0] - pts[j][0], p[1] - pts[j][1]);\n                if (dist < limit)\n                    line([p, pts[j]], hex(accent, (1 - dist / limit) * .45));\n            } ctx.beginPath(); ctx.arc(p[0], p[1], 1.4 + rnd(i) * 1.7, 0, tau); ctx.fillStyle = hex(accent, .9); ctx.fill(); });\n        }\n        else if (config.kind === 'orbits') {\n            const x0 = w * (.59 + (pointer.x - .5) * .06), y0 = h * (.5 + (pointer.y - .5) * .06);\n            glow(x0, y0, scale * .55, accent);\n            ctx.globalCompositeOperation = 'screen';\n            for (let j = 0; j < 12 + Math.round(d * 20); j++) {\n                const angle = j * .21 + t * .05, rx = scale * (.23 + j * .003), ry = scale * (.08 + j * .005), pts = [];\n                for (let i = 0; i <= 128; i++) {\n                    const a = i / 128 * tau, xx = Math.cos(a) * rx, yy = Math.sin(a) * ry;\n                    pts.push([x0 + xx * Math.cos(angle) - yy * Math.sin(angle), y0 + xx * Math.sin(angle) + yy * Math.cos(angle)]);\n                }\n                line(pts, hex(accent, .16));\n                const phase = (t * .035 + j * .067) % 1, p = pts[Math.floor(phase * 128)];\n                ctx.fillStyle = '#fff1dc';\n                ctx.beginPath();\n                ctx.arc(p[0], p[1], j % 3 === 0 ? 2.6 : 1.3, 0, tau);\n                ctx.fill();\n            }\n            ctx.globalCompositeOperation = 'source-over';\n        }\n        else {\n            const n = 8 + Math.round(d * 3) * 2, x0 = w * .58, y0 = h * .5;\n            glow(x0, y0, scale * .65, accent);\n            ctx.save();\n            ctx.translate(x0, y0);\n            ctx.rotate(t * .03 + (pointer.x - .5) * .25);\n            for (let layer = 0; layer < 9; layer++) {\n                const r = scale * (.08 + layer * .035);\n                for (let j = 0; j < n; j++) {\n                    const a = j / n * tau, spread = .17 + wave(t * .18 + layer * .3) * .08;\n                    ctx.save();\n                    ctx.rotate(a + layer * .025);\n                    ctx.beginPath();\n                    ctx.moveTo(r * .3, 0);\n                    ctx.bezierCurveTo(r * .6, -r * spread, r * 1.45, -r * spread, r * 1.45, 0);\n                    ctx.bezierCurveTo(r * 1.45, r * spread, r * .6, r * spread, r * .3, 0);\n                    ctx.strokeStyle = hex(layer % 2 ? accent : '#8acde8', .18 + layer * .045);\n                    ctx.lineWidth = .8;\n                    ctx.stroke();\n                    ctx.restore();\n                }\n            }\n            ctx.restore();\n        }\n        document.documentElement.dataset.frame = String(Math.round(time * 100));\n    }\n    function sync() { cancelAnimationFrame(raf); raf = 0; last = 0; const paused = localPause || hostPause || document.hidden; pause.textContent = localPause ? 'Play motion' : 'Pause motion'; pause.setAttribute('aria-pressed', String(localPause)); document.documentElement.dataset.motion = paused ? 'paused' : 'playing'; if (!stopped && !paused)\n        raf = requestAnimationFrame(tick); }\n    function tick(now) { if (stopped)\n        return; if (!last)\n        last = now; if (now - last >= 1000 / 30) {\n        time += Math.min((now - last) / 1000, .08) * speed;\n        last = now;\n        paint();\n    } raf = requestAnimationFrame(tick); }\n    function resize() { const box = canvas.getBoundingClientRect(); w = Math.max(1, box.width); h = Math.max(1, box.height); ratio = Math.min(devicePixelRatio || 1, 1.5); canvas.width = Math.round(w * ratio); canvas.height = Math.round(h * ratio); paint(); }\n    const observer = new ResizeObserver(resize);\n    observer.observe(canvas);\n    canvas.addEventListener('pointermove', e => { const r = canvas.getBoundingClientRect(); pointer.x = (e.clientX - r.left) / r.width; pointer.y = (e.clientY - r.top) / r.height; if (localPause || hostPause)\n        paint(); });\n    canvas.addEventListener('pointerdown', e => { const r = canvas.getBoundingClientRect(); pointer.x = (e.clientX - r.left) / r.width; pointer.y = (e.clientY - r.top) / r.height; paint(); });\n    const change = () => { localPause = reduced.matches; sync(); paint(); };\n    reduced.addEventListener('change', change);\n    document.addEventListener('visibilitychange', sync);\n    pause.addEventListener('click', () => { localPause = !localPause; sync(); });\n    density.addEventListener('input', () => { d = Number(density.value) / 100; document.querySelector('output').textContent = density.value + '%'; paint(); });\n    document.querySelector('[data-variation]').addEventListener('click', () => { seed++; time += 1.7; paint(); });\n    document.querySelector('[data-overlay]').addEventListener('click', e => { const b = e.currentTarget; const off = document.body.classList.toggle('no-copy'); b.textContent = off ? 'Show typography' : 'Hide typography'; b.setAttribute('aria-pressed', String(off)); });\n    const runtime = { setOptions(options) { if (options.paused !== undefined)\n            hostPause = options.paused; if (options.speed !== undefined && Number.isFinite(options.speed))\n            speed = Math.max(.25, Math.min(3, options.speed)); if (options.accent && /^#[\\da-f]{6}$/i.test(options.accent))\n            accent = options.accent; if (options.title)\n            document.querySelector('h1').textContent = options.title; paint(); sync(); }, destroy() { stopped = true; cancelAnimationFrame(raf); observer.disconnect(); reduced.removeEventListener('change', change); document.removeEventListener('visibilitychange', sync); } };\n    window.motionvaultPreview = runtime;\n    resize();\n    sync();\n})({\"kind\":\"contours\",\"color\":\"#e9b77b\",\"background\":\"#171b1c\",\"speed\":1});</script></body></html>","assets":[],"license":"MotionVault end-project license","licenseURL":"https://motionvault.site/license","formSetup":"Exported forms prepare an enquiry file. Configure data-contact-endpoint to connect your own submission service."}