This is the snapshot the production landing site (nibiru-framework.com) is deployed from. Brings together the recent splash + docs migration to the v4 "Cosmos" design system, the new in-framework AI module, and the framework groundwork that backs the framework-reference extraction. What lands: - docs/: Astro + Starlight site with the v4 dark cosmic palette, GalaxyHero canvas constellation, Mission Control chat (wired to /api/oracle → api.neuronetz.ai via providers.mjs Ollama), 5-panel MMVC stage (Model · AI · Module · Controller · View), translated EN/DE/JA/ES/FR content, PWA + sitemap + llms.txt + Umami analytics. - docs/design-system/: canonical mockup bundle (source/index-v2.html for splash, source/docs-system.html + preview/ for docs, SPEC.md, tokens). - docs/scripts/extraction/framework-reference-v2.md: deep framework reference (~1.6k lines, file:line citations, every public factory and idiom — basis for the LoRA training corpus. - application/module/ai/: AI module with chat / embed / RAG / agent plugins, plus pdoQuery / httpGet / fileRead tools and Modelfile + smoke-test in training/. - application/module/users/: user / ACL / form-factory traits used as the reference plugin pattern for the framework docs. - application/settings/config/database/: schema + seed migrations including the AI module tables (200–203). - Form factory + autogenerator changes the framework-reference-v2 covers. Production secrets stay out: docs/.env, settings.production.ini and ai.production.ini are all gitignored (.example files are in tree). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1001 lines
38 KiB
JavaScript
1001 lines
38 KiB
JavaScript
/* ============================================================
|
||
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 = `
|
||
<span>MODEL <strong>${currentModel}</strong></span>
|
||
<span>LATENCY <strong>${latency != null ? latency + ' ms' : '—'}</strong></span>
|
||
<span>TOKENS <strong>${tokens}</strong></span>
|
||
`;
|
||
};
|
||
|
||
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<Retriever>::query(prompt) → 5 docs',
|
||
' · Module<ReActPlanner>::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: '<?php\n', delay: 80 },
|
||
{ code: '<span class="tk-c">// Nibiru — Model, Module, View, Controller</span>\n', delay: 160 },
|
||
{ code: '<span class="tk-k">use</span> Nibiru\\{Model, Module, Controller, View};\n', delay: 220 },
|
||
{ code: '<span class="tk-k">use</span> Nibiru\\Std\\{Retriever, ReActPlanner, ChatView};\n\n', delay: 300 },
|
||
|
||
{ code: '<span class="tk-v">$model</span> = <span class="tk-cls">Model</span>::<span class="tk-fn">load</span>(<span class="tk-s">\'nibiru-base-7B\'</span>);\n', delay: 360, fx: () => addLaunchSatellite('Model', 0xffb574, 0, true) },
|
||
|
||
{ code: '<span class="tk-v">$retriever</span> = <span class="tk-k">new</span> <span class="tk-cls">Retriever</span>(<span class="tk-attr">index</span>: <span class="tk-s">\'./docs\'</span>, <span class="tk-attr">k</span>: <span class="tk-n">5</span>);\n', delay: 380, fx: () => addLaunchSatellite('Retriever', 0xb86bff, 1) },
|
||
|
||
{ code: '<span class="tk-v">$planner</span> = <span class="tk-k">new</span> <span class="tk-cls">ReActPlanner</span>(\n', delay: 240 },
|
||
{ code: ' <span class="tk-attr">model</span>: <span class="tk-v">$model</span>,\n', delay: 200 },
|
||
{ code: ' <span class="tk-attr">tools</span>: [<span class="tk-v">$retriever</span>],\n', delay: 200 },
|
||
{ code: ');\n\n', delay: 200, fx: () => addLaunchSatellite('ReActPlanner', 0x5b8dff, 2) },
|
||
|
||
{ code: '<span class="tk-v">$agent</span> = <span class="tk-k">new</span> <span class="tk-cls">Controller</span>(\n', delay: 240 },
|
||
{ code: ' <span class="tk-v">$planner</span>,\n', delay: 200 },
|
||
{ code: ' <span class="tk-attr">view</span>: <span class="tk-k">new</span> <span class="tk-cls">ChatView</span>(),\n', delay: 200 },
|
||
{ code: ');\n\n', delay: 220, fx: () => addLaunchSatellite('ChatView', 0x7ad6a3, 3) },
|
||
|
||
{ code: '<span class="tk-k">foreach</span> (<span class="tk-v">$agent</span>-><span class="tk-fn">stream</span>(<span class="tk-s">\'hello\'</span>) <span class="tk-k">as</span> <span class="tk-v">$tok</span>) {\n', delay: 280, fx: () => simulateTokens() },
|
||
{ code: ' <span class="tk-k">echo</span> <span class="tk-v">$tok</span>;\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 = '<span class="caret"></span>';
|
||
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;
|
||
}
|
||
|
||
})();
|