/* ============================================================ Nibiru v2 — site script ------------------------------------------------------------ - Hero constellation (Three.js) - Comet-trail cursor - MMVC pinned narrative (scroll-driven camera focus + panel sync) - Mission Control chat (window.claude.complete + telemetry) - Launch-sequence typewriter + live system canvas - Footer wide-shot constellation - Nav condense / back-to-top ============================================================ */ (() => { 'use strict'; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const hasThree = typeof THREE !== 'undefined'; /* ====================== NAV CONDENSE ====================== */ const nav = document.getElementById('nav'); const toTop = document.getElementById('toTop'); const onScroll = () => { const y = window.scrollY || 0; nav.classList.toggle('condensed', y > 40); toTop.classList.toggle('visible', y > window.innerHeight * 0.8); }; window.addEventListener('scroll', onScroll, { passive: true }); toTop.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' })); onScroll(); /* ====================== CURSOR GLOW (subtle) ====================== */ // A small soft star-glow follows the cursor — much subtler than before. // Disabled on touch / small screens. if (!reducedMotion && window.matchMedia('(hover: hover)').matches && innerWidth > 900) { const trail = document.getElementById('comet-trail'); const tctx = trail.getContext('2d'); let mx = -100, my = -100, tx = -100, ty = -100; const resize = () => { trail.width = innerWidth * devicePixelRatio; trail.height = innerHeight * devicePixelRatio; trail.style.width = innerWidth + 'px'; trail.style.height = innerHeight + 'px'; tctx.setTransform(1, 0, 0, 1, 0, 0); tctx.scale(devicePixelRatio, devicePixelRatio); }; resize(); window.addEventListener('resize', resize); window.addEventListener('mousemove', (e) => { mx = e.clientX; my = e.clientY; }); const tick = () => { // ease the rendered position so it lags slightly tx += (mx - tx) * 0.18; ty += (my - ty) * 0.18; tctx.clearRect(0, 0, innerWidth, innerHeight); const r = 18; const g = tctx.createRadialGradient(tx, ty, 0, tx, ty, r); g.addColorStop(0, 'rgba(244,238,219,0.22)'); g.addColorStop(0.4, 'rgba(184,107,255,0.10)'); g.addColorStop(1, 'rgba(91,141,255,0)'); tctx.fillStyle = g; tctx.beginPath(); tctx.arc(tx, ty, r, 0, Math.PI * 2); tctx.fill(); requestAnimationFrame(tick); }; requestAnimationFrame(tick); } /* ====================== HERO GALAXY ====================== */ const heroCanvas = document.getElementById('constellation'); let heroSystem = null; if (hasThree && heroCanvas && !reducedMotion) { heroSystem = makeGalaxy(heroCanvas); } /* ====================== MMVC PINNED NARRATIVE ====================== */ const mmvcTrack = document.getElementById('mmvcTrack'); const mmvcPanels = document.querySelectorAll('.mmvc-copy .panel'); const mmvcSteps = document.querySelectorAll('.mmvc-progress .step'); const mmvcCanvas = document.getElementById('mmvc-canvas'); // Dedicated MMVC system (separate canvas inside .mmvc-visual) let mmvcSystem = null; if (hasThree && mmvcCanvas && !reducedMotion) { mmvcSystem = makeNibiruSystem(mmvcCanvas, { withStarfield: true, labels: false, autoRotate: true, cameraDistance: 6.5, modules: [ { name: 'Retriever', sig: '', angle: 0, radius: 2.2, color: 0xb86bff, size: 0.16 }, { name: 'ReActPlanner', sig: '', angle: 1.3, radius: 2.6, color: 0xffb574, size: 0.18 }, { name: 'Vision', sig: '', angle: 2.5, radius: 2.0, color: 0x5b8dff, size: 0.14 }, { name: 'Tool::calc', sig: '', angle: 3.7, radius: 2.8, color: 0x7ad6a3, size: 0.12 }, { name: 'Memory', sig: '', angle: 4.9, radius: 2.3, color: 0xff8fb1, size: 0.14 }, ] }); } let currentMmvcStep = -1; const setMmvcStep = (n) => { if (n === currentMmvcStep) return; currentMmvcStep = n; mmvcPanels.forEach((p, i) => p.classList.toggle('active', i === n)); mmvcSteps.forEach((p, i) => p.classList.toggle('active', i === n)); if (mmvcSystem) mmvcSystem.focus(n); // 0=Model, 1=AI, 2=Module, 3=Controller, 4=View }; const PANEL_COUNT = 5; const stickyCanvas = () => { if (!mmvcTrack) return; const trackRect = mmvcTrack.getBoundingClientRect(); const trackTop = trackRect.top; const total = mmvcTrack.offsetHeight - window.innerHeight; const progress = Math.max(0, Math.min(1, -trackTop / total)); let step = Math.floor(progress * PANEL_COUNT); if (step > PANEL_COUNT - 1) step = PANEL_COUNT - 1; if (progress >= 0 && trackTop <= 0) { setMmvcStep(step); } else if (trackTop > 0) { setMmvcStep(0); } }; window.addEventListener('scroll', stickyCanvas, { passive: true }); window.addEventListener('resize', stickyCanvas); stickyCanvas(); /* ====================== MISSION CONTROL CHAT ====================== */ const mcBody = document.getElementById('mcBody'); const mcInput = document.getElementById('mcInput'); const mcSend = document.getElementById('mcSend'); const mcReset = document.getElementById('mcReset'); const mcTele = document.getElementById('mcTele'); let totalTokens = 0; let currentModel = 'nibiru-base-7B'; const teleUpdate = (latency, tokens, model) => { if (model) currentModel = model; mcTele.innerHTML = ` MODEL ${currentModel} LATENCY ${latency != null ? latency + ' ms' : '—'} TOKENS ${tokens} `; }; const mcAddLine = (cls, text) => { const l = document.createElement('div'); l.className = 'mc-line ' + cls; l.textContent = text; mcBody.appendChild(l); mcBody.scrollTop = mcBody.scrollHeight; return l; }; const moduleTrace = [ ' · Controller::dispatch(prompt)', ' · Module::query(prompt) → 5 docs', ' · Module::run(prompt, ctx)', ' · Model::stream(messages) → tokens', ]; const askAgent = async (prompt) => { if (!prompt.trim()) return; mcInput.value = ''; mcInput.disabled = true; mcSend.disabled = true; mcAddLine('usr', prompt); // Trace lines (cosmetic — the "modules lighting up") for (const t of moduleTrace) { await sleep(160 + Math.random() * 100); mcAddLine('trc', t); } mcAddLine('asst-label', '◇ assistant'); const out = document.createElement('div'); out.className = 'mc-line asst-stream'; mcBody.appendChild(out); const cursor = document.createElement('span'); cursor.className = 'mc-cursor'; out.appendChild(cursor); const start = performance.now(); let text = ''; let modelLabel = null; try { const res = await fetch('/api/oracle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', content: prompt }] }) }); if (!res.ok) throw new Error('oracle ' + res.status); const data = await res.json(); text = (data.answer || '').trim(); modelLabel = data.model || null; } catch (e) { text = 'Link interrupted. Re-establishing controller… try again in a moment.'; } // Stream-render the response by chunks of 1–2 chars out.textContent = ''; out.appendChild(cursor); for (let i = 0; i < text.length; i++) { out.insertBefore(document.createTextNode(text[i]), cursor); totalTokens += 0.25; // pseudo-token counter if (i % 4 === 0) { teleUpdate(Math.round(performance.now() - start), Math.floor(totalTokens)); mcBody.scrollTop = mcBody.scrollHeight; } await sleep(8 + Math.random() * 18); } cursor.remove(); const elapsed = Math.round(performance.now() - start); teleUpdate(elapsed, Math.floor(totalTokens), modelLabel); mcAddLine('done', `✓ done · ${elapsed} ms · ${Math.floor(totalTokens)} tok`); mcInput.disabled = false; mcSend.disabled = false; mcInput.focus(); }; mcSend.addEventListener('click', () => askAgent(mcInput.value)); mcInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') askAgent(mcInput.value); }); mcReset.addEventListener('click', () => { mcBody.innerHTML = ''; mcAddLine('sys', '› session reset · controller online · 4 modules in orbit'); totalTokens = 0; teleUpdate(null, 0); }); document.querySelectorAll('.mc-suggestion').forEach(b => { b.addEventListener('click', () => { const map = { 'explain mmvc': 'Explain the MMVC pattern in two sentences. Why split Model into Model and Module?', 'rag': 'Show a tiny PHP snippet that builds a RAG agent with Nibiru: a Retriever module wrapping a model, plus a ReActPlanner.', 'tools': "How do I add a custom 'calculator' tool to a Nibiru agent? Give a short PHP snippet using tool('calc', fn).", 'haiku': 'Write a haiku about a small AI agent orbiting a PHP server.' }; askAgent(map[b.dataset.q] || b.textContent); }); }); /* ====================== LAUNCH SEQUENCE ====================== */ const launchBody = document.getElementById('launchBody'); const launchPlay = document.getElementById('launchPlay'); const launchCanvas = document.getElementById('launch-canvas'); const lsModules = document.getElementById('lsModules'); const lsTokens = document.getElementById('lsTokens'); const lsLatency = document.getElementById('lsLatency'); // Build a small system canvas dedicated to the launch demo let launchSys = null; if (hasThree && launchCanvas && !reducedMotion) { launchSys = makeNibiruSystem(launchCanvas, { withStarfield: false, labels: false, autoRotate: true, modules: [] // start empty; launch sequence adds them }); } // Tokenized PHP code script. Each step writes a chunk and may register a module. const LAUNCH_SCRIPT = [ { code: '// Nibiru — Model, Module, View, Controller\n', delay: 160 }, { code: 'use Nibiru\\{Model, Module, Controller, View};\n', delay: 220 }, { code: 'use Nibiru\\Std\\{Retriever, ReActPlanner, ChatView};\n\n', delay: 300 }, { code: '$model = Model::load(\'nibiru-base-7B\');\n', delay: 360, fx: () => addLaunchSatellite('Model', 0xffb574, 0, true) }, { code: '$retriever = new Retriever(index: \'./docs\', k: 5);\n', delay: 380, fx: () => addLaunchSatellite('Retriever', 0xb86bff, 1) }, { code: '$planner = new ReActPlanner(\n', delay: 240 }, { code: ' model: $model,\n', delay: 200 }, { code: ' tools: [$retriever],\n', delay: 200 }, { code: ');\n\n', delay: 200, fx: () => addLaunchSatellite('ReActPlanner', 0x5b8dff, 2) }, { code: '$agent = new Controller(\n', delay: 240 }, { code: ' $planner,\n', delay: 200 }, { code: ' view: new ChatView(),\n', delay: 200 }, { code: ');\n\n', delay: 220, fx: () => addLaunchSatellite('ChatView', 0x7ad6a3, 3) }, { code: 'foreach ($agent->stream(\'hello\') as $tok) {\n', delay: 280, fx: () => simulateTokens() }, { code: ' echo $tok;\n', delay: 200 }, { code: '}\n', delay: 220 }, ]; let satelliteCount = 0; function addLaunchSatellite(name, color, idx, isCenter = false) { if (!launchSys) return; if (isCenter) { // model already exists at center; just blink it launchSys.flashCenter(); return; } const angle = (idx - 1) * (Math.PI * 2 / 4); launchSys.addModule({ name, angle, radius: 2.2 + (idx % 2) * 0.4, color, size: 0.15 }); satelliteCount++; if (lsModules) lsModules.textContent = satelliteCount; } async function simulateTokens() { if (!lsTokens || !lsLatency) return; const start = performance.now(); let n = 0; const total = 87; const step = () => { n++; lsTokens.textContent = n; lsLatency.textContent = Math.round(performance.now() - start) + ' ms'; if (launchSys) launchSys.pulseGravity(); if (n < total) setTimeout(step, 18 + Math.random() * 24); }; step(); } let launchRunning = false; async function runLaunch() { if (launchRunning) return; launchRunning = true; launchPlay.textContent = '▶ Replay'; launchBody.innerHTML = ''; if (launchSys) launchSys.reset(); satelliteCount = 0; if (lsModules) lsModules.textContent = '0'; if (lsTokens) lsTokens.textContent = '0'; if (lsLatency) lsLatency.textContent = '— ms'; const caret = ''; let html = ''; for (const part of LAUNCH_SCRIPT) { if (part.fx) part.fx(); // type out gradually for nicer effect const raw = part.code; // For the typewriter feel we render full chunk then a small delay html += raw; launchBody.innerHTML = html + caret; await sleep(part.delay); } launchBody.innerHTML = html; launchRunning = false; } if (launchPlay) launchPlay.addEventListener('click', runLaunch); // Auto-launch when the section enters view if ('IntersectionObserver' in window && launchPlay) { const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { runLaunch(); io.disconnect(); } }); }, { threshold: 0.4 }); io.observe(document.getElementById('code')); } /* ====================== FOOTER WIDE-SHOT ====================== */ const footerCanvas = document.getElementById('footer-canvas'); if (hasThree && footerCanvas && !reducedMotion) { makeNibiruSystem(footerCanvas, { withStarfield: true, labels: false, autoRotate: true, cameraDistance: 9, modules: [ { name: '', angle: 0, radius: 2.2, color: 0xb86bff, size: 0.10 }, { name: '', angle: 1.4, radius: 2.6, color: 0xffb574, size: 0.10 }, { name: '', angle: 2.7, radius: 2.0, color: 0x5b8dff, size: 0.08 }, { name: '', angle: 4.0, radius: 2.8, color: 0x7ad6a3, size: 0.08 }, ] }); } /* ====================== HELPERS ====================== */ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function makeRadialTexture(stops) { const c = document.createElement('canvas'); c.width = c.height = 128; const ctx = c.getContext('2d'); const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64); stops.forEach((s, i) => g.addColorStop(i / (stops.length - 1), s)); ctx.fillStyle = g; ctx.fillRect(0, 0, 128, 128); const tex = new THREE.CanvasTexture(c); tex.needsUpdate = true; return tex; } /* ====================== GALAXY (hero) ====================== */ function makeGalaxy(canvas) { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(2.5, devicePixelRatio)); renderer.setClearColor(0x000000, 0); // Camera spherical coords const cam = { radius: 11, minRadius: 5.5, maxRadius: 28, theta: Math.PI * 0.25, phi: 1.05, target: new THREE.Vector3(0, 0, 0), // Visual offset: shift the rendered scene UP so the galaxy sits in the // upper portion of the hero, above the headline. yOffset: 1.2, }; function applyCamera() { const r = cam.radius; const sp = Math.sin(cam.phi); camera.position.set( r * sp * Math.sin(cam.theta), r * Math.cos(cam.phi) + cam.yOffset, r * sp * Math.cos(cam.theta) ); const look = cam.target.clone(); look.y += cam.yOffset; camera.lookAt(look); } applyCamera(); const resize = () => { const parent = canvas.parentElement; const rect = (parent || canvas).getBoundingClientRect(); const w = Math.max(2, rect.width), h = Math.max(2, rect.height); camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, true); }; resize(); window.addEventListener('resize', resize); if (typeof ResizeObserver !== 'undefined') { new ResizeObserver(resize).observe(canvas.parentElement || canvas); } // ---------- Galaxy particles ---------- const PARTICLES = 18000; const ARMS = 4; const positions = new Float32Array(PARTICLES * 3); const colors = new Float32Array(PARTICLES * 3); const sizes = new Float32Array(PARTICLES); const baseAngle = new Float32Array(PARTICLES); const radii = new Float32Array(PARTICLES); const heights = new Float32Array(PARTICLES); const COLOR_CORE = new THREE.Color('#ffe6b8'); const COLOR_HOT = new THREE.Color('#ff9d6c'); const COLOR_MID = new THREE.Color('#b86bff'); const COLOR_OUT = new THREE.Color('#6d9bff'); const COLOR_DUST = new THREE.Color('#3a1f5a'); for (let i = 0; i < PARTICLES; i++) { const r = Math.pow(Math.random(), 1.8) * 6.4 + 0.05; const arm = Math.floor(Math.random() * ARMS); const armOffset = (arm / ARMS) * Math.PI * 2; const curl = r * 0.9; // Tighter arm spread near outer const armSpread = (Math.random() - 0.5) * 0.55 * Math.exp(-r * 0.12); // Background "haze" stars between arms const isArmStar = Math.random() < 0.78; const angleNoise = isArmStar ? armSpread : (Math.random() - 0.5) * Math.PI * 0.9; const angle = armOffset + curl + angleNoise; const verticalScale = Math.exp(-r * 0.45) * 0.45 + 0.06; const yJitter = (Math.random() - 0.5) * verticalScale; const x = Math.cos(angle) * r; const z = Math.sin(angle) * r; const y = yJitter; positions[i * 3] = x; positions[i * 3 + 1] = y; positions[i * 3 + 2] = z; baseAngle[i] = angle; radii[i] = r; heights[i] = y; // Color blend: core warm → hot orange → magenta → outer blue let c; if (r < 1.0) { c = COLOR_CORE.clone().lerp(COLOR_HOT, r); } else if (r < 3.0) { c = COLOR_HOT.clone().lerp(COLOR_MID, (r - 1.0) / 2.0); } else { c = COLOR_MID.clone().lerp(COLOR_OUT, Math.min(1, (r - 3.0) / 3.0)); } // Dust-lane darkening — slight chance to be a "dust" particle if (!isArmStar && Math.random() < 0.35 && r > 1.2 && r < 5.0) { c.lerp(COLOR_DUST, 0.55); } const v = 0.6 + Math.random() * 0.7; colors[i * 3] = c.r * v; colors[i * 3 + 1] = c.g * v; colors[i * 3 + 2] = c.b * v; // Size: bigger near core, occasional bright stars const coreBoost = (1 - Math.min(1, r / 6)) * 1.4; const bright = Math.random() < 0.025 ? 2.2 : (Math.random() < 0.08 ? 1.4 : 1); sizes[i] = (0.4 + Math.random() * 0.5 + coreBoost) * bright; } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geo.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)); // Soft star sprite (radial gradient on a canvas) — gives points a real glow const sprite = makeRadialTexture([ 'rgba(255,255,255,1)', 'rgba(255,255,255,0.55)', 'rgba(255,255,255,0.12)', 'rgba(255,255,255,0)' ]); // Custom shader so we can drive per-particle size + soft sprite const mat = new THREE.ShaderMaterial({ uniforms: { uTex: { value: sprite }, uPixelRatio: { value: renderer.getPixelRatio() }, uBaseSize: { value: 18.0 }, }, vertexShader: ` attribute float aSize; varying vec3 vColor; uniform float uBaseSize; uniform float uPixelRatio; void main() { vColor = color; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv; // perspective-attenuated size gl_PointSize = aSize * uBaseSize * uPixelRatio * (1.0 / -mv.z); } `, fragmentShader: ` uniform sampler2D uTex; varying vec3 vColor; void main() { vec4 t = texture2D(uTex, gl_PointCoord); if (t.a < 0.02) discard; gl_FragColor = vec4(vColor, 1.0) * t; } `, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true, }); const points = new THREE.Points(geo, mat); scene.add(points); // ---------- Bright galactic core ---------- const coreSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: makeRadialTexture(['rgba(255,240,200,1)','rgba(255,170,100,0.7)','rgba(184,107,255,0.15)','rgba(0,0,0,0)']), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending })); coreSprite.scale.set(3.4, 3.4, 1); scene.add(coreSprite); const coreOuter = new THREE.Sprite(new THREE.SpriteMaterial({ map: makeRadialTexture(['rgba(184,107,255,0.45)','rgba(91,141,255,0.12)','rgba(0,0,0,0)']), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending })); coreOuter.scale.set(9, 9, 1); scene.add(coreOuter); // ---------- Far starfield (parallax background) ---------- const bgCount = 1200; const bgPos = new Float32Array(bgCount * 3); for (let i = 0; i < bgCount; i++) { const r = 60 + Math.random() * 80; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); bgPos[i*3] = r * Math.sin(phi) * Math.cos(theta); bgPos[i*3+1] = r * Math.sin(phi) * Math.sin(theta); bgPos[i*3+2] = r * Math.cos(phi); } const bgGeo = new THREE.BufferGeometry(); bgGeo.setAttribute('position', new THREE.BufferAttribute(bgPos, 3)); const bgStars = new THREE.Points(bgGeo, new THREE.PointsMaterial({ color: 0xf4eedb, size: 0.18, transparent: true, opacity: 0.7, depthWrite: false, })); scene.add(bgStars); // ---------- Interaction: drag rotate, wheel zoom ---------- let dragging = false, lastX = 0, lastY = 0; let userInteracted = false; let interactTimeout = null; const markInteract = () => { userInteracted = true; clearTimeout(interactTimeout); interactTimeout = setTimeout(() => { userInteracted = false; }, 3000); }; canvas.addEventListener('pointerdown', (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; canvas.setPointerCapture(e.pointerId); canvas.style.cursor = 'grabbing'; markInteract(); }); canvas.addEventListener('pointermove', (e) => { if (!dragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; cam.theta -= dx * 0.005; cam.phi = Math.max(0.25, Math.min(Math.PI - 0.25, cam.phi - dy * 0.005)); markInteract(); }); const endDrag = () => { dragging = false; canvas.style.cursor = 'grab'; }; canvas.addEventListener('pointerup', endDrag); canvas.addEventListener('pointercancel', endDrag); canvas.addEventListener('pointerleave', endDrag); canvas.style.cursor = 'grab'; canvas.addEventListener('wheel', (e) => { // Only zoom when hero canvas is in foreground (top of page) e.preventDefault(); const factor = Math.exp(e.deltaY * 0.0015); cam.radius = Math.max(cam.minRadius, Math.min(cam.maxRadius, cam.radius * factor)); markInteract(); }, { passive: false }); // ---------- Animate ---------- let last = performance.now(); function frame() { const now = performance.now(); const dt = Math.min(0.05, (now - last) / 1000); last = now; // Differential rotation: inner faster, outer slower const pos = geo.attributes.position.array; for (let i = 0; i < PARTICLES; i++) { const r = radii[i]; const speed = 0.08 / (0.4 + r * 0.18); baseAngle[i] += speed * dt; const a = baseAngle[i]; pos[i*3] = Math.cos(a) * r; pos[i*3 + 1] = heights[i]; pos[i*3 + 2] = Math.sin(a) * r; } geo.attributes.position.needsUpdate = true; // Auto-orbit camera if user idle if (!userInteracted && !dragging) { cam.theta += dt * 0.04; } applyCamera(); // Core breathing const breath = 1 + Math.sin(now / 1400) * 0.04; coreSprite.scale.set(3.4 * breath, 3.4 * breath, 1); // Position the core sprites at the visual offset so they ride with the galaxy coreSprite.position.set(0, cam.yOffset, 0); coreOuter.position.set(0, cam.yOffset, 0); // Move the points cloud up too points.position.y = cam.yOffset; bgStars.position.y = cam.yOffset; renderer.render(scene, camera); requestAnimationFrame(frame); } requestAnimationFrame(frame); return { resize }; } /* ====================== NIBIRU SYSTEM (Three.js) ====================== */ function makeNibiruSystem(canvas, opts) { const o = Object.assign({ withStarfield: true, labels: false, autoRotate: true, cameraDistance: 6.5, modules: [] }, opts || {}); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 200); camera.position.set(0, 1.8, o.cameraDistance); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(2, devicePixelRatio)); renderer.setClearColor(0x000000, 0); // Sizing const resize = () => { const r = canvas.getBoundingClientRect(); const w = Math.max(2, r.width); const h = Math.max(2, r.height); camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false); }; resize(); // Starfield if (o.withStarfield) { const starGeo = new THREE.BufferGeometry(); const starCount = 1200; const positions = new Float32Array(starCount * 3); const sizes = new Float32Array(starCount); for (let i = 0; i < starCount; i++) { const r = 30 + Math.random() * 50; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); positions[i*3] = r * Math.sin(phi) * Math.cos(theta); positions[i*3+1] = r * Math.sin(phi) * Math.sin(theta); positions[i*3+2] = r * Math.cos(phi); sizes[i] = Math.random() < 0.05 ? 0.06 : 0.02 + Math.random() * 0.02; } starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); starGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); const starMat = new THREE.PointsMaterial({ color: 0xf4eedb, size: 0.04, sizeAttenuation: true, transparent: true, opacity: 0.85, depthWrite: false }); const stars = new THREE.Points(starGeo, starMat); scene.add(stars); } // === Center: the Model star === // Layered glow: bright core + soft halo const coreGeo = new THREE.SphereGeometry(0.32, 48, 48); const coreMat = new THREE.MeshBasicMaterial({ color: 0xfff2d6 }); const core = new THREE.Mesh(coreGeo, coreMat); scene.add(core); // Halo (sprite) const haloTex = makeRadialTexture(['rgba(255,228,170,1)','rgba(255,150,90,0.6)','rgba(184,107,255,0.0)']); const haloMat = new THREE.SpriteMaterial({ map: haloTex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }); const halo = new THREE.Sprite(haloMat); halo.scale.set(2.2, 2.2, 1); scene.add(halo); // Outer corona const corona = new THREE.Sprite(new THREE.SpriteMaterial({ map: makeRadialTexture(['rgba(184,107,255,0.5)','rgba(91,141,255,0.15)','rgba(0,0,0,0)']), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending })); corona.scale.set(5.2, 5.2, 1); scene.add(corona); // Orbits group const orbits = new THREE.Group(); scene.add(orbits); // Modules const moduleObjs = []; function buildOrbit(radius) { const seg = 128; const pts = []; for (let i = 0; i <= seg; i++) { const a = (i / seg) * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * radius, 0, Math.sin(a) * radius)); } const g = new THREE.BufferGeometry().setFromPoints(pts); const m = new THREE.LineBasicMaterial({ color: 0xb86bff, transparent: true, opacity: 0.18 }); return new THREE.Line(g, m); } function addModule(spec) { const orbit = buildOrbit(spec.radius); orbits.add(orbit); const mGeo = new THREE.SphereGeometry(spec.size || 0.14, 24, 24); const mMat = new THREE.MeshBasicMaterial({ color: spec.color }); const mesh = new THREE.Mesh(mGeo, mMat); // glow sprite const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: makeRadialTexture([ `rgba(${rgb(spec.color)},0.8)`, `rgba(${rgb(spec.color)},0.2)`, `rgba(0,0,0,0)` ]), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending })); glow.scale.set(spec.size * 5, spec.size * 5, 1); const node = new THREE.Group(); node.add(mesh); node.add(glow); // initial position (animated to orbit on add) node.position.set(Math.cos(spec.angle) * spec.radius, 0, Math.sin(spec.angle) * spec.radius); // arrival animation node.scale.setScalar(0.001); scene.add(node); const obj = { spec, node, mesh, glow, orbit, angle: spec.angle, radius: spec.radius, speed: 0.12 + Math.random() * 0.08, arriveStart: performance.now(), arriveDur: 700, }; moduleObjs.push(obj); return obj; } o.modules.forEach(addModule); // Gravity lines (controller "field") const linesGroup = new THREE.Group(); scene.add(linesGroup); function rebuildLines() { while (linesGroup.children.length) linesGroup.remove(linesGroup.children[0]); moduleObjs.forEach(m => { const g = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0,0,0), m.node.position.clone() ]); const mat = new THREE.LineBasicMaterial({ color: 0xb86bff, transparent: true, opacity: 0.0 }); const line = new THREE.Line(g, mat); line._target = m; linesGroup.add(line); }); } rebuildLines(); // Tooltip const tip = document.getElementById('moduleTip'); const raycaster = hasThree ? new THREE.Raycaster() : null; const pointer = new THREE.Vector2(); let hovered = null; function onMove(e) { if (!o.labels || !tip) return; const r = canvas.getBoundingClientRect(); pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1; pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1; } canvas.addEventListener('mousemove', onMove); canvas.addEventListener('mouseleave', () => { hovered = null; if (tip) tip.classList.remove('visible'); }); // === Focus / camera presets === let cameraTarget = { pos: camera.position.clone(), look: new THREE.Vector3(0,0,0) }; let cameraEase = 0.06; function focus(step) { // step: 0=Model, 1=AI, 2=Module, 3=Controller (lines on), 4=View (pull back) switch (step) { case 0: // MODEL — close on the centre cameraTarget = { pos: new THREE.Vector3(0, 1.0, 3.5), look: new THREE.Vector3(0,0,0) }; linesVisible = 0; break; case 1: // AI — slight orbit, slightly elevated, gravity lines start to flicker on cameraTarget = { pos: new THREE.Vector3(0.8, 1.6, 3.8), look: new THREE.Vector3(0, 0.2, 0) }; linesVisible = 0.25; break; case 2: // MODULE — offset right, looking at orbiting module nodes cameraTarget = { pos: new THREE.Vector3(2.6, 1.2, 3.6), look: new THREE.Vector3(1.6, 0, 0) }; linesVisible = 0; break; case 3: // CONTROLLER — high up, looking down, all gravity lines visible cameraTarget = { pos: new THREE.Vector3(0, 4.8, 6.0), look: new THREE.Vector3(0,0,0) }; linesVisible = 1; break; case 4: default: // VIEW — pulled back, full system in frame cameraTarget = { pos: new THREE.Vector3(0, 1.8, o.cameraDistance), look: new THREE.Vector3(0,0,0) }; linesVisible = 0.6; break; } } let linesVisible = 0; function pulseGravity() { linesGroup.children.forEach(l => { l.material.opacity = Math.min(1, l.material.opacity + 0.4); }); } function flashCenter() { core.scale.setScalar(1.4); halo.scale.set(3.0, 3.0, 1); } function reset() { while (moduleObjs.length) { const m = moduleObjs.pop(); scene.remove(m.node); orbits.remove(m.orbit); } rebuildLines(); } // Animate let t0 = performance.now(); function frame() { const t = performance.now(); const dt = Math.min(0.05, (t - t0) / 1000); t0 = t; // Module orbit + arrival animation moduleObjs.forEach(m => { m.angle += m.speed * dt; const x = Math.cos(m.angle) * m.radius; const z = Math.sin(m.angle) * m.radius; m.node.position.set(x, 0, z); // arrival ease const p = Math.min(1, (t - m.arriveStart) / m.arriveDur); const e = 1 - Math.pow(1 - p, 3); m.node.scale.setScalar(e); }); // Core breathing const breath = 1 + Math.sin(t / 700) * 0.04; core.scale.lerp(new THREE.Vector3(breath, breath, breath), 0.1); halo.scale.lerp(new THREE.Vector3(2.2 * breath, 2.2 * breath, 1), 0.1); // Camera ease camera.position.lerp(cameraTarget.pos, cameraEase); const lookCurrent = new THREE.Vector3(); camera.getWorldDirection(lookCurrent); const desiredDir = cameraTarget.look.clone().sub(camera.position).normalize(); // Smooth lookAt by easing the camera's quaternion toward a temp lookAt camera const tmp = new THREE.Object3D(); tmp.position.copy(camera.position); tmp.lookAt(cameraTarget.look); camera.quaternion.slerp(tmp.quaternion, cameraEase); // Auto rotate around Y by orbiting target slightly if (o.autoRotate) { const ang = t / 12000; const offset = new THREE.Vector3(Math.sin(ang) * 0.4, 0, 0); camera.position.add(offset.multiplyScalar(0.001)); } // Gravity lines opacity linesGroup.children.forEach(l => { const target = linesVisible; l.material.opacity += (target - l.material.opacity) * 0.05; // refresh geometry to current module pos if (l._target) { const pts = [new THREE.Vector3(0,0,0), l._target.node.position.clone()]; l.geometry.setFromPoints(pts); } }); // Hover detection if (o.labels && tip && raycaster) { raycaster.setFromCamera(pointer, camera); const meshes = moduleObjs.map(m => m.mesh); const hits = raycaster.intersectObjects(meshes, false); if (hits.length) { const m = moduleObjs.find(m => m.mesh === hits[0].object); if (m && m !== hovered) { hovered = m; tip.querySelector('.name').textContent = m.spec.name; tip.querySelector('.sig').textContent = m.spec.sig || ''; tip.classList.add('visible'); } if (hovered) { // project module position to screen const v = hovered.node.position.clone().project(camera); const r = canvas.getBoundingClientRect(); const sx = (v.x * 0.5 + 0.5) * r.width + r.left; const sy = (-v.y * 0.5 + 0.5) * r.height + r.top; tip.style.left = sx + 'px'; tip.style.top = sy + 'px'; canvas.style.cursor = 'pointer'; } } else if (hovered) { hovered = null; tip.classList.remove('visible'); canvas.style.cursor = ''; } } renderer.render(scene, camera); requestAnimationFrame(frame); } requestAnimationFrame(frame); window.addEventListener('resize', resize); return { focus, addModule, reset, resize, pulseGravity, flashCenter }; } function rgb(hex) { const r = (hex >> 16) & 255; const g = (hex >> 8) & 255; const b = hex & 255; return `${r},${g},${b}`; } function makeRadialTexture(stops) { const c = document.createElement('canvas'); c.width = c.height = 128; const ctx = c.getContext('2d'); const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64); stops.forEach((s, i) => g.addColorStop(i / (stops.length - 1), s)); ctx.fillStyle = g; ctx.fillRect(0, 0, 128, 128); const tex = new THREE.CanvasTexture(c); tex.needsUpdate = true; return tex; } })();