[claude] Phase 3: Panel modules — Heatmap, Agent Board, Dual-Brain, LoRA, Sovereignty, Earth (#422) (#446)
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
This commit was merged in pull request #446.
This commit is contained in:
35
modules/core/state.js
Normal file
35
modules/core/state.js
Normal file
@@ -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<AgentRecord> } | 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;
|
||||
},
|
||||
};
|
||||
56
modules/core/theme.js
Normal file
56
modules/core/theme.js
Normal file
@@ -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',
|
||||
},
|
||||
};
|
||||
46
modules/core/ticker.js
Normal file
46
modules/core/ticker.js
Normal file
@@ -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; }
|
||||
191
modules/panels/agent-board.js
Normal file
191
modules/panels/agent-board.js
Normal file
@@ -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);
|
||||
}
|
||||
200
modules/panels/dual-brain.js
Normal file
200
modules/panels/dual-brain.js
Normal file
@@ -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();
|
||||
}
|
||||
212
modules/panels/earth.js
Normal file
212
modules/panels/earth.js
Normal file
@@ -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();
|
||||
}
|
||||
125
modules/panels/heatmap.js
Normal file
125
modules/panels/heatmap.js
Normal file
@@ -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();
|
||||
}
|
||||
167
modules/panels/lora-panel.js
Normal file
167
modules/panels/lora-panel.js
Normal file
@@ -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);
|
||||
}
|
||||
147
modules/panels/sovereignty.js
Normal file
147
modules/panels/sovereignty.js
Normal file
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user