The code returns the following error:
./app/page.tsx:3:40
Ecmascript file had an error
1 | import dynamic from "next/dynamic"
2 |
> 3 | const CherenkovKuramotoVisualization = dynamic(
| ^^^^^^^^
> 4 | () =>
| ^^^^^^^
> 5 | import("@/components/cherenkov-kuramoto/visualization").then(
| ^^^^^^^
> 6 | (mod) => mod.CherenkovKuramotoVisualization
| ^^^^^^^
> 7 | ),
| ^^^^^^^
> 8 | { ssr: false }
| ^^^^^^^
> 9 | )
| ^^
10 |
11 | export default function Page() {
12 | return <CherenkovKuramotoVisualization />
`ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.
[browser] ./app/page.tsx:3:40
Ecmascript file had an error
1 | import dynamic from "next/dynamic"
2 |
> 3 | const CherenkovKuramotoVisualization = dynamic(
| ^^^^^^^^
> 4 | () =>
| ^^^^^^^
> 5 | import("@/components/cherenkov-kuramoto/visualization").then(
| ^^^^^^^
> 6 | (mod) => mod.CherenkovKuramotoVisualization
| ^^^^^^^
> 7 | ),
| ^^^^^^^
> 8 | { ssr: false }
| ^^^^^^^
> 9 | )
| ^^
10 |
11 | export default function Page() {
12 | return <CherenkovKuramotoVisualization />
`ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.
Revise the code to address the error.
The "use client" is already there, but the build is still failing. Let me create a separate client wrapper component and keep page.tsx as a server component that simply renders the wrapper:
Moved the next/dynamic with ssr: false into a dedicated Client Component (client-wrapper.tsx). The page is now a Server Component that imports and renders this client wrapper — this pattern properly separates the SSR boundary and should resolve the build error.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>Cherenkov–Kuramoto Scroll Ring</title> <style> @import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,600;1,300&display=swap'); *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { –bg: #020408; –panel: rgba(4, 12, 24, 0.88); –accent: #00d4ff; –accent2: #ff6b35; –accent3: #a855f7; –green: #22c55e; –text: #c8d8e8; –muted: #4a6070; –border: rgba(0, 212, 255, 0.15); } html, body { width: 100%; height: 100%; background: var(–bg); color: var(–text); font-family: ‘Space Mono’, monospace; overflow: hidden; touch-action: none; } canvas#main { display: block; position: fixed; top: 0; left: 0; z-index: 0; } #ui { position: fixed; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 10; } /* ── TITLE ── */ #title-block { position: absolute; top: 16px; left: 16px; pointer-events: auto; } #title-block h1 { font-family: ‘Crimson Pro’, serif; font-size: clamp(0.85rem, 2.5vw, 1.05rem); font-weight: 300; font-style: italic; letter-spacing: 0.04em; color: var(–accent); line-height: 1.2; } #title-block .sub { font-size: clamp(0.5rem, 1.5vw, 0.6rem); color: var(–muted); letter-spacing: 0.12em; text-transform: uppercase; margin-top: 3px; } /* ── STATE PANEL (top right) ── */ #info-panel { position: absolute; top: 16px; right: 16px; background: var(–panel); border: 1px solid var(–border); border-radius: 6px; padding: 12px 14px; backdrop-filter: blur(14px); width: clamp(170px, 42vw, 210px); pointer-events: auto; } #info-panel .section-title { font-size: 0.52rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(–muted); margin-bottom: 8px; } .phase-display { display: flex; justify-content: space-between; margin-bottom: 5px; font-size: clamp(0.52rem, 1.6vw, 0.6rem); } .phase-display .key { color: var(–muted); } .phase-display .val { color: var(–accent); font-weight: 700; } .eq-block { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(–border); font-family: ‘Crimson Pro’, serif; font-style: italic; font-size: clamp(0.62rem, 2vw, 0.72rem); color: var(–accent3); line-height: 1.55; } /* ── PHASE PORTRAIT CANVAS ── */ #portrait-wrap { position: absolute; bottom: 16px; right: 16px; background: var(–panel); border: 1px solid var(–border); border-radius: 6px; padding: 8px 10px 6px; backdrop-filter: blur(14px); pointer-events: auto; } #portrait-label { font-size: 0.5rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(–muted); margin-bottom: 5px; display: flex; justify-content: space-between; align-items: center; } #portrait-label span { color: var(–green); } #portrait { display: block; border-radius: 3px; } /* ── CONTROLS (bottom left) ── */ #controls { position: absolute; bottom: 16px; left: 16px; display: flex; flex-direction: column; gap: 10px; pointer-events: auto; width: clamp(190px, 48vw, 230px); } .ctrl-group { background: var(–panel); border: 1px solid var(–border); border-radius: 6px; padding: 10px 12px; backdrop-filter: blur(14px); } .ctrl-label { font-size: 0.5rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(–muted); margin-bottom: 7px; } .slider-row { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; } .slider-row:last-child { margin-bottom: 0; } .slider-name { font-size: clamp(0.5rem, 1.5vw, 0.58rem); color: var(–text); width: 52px; flex-shrink: 0; white-space: nowrap; } input[type=range] { -webkit-appearance: none; width: 100%; height: 2px; background: var(–muted); border-radius: 2px; outline: none; cursor: pointer; } input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(–accent); cursor: pointer; box-shadow: 0 0 8px var(–accent); } .slider-val { font-size: 0.52rem; color: var(–accent); width: 32px; text-align: right; flex-shrink: 0; } /* ── MODE BUTTONS ── */ #btn-row { display: flex; gap: 6px; margin-top: 2px; } .btn { flex: 1; font-family: ‘Space Mono’, monospace; font-size: clamp(0.46rem, 1.4vw, 0.55rem); letter-spacing: 0.06em; text-transform: uppercase; color: var(–text); background: rgba(0,212,255,0.06); border: 1px solid var(–border); border-radius: 3px; padding: 7px 2px; cursor: pointer; transition: all 0.2s; pointer-events: auto; } .btn:hover { background: rgba(0,212,255,0.15); color: var(–accent); border-color: var(–accent); } .btn.active { background: rgba(0,212,255,0.2); color: var(–accent); border-color: var(–accent); box-shadow: 0 0 8px rgba(0,212,255,0.2); } /* ── LEGEND ── */ #legend { position: absolute; /* sits just above the portrait panel on mobile */ bottom: 16px; right: 16px; display: none; /* hidden on mobile, shown on desktop via media query */ flex-direction: column; gap: 4px; pointer-events: none; } .legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.54rem; color: var(–muted); } .legend-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } @media (min-width: 700px) { #title-block { top: 28px; left: 32px; } #info-panel { top: 28px; right: 32px; width: 210px; } #controls { bottom: 28px; left: 32px; width: 230px; } #portrait-wrap { bottom: 28px; right: 32px; } #legend { display: flex; bottom: 200px; right: 32px; } } </style> </head> <body> <canvas id="main"></canvas> <canvas id="portrait" width="160" height="160"></canvas><!-- will be re-inserted into wrap --> <div id="ui"> <div id="title-block"> <h1>Cherenkov–Kuramoto Scroll Ring</h1> <div class="sub">Toroidal Phase Attractor · CGLE Geometry</div> </div> <div id="info-panel"> <div class="section-title">System State</div> <div class="phase-display"><span class="key">θ₁ (internal)</span><span class="val" id="disp-t1">0.000</span></div> <div class="phase-display"><span class="key">θ₂ (collective)</span><span class="val" id="disp-t2">0.000</span></div> <div class="phase-display"><span class="key">Coherence r</span><span class="val" id="disp-r">0.000</span></div> <div class="phase-display"><span class="key">Shock angle</span><span class="val" id="disp-shock">0.000</span></div> <div class="phase-display"><span class="key">Winding #</span><span class="val" id="disp-wind">0</span></div> <div class="eq-block"> ∂A/∂t = A − (1+ib)|A|²A<br>+ (1+ic)∇²A </div> </div> <div id="portrait-wrap"> <div id="portrait-label"> θ₁ vs θ₂ — Phase Portrait <span id="portrait-pts">0 pts</span> </div> <!-- portrait canvas inserted here by JS --> </div> <div id="controls"> <div class="ctrl-group"> <div class="ctrl-label">Coupling Parameters</div> <div class="slider-row"> <span class="slider-name">K (sync)</span> <input type="range" id="sl-K" min="0" max="1" step="0.01" value="0.65"> <span class="slider-val" id="val-K">0.65</span> </div> <div class="slider-row"> <span class="slider-name">ω₁/ω₂</span> <input type="range" id="sl-ratio" min="0.1" max="0.95" step="0.005" value="0.618"> <span class="slider-val" id="val-ratio">0.618</span> </div> <div class="slider-row"> <span class="slider-name">b (CGLE)</span> <input type="range" id="sl-b" min="-2" max="2" step="0.05" value="0.5"> <span class="slider-val" id="val-b">0.50</span> </div> <div class="slider-row"> <span class="slider-name">c (CGLE)</span> <input type="range" id="sl-c" min="-2" max="2" step="0.05" value="-1.5"> <span class="slider-val" id="val-c">-1.50</span> </div> </div> ``` <div class="ctrl-group"> <div class="ctrl-label">Visualization</div> <div id="btn-row"> <button class="btn active" id="btn-phase">Phase</button> <button class="btn" id="btn-coherence">Coher.</button> <button class="btn" id="btn-shock">Shock</button> </div> <div class="slider-row" style="margin-top:9px"> <span class="slider-name">Speed</span> <input type="range" id="sl-speed" min="0.1" max="3" step="0.05" value="1"> <span class="slider-val" id="val-speed">1.0</span> </div> <div class="slider-row"> <span class="slider-name">Density</span> <input type="range" id="sl-density" min="20" max="100" step="5" value="55"> <span class="slider-val" id="val-density">55</span> </div> </div> ``` </div> <div id="legend"> <div class="legend-item"><div class="legend-dot" style="background:#00d4ff;box-shadow:0 0 4px #00d4ff"></div><span>Phase-locked (Cherenkov front)</span></div> <div class="legend-item"><div class="legend-dot" style="background:#a855f7;box-shadow:0 0 4px #a855f7"></div><span>Incoherent oscillators</span></div> <div class="legend-item"><div class="legend-dot" style="background:#ff6b35;box-shadow:0 0 4px #ff6b35"></div><span>Phase singularity (core filament)</span></div> <div class="legend-item"><div class="legend-dot" style="background:#22c55e;box-shadow:0 0 4px #22c55e"></div><span>Toroidal trajectory</span></div> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script> // ── Move portrait canvas into wrap ────────────────────────────────────────── const portraitWrap = document.getElementById('portrait-wrap'); const portraitCanvas = document.getElementById('portrait'); portraitWrap.appendChild(portraitCanvas); // ── Responsive portrait size ───────────────────────────────────────────────── function portraitSize() { return window.innerWidth < 700 ? 130 : 160; } function resizePortrait() { const s = portraitSize(); portraitCanvas.width = s; portraitCanvas.height = s; portraitCanvas.style.width = s + 'px'; portraitCanvas.style.height = s + 'px'; } resizePortrait(); const pCtx = portraitCanvas.getContext('2d'); // ── THREE scene ────────────────────────────────────────────────────────────── const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('main'), antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight); renderer.setClearColor(0x020408, 1); const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020408, 0.038); const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.01, 500); camera.position.set(0, 3.5, 7); camera.lookAt(0, 0, 0); scene.add(new THREE.AmbientLight(0x0a1520, 1)); const ptLight = new THREE.PointLight(0x00d4ff, 2, 20); const ptLight2 = new THREE.PointLight(0xa855f7, 1.5, 15); ptLight2.position.set(3, -2, -3); scene.add(ptLight, ptLight2); // ── Params ─────────────────────────────────────────────────────────────────── const P = { K: 0.65, ratio: 0.618, b: 0.5, c: -1.5, speed: 1, density: 55, mode: 'phase' }; const R = 2.8, r = 0.9; // ── Torus ──────────────────────────────────────────────────────────────────── const torusMat = new THREE.MeshPhongMaterial({ color: 0x0a1a2a, emissive: 0x050d14, transparent: true, opacity: 0.18, side: THREE.DoubleSide }); scene.add(new THREE.Mesh(new THREE.TorusGeometry(R, r, 80, 120), torusMat)); const wireMat = new THREE.MeshBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.04, wireframe: true }); scene.add(new THREE.Mesh(new THREE.TorusGeometry(R, r, 28, 60), wireMat)); // ── Oscillators ─────────────────────────────────────────────────────────────── let oscGroup = new THREE.Group(); scene.add(oscGroup); function buildOscillators() { while (oscGroup.children.length) oscGroup.remove(oscGroup.children[0]); const N = P.density; const geo = new THREE.SphereGeometry(0.045, 6, 6); for (let i = 0; i < N; i++) { for (let j = 0; j < Math.floor(N * 0.4); j++) { const mat = new THREE.MeshBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.7 }); const mesh = new THREE.Mesh(geo, mat); mesh.userData = { u: (i / N) * Math.PI * 2, v: (j / Math.floor(N * 0.4)) * Math.PI * 2, phase: Math.random() * Math.PI * 2, natFreq: 1 + (Math.random() - 0.5) * 0.4 }; oscGroup.add(mesh); } } } buildOscillators(); // ── Shock ring ──────────────────────────────────────────────────────────────── const shockPosArr = new Float32Array(201 * 3); const shockGeo = new THREE.BufferGeometry(); shockGeo.setAttribute('position', new THREE.BufferAttribute(shockPosArr, 3)); const shockMat = new THREE.LineBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.9 }); const shockRing = new THREE.Line(shockGeo, shockMat); scene.add(shockRing); // ── Trace ───────────────────────────────────────────────────────────────────── const TRACE_LEN = 900; const tracePosArr = new Float32Array(TRACE_LEN * 3); const traceGeo = new THREE.BufferGeometry(); traceGeo.setAttribute('position', new THREE.BufferAttribute(tracePosArr, 3)); traceGeo.setDrawRange(0, 0); const traceMat = new THREE.LineBasicMaterial({ color: 0x22c55e, transparent: true, opacity: 0.75 }); scene.add(new THREE.Line(traceGeo, traceMat)); let traceHead = 0, traceCount = 0; // ── Core filament ───────────────────────────────────────────────────────────── const filPts = Array.from({length: 121}, (_, i) => { const a = (i / 120) * Math.PI * 2; return new THREE.Vector3(R * Math.cos(a), 0, R * Math.sin(a)); }); const filGeo = new THREE.BufferGeometry().setFromPoints(filPts); const filMat = new THREE.LineBasicMaterial({ color: 0xff6b35, transparent: true, opacity: 0.85 }); const filament = new THREE.Line(filGeo, filMat); scene.add(filament); const filGlowMat = new THREE.MeshBasicMaterial({ color: 0xff6b35, transparent: true, opacity: 0.25 }); scene.add(new THREE.Mesh(new THREE.TorusGeometry(R, 0.07, 8, 100), filGlowMat)); // ── BG stars ────────────────────────────────────────────────────────────────── const bgPts = new Float32Array(1200 * 3).map(() => (Math.random() - 0.5) * 80); const bgGeo = new THREE.BufferGeometry(); bgGeo.setAttribute('position', new THREE.Float32BufferAttribute(bgPts, 3)); scene.add(new THREE.Points(bgGeo, new THREE.PointsMaterial({ color: 0x1a3050, size: 0.08, transparent: true, opacity: 0.4 }))); // ── Camera orbit (mouse + touch) ────────────────────────────────────────────── let camTheta = 0, camPhi = 0.42, camR = 8; let isDragging = false, lastX = 0, lastY = 0; function onDown(x, y) { isDragging = true; lastX = x; lastY = y; } function onUp() { isDragging = false; } function onMove(x, y) { if (!isDragging) return; camTheta -= (x - lastX) * 0.005; camPhi -= (y - lastY) * 0.005; camPhi = Math.max(-1.2, Math.min(1.2, camPhi)); lastX = x; lastY = y; } const cvs = renderer.domElement; cvs.addEventListener('mousedown', e => onDown(e.clientX, e.clientY)); cvs.addEventListener('mouseup', onUp); cvs.addEventListener('mousemove', e => onMove(e.clientX, e.clientY)); cvs.addEventListener('wheel', e => { camR = Math.max(4, Math.min(18, camR + e.deltaY * 0.01)); }); cvs.addEventListener('touchstart', e => { e.preventDefault(); onDown(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false }); cvs.addEventListener('touchend', onUp); cvs.addEventListener('touchmove', e => { e.preventDefault(); onMove(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false }); // ── Sliders ─────────────────────────────────────────────────────────────────── function bindSlider(id, key, valId, dec = 2) { const sl = document.getElementById(id); const vl = document.getElementById(valId); sl.addEventListener('input', () => { P[key] = parseFloat(sl.value); vl.textContent = dec === 3 ? P[key].toFixed(3) : P[key].toFixed(dec); if (key === 'density') buildOscillators(); if (key === 'ratio') clearPortrait(); }); } bindSlider('sl-K', 'K', 'val-K'); bindSlider('sl-ratio', 'ratio', 'val-ratio', 3); bindSlider('sl-b', 'b', 'val-b'); bindSlider('sl-c', 'c', 'val-c'); bindSlider('sl-speed', 'speed', 'val-speed', 1); bindSlider('sl-density', 'density', 'val-density', 0); ['phase','coherence','shock'].forEach(m => { document.getElementById('btn-' + m).addEventListener('click', () => { P.mode = m; document.querySelectorAll('.btn').forEach(b => b.classList.remove('active')); document.getElementById('btn-' + m).classList.add('active'); }); }); // ── Phase portrait state ────────────────────────────────────────────────────── const MAX_PORT_PTS = 4000; const portX = new Float32Array(MAX_PORT_PTS); const portY = new Float32Array(MAX_PORT_PTS); let portHead = 0, portCount = 0; function clearPortrait() { portHead = 0; portCount = 0; const s = portraitSize(); pCtx.clearRect(0, 0, s, s); } function drawPortrait(theta1, theta2) { const s = portraitSize(); // Store point (map 0..2π → 0..s) portX[portHead] = (theta1 / (Math.PI * 2)) * s; portY[portHead] = (theta2 / (Math.PI * 2)) * s; portHead = (portHead + 1) % MAX_PORT_PTS; portCount = Math.min(portCount + 1, MAX_PORT_PTS); // Redraw every 3 frames for performance if (portHead % 3 !== 0) return; pCtx.clearRect(0, 0, s, s); // Background grid pCtx.strokeStyle = 'rgba(0,212,255,0.06)'; pCtx.lineWidth = 0.5; [0.25, 0.5, 0.75].forEach(f => { pCtx.beginPath(); pCtx.moveTo(f * s, 0); pCtx.lineTo(f * s, s); pCtx.stroke(); pCtx.beginPath(); pCtx.moveTo(0, f * s); pCtx.lineTo(s, f * s); pCtx.stroke(); }); // Axes labels (tiny) pCtx.fillStyle = 'rgba(74,96,112,0.8)'; pCtx.font = `${Math.floor(s * 0.065)}px 'Space Mono', monospace`; pCtx.fillText('0', 2, s - 3); pCtx.fillText('2π', s - s * 0.14, s - 3); pCtx.fillText('θ₁', s / 2 - 6, s - 3); pCtx.save(); pCtx.translate(4, s / 2 + 8); pCtx.rotate(-Math.PI / 2); pCtx.fillText('θ₂', 0, 0); pCtx.restore(); // Draw trail: color fades from old (dark) to new (bright green) const total = portCount; const start = total < MAX_PORT_PTS ? 0 : portHead; for (let i = 1; i < total; i++) { const idx = (start + i) % MAX_PORT_PTS; const prev = (start + i - 1) % MAX_PORT_PTS; const frac = i / total; const r = Math.floor(34 + frac * (0 - 34)); const g = Math.floor(197 + frac * (255 - 197)); const b = Math.floor(94 + frac * (180 - 94)); const a = 0.15 + frac * 0.75; pCtx.strokeStyle = `rgba(${r},${g},${b},${a})`; pCtx.lineWidth = 0.8 + frac * 0.8; pCtx.beginPath(); pCtx.moveTo(portX[prev], portY[prev]); pCtx.lineTo(portX[idx], portY[idx]); pCtx.stroke(); } // Current point dot const curIdx = (portHead - 1 + MAX_PORT_PTS) % MAX_PORT_PTS; pCtx.fillStyle = '#00d4ff'; pCtx.shadowColor = '#00d4ff'; pCtx.shadowBlur = 6; pCtx.beginPath(); pCtx.arc(portX[curIdx], portY[curIdx], 3, 0, Math.PI * 2); pCtx.fill(); pCtx.shadowBlur = 0; document.getElementById('portrait-pts').textContent = portCount + ' pts'; } // ── Kuramoto order parameter ────────────────────────────────────────────────── function kuramotoR(phases) { let sx = 0, sy = 0; for (const ph of phases) { sx += Math.cos(ph); sy += Math.sin(ph); } return Math.sqrt(sx * sx + sy * sy) / phases.length; } // ── Main loop ───────────────────────────────────────────────────────────────── let t = 0, windingCount = 0, prevTheta2 = 0; function animate() { requestAnimationFrame(animate); const dt = 0.016 * P.speed; t += dt; const omega1 = 1.0; const omega2 = P.ratio * omega1; const theta1 = (omega1 * t) % (Math.PI * 2); const theta2 = (omega2 * t) % (Math.PI * 2); if (theta2 < prevTheta2) windingCount++; prevTheta2 = theta2; // ── Oscillators ── const oscs = oscGroup.children; let mx = 0, my = 0; for (const o of oscs) { mx += Math.cos(o.userData.phase); my += Math.sin(o.userData.phase); } const meanPhi = Math.atan2(my, mx); for (const osc of oscs) { const ud = osc.userData; const cherePhase = ud.u + theta2 * 2 + P.b * Math.sin(ud.v - theta1 * 3); ud.phase += dt * (ud.natFreq + P.K * Math.sin(meanPhi - ud.phase) + 0.3 * Math.sin(cherePhase - ud.phase)); ud.phase = ud.phase % (Math.PI * 2); const u = ud.u + theta2 * 0.3; const v = ud.v + theta1 * 0.8; osc.position.set( (R + r * Math.cos(v)) * Math.cos(u), r * Math.sin(v), (R + r * Math.cos(v)) * Math.sin(u) ); let col, opac; if (P.mode === 'phase') { const locked = 1 - Math.abs(Math.sin((ud.phase - cherePhase) / 2)); col = new THREE.Color().lerpColors(new THREE.Color(0xa855f7), new THREE.Color(0x00d4ff), locked * P.K); opac = 0.3 + locked * 0.6; } else if (P.mode === 'coherence') { const rl = Math.abs(Math.cos(ud.phase - meanPhi)); col = new THREE.Color().lerpColors(new THREE.Color(0x1a1a4a), new THREE.Color(0x00ffaa), rl); opac = 0.2 + rl * 0.7; } else { const frontAngle = theta2 * 2 % (Math.PI * 2); const angDiff = Math.abs(((ud.u - frontAngle + Math.PI * 3) % (Math.PI * 2)) - Math.PI); const isShock = Math.exp(-angDiff * angDiff / 0.1); col = new THREE.Color().lerpColors(new THREE.Color(0x0a0a20), new THREE.Color(0xffd700), isShock); opac = 0.1 + isShock * 0.9; } osc.material.color = col; osc.material.opacity = opac; osc.scale.setScalar(0.6 + 0.6 * Math.abs(Math.cos(ud.phase))); } // ── Shock ring ── const shockAngle = theta2 * 2; const coneHalf = Math.PI * 0.18 * (1 - P.K * 0.5); const sp = shockGeo.attributes.position; for (let i = 0; i <= 200; i++) { const a = (i / 200) * Math.PI * 2; const vOff = coneHalf * Math.sin(a - shockAngle); sp.setXYZ(i, (R + r * Math.cos(vOff)) * Math.cos(a), r * Math.sin(vOff) * (1 + 0.3 * Math.cos(theta1)), (R + r * Math.cos(vOff)) * Math.sin(a) ); } sp.needsUpdate = true; const pulse = 0.5 + 0.5 * Math.sin(t * 3); shockMat.opacity = 0.5 + 0.4 * pulse * P.K; shockMat.color.setHSL(0.55 + P.b * 0.05, 1, 0.6 + pulse * 0.2); // ── Trace ── const traceU = theta2 * 6; const traceV = theta1 * 3; const tx = (R + r * 0.7 * Math.cos(traceV)) * Math.cos(traceU); const ty = r * 0.7 * Math.sin(traceV); const tz = (R + r * 0.7 * Math.cos(traceU)) * Math.sin(traceU); tracePosArr[traceHead * 3] = tx; tracePosArr[traceHead * 3 + 1] = ty; tracePosArr[traceHead * 3 + 2] = tz; traceGeo.attributes.position.needsUpdate = true; traceHead = (traceHead + 1) % TRACE_LEN; traceCount = Math.min(traceCount + 1, TRACE_LEN); traceGeo.setDrawRange(0, traceCount); // ── Filament ── const fs = 1 + 0.04 * Math.sin(t * 2.5); filament.scale.set(fs, fs, fs); filMat.opacity = 0.6 + 0.3 * Math.abs(Math.sin(t * 1.8)); // ── Camera ── camera.position.set( camR * Math.cos(camPhi) * Math.sin(camTheta + t * 0.04), camR * Math.sin(camPhi), camR * Math.cos(camPhi) * Math.cos(camTheta + t * 0.04) ); camera.lookAt(0, 0, 0); ptLight.position.set(5 * Math.cos(t * 0.7), 3 + Math.sin(t * 0.4), 5 * Math.sin(t * 0.7)); // ── Phase portrait ── drawPortrait(theta1, theta2); // ── HUD ── const phases = oscs.slice(0, Math.min(100, oscs.length)).map(o => o.userData.phase); const r_order = kuramotoR(phases); document.getElementById('disp-t1').textContent = theta1.toFixed(3); document.getElementById('disp-t2').textContent = theta2.toFixed(3); document.getElementById('disp-r').textContent = r_order.toFixed(3); document.getElementById('disp-shock').textContent = (shockAngle % (Math.PI * 2)).toFixed(3); document.getElementById('disp-wind').textContent = windingCount; renderer.render(scene, camera); } // ── Resize ──────────────────────────────────────────────────────────────────── window.addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); resizePortrait(); clearPortrait(); }); animate(); </script> </body> </html> v2 is ready. Here’s what changed: Mobile fixes: ∙ All panels clamp with clamp() to scale properly across screen sizes ∙ Touch targets enlarged (14px slider thumbs vs 10px) ∙ ω₁/ω₂ label no longer wraps — shortened to ω₁/ω₂ ∙ Controls and portrait auto-reposition via media query breakpoint at 700px ∙ touch-action: none + passive event suppression for clean drag behavior Phase portrait inset (bottom right): ∙ Plots θ₁ (x-axis) vs θ₂ (y-axis) in real time, both mapped 0→2π ∙ Trail fades dark-green → bright-green as it ages, with a cyan dot at the current position ∙ At φ⁻¹ = 0.618…, you’ll watch it never close — the Lissajous figure gradually fills the entire square, which is the visual proof of irrational winding covering the torus densely ∙ Try sliding ω₁/ω₂ to exactly 0.500 or 0.333 — it will snap into a closed loop immediately, confirming commensurate resonance ∙ Resets automatically when you change the ratio slider ∙ Point count displayed so you can see density accumulate
I'll update the React implementation to match v2 with the phase portrait and mobile improvements.
Now I'll create the phase portrait component and update the visualization to include all v2 features:
Now let me update the controls panel with improved mobile sizing and the corrected slider for ratio:
Now let's update the info panel with responsive sizing:
Now let's update the legend to hide on mobile and show on desktop:
I've updated the visualization to v2 with all the requested features:
clamp() for responsive sizing, enlarged touch targets to 44px minimum, added touch-none for clean drag handling, and repositioned legend to only show on desktop (md breakpoint).<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>Cherenkov–Kuramoto Scroll Ring</title> <style> @import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,600;1,300&display=swap'); *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { –bg: #020408; –panel: rgba(3, 10, 20, 0.92); –accent: #00d4ff; –accent3: #a855f7; –green: #22c55e; –orange: #ff6b35; –text: #c8d8e8; –muted: #4a6070; –border: rgba(0, 212, 255, 0.14); –drawer-h: 230px; /* mobile drawer height */ } html, body { width: 100%; height: 100%; background: var(–bg); color: var(–text); font-family: ‘Space Mono’, monospace; overflow: hidden; touch-action: none; } /* ─── 3D Canvas ─────────────────────────────────────────────────────── */ canvas#main { display: block; position: fixed; top: 0; left: 0; z-index: 0; } /* ─── Top bar: title + state numbers ──────────────────────────────────── */ #topbar { position: fixed; top: 0; left: 0; right: 0; z-index: 20; display: flex; align-items: stretch; justify-content: space-between; padding: 10px 14px 8px; background: linear-gradient(to bottom, rgba(2,4,8,0.95) 60%, transparent); pointer-events: none; } #title-block h1 { font-family: ‘Crimson Pro’, serif; font-size: clamp(0.78rem, 2.2vw, 1rem); font-weight: 300; font-style: italic; color: var(–accent); letter-spacing: 0.03em; line-height: 1.15; } #title-block .sub { font-size: 0.48rem; color: var(–muted); letter-spacing: 0.11em; text-transform: uppercase; margin-top: 2px; } /* compact numeric readout — top right */ #state-bar { display: grid; grid-template-columns: auto auto; gap: 1px 10px; align-content: center; text-align: right; } .sb-key { font-size: 0.46rem; color: var(–muted); letter-spacing: 0.08em; text-transform: uppercase; line-height: 1.6; } .sb-val { font-size: 0.52rem; color: var(–accent); font-weight: 700; line-height: 1.6; } /* ─── Bottom drawer ────────────────────────────────────────────────────── */ #drawer { position: fixed; bottom: 0; left: 0; right: 0; z-index: 20; background: var(–panel); border-top: 1px solid var(–border); backdrop-filter: blur(18px); transition: transform 0.3s cubic-bezier(0.4,0,0.2,1); pointer-events: auto; } /* drag handle */ #drawer-handle { display: flex; justify-content: center; align-items: center; height: 22px; cursor: pointer; touch-action: none; } #drawer-handle::after { content: ‘’; width: 36px; height: 3px; border-radius: 2px; background: var(–muted); } #drawer-body { display: flex; gap: 10px; padding: 0 12px 12px; overflow: hidden; } /* LEFT col: sliders */ #drawer-sliders { flex: 1; display: flex; flex-direction: column; gap: 6px; } .ctrl-section-label { font-size: 0.44rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(–muted); margin-bottom: 2px; } .slider-row { display: flex; align-items: center; gap: 6px; } .slider-name { font-size: 0.5rem; color: var(–text); width: 46px; flex-shrink: 0; white-space: nowrap; } input[type=range] { -webkit-appearance: none; flex: 1; height: 2px; background: var(–muted); border-radius: 2px; outline: none; cursor: pointer; min-width: 0; } input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(–accent); cursor: pointer; box-shadow: 0 0 8px rgba(0,212,255,0.6); } .slider-val { font-size: 0.48rem; color: var(–accent); width: 34px; text-align: right; flex-shrink: 0; } /* divider */ .drawer-divider { width: 1px; background: var(–border); flex-shrink: 0; align-self: stretch; } /* RIGHT col: portrait + mode buttons */ #drawer-right { display: flex; flex-direction: column; gap: 6px; align-items: center; flex-shrink: 0; } #portrait-wrap { display: flex; flex-direction: column; align-items: center; gap: 3px; } #portrait-label-row { display: flex; width: 100%; justify-content: space-between; font-size: 0.42rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(–muted); } #portrait-label-row span { color: var(–green); } #portrait { display: block; border-radius: 3px; border: 1px solid var(–border); } /* mode buttons */ #btn-row { display: flex; gap: 5px; width: 100%; } .btn { flex: 1; font-family: ‘Space Mono’, monospace; font-size: 0.46rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(–text); background: rgba(0,212,255,0.05); border: 1px solid var(–border); border-radius: 3px; padding: 6px 2px; cursor: pointer; transition: all 0.18s; white-space: nowrap; } .btn:active, .btn.active { background: rgba(0,212,255,0.18); color: var(–accent); border-color: var(–accent); box-shadow: 0 0 6px rgba(0,212,255,0.18); } /* CGLE equation strip */ #eq-strip { font-family: ‘Crimson Pro’, serif; font-style: italic; font-size: 0.62rem; color: var(–accent3); line-height: 1.4; padding: 4px 12px 0; opacity: 0.85; } /* ─── Desktop overrides (≥700px) ─────────────────────────────────────── */ @media (min-width: 700px) { :root { –drawer-h: 0px; } ``` #topbar { padding: 20px 28px 16px; } #drawer { /* on desktop: left panel, not bottom drawer */ bottom: 28px; left: 28px; right: auto; top: auto; width: 260px; border: 1px solid var(--border); border-radius: 8px; border-top: 1px solid var(--border); transform: none !important; } #drawer-handle { display: none; } #drawer-body { flex-direction: column; padding: 12px 14px 14px; gap: 12px; } #drawer-sliders { gap: 7px; } .drawer-divider { display: none; } #drawer-right { flex-direction: row; align-items: flex-start; flex-wrap: wrap; gap: 8px; } #portrait-wrap { align-items: flex-start; } #btn-row { width: auto; gap: 6px; } /* desktop: info panel top-right */ #state-bar { gap: 2px 14px; } .sb-key, .sb-val { font-size: 0.58rem; } #eq-strip { display: none; } /* shown in portrait area on desktop */ ``` } </style> </head> <body> <canvas id="main"></canvas> <!-- TOP BAR --> <div id="topbar"> <div id="title-block"> <h1>Cherenkov–Kuramoto Scroll Ring</h1> <div class="sub">Toroidal Phase Attractor · CGLE</div> </div> <div id="state-bar"> <span class="sb-key">θ₁</span><span class="sb-val" id="disp-t1">0.000</span> <span class="sb-key">θ₂</span><span class="sb-val" id="disp-t2">0.000</span> <span class="sb-key">r</span><span class="sb-val" id="disp-r">0.000</span> <span class="sb-key">wind</span><span class="sb-val" id="disp-wind">0</span> </div> </div> <!-- BOTTOM DRAWER --> <div id="drawer"> <div id="drawer-handle" id="drawer-toggle"></div> <div id="eq-strip">∂A/∂t = A−(1+ib)|A|²A + (1+ic)∇²A</div> <div id="drawer-body"> <!-- LEFT: sliders --> <div id="drawer-sliders"> <div class="ctrl-section-label">Coupling</div> <div class="slider-row"> <span class="slider-name">K (sync)</span> <input type="range" id="sl-K" min="0" max="1" step="0.01" value="0.65"> <span class="slider-val" id="val-K">0.65</span> </div> <div class="slider-row"> <span class="slider-name">ω₁/ω₂</span> <input type="range" id="sl-ratio" min="0.1" max="0.95" step="0.005" value="0.618"> <span class="slider-val" id="val-ratio">0.618</span> </div> <div class="slider-row"> <span class="slider-name">b (CGLE)</span> <input type="range" id="sl-b" min="-2" max="2" step="0.05" value="0.5"> <span class="slider-val" id="val-b">0.50</span> </div> <div class="slider-row"> <span class="slider-name">c (CGLE)</span> <input type="range" id="sl-c" min="-2" max="2" step="0.05" value="-1.5"> <span class="slider-val" id="val-c">-1.50</span> </div> <div class="ctrl-section-label" style="margin-top:4px">Display</div> <div class="slider-row"> <span class="slider-name">Speed</span> <input type="range" id="sl-speed" min="0.1" max="3" step="0.05" value="1"> <span class="slider-val" id="val-speed">1.0</span> </div> <div class="slider-row"> <span class="slider-name">Density</span> <input type="range" id="sl-density" min="20" max="100" step="5" value="55"> <span class="slider-val" id="val-density">55</span> </div> </div> ``` <div class="drawer-divider"></div> <!-- RIGHT: phase portrait + mode buttons --> <div id="drawer-right"> <div id="portrait-wrap"> <div id="portrait-label-row"> <span>θ₁ vs θ₂</span> <span id="portrait-pts">0 pts</span> </div> <canvas id="portrait"></canvas> </div> <div id="btn-row"> <button class="btn active" id="btn-phase">Phase</button> <button class="btn" id="btn-coherence">Coher</button> <button class="btn" id="btn-shock">Shock</button> </div> </div> ``` </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script> // ── Drawer collapse/expand on mobile ──────────────────────────────────────── const drawer = document.getElementById('drawer'); const handle = document.getElementById('drawer-handle'); let drawerOpen = true; function setDrawer(open) { drawerOpen = open; if (window.innerWidth >= 700) { drawer.style.transform = ''; return; } const h = drawer.offsetHeight; const handleH = handle.offsetHeight; drawer.style.transform = open ? '' : `translateY(${h - handleH - 8}px)`; } handle.addEventListener('click', () => setDrawer(!drawerOpen)); // ── Portrait canvas sizing ─────────────────────────────────────────────────── const portraitCanvas = document.getElementById('portrait'); const pCtx = portraitCanvas.getContext('2d'); function portraitSize() { return window.innerWidth < 700 ? 110 : 150; } function resizePortrait() { const s = portraitSize(); portraitCanvas.width = s; portraitCanvas.height = s; portraitCanvas.style.width = s + 'px'; portraitCanvas.style.height = s + 'px'; } resizePortrait(); // ── THREE setup ────────────────────────────────────────────────────────────── const mainCanvas = document.getElementById('main'); const renderer = new THREE.WebGLRenderer({ canvas: mainCanvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight); renderer.setClearColor(0x020408, 1); const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020408, 0.036); const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.01, 500); camera.position.set(0, 3.2, 7.2); camera.lookAt(0, 0, 0); scene.add(new THREE.AmbientLight(0x0a1520, 1)); const ptLight = new THREE.PointLight(0x00d4ff, 2.2, 22); const ptLight2 = new THREE.PointLight(0xa855f7, 1.6, 16); ptLight2.position.set(3, -2, -3); scene.add(ptLight, ptLight2); // ── Torus ──────────────────────────────────────────────────────────────────── const R = 2.8, r = 0.9; const torusMat = new THREE.MeshPhongMaterial({ color: 0x0a1a2a, emissive: 0x050d14, transparent: true, opacity: 0.18, side: THREE.DoubleSide }); scene.add(new THREE.Mesh(new THREE.TorusGeometry(R, r, 80, 120), torusMat)); scene.add(new THREE.Mesh( new THREE.TorusGeometry(R, r, 28, 60), new THREE.MeshBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.04, wireframe: true }) )); // ── Oscillators ────────────────────────────────────────────────────────────── const P = { K: 0.65, ratio: 0.618, b: 0.5, c: -1.5, speed: 1, density: 55, mode: 'phase' }; let oscGroup = new THREE.Group(); scene.add(oscGroup); function buildOscillators() { while (oscGroup.children.length) oscGroup.remove(oscGroup.children[0]); const N = P.density; const geo = new THREE.SphereGeometry(0.045, 6, 6); for (let i = 0; i < N; i++) { for (let j = 0; j < Math.floor(N * 0.4); j++) { const mat = new THREE.MeshBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.7 }); const mesh = new THREE.Mesh(geo, mat); mesh.userData = { u: (i / N) * Math.PI * 2, v: (j / Math.floor(N * 0.4)) * Math.PI * 2, phase: Math.random() * Math.PI * 2, natFreq: 1 + (Math.random() - 0.5) * 0.4 }; oscGroup.add(mesh); } } } buildOscillators(); // ── Shock ring ─────────────────────────────────────────────────────────────── const shockPosArr = new Float32Array(201 * 3); const shockGeo = new THREE.BufferGeometry(); shockGeo.setAttribute('position', new THREE.BufferAttribute(shockPosArr, 3)); const shockMat = new THREE.LineBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.9 }); scene.add(new THREE.Line(shockGeo, shockMat)); // ── Trace ──────────────────────────────────────────────────────────────────── const TRACE_LEN = 900; const tracePosArr = new Float32Array(TRACE_LEN * 3); const traceGeo = new THREE.BufferGeometry(); traceGeo.setAttribute('position', new THREE.BufferAttribute(tracePosArr, 3)); traceGeo.setDrawRange(0, 0); scene.add(new THREE.Line(traceGeo, new THREE.LineBasicMaterial({ color: 0x22c55e, transparent: true, opacity: 0.75 }))); let traceHead = 0, traceCount = 0; // ── Core filament ───────────────────────────────────────────────────────────── const filPts = Array.from({length: 121}, (_, i) => { const a = (i / 120) * Math.PI * 2; return new THREE.Vector3(R * Math.cos(a), 0, R * Math.sin(a)); }); const filMat = new THREE.LineBasicMaterial({ color: 0xff6b35, transparent: true, opacity: 0.85 }); const filament = new THREE.Line(new THREE.BufferGeometry().setFromPoints(filPts), filMat); scene.add(filament); scene.add(new THREE.Mesh( new THREE.TorusGeometry(R, 0.07, 8, 100), new THREE.MeshBasicMaterial({ color: 0xff6b35, transparent: true, opacity: 0.22 }) )); // ── BG stars ───────────────────────────────────────────────────────────────── const bgPts = new Float32Array(1400 * 3).map(() => (Math.random() - 0.5) * 80); const bgGeo = new THREE.BufferGeometry(); bgGeo.setAttribute('position', new THREE.Float32BufferAttribute(bgPts, 3)); scene.add(new THREE.Points(bgGeo, new THREE.PointsMaterial({ color: 0x1a3050, size: 0.07, transparent: true, opacity: 0.35 }))); // ── Camera drag ─────────────────────────────────────────────────────────────── let camTheta = 0, camPhi = 0.42, camR = 8; let isDragging = false, lastX = 0, lastY = 0; function onDown(x, y) { isDragging = true; lastX = x; lastY = y; } function onUp() { isDragging = false; } function onMove(x, y) { if (!isDragging) return; camTheta -= (x - lastX) * 0.005; camPhi -= (y - lastY) * 0.005; camPhi = Math.max(-1.2, Math.min(1.2, camPhi)); lastX = x; lastY = y; } mainCanvas.addEventListener('mousedown', e => onDown(e.clientX, e.clientY)); mainCanvas.addEventListener('mouseup', onUp); mainCanvas.addEventListener('mousemove', e => onMove(e.clientX, e.clientY)); mainCanvas.addEventListener('wheel', e => { camR = Math.max(4, Math.min(18, camR + e.deltaY * 0.01)); }); mainCanvas.addEventListener('touchstart', e => { e.preventDefault(); onDown(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false }); mainCanvas.addEventListener('touchend', onUp); mainCanvas.addEventListener('touchmove', e => { e.preventDefault(); onMove(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false }); // ── Sliders ─────────────────────────────────────────────────────────────────── function bindSlider(id, key, valId, dec) { const sl = document.getElementById(id); const vl = document.getElementById(valId); sl.addEventListener('input', () => { P[key] = parseFloat(sl.value); vl.textContent = P[key].toFixed(dec ?? 2); if (key === 'density') buildOscillators(); if (key === 'ratio') clearPortrait(); }); } bindSlider('sl-K', 'K', 'val-K', 2); bindSlider('sl-ratio', 'ratio', 'val-ratio', 3); bindSlider('sl-b', 'b', 'val-b', 2); bindSlider('sl-c', 'c', 'val-c', 2); bindSlider('sl-speed', 'speed', 'val-speed', 1); bindSlider('sl-density', 'density', 'val-density', 0); ['phase','coherence','shock'].forEach(m => { document.getElementById('btn-' + m).addEventListener('click', () => { P.mode = m; document.querySelectorAll('.btn').forEach(b => b.classList.remove('active')); document.getElementById('btn-' + m).classList.add('active'); }); }); // ── Phase portrait ──────────────────────────────────────────────────────────── const MAX_PORT_PTS = 5000; const portX = new Float32Array(MAX_PORT_PTS); const portY = new Float32Array(MAX_PORT_PTS); let portHead = 0, portCount = 0; function clearPortrait() { portHead = 0; portCount = 0; const s = portraitSize(); pCtx.clearRect(0, 0, s, s); } let frameN = 0; function drawPortrait(theta1, theta2) { const s = portraitSize(); portX[portHead] = (theta1 / (Math.PI * 2)) * s; portY[portHead] = (theta2 / (Math.PI * 2)) * s; portHead = (portHead + 1) % MAX_PORT_PTS; portCount = Math.min(portCount + 1, MAX_PORT_PTS); // Only redraw every 2nd frame frameN++; if (frameN % 2 !== 0) return; pCtx.clearRect(0, 0, s, s); // Grid pCtx.strokeStyle = 'rgba(0,212,255,0.07)'; pCtx.lineWidth = 0.5; [0.25, 0.5, 0.75].forEach(f => { pCtx.beginPath(); pCtx.moveTo(f * s, 0); pCtx.lineTo(f * s, s); pCtx.stroke(); pCtx.beginPath(); pCtx.moveTo(0, f * s); pCtx.lineTo(s, f * s); pCtx.stroke(); }); // border pCtx.strokeStyle = 'rgba(0,212,255,0.12)'; pCtx.lineWidth = 0.5; pCtx.strokeRect(0.5, 0.5, s - 1, s - 1); // axis labels const fs = Math.max(8, Math.floor(s * 0.07)); pCtx.fillStyle = 'rgba(74,96,112,0.9)'; pCtx.font = `${fs}px monospace`; pCtx.fillText('θ₁→', s / 2 - fs * 1.2, s - 1); // Trail const total = portCount; const start = total < MAX_PORT_PTS ? 0 : portHead; // batch draw for perf: group by age bucket const BUCKETS = 8; const bucketSize = Math.ceil(total / BUCKETS); for (let b = 0; b < BUCKETS; b++) { const bStart = b * bucketSize; const bEnd = Math.min((b + 1) * bucketSize, total); if (bStart >= total) break; const frac = (b + 0.5) / BUCKETS; const alpha = 0.08 + frac * 0.85; const green = Math.floor(140 + frac * 115); pCtx.strokeStyle = `rgba(0,${green},80,${alpha})`; pCtx.lineWidth = 0.6 + frac * 1.0; pCtx.beginPath(); let first = true; for (let i = bStart; i < bEnd; i++) { const idx = (start + i) % MAX_PORT_PTS; if (first) { pCtx.moveTo(portX[idx], portY[idx]); first = false; } else pCtx.lineTo(portX[idx], portY[idx]); } pCtx.stroke(); } // current dot const cur = (portHead - 1 + MAX_PORT_PTS) % MAX_PORT_PTS; pCtx.fillStyle = '#00d4ff'; pCtx.shadowColor = '#00d4ff'; pCtx.shadowBlur = 8; pCtx.beginPath(); pCtx.arc(portX[cur], portY[cur], 3.5, 0, Math.PI * 2); pCtx.fill(); pCtx.shadowBlur = 0; document.getElementById('portrait-pts').textContent = portCount >= 1000 ? (portCount / 1000).toFixed(1) + 'k pts' : portCount + ' pts'; } // ── Kuramoto r ──────────────────────────────────────────────────────────────── function kuramotoR(phases) { let sx = 0, sy = 0; for (const ph of phases) { sx += Math.cos(ph); sy += Math.sin(ph); } return Math.sqrt(sx * sx + sy * sy) / phases.length; } // ── Main loop ───────────────────────────────────────────────────────────────── let t = 0, windingCount = 0, prevTheta2 = 0; function animate() { requestAnimationFrame(animate); const dt = 0.016 * P.speed; t += dt; const theta1 = (t) % (Math.PI * 2); const theta2 = (P.ratio * t) % (Math.PI * 2); if (theta2 < prevTheta2) windingCount++; prevTheta2 = theta2; // Oscillators const oscs = oscGroup.children; let mx = 0, my = 0; for (const o of oscs) { mx += Math.cos(o.userData.phase); my += Math.sin(o.userData.phase); } const meanPhi = Math.atan2(my, mx); for (const osc of oscs) { const ud = osc.userData; const cherePhase = ud.u + theta2 * 2 + P.b * Math.sin(ud.v - theta1 * 3); ud.phase += dt * (ud.natFreq + P.K * Math.sin(meanPhi - ud.phase) + 0.3 * Math.sin(cherePhase - ud.phase)); ud.phase %= Math.PI * 2; const u = ud.u + theta2 * 0.3; const v = ud.v + theta1 * 0.8; osc.position.set((R + r * Math.cos(v)) * Math.cos(u), r * Math.sin(v), (R + r * Math.cos(v)) * Math.sin(u)); let col, opac; if (P.mode === 'phase') { const locked = 1 - Math.abs(Math.sin((ud.phase - cherePhase) / 2)); col = new THREE.Color().lerpColors(new THREE.Color(0xa855f7), new THREE.Color(0x00d4ff), locked * P.K); opac = 0.3 + locked * 0.6; } else if (P.mode === 'coherence') { const rl = Math.abs(Math.cos(ud.phase - meanPhi)); col = new THREE.Color().lerpColors(new THREE.Color(0x1a1a4a), new THREE.Color(0x00ffaa), rl); opac = 0.2 + rl * 0.7; } else { const fd = theta2 * 2 % (Math.PI * 2); const ad = Math.abs(((ud.u - fd + Math.PI * 3) % (Math.PI * 2)) - Math.PI); const isShock = Math.exp(-ad * ad / 0.1); col = new THREE.Color().lerpColors(new THREE.Color(0x0a0a20), new THREE.Color(0xffd700), isShock); opac = 0.1 + isShock * 0.9; } osc.material.color = col; osc.material.opacity = opac; osc.scale.setScalar(0.6 + 0.6 * Math.abs(Math.cos(ud.phase))); } // Shock ring const shockAngle = theta2 * 2; const coneHalf = Math.PI * 0.18 * (1 - P.K * 0.5); const sp = shockGeo.attributes.position; for (let i = 0; i <= 200; i++) { const a = (i / 200) * Math.PI * 2; const vOff = coneHalf * Math.sin(a - shockAngle); sp.setXYZ(i, (R + r * Math.cos(vOff)) * Math.cos(a), r * Math.sin(vOff) * (1 + 0.3 * Math.cos(theta1)), (R + r * Math.cos(vOff)) * Math.sin(a)); } sp.needsUpdate = true; const pulse = 0.5 + 0.5 * Math.sin(t * 3); shockMat.opacity = 0.5 + 0.4 * pulse * P.K; shockMat.color.setHSL(0.55 + P.b * 0.05, 1, 0.6 + pulse * 0.2); // Trace const traceU = theta2 * 6, traceV = theta1 * 3; tracePosArr[traceHead * 3] = (R + r * 0.7 * Math.cos(traceV)) * Math.cos(traceU); tracePosArr[traceHead * 3 + 1] = r * 0.7 * Math.sin(traceV); tracePosArr[traceHead * 3 + 2] = (R + r * 0.7 * Math.cos(traceU)) * Math.sin(traceU); traceGeo.attributes.position.needsUpdate = true; traceHead = (traceHead + 1) % TRACE_LEN; traceCount = Math.min(traceCount + 1, TRACE_LEN); traceGeo.setDrawRange(0, traceCount); // Filament filament.scale.setScalar(1 + 0.04 * Math.sin(t * 2.5)); filMat.opacity = 0.6 + 0.3 * Math.abs(Math.sin(t * 1.8)); // Camera camera.position.set( camR * Math.cos(camPhi) * Math.sin(camTheta + t * 0.038), camR * Math.sin(camPhi), camR * Math.cos(camPhi) * Math.cos(camTheta + t * 0.038) ); camera.lookAt(0, 0, 0); ptLight.position.set(5 * Math.cos(t * 0.65), 3 + Math.sin(t * 0.4), 5 * Math.sin(t * 0.65)); // Phase portrait drawPortrait(theta1, theta2); // HUD const phases = oscs.slice(0, Math.min(80, oscs.length)).map(o => o.userData.phase); document.getElementById('disp-t1').textContent = theta1.toFixed(3); document.getElementById('disp-t2').textContent = theta2.toFixed(3); document.getElementById('disp-r').textContent = kuramotoR(phases).toFixed(3); document.getElementById('disp-wind').textContent = windingCount; renderer.render(scene, camera); } // ── Resize ──────────────────────────────────────────────────────────────────── window.addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); resizePortrait(); clearPortrait(); // re-evaluate drawer position setTimeout(() => setDrawer(drawerOpen), 50); }); // init drawer position after layout settles setTimeout(() => setDrawer(true), 100); animate(); </script> </body> </html>
You can send messages while the preview loads