/* ============================================================
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;
}
})();