diff --git a/modules/core/state.js b/modules/core/state.js new file mode 100644 index 0000000..6d156e9 --- /dev/null +++ b/modules/core/state.js @@ -0,0 +1,35 @@ +// modules/core/state.js — Shared reactive data bus +// Data modules write here; visual modules read from here. +// No module may call fetch() except those under modules/data/. + +export const state = { + // Commit heatmap (written by data/gitea.js) + zoneIntensity: {}, // { zoneName: [0..1], ... } + commits: [], // raw commit objects (last N) + commitHashes: [], // short hashes for matrix rain + + // Agent status (written by data/gitea.js) + agentStatus: null, // { agents: Array } | null + activeAgentCount: 0, // count of agents with status === 'working' + + // Weather (written by data/weather.js) + weather: null, // { cloud_cover, precipitation, ... } | null + + // Bitcoin (written by data/bitcoin.js) + blockHeight: 0, + lastBlockHeight: 0, + newBlockDetected: false, + starPulseIntensity: 0, + + // Portal / sovereignty / SOUL (written by data/loaders.js) + portals: [], // portal descriptor objects + sovereignty: null, // { score, label, assessment_type } | null + soulMd: '', // raw SOUL.md text + + // Computed helpers + totalActivity() { + const vals = Object.values(this.zoneIntensity); + if (vals.length === 0) return 0; + return vals.reduce((s, v) => s + v, 0) / vals.length; + }, +}; diff --git a/modules/core/theme.js b/modules/core/theme.js new file mode 100644 index 0000000..96dc7ee --- /dev/null +++ b/modules/core/theme.js @@ -0,0 +1,56 @@ +// modules/core/theme.js — Visual design system for the Nexus +// All colors, fonts, line weights, and glow params live here. +// No module may use inline hex codes — all visual constants come from NEXUS.theme. + +export const NEXUS = { + theme: { + // Core palette + bg: 0x000008, + accent: 0x4488ff, + accentStr: '#4488ff', + starCore: 0xffffff, + starDim: 0x8899cc, + constellationLine: 0x334488, + + // Agent status colors (hex strings for canvas, hex numbers for THREE) + agentWorking: '#00ff88', + agentWorkingHex: 0x00ff88, + agentIdle: '#4488ff', + agentIdleHex: 0x4488ff, + agentDormant: '#334466', + agentDormantHex: 0x334466, + agentDead: '#ff4444', + agentDeadHex: 0xff4444, + + // Sovereignty meter colors + sovereignHigh: '#00ff88', // score >= 80 + sovereignHighHex: 0x00ff88, + sovereignMid: '#ffcc00', // score >= 40 + sovereignMidHex: 0xffcc00, + sovereignLow: '#ff4444', // score < 40 + sovereignLowHex: 0xff4444, + + // LoRA / training panel + loraAccent: '#cc44ff', + loraAccentHex: 0xcc44ff, + loraActive: '#00ff88', + loraInactive: '#334466', + + // Earth + earthOcean: 0x003d99, + earthLand: 0x1a5c2a, + earthAtm: 0x1144cc, + earthGlow: 0x4488ff, + + // Panel chrome + panelBg: 'rgba(0, 6, 20, 0.90)', + panelBorder: '#4488ff', + panelBorderFaint: '#1a3a6a', + panelText: '#ccd6f6', + panelDim: '#556688', + panelVeryDim: '#334466', + + // Typography + fontMono: '"Courier New", monospace', + }, +}; diff --git a/modules/core/ticker.js b/modules/core/ticker.js new file mode 100644 index 0000000..a66f95c --- /dev/null +++ b/modules/core/ticker.js @@ -0,0 +1,46 @@ +// modules/core/ticker.js — Global Animation Clock +// Single requestAnimationFrame loop. All modules subscribe here. +// No module may call requestAnimationFrame directly. + +import * as THREE from 'three'; + +const _clock = new THREE.Clock(); +const _subscribers = []; + +let _running = false; +let _elapsed = 0; + +/** + * Subscribe a callback to the animation loop. + * @param {(elapsed: number, delta: number) => void} fn + */ +export function subscribe(fn) { + _subscribers.push(fn); +} + +/** + * Unsubscribe a callback from the animation loop. + * @param {(elapsed: number, delta: number) => void} fn + */ +export function unsubscribe(fn) { + const idx = _subscribers.indexOf(fn); + if (idx !== -1) _subscribers.splice(idx, 1); +} + +/** Start the animation loop. Called once by app.js after all modules are init'd. */ +export function start() { + if (_running) return; + _running = true; + _tick(); +} + +function _tick() { + if (!_running) return; + requestAnimationFrame(_tick); + const delta = _clock.getDelta(); + _elapsed += delta; + for (const fn of _subscribers) fn(_elapsed, delta); +} + +/** Current elapsed time in seconds (read-only). */ +export function elapsed() { return _elapsed; } diff --git a/modules/panels/agent-board.js b/modules/panels/agent-board.js new file mode 100644 index 0000000..58d1033 --- /dev/null +++ b/modules/panels/agent-board.js @@ -0,0 +1,191 @@ +// modules/panels/agent-board.js — Agent status holographic board +// Reads state.agentStatus (populated by data/gitea.js) and renders one floating +// sprite panel per agent. Board arcs behind the platform on the negative-Z side. +// +// Data category: REAL +// Data source: state.agentStatus (Gitea commits + open PRs via data/gitea.js) + +import * as THREE from 'three'; +import { state } from '../core/state.js'; +import { NEXUS } from '../core/theme.js'; +import { subscribe } from '../core/ticker.js'; + +const BOARD_RADIUS = 9.5; +const BOARD_Y = 4.2; +const BOARD_SPREAD = Math.PI * 0.75; // 135° arc, centred on -Z + +const STATUS_COLOR = { + working: NEXUS.theme.agentWorking, + idle: NEXUS.theme.agentIdle, + dormant: NEXUS.theme.agentDormant, + dead: NEXUS.theme.agentDead, + unreachable: NEXUS.theme.agentDead, +}; + +let _group, _scene; +let _lastAgentStatus = null; +let _sprites = []; + +/** + * Builds a canvas texture for a single agent holo-panel. + * @param {{ name: string, status: string, issue: string|null, prs_today: number, local: boolean }} agent + * @returns {THREE.CanvasTexture} + */ +function _makeTexture(agent) { + const W = 400, H = 200; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + const sc = STATUS_COLOR[agent.status] || NEXUS.theme.accentStr; + const font = NEXUS.theme.fontMono; + + 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.globalAlpha = 0.3; + ctx.strokeRect(4, 4, W - 8, H - 8); + ctx.globalAlpha = 1.0; + + // Agent name + ctx.font = `bold 28px ${font}`; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'left'; + ctx.fillText(agent.name.toUpperCase(), 16, 44); + + // Status dot + ctx.beginPath(); + ctx.arc(W - 30, 26, 10, 0, Math.PI * 2); + ctx.fillStyle = sc; + ctx.fill(); + + // Status label + ctx.font = `13px ${font}`; + ctx.fillStyle = sc; + ctx.textAlign = 'right'; + ctx.fillText(agent.status.toUpperCase(), W - 16, 60); + + // Separator + ctx.strokeStyle = NEXUS.theme.panelBorderFaint; + ctx.lineWidth = 1; + ctx.textAlign = 'left'; + ctx.beginPath(); ctx.moveTo(16, 70); ctx.lineTo(W - 16, 70); ctx.stroke(); + + // Current issue + ctx.font = `10px ${font}`; + ctx.fillStyle = NEXUS.theme.panelDim; + ctx.fillText('CURRENT ISSUE', 16, 90); + + ctx.font = `13px ${font}`; + ctx.fillStyle = NEXUS.theme.panelText; + const raw = agent.issue || '\u2014 none \u2014'; + ctx.fillText(raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw, 16, 110); + + // Separator + ctx.strokeStyle = NEXUS.theme.panelBorderFaint; + ctx.beginPath(); ctx.moveTo(16, 128); ctx.lineTo(W - 16, 128); ctx.stroke(); + + // PRs label + count + ctx.font = `10px ${font}`; + ctx.fillStyle = NEXUS.theme.panelDim; + ctx.fillText('PRs MERGED TODAY', 16, 148); + + ctx.font = `bold 28px ${font}`; + ctx.fillStyle = NEXUS.theme.accentStr; + ctx.fillText(String(agent.prs_today), 16, 182); + + // Runtime indicator + const isLocal = agent.local === true; + const rtColor = isLocal ? NEXUS.theme.agentWorking : NEXUS.theme.agentDead; + const rtLabel = isLocal ? 'LOCAL' : 'CLOUD'; + + ctx.font = `10px ${font}`; + ctx.fillStyle = NEXUS.theme.panelDim; + ctx.textAlign = 'right'; + ctx.fillText('RUNTIME', W - 16, 148); + + ctx.font = `bold 13px ${font}`; + ctx.fillStyle = rtColor; + ctx.fillText(rtLabel, W - 28, 172); + ctx.textAlign = 'left'; + + ctx.beginPath(); + ctx.arc(W - 16, 167, 6, 0, Math.PI * 2); + ctx.fillStyle = rtColor; + ctx.fill(); + + return new THREE.CanvasTexture(canvas); +} + +function _rebuild(statusData) { + // Remove old sprites + while (_group.children.length) _group.remove(_group.children[0]); + for (const s of _sprites) { + if (s.material.map) s.material.map.dispose(); + s.material.dispose(); + } + _sprites = []; + + const agents = statusData.agents; + const n = agents.length; + 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 = _makeTexture(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, + zoomLabel: `Agent: ${agent.name}`, + }; + _group.add(sprite); + _sprites.push(sprite); + }); +} + +/** @param {THREE.Scene} scene */ +export function init(scene) { + _scene = scene; + _group = new THREE.Group(); + scene.add(_group); + + // If state already has agent data (unlikely on first load, but handle it) + if (state.agentStatus) { + _rebuild(state.agentStatus); + _lastAgentStatus = state.agentStatus; + } + + subscribe(update); +} + +/** + * @param {number} elapsed + * @param {number} delta + */ +export function update(elapsed, delta) { + // Rebuild board when state.agentStatus changes + if (state.agentStatus && state.agentStatus !== _lastAgentStatus) { + _rebuild(state.agentStatus); + _lastAgentStatus = state.agentStatus; + } + + // Animate gentle float + for (const sprite of _sprites) { + const ud = sprite.userData; + sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.15; + } +} + +export function dispose() { + if (_group) _scene.remove(_group); +} diff --git a/modules/panels/dual-brain.js b/modules/panels/dual-brain.js new file mode 100644 index 0000000..d93f1ae --- /dev/null +++ b/modules/panels/dual-brain.js @@ -0,0 +1,200 @@ +// modules/panels/dual-brain.js — Dual-Brain Status holographic panel +// Shows the Brain Gap Scorecard with two glowing brain orbs. +// Displayed as HONEST-OFFLINE: the dual-brain system is not yet deployed. +// Brain pulse particles are set to ZERO — will flow when system comes online. +// +// Data category: HONEST-OFFLINE +// Data source: — (dual-brain system not deployed; shows "AWAITING DEPLOYMENT") + +import * as THREE from 'three'; +import { NEXUS } from '../core/theme.js'; +import { subscribe } from '../core/ticker.js'; + +const ORIGIN = new THREE.Vector3(10, 3, -8); +const OFFLINE_COLOR = NEXUS.theme.agentDormantHex; // dim blue — system offline +const ACCENT = NEXUS.theme.accentStr; +const FONT = NEXUS.theme.fontMono; + +let _group, _sprite, _scanSprite, _scanCanvas, _scanCtx, _scanTexture; +let _cloudOrb, _localOrb; +let _scene; + +function _buildPanelTexture() { + const W = 512, H = 512; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = NEXUS.theme.panelBg; + ctx.fillRect(0, 0, W, H); + + ctx.strokeStyle = ACCENT; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, W - 2, H - 2); + ctx.strokeStyle = '#223366'; + ctx.lineWidth = 1; + ctx.strokeRect(5, 5, W - 10, H - 10); + + // Title + ctx.font = `bold 22px ${FONT}`; + ctx.fillStyle = '#88ccff'; + ctx.textAlign = 'center'; + ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40); + + ctx.strokeStyle = NEXUS.theme.panelBorderFaint; + ctx.beginPath(); ctx.moveTo(20, 52); ctx.lineTo(W - 20, 52); ctx.stroke(); + + // Section header + ctx.font = `11px ${FONT}`; + ctx.fillStyle = NEXUS.theme.panelDim; + ctx.textAlign = 'left'; + ctx.fillText('BRAIN GAP SCORECARD', 20, 74); + + const categories = ['Triage', 'Tool Use', 'Code Gen', 'Planning', 'Communication', 'Reasoning']; + const barX = 20, barW = W - 130, barH = 20; + let y = 90; + + for (const cat of categories) { + ctx.font = `13px ${FONT}`; + ctx.fillStyle = NEXUS.theme.agentDormant; + ctx.textAlign = 'left'; + ctx.fillText(cat, barX, y + 14); + + ctx.font = `bold 13px ${FONT}`; + ctx.fillStyle = NEXUS.theme.panelVeryDim; + ctx.textAlign = 'right'; + ctx.fillText('\u2014', W - 20, y + 14); // em dash — no data + y += 22; + + ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.fillRect(barX, y, barW, barH); // empty bar background only + y += barH + 12; + } + + ctx.strokeStyle = NEXUS.theme.panelBorderFaint; + ctx.beginPath(); ctx.moveTo(20, y + 4); ctx.lineTo(W - 20, y + 4); ctx.stroke(); + y += 22; + + // Honest offline status + ctx.font = `bold 18px ${FONT}`; + ctx.fillStyle = NEXUS.theme.panelVeryDim; + ctx.textAlign = 'center'; + ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10); + + ctx.font = `11px ${FONT}`; + ctx.fillStyle = '#223344'; + ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32); + + // Brain indicators — offline dim + y += 52; + ctx.beginPath(); + ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2); + ctx.fillStyle = NEXUS.theme.panelVeryDim; + ctx.fill(); + ctx.font = `11px ${FONT}`; + ctx.fillStyle = NEXUS.theme.panelVeryDim; + ctx.textAlign = 'left'; + ctx.fillText('CLOUD', W / 2 - 48, y + 12); + + ctx.beginPath(); + ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2); + ctx.fillStyle = NEXUS.theme.panelVeryDim; + ctx.fill(); + ctx.fillText('LOCAL', W / 2 + 42, y + 12); + + return new THREE.CanvasTexture(canvas); +} + +/** @param {THREE.Scene} scene */ +export function init(scene) { + _scene = scene; + + _group = new THREE.Group(); + _group.position.copy(ORIGIN); + _group.lookAt(0, 3, 0); + scene.add(_group); + + // Static panel sprite + const texture = _buildPanelTexture(); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.92, depthWrite: false }); + _sprite = new THREE.Sprite(material); + _sprite.scale.set(5.0, 5.0, 1); + _sprite.userData = { baseY: 0, floatPhase: 0, floatSpeed: 0.22, zoomLabel: 'Dual-Brain Status' }; + _group.add(_sprite); + + // Accent light + const light = new THREE.PointLight(NEXUS.theme.accent, 0.6, 10); + light.position.set(0, 0.5, 1); + _group.add(light); + + // Offline brain orbs — dim + const orbGeo = new THREE.SphereGeometry(0.35, 32, 32); + const orbMat = (color) => new THREE.MeshStandardMaterial({ + color, emissive: new THREE.Color(color), emissiveIntensity: 0.1, + metalness: 0.3, roughness: 0.2, transparent: true, opacity: 0.85, + }); + + _cloudOrb = new THREE.Mesh(orbGeo, orbMat(OFFLINE_COLOR)); + _cloudOrb.position.set(-2.0, 3.0, 0); + _cloudOrb.userData.zoomLabel = 'Cloud Brain'; + _group.add(_cloudOrb); + + _localOrb = new THREE.Mesh(orbGeo.clone(), orbMat(OFFLINE_COLOR)); + _localOrb.position.set(2.0, 3.0, 0); + _localOrb.userData.zoomLabel = 'Local Brain'; + _group.add(_localOrb); + + // Brain pulse particles — ZERO count (system offline) + const particleGeo = new THREE.BufferGeometry(); + particleGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(0), 3)); + const particleMat = new THREE.PointsMaterial({ + color: 0x44ddff, size: 0.08, sizeAttenuation: true, + transparent: true, opacity: 0.8, depthWrite: false, + }); + _group.add(new THREE.Points(particleGeo, particleMat)); + + // Scan line overlay + _scanCanvas = document.createElement('canvas'); + _scanCanvas.width = 512; + _scanCanvas.height = 512; + _scanCtx = _scanCanvas.getContext('2d'); + _scanTexture = new THREE.CanvasTexture(_scanCanvas); + + const scanMat = new THREE.SpriteMaterial({ + map: _scanTexture, transparent: true, opacity: 0.18, depthWrite: false, + }); + _scanSprite = new THREE.Sprite(scanMat); + _scanSprite.scale.set(5.0, 5.0, 1); + _scanSprite.position.set(0, 0, 0.01); + _group.add(_scanSprite); + + subscribe(update); +} + +/** + * @param {number} elapsed + * @param {number} _delta + */ +export function update(elapsed, _delta) { + // Gentle float animation + const ud = _sprite.userData; + _sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.08; + + // Scan line — horizontal sweep + const W = 512, H = 512; + _scanCtx.clearRect(0, 0, W, H); + const scanY = ((elapsed * 60) % H); + const grad = _scanCtx.createLinearGradient(0, scanY - 20, 0, scanY + 20); + grad.addColorStop(0, 'rgba(68, 136, 255, 0)'); + grad.addColorStop(0.5, 'rgba(68, 136, 255, 0.4)'); + grad.addColorStop(1, 'rgba(68, 136, 255, 0)'); + _scanCtx.fillStyle = grad; + _scanCtx.fillRect(0, scanY - 20, W, 40); + _scanTexture.needsUpdate = true; +} + +export function dispose() { + if (_group) _scene.remove(_group); + if (_scanTexture) _scanTexture.dispose(); +} diff --git a/modules/panels/earth.js b/modules/panels/earth.js new file mode 100644 index 0000000..3ce9402 --- /dev/null +++ b/modules/panels/earth.js @@ -0,0 +1,212 @@ +// modules/panels/earth.js — Holographic Earth floating above the Nexus +// A procedural planet Earth with continent noise, scan lines, and fresnel rim glow. +// Rotation speed is tethered to state.totalActivity() — more commits = faster spin. +// Lat/lon grid, atmosphere shell, and a tether beam to the platform center. +// +// Data category: DATA-TETHERED AESTHETIC +// Data source: state.totalActivity() (computed from state.zoneIntensity) + +import * as THREE from 'three'; +import { state } from '../core/state.js'; +import { NEXUS } from '../core/theme.js'; +import { subscribe } from '../core/ticker.js'; + +const EARTH_RADIUS = 2.8; +const EARTH_Y = 20.0; +const EARTH_AXIAL_TILT = 23.4 * (Math.PI / 180); +const ROTATION_SPEED_BASE = 0.02; // rad/s minimum +const ROTATION_SPEED_MAX = 0.08; // rad/s at full activity + +let _group, _surfaceMat, _scene; + +const _vertexShader = ` + varying vec3 vNormal; + varying vec3 vWorldPos; + varying vec2 vUv; + void main() { + vNormal = normalize(normalMatrix * normal); + vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const _fragmentShader = ` + uniform float uTime; + uniform vec3 uOceanColor; + uniform vec3 uLandColor; + uniform vec3 uGlowColor; + varying vec3 vNormal; + varying vec3 vWorldPos; + varying vec2 vUv; + + vec3 _m3(vec3 x){ return x - floor(x*(1./289.))*289.; } + vec4 _m4(vec4 x){ return x - floor(x*(1./289.))*289.; } + vec4 _p4(vec4 x){ return _m4((x*34.+1.)*x); } + float snoise(vec3 v){ + const vec2 C = vec2(1./6., 1./3.); + vec3 i = floor(v + dot(v, C.yyy)); + vec3 x0 = v - i + dot(i, C.xxx); + vec3 g = step(x0.yzx, x0.xyz); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + C.xxx; + vec3 x2 = x0 - i2 + C.yyy; + vec3 x3 = x0 - 0.5; + i = _m3(i); + vec4 p = _p4(_p4(_p4( + i.z+vec4(0.,i1.z,i2.z,1.))+ + i.y+vec4(0.,i1.y,i2.y,1.))+ + i.x+vec4(0.,i1.x,i2.x,1.))); + float n_ = .142857142857; + vec3 ns = n_*vec3(2.,0.,-1.)+vec3(0.,-.5,1.); + vec4 j = p - 49.*floor(p*ns.z*ns.z); + vec4 x_ = floor(j*ns.z); + vec4 y_ = floor(j - 7.*x_); + vec4 h = 1. - abs(x_*(2./7.)) - abs(y_*(2./7.)); + vec4 b0 = vec4(x_.xy,y_.xy)*(2./7.); + vec4 b1 = vec4(x_.zw,y_.zw)*(2./7.); + vec4 s0 = floor(b0)*2.+1.; vec4 s1 = floor(b1)*2.+1.; + vec4 sh = -step(h, vec4(0.)); + vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; + vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); + vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w); + vec4 nm = max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.); + vec4 nr = 1.79284291400159-0.85373472095314*nm; + p0*=nr.x; p1*=nr.y; p2*=nr.z; p3*=nr.w; + nm = nm*nm; + return 42.*dot(nm*nm, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3))); + } + + void main() { + vec3 n = normalize(vNormal); + vec3 vd = normalize(cameraPosition - vWorldPos); + + float lat = (vUv.y - 0.5) * 3.14159265; + float lon = vUv.x * 6.28318530; + vec3 sp = vec3(cos(lat)*cos(lon), sin(lat), cos(lat)*sin(lon)); + + float c = snoise(sp*1.8)*0.60 + snoise(sp*3.6)*0.30 + snoise(sp*7.2)*0.10; + float land = smoothstep(0.05, 0.30, c); + + vec3 surf = mix(uOceanColor, uLandColor, land); + surf = mix(surf, uGlowColor * 0.45, 0.38); + + float scan = 0.5 + 0.5*sin(vUv.y * 220.0 + uTime * 1.8); + scan = smoothstep(0.30, 0.70, scan) * 0.14; + + float fresnel = pow(1.0 - max(dot(n, vd), 0.0), 4.0); + + vec3 col = surf + scan*uGlowColor*0.9 + fresnel*uGlowColor*1.5; + float alpha = 0.48 + fresnel * 0.42; + gl_FragColor = vec4(col, alpha); + } +`; + +/** @param {THREE.Scene} scene */ +export function init(scene) { + _scene = scene; + + _group = new THREE.Group(); + _group.position.set(0, EARTH_Y, 0); + _group.rotation.z = EARTH_AXIAL_TILT; + + // Surface shader + _surfaceMat = new THREE.ShaderMaterial({ + uniforms: { + uTime: { value: 0.0 }, + uOceanColor: { value: new THREE.Color(NEXUS.theme.earthOcean) }, + uLandColor: { value: new THREE.Color(NEXUS.theme.earthLand) }, + uGlowColor: { value: new THREE.Color(NEXUS.theme.earthGlow) }, + }, + vertexShader: _vertexShader, + fragmentShader: _fragmentShader, + transparent: true, + depthWrite: false, + side: THREE.FrontSide, + }); + + const earthMesh = new THREE.Mesh(new THREE.SphereGeometry(EARTH_RADIUS, 64, 32), _surfaceMat); + earthMesh.userData.zoomLabel = 'Planet Earth'; + _group.add(earthMesh); + + // Lat/lon grid + const lineMat = new THREE.LineBasicMaterial({ color: 0x2266bb, transparent: true, opacity: 0.30 }); + const r = EARTH_RADIUS + 0.015; + const SEG = 64; + + for (let lat = -60; lat <= 60; lat += 30) { + const phi = lat * (Math.PI / 180); + const pts = []; + for (let i = 0; i <= SEG; i++) { + const th = (i / SEG) * Math.PI * 2; + pts.push(new THREE.Vector3(Math.cos(phi)*Math.cos(th)*r, Math.sin(phi)*r, Math.cos(phi)*Math.sin(th)*r)); + } + _group.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat)); + } + for (let lon = 0; lon < 360; lon += 30) { + const th = lon * (Math.PI / 180); + const pts = []; + for (let i = 0; i <= SEG; i++) { + const phi = (i / SEG) * Math.PI - Math.PI / 2; + pts.push(new THREE.Vector3(Math.cos(phi)*Math.cos(th)*r, Math.sin(phi)*r, Math.cos(phi)*Math.sin(th)*r)); + } + _group.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat)); + } + + // Atmosphere shell + _group.add(new THREE.Mesh( + new THREE.SphereGeometry(EARTH_RADIUS * 1.14, 32, 16), + new THREE.MeshBasicMaterial({ + color: NEXUS.theme.earthAtm, transparent: true, opacity: 0.07, + side: THREE.BackSide, depthWrite: false, blending: THREE.AdditiveBlending, + }) + )); + + // Glow light + _group.add(new THREE.PointLight(NEXUS.theme.earthGlow, 0.4, 25)); + + _group.traverse(obj => { + if (obj.isMesh || obj.isLine) obj.userData.zoomLabel = 'Planet Earth'; + }); + + // Tether beam to platform + const beamPts = [ + new THREE.Vector3(0, EARTH_Y - EARTH_RADIUS * 1.15, 0), + new THREE.Vector3(0, 0.5, 0), + ]; + scene.add(new THREE.Line( + new THREE.BufferGeometry().setFromPoints(beamPts), + new THREE.LineBasicMaterial({ + color: NEXUS.theme.earthGlow, transparent: true, opacity: 0.08, + depthWrite: false, blending: THREE.AdditiveBlending, + }) + )); + + scene.add(_group); + + subscribe(update); +} + +/** + * @param {number} elapsed + * @param {number} delta + */ +export function update(elapsed, delta) { + if (!_group) return; + + // Tether rotation speed to commit activity + const activity = state.totalActivity(); + const speed = ROTATION_SPEED_BASE + activity * (ROTATION_SPEED_MAX - ROTATION_SPEED_BASE); + _group.rotation.y += speed * delta; + + // Update shader time uniform for scan line animation + _surfaceMat.uniforms.uTime.value = elapsed; +} + +export function dispose() { + if (_group) _scene.remove(_group); + if (_surfaceMat) _surfaceMat.dispose(); +} diff --git a/modules/panels/heatmap.js b/modules/panels/heatmap.js new file mode 100644 index 0000000..fc61d5b --- /dev/null +++ b/modules/panels/heatmap.js @@ -0,0 +1,125 @@ +// modules/panels/heatmap.js — Commit heatmap floor overlay +// Canvas-texture circle on the glass platform floor. +// Each agent occupies a polar sector; recent commits make that sector glow brighter. +// Activity decays over 24 h (driven by state.zoneIntensity, written by data/gitea.js). +// +// Data category: DATA-TETHERED AESTHETIC +// Data source: state.zoneIntensity (populated from Gitea commits API by data/gitea.js) + +import * as THREE from 'three'; +import { state } from '../core/state.js'; +import { NEXUS } from '../core/theme.js'; +import { subscribe } from '../core/ticker.js'; + +export const HEATMAP_ZONES = [ + { name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 }, + { name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 }, + { name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 }, + { name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 }, +]; + +const HEATMAP_SIZE = 512; +const HEATMAP_ZONE_SPAN_RAD = Math.PI / 2; // 90° per zone +const GLASS_RADIUS = 4.55; // matches terrain/island.js platform radius + +let _canvas, _ctx, _texture, _mesh; +let _scene; + +function _draw() { + const cx = HEATMAP_SIZE / 2; + const cy = HEATMAP_SIZE / 2; + const r = cx * 0.96; + + _ctx.clearRect(0, 0, HEATMAP_SIZE, HEATMAP_SIZE); + _ctx.save(); + _ctx.beginPath(); + _ctx.arc(cx, cy, r, 0, Math.PI * 2); + _ctx.clip(); + + for (const zone of HEATMAP_ZONES) { + const intensity = state.zoneIntensity[zone.name] || 0; + if (intensity < 0.01) continue; + + const [rr, gg, bb] = zone.color; + const baseRad = zone.angleDeg * (Math.PI / 180); + const startRad = baseRad - HEATMAP_ZONE_SPAN_RAD / 2; + const endRad = baseRad + HEATMAP_ZONE_SPAN_RAD / 2; + const gx = cx + Math.cos(baseRad) * r * 0.55; + const gy = cy + Math.sin(baseRad) * r * 0.55; + + const grad = _ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75); + grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`); + grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`); + grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`); + + _ctx.beginPath(); + _ctx.moveTo(cx, cy); + _ctx.arc(cx, cy, r, startRad, endRad); + _ctx.closePath(); + _ctx.fillStyle = grad; + _ctx.fill(); + + if (intensity > 0.05) { + const lx = cx + Math.cos(baseRad) * r * 0.62; + const ly = cy + Math.sin(baseRad) * r * 0.62; + _ctx.font = `bold ${Math.round(13 * intensity + 7)}px ${NEXUS.theme.fontMono}`; + _ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`; + _ctx.textAlign = 'center'; + _ctx.textBaseline = 'middle'; + _ctx.fillText(zone.name, lx, ly); + } + } + + _ctx.restore(); + _texture.needsUpdate = true; +} + +/** @param {THREE.Scene} scene */ +export function init(scene) { + _scene = scene; + + _canvas = document.createElement('canvas'); + _canvas.width = HEATMAP_SIZE; + _canvas.height = HEATMAP_SIZE; + _ctx = _canvas.getContext('2d'); + + _texture = new THREE.CanvasTexture(_canvas); + + const mat = new THREE.MeshBasicMaterial({ + map: _texture, + transparent: true, + opacity: 0.9, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }); + + _mesh = new THREE.Mesh(new THREE.CircleGeometry(GLASS_RADIUS, 64), mat); + _mesh.rotation.x = -Math.PI / 2; + _mesh.position.y = 0.005; + _mesh.userData.zoomLabel = 'Activity Heatmap'; + scene.add(_mesh); + + // Draw initial empty state + _draw(); + + subscribe(update); +} + +let _lastDrawElapsed = 0; +const REDRAW_INTERVAL = 0.5; // redraw at most every 500 ms (data changes slowly) + +/** + * @param {number} elapsed + * @param {number} _delta + */ +export function update(elapsed, _delta) { + if (elapsed - _lastDrawElapsed < REDRAW_INTERVAL) return; + _lastDrawElapsed = elapsed; + _draw(); +} + +export function dispose() { + if (_mesh) { _scene.remove(_mesh); _mesh.geometry.dispose(); _mesh.material.dispose(); } + if (_texture) _texture.dispose(); +} diff --git a/modules/panels/lora-panel.js b/modules/panels/lora-panel.js new file mode 100644 index 0000000..832c42d --- /dev/null +++ b/modules/panels/lora-panel.js @@ -0,0 +1,167 @@ +// modules/panels/lora-panel.js — LoRA Adapter Status holographic panel +// Shows the model training / LoRA fine-tuning adapter status. +// Displayed as HONEST-OFFLINE: no adapters are deployed. Panel shows empty state. +// Will render real adapters when state.loraAdapters is populated in the future. +// +// Data category: HONEST-OFFLINE +// Data source: — (no LoRA adapters deployed; shows "NO ADAPTERS DEPLOYED") + +import * as THREE from 'three'; +import { NEXUS } from '../core/theme.js'; +import { subscribe } from '../core/ticker.js'; + +const PANEL_POS = new THREE.Vector3(-10.5, 4.5, 2.5); +const LORA_ACCENT = NEXUS.theme.loraAccent; +const LORA_ACTIVE = NEXUS.theme.loraActive; +const LORA_OFFLINE = NEXUS.theme.loraInactive; +const FONT = NEXUS.theme.fontMono; + +let _group, _sprite, _scene; + +/** + * Builds the LoRA panel canvas texture. + * @param {{ adapters: Array }|null} data + * @returns {THREE.CanvasTexture} + */ +function _makeTexture(data) { + const W = 420, H = 260; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = NEXUS.theme.panelBg; + ctx.fillRect(0, 0, W, H); + + ctx.strokeStyle = LORA_ACCENT; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, W - 2, H - 2); + ctx.strokeStyle = LORA_ACCENT; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.3; + ctx.strokeRect(4, 4, W - 8, H - 8); + ctx.globalAlpha = 1.0; + + ctx.font = `bold 14px ${FONT}`; + ctx.fillStyle = LORA_ACCENT; + ctx.textAlign = 'left'; + ctx.fillText('MODEL TRAINING', 14, 24); + + ctx.font = `10px ${FONT}`; + ctx.fillStyle = '#664488'; + ctx.fillText('LoRA ADAPTERS', 14, 38); + + ctx.strokeStyle = '#2a1a44'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(14, 46); ctx.lineTo(W - 14, 46); ctx.stroke(); + + const adapters = data && Array.isArray(data.adapters) ? data.adapters : []; + + if (adapters.length === 0) { + // Honest empty state + ctx.font = `bold 18px ${FONT}`; + ctx.fillStyle = LORA_OFFLINE; + ctx.textAlign = 'center'; + ctx.fillText('NO ADAPTERS DEPLOYED', W / 2, H / 2 + 10); + ctx.font = `11px ${FONT}`; + ctx.fillStyle = '#223344'; + ctx.fillText('Adapters will appear here when trained', W / 2, H / 2 + 36); + return new THREE.CanvasTexture(canvas); + } + + // Active count header + const activeCount = adapters.filter(a => a.active).length; + ctx.font = `bold 13px ${FONT}`; + ctx.fillStyle = LORA_ACTIVE; + ctx.textAlign = 'right'; + ctx.fillText(`${activeCount}/${adapters.length} ACTIVE`, W - 14, 26); + ctx.textAlign = 'left'; + + // Adapter rows + const ROW_H = 44; + adapters.forEach((adapter, i) => { + const rowY = 50 + i * ROW_H; + const col = adapter.active ? LORA_ACTIVE : LORA_OFFLINE; + + ctx.beginPath(); + ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2); + ctx.fillStyle = col; + ctx.fill(); + + ctx.font = `bold 13px ${FONT}`; + ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566'; + ctx.fillText(adapter.name, 36, rowY + 16); + + ctx.font = `10px ${FONT}`; + ctx.fillStyle = NEXUS.theme.panelDim; + ctx.textAlign = 'right'; + ctx.fillText(adapter.base, W - 14, rowY + 16); + ctx.textAlign = 'left'; + + if (adapter.active) { + const BX = 36, BW = W - 80, BY = rowY + 22, BH = 5; + ctx.fillStyle = '#0a1428'; + ctx.fillRect(BX, BY, BW, BH); + ctx.fillStyle = col; + ctx.globalAlpha = 0.7; + ctx.fillRect(BX, BY, BW * (adapter.strength || 0), BH); + ctx.globalAlpha = 1.0; + } + + if (i < adapters.length - 1) { + ctx.strokeStyle = '#1a0a2a'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(14, rowY + ROW_H - 2); ctx.lineTo(W - 14, rowY + ROW_H - 2); ctx.stroke(); + } + }); + + return new THREE.CanvasTexture(canvas); +} + +function _buildSprite(data) { + if (_sprite) { + _group.remove(_sprite); + if (_sprite.material.map) _sprite.material.map.dispose(); + _sprite.material.dispose(); + _sprite = null; + } + const texture = _makeTexture(data); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.93, depthWrite: false }); + _sprite = new THREE.Sprite(material); + _sprite.scale.set(6.0, 3.6, 1); + _sprite.position.copy(PANEL_POS); + _sprite.userData = { + baseY: PANEL_POS.y, + floatPhase: 1.1, + floatSpeed: 0.14, + zoomLabel: 'Model Training — LoRA Adapters', + }; + _group.add(_sprite); +} + +/** @param {THREE.Scene} scene */ +export function init(scene) { + _scene = scene; + _group = new THREE.Group(); + scene.add(_group); + + // Honest empty state on init — no adapters deployed + _buildSprite({ adapters: [] }); + + subscribe(update); +} + +/** + * @param {number} elapsed + * @param {number} _delta + */ +export function update(elapsed, _delta) { + if (_sprite) { + const ud = _sprite.userData; + _sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.12; + } +} + +export function dispose() { + if (_group) _scene.remove(_group); +} diff --git a/modules/panels/sovereignty.js b/modules/panels/sovereignty.js new file mode 100644 index 0000000..872dde6 --- /dev/null +++ b/modules/panels/sovereignty.js @@ -0,0 +1,147 @@ +// modules/panels/sovereignty.js — Sovereignty Meter holographic arc gauge +// Floating arc gauge above the platform showing the current sovereignty score. +// Reads from state.sovereignty (populated by data/loaders.js via sovereignty-status.json). +// The assessment is MANUAL — the panel always labels itself as such. +// +// Data category: REAL (manual assessment) +// Data source: state.sovereignty (sovereignty-status.json via data/loaders.js) + +import * as THREE from 'three'; +import { state } from '../core/state.js'; +import { NEXUS } from '../core/theme.js'; +import { subscribe } from '../core/ticker.js'; + +const FONT = NEXUS.theme.fontMono; + +// Defaults shown before data loads +let _score = 85; +let _label = 'Mostly Sovereign'; +let _assessmentType = 'MANUAL'; + +let _group, _arcMesh, _arcMat, _light, _spriteMat, _scene; +let _lastSovereignty = null; + +function _scoreColor(score) { + if (score >= 80) return NEXUS.theme.sovereignHighHex; + if (score >= 40) return NEXUS.theme.sovereignMidHex; + return NEXUS.theme.sovereignLowHex; +} + +function _scoreColorStr(score) { + if (score >= 80) return NEXUS.theme.sovereignHigh; + if (score >= 40) return NEXUS.theme.sovereignMid; + return NEXUS.theme.sovereignLow; +} + +function _buildArcGeo(score) { + return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2); +} + +function _buildMeterTexture(score, label, assessmentType) { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + const col = _scoreColorStr(score); + + ctx.clearRect(0, 0, 256, 128); + + ctx.font = `bold 52px ${FONT}`; + ctx.fillStyle = col; + ctx.textAlign = 'center'; + ctx.fillText(`${score}%`, 128, 50); + + ctx.font = `16px ${FONT}`; + ctx.fillStyle = '#8899bb'; + ctx.fillText(label.toUpperCase(), 128, 74); + + ctx.font = `11px ${FONT}`; + ctx.fillStyle = '#445566'; + ctx.fillText('SOVEREIGNTY', 128, 94); + + ctx.font = `9px ${FONT}`; + ctx.fillStyle = '#334455'; + ctx.fillText('MANUAL ASSESSMENT', 128, 112); + + return new THREE.CanvasTexture(canvas); +} + +function _applyScore(score, label, assessmentType) { + _score = score; + _label = label; + _assessmentType = assessmentType; + + _arcMesh.geometry.dispose(); + _arcMesh.geometry = _buildArcGeo(score); + + const col = _scoreColor(score); + _arcMat.color.setHex(col); + _light.color.setHex(col); + + if (_spriteMat.map) _spriteMat.map.dispose(); + _spriteMat.map = _buildMeterTexture(score, label, assessmentType); + _spriteMat.needsUpdate = true; +} + +/** @param {THREE.Scene} scene */ +export function init(scene) { + _scene = scene; + + _group = new THREE.Group(); + _group.position.set(0, 3.8, 0); + + // Background ring + const bgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 }); + _group.add(new THREE.Mesh(new THREE.TorusGeometry(1.6, 0.1, 8, 64), bgMat)); + + // Score arc + _arcMat = new THREE.MeshBasicMaterial({ + color: _scoreColor(_score), + transparent: true, + opacity: 0.9, + }); + _arcMesh = new THREE.Mesh(_buildArcGeo(_score), _arcMat); + _arcMesh.rotation.z = Math.PI / 2; // arc starts at 12 o'clock + _group.add(_arcMesh); + + // Glow light + _light = new THREE.PointLight(_scoreColor(_score), 0.7, 6); + _group.add(_light); + + // Sprite label + _spriteMat = new THREE.SpriteMaterial({ + map: _buildMeterTexture(_score, _label, _assessmentType), + transparent: true, + depthWrite: false, + }); + const sprite = new THREE.Sprite(_spriteMat); + sprite.scale.set(3.2, 1.6, 1); + _group.add(sprite); + + scene.add(_group); + _group.traverse(obj => { + if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter'; + }); + + subscribe(update); +} + +/** + * @param {number} _elapsed + * @param {number} _delta + */ +export function update(_elapsed, _delta) { + if (state.sovereignty && state.sovereignty !== _lastSovereignty) { + const { score, label, assessment_type } = state.sovereignty; + const s = Math.max(0, Math.min(100, typeof score === 'number' ? score : _score)); + const l = typeof label === 'string' ? label : _label; + const t = typeof assessment_type === 'string' ? assessment_type : 'MANUAL'; + _applyScore(s, l, t); + _lastSovereignty = state.sovereignty; + } +} + +export function dispose() { + if (_group) _scene.remove(_group); + if (_spriteMat.map) _spriteMat.map.dispose(); +}