Files
the-nexus/modules/effects.js
Alexander Whitestone 4f196a175f
Some checks failed
CI / validate (pull_request) Failing after 14s
CI / auto-merge (pull_request) Has been skipped
refactor: split app.js into ES modules (scene, effects, controls, ui)
Refs #143

- modules/scene.js  — NEXUS palette, scene/camera/renderer, lighting, EffectComposer, OrbitControls
- modules/effects.js — star field, constellation lines, glass platform, sovereignty meter, commit banners, agent status board
- modules/controls.js — mouse state, overview mode (Tab), photo mode (P), resize handler
- modules/ui.js — debug toggle, WebSocket client, sovereignty easter egg

app.js is now a lean orchestrator: imports all modules, runs the
asset-loading manager, and owns the animate() loop. All files pass
`node --check` and are well under the 500 KB file-size budget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:40:17 -04:00

495 lines
15 KiB
JavaScript

import * as THREE from 'three';
import { NEXUS, scene } from './scene.js';
// === STAR FIELD ===
export const STAR_COUNT = 800;
const STAR_SPREAD = 400;
const CONSTELLATION_DISTANCE = 30;
export const starPositions = [];
const starGeo = new THREE.BufferGeometry();
const posArray = new Float32Array(STAR_COUNT * 3);
const sizeArray = new Float32Array(STAR_COUNT);
for (let i = 0; i < STAR_COUNT; i++) {
const x = (Math.random() - 0.5) * STAR_SPREAD;
const y = (Math.random() - 0.5) * STAR_SPREAD;
const z = (Math.random() - 0.5) * STAR_SPREAD;
posArray[i * 3] = x;
posArray[i * 3 + 1] = y;
posArray[i * 3 + 2] = z;
sizeArray[i] = Math.random() * 2.5 + 0.5;
starPositions.push(new THREE.Vector3(x, y, z));
}
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1));
export const starMaterial = new THREE.PointsMaterial({
color: NEXUS.colors.starCore,
size: 0.6,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
});
export const stars = new THREE.Points(starGeo, starMaterial);
scene.add(stars);
// === CONSTELLATION LINES ===
/**
* Builds constellation line segments connecting nearby stars.
* @returns {THREE.LineSegments}
*/
function buildConstellationLines() {
const linePositions = [];
const MAX_CONNECTIONS_PER_STAR = 3;
const connectionCount = new Array(STAR_COUNT).fill(0);
for (let i = 0; i < STAR_COUNT; i++) {
if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue;
const neighbors = [];
for (let j = i + 1; j < STAR_COUNT; j++) {
if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue;
const dist = starPositions[i].distanceTo(starPositions[j]);
if (dist < CONSTELLATION_DISTANCE) {
neighbors.push({ j, dist });
}
}
neighbors.sort((/** @type {{j: number, dist: number}} */ a, /** @type {{j: number, dist: number}} */ b) => a.dist - b.dist);
const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]);
for (const { j } of toConnect) {
linePositions.push(
starPositions[i].x, starPositions[i].y, starPositions[i].z,
starPositions[j].x, starPositions[j].y, starPositions[j].z
);
connectionCount[i]++;
connectionCount[j]++;
}
}
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
const lineMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.constellationLine,
transparent: true,
opacity: 0.18,
});
return new THREE.LineSegments(lineGeo, lineMat);
}
export const constellationLines = buildConstellationLines();
scene.add(constellationLines);
// === GLASS PLATFORM ===
const glassPlatformGroup = new THREE.Group();
const platformFrameMat = new THREE.MeshStandardMaterial({
color: 0x0a1828,
metalness: 0.9,
roughness: 0.1,
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.06),
});
const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64);
const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat);
platformRim.rotation.x = -Math.PI / 2;
glassPlatformGroup.add(platformRim);
const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64);
const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat);
borderTorus.rotation.x = Math.PI / 2;
glassPlatformGroup.add(borderTorus);
const glassTileMat = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(NEXUS.colors.accent),
transparent: true,
opacity: 0.09,
roughness: 0.0,
metalness: 0.0,
transmission: 0.92,
thickness: 0.06,
side: THREE.DoubleSide,
depthWrite: false,
});
const glassEdgeBaseMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.accent,
transparent: true,
opacity: 0.55,
});
const GLASS_TILE_SIZE = 0.85;
const GLASS_TILE_GAP = 0.14;
const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP;
const GLASS_RADIUS = 4.55;
const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE);
const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo);
/** @type {Array<{mat: THREE.LineBasicMaterial, distFromCenter: number}>} */
export const glassEdgeMaterials = [];
for (let row = -5; row <= 5; row++) {
for (let col = -5; col <= 5; col++) {
const x = col * GLASS_TILE_STEP;
const z = row * GLASS_TILE_STEP;
const distFromCenter = Math.sqrt(x * x + z * z);
if (distFromCenter > GLASS_RADIUS) continue;
const tile = new THREE.Mesh(tileGeo, glassTileMat.clone());
tile.rotation.x = -Math.PI / 2;
tile.position.set(x, 0, z);
glassPlatformGroup.add(tile);
const mat = glassEdgeBaseMat.clone();
const edges = new THREE.LineSegments(tileEdgeGeo, mat);
edges.rotation.x = -Math.PI / 2;
edges.position.set(x, 0.002, z);
glassPlatformGroup.add(edges);
glassEdgeMaterials.push({ mat, distFromCenter });
}
}
export const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14);
voidLight.position.set(0, -3.5, 0);
glassPlatformGroup.add(voidLight);
scene.add(glassPlatformGroup);
// === SOVEREIGNTY METER ===
export const sovereigntyGroup = new THREE.Group();
sovereigntyGroup.position.set(0, 3.8, 0);
const meterBgGeo = new THREE.TorusGeometry(1.6, 0.1, 8, 64);
const meterBgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 });
sovereigntyGroup.add(new THREE.Mesh(meterBgGeo, meterBgMat));
let sovereigntyScore = 85;
let sovereigntyLabel = 'Mostly Sovereign';
function sovereigntyHexColor(score) {
if (score >= 80) return 0x00ff88;
if (score >= 40) return 0xffcc00;
return 0xff4444;
}
function buildScoreArcGeo(score) {
return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2);
}
export const scoreArcMat = new THREE.MeshBasicMaterial({
color: sovereigntyHexColor(sovereigntyScore),
transparent: true,
opacity: 0.9,
});
export const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), scoreArcMat);
scoreArcMesh.rotation.z = Math.PI / 2;
sovereigntyGroup.add(scoreArcMesh);
export const meterLight = new THREE.PointLight(sovereigntyHexColor(sovereigntyScore), 0.7, 6);
sovereigntyGroup.add(meterLight);
function buildMeterTexture(score, label) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 128;
const ctx = canvas.getContext('2d');
const hexStr = score >= 80 ? '#00ff88' : score >= 40 ? '#ffcc00' : '#ff4444';
ctx.clearRect(0, 0, 256, 128);
ctx.font = 'bold 52px "Courier New", monospace';
ctx.fillStyle = hexStr;
ctx.textAlign = 'center';
ctx.fillText(`${score}%`, 128, 58);
ctx.font = '16px "Courier New", monospace';
ctx.fillStyle = '#8899bb';
ctx.fillText(label.toUpperCase(), 128, 82);
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#445566';
ctx.fillText('SOVEREIGNTY', 128, 104);
return new THREE.CanvasTexture(canvas);
}
export const meterSpriteMat = new THREE.SpriteMaterial({
map: buildMeterTexture(sovereigntyScore, sovereigntyLabel),
transparent: true,
depthWrite: false,
});
const meterSprite = new THREE.Sprite(meterSpriteMat);
meterSprite.scale.set(3.2, 1.6, 1);
sovereigntyGroup.add(meterSprite);
scene.add(sovereigntyGroup);
export async function loadSovereigntyStatus() {
try {
const res = await fetch('./sovereignty-status.json');
if (!res.ok) throw new Error('not found');
const data = await res.json();
const score = Math.max(0, Math.min(100, typeof data.score === 'number' ? data.score : 85));
const label = typeof data.label === 'string' ? data.label : '';
sovereigntyScore = score;
sovereigntyLabel = label;
scoreArcMesh.geometry.dispose();
scoreArcMesh.geometry = buildScoreArcGeo(score);
const col = sovereigntyHexColor(score);
scoreArcMat.color.setHex(col);
meterLight.color.setHex(col);
if (meterSpriteMat.map) meterSpriteMat.map.dispose();
meterSpriteMat.map = buildMeterTexture(score, label);
meterSpriteMat.needsUpdate = true;
} catch {
// defaults already set above
}
}
// === COMMIT BANNERS ===
export const commitBanners = [];
/**
* Creates a canvas texture for a commit banner.
* @param {string} hash - Short commit hash
* @param {string} message - Commit subject line
* @returns {THREE.CanvasTexture}
*/
function createCommitTexture(hash, message) {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 0, 16, 0.75)';
ctx.fillRect(0, 0, 512, 64);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 511, 63);
ctx.font = 'bold 11px "Courier New", monospace';
ctx.fillStyle = '#4488ff';
ctx.fillText(hash, 10, 20);
ctx.font = '12px "Courier New", monospace';
ctx.fillStyle = '#ccd6f6';
const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message;
ctx.fillText(displayMsg, 10, 46);
return new THREE.CanvasTexture(canvas);
}
/**
* Fetches recent commits and spawns floating banner sprites.
*/
export async function initCommitBanners() {
let commits;
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=5',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
commits = data.map(/** @type {(c: any) => {hash: string, message: string}} */ c => ({
hash: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0],
}));
} catch {
commits = [
{ hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' },
{ hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' },
{ hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' },
{ hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' },
{ hash: 'q3r4s5t', message: 'feat: star field and constellation lines' },
];
}
const spreadX = [-7, -3.5, 0, 3.5, 7];
const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6];
const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8];
commits.forEach((commit, i) => {
const texture = createCommitTexture(commit.hash, commit.message);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(12, 1.5, 1);
sprite.position.set(
spreadX[i % spreadX.length],
spreadY[i % spreadY.length],
spreadZ[i % spreadZ.length]
);
sprite.userData = {
baseY: spreadY[i % spreadY.length],
floatPhase: (i / commits.length) * Math.PI * 2,
floatSpeed: 0.25 + i * 0.07,
startDelay: i * 2.5,
lifetime: 12 + i * 1.5,
spawnTime: /** @type {number|null} */ (null),
};
scene.add(sprite);
commitBanners.push(sprite);
});
}
// === AGENT STATUS BOARD ===
const AGENT_STATUS_STUB = {
agents: [
{ name: 'claude', status: 'working', issue: 'Live agent status board (#199)', prs_today: 3 },
{ name: 'gemini', status: 'idle', issue: null, prs_today: 1 },
{ name: 'kimi', status: 'working', issue: 'Portal system YAML registry (#5)', prs_today: 2 },
{ name: 'groq', status: 'idle', issue: null, prs_today: 0 },
{ name: 'grok', status: 'dead', issue: null, prs_today: 0 },
]
};
const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dead: '#ff4444' };
/**
* Builds a canvas texture for a single agent holo-panel.
* @param {{ name: string, status: string, issue: string|null, prs_today: number }} agent
* @returns {THREE.CanvasTexture}
*/
function createAgentPanelTexture(agent) {
const W = 400, H = 200;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
const sc = AGENT_STATUS_COLORS[agent.status] || '#4488ff';
ctx.fillStyle = 'rgba(0, 8, 24, 0.88)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = sc;
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.strokeStyle = sc;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.3;
ctx.strokeRect(4, 4, W - 8, H - 8);
ctx.globalAlpha = 1.0;
ctx.font = 'bold 28px "Courier New", monospace';
ctx.fillStyle = '#ffffff';
ctx.fillText(agent.name.toUpperCase(), 16, 44);
ctx.beginPath();
ctx.arc(W - 30, 26, 10, 0, Math.PI * 2);
ctx.fillStyle = sc;
ctx.fill();
ctx.font = '13px "Courier New", monospace';
ctx.fillStyle = sc;
ctx.textAlign = 'right';
ctx.fillText(agent.status.toUpperCase(), W - 16, 60);
ctx.textAlign = 'left';
ctx.strokeStyle = '#1a3a6a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(16, 70);
ctx.lineTo(W - 16, 70);
ctx.stroke();
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.fillText('CURRENT ISSUE', 16, 90);
ctx.font = '13px "Courier New", monospace';
ctx.fillStyle = '#ccd6f6';
const issueText = agent.issue || '\u2014 none \u2014';
const displayIssue = issueText.length > 40 ? issueText.slice(0, 40) + '\u2026' : issueText;
ctx.fillText(displayIssue, 16, 110);
ctx.strokeStyle = '#1a3a6a';
ctx.beginPath();
ctx.moveTo(16, 128);
ctx.lineTo(W - 16, 128);
ctx.stroke();
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.fillText('PRs MERGED TODAY', 16, 148);
ctx.font = 'bold 28px "Courier New", monospace';
ctx.fillStyle = '#4488ff';
ctx.fillText(String(agent.prs_today), 16, 182);
return new THREE.CanvasTexture(canvas);
}
/** Group holding all agent panels so they can be toggled/repositioned together. */
export const agentBoardGroup = new THREE.Group();
scene.add(agentBoardGroup);
const BOARD_RADIUS = 9.5;
const BOARD_Y = 4.2;
const BOARD_SPREAD = Math.PI * 0.75;
/** @type {THREE.Sprite[]} */
export const agentPanelSprites = [];
/**
* (Re)builds the agent panel sprites from fresh status data.
* @param {{ agents: Array<{ name: string, status: string, issue: string|null, prs_today: number }> }} statusData
*/
function rebuildAgentPanels(statusData) {
while (agentBoardGroup.children.length) agentBoardGroup.remove(agentBoardGroup.children[0]);
agentPanelSprites.length = 0;
const n = statusData.agents.length;
statusData.agents.forEach((agent, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
const angle = Math.PI + (t - 0.5) * BOARD_SPREAD;
const x = Math.cos(angle) * BOARD_RADIUS;
const z = Math.sin(angle) * BOARD_RADIUS;
const texture = createAgentPanelTexture(agent);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.93,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(6.4, 3.2, 1);
sprite.position.set(x, BOARD_Y, z);
sprite.userData = {
baseY: BOARD_Y,
floatPhase: (i / n) * Math.PI * 2,
floatSpeed: 0.18 + i * 0.04,
};
agentBoardGroup.add(sprite);
agentPanelSprites.push(sprite);
});
}
/**
* Fetches live agent status, falling back to the stub when the endpoint is unavailable.
* @returns {Promise<typeof AGENT_STATUS_STUB>}
*/
async function fetchAgentStatus() {
try {
const res = await fetch('/api/status.json');
if (!res.ok) throw new Error('status ' + res.status);
return await res.json();
} catch {
return AGENT_STATUS_STUB;
}
}
export async function refreshAgentBoard() {
const data = await fetchAgentStatus();
rebuildAgentPanels(data);
}