forked from Rockachopa/the-matrix
Create a floating 3D panel in the scene (js/metrics.js) that pulls live Gitea stats — issues closed, PRs merged, success rate — via the Gitea REST API. The panel renders via CanvasTexture onto a Three.js PlaneGeometry, floats gently in world-space, and refreshes every 60 s. Config exposed via URL params (?gitea=, ?gtoken=, ?grepo=) and Vite env vars (VITE_GITEA_URL / VITE_GITEA_TOKEN / VITE_GITEA_REPO). Fixes #6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
286 lines
7.7 KiB
JavaScript
286 lines
7.7 KiB
JavaScript
/**
|
|
* metrics.js — Floating holographic metrics panel in the 3D world.
|
|
*
|
|
* Displays live Gitea repository stats:
|
|
* - Issues closed
|
|
* - PRs merged
|
|
* - Success rate (merged / total PRs)
|
|
*
|
|
* Fetches from Gitea API on init and every 60 s thereafter.
|
|
* Configurable via VITE_GITEA_URL / VITE_GITEA_TOKEN / VITE_GITEA_REPO
|
|
* or URL params: ?gitea=http://... ?gtoken=... ?grepo=owner/repo
|
|
*
|
|
* Fixes #6
|
|
*/
|
|
|
|
import * as THREE from 'three';
|
|
import { Config } from './config.js';
|
|
|
|
/* ── Panel world-space dimensions ── */
|
|
const PANEL_W = 9;
|
|
const PANEL_H = 5.5;
|
|
const PANEL_X = 0;
|
|
const PANEL_Y = 11;
|
|
const PANEL_Z = -18;
|
|
|
|
/* ── Canvas texture resolution ── */
|
|
const TEX_W = 512;
|
|
const TEX_H = 320;
|
|
|
|
let _scene = null;
|
|
let panelGroup = null;
|
|
let panelMesh = null;
|
|
let borderLines = null;
|
|
let glowLight = null;
|
|
let panelTexture = null;
|
|
let panelCanvas = null;
|
|
let panelCtx = null;
|
|
let fetchTimer = null;
|
|
|
|
const pulsePhase = Math.random() * Math.PI * 2;
|
|
|
|
const metrics = {
|
|
issuesClosed: '--',
|
|
prsMerged: '--',
|
|
successRate: '--',
|
|
lastUpdated: null,
|
|
};
|
|
|
|
/* ── Canvas drawing ─────────────────────────────────────────── */
|
|
|
|
function drawPanel() {
|
|
const ctx = panelCtx;
|
|
const w = TEX_W;
|
|
const h = TEX_H;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Dark holographic background
|
|
ctx.fillStyle = 'rgba(0, 8, 0, 0.88)';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// Outer border with glow
|
|
ctx.strokeStyle = '#00ff88';
|
|
ctx.lineWidth = 3;
|
|
ctx.shadowColor = '#00ff88';
|
|
ctx.shadowBlur = 16;
|
|
ctx.strokeRect(4, 4, w - 8, h - 8);
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Corner accents
|
|
const CA = 20;
|
|
ctx.strokeStyle = '#00ffcc';
|
|
ctx.lineWidth = 2;
|
|
[
|
|
[4, 4], // top-left
|
|
[w - 4, 4], // top-right
|
|
[4, h - 4], // bottom-left
|
|
[w - 4, h - 4], // bottom-right
|
|
].forEach(([cx, cy]) => {
|
|
const sx = cx === 4 ? 1 : -1;
|
|
const sy = cy === 4 ? 1 : -1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + sx * CA, cy);
|
|
ctx.lineTo(cx, cy);
|
|
ctx.lineTo(cx, cy + sy * CA);
|
|
ctx.stroke();
|
|
});
|
|
|
|
// Title
|
|
ctx.fillStyle = '#00ff88';
|
|
ctx.font = 'bold 26px Courier New';
|
|
ctx.textAlign = 'center';
|
|
ctx.shadowColor = '#00ff41';
|
|
ctx.shadowBlur = 14;
|
|
ctx.fillText('[ REPO METRICS ]', w / 2, 48);
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Horizontal rule
|
|
ctx.strokeStyle = '#003a00';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(24, 62);
|
|
ctx.lineTo(w - 24, 62);
|
|
ctx.stroke();
|
|
|
|
// Metric rows
|
|
const rows = [
|
|
{ label: 'ISSUES CLOSED', value: metrics.issuesClosed },
|
|
{ label: 'PRs MERGED', value: metrics.prsMerged },
|
|
{ label: 'SUCCESS RATE', value: metrics.successRate },
|
|
];
|
|
|
|
rows.forEach((row, i) => {
|
|
const y = 100 + i * 72;
|
|
|
|
// Label
|
|
ctx.fillStyle = '#006622';
|
|
ctx.font = '15px Courier New';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`▸ ${row.label}`, 32, y);
|
|
|
|
// Value (right-aligned, large)
|
|
ctx.fillStyle = '#00ff41';
|
|
ctx.font = 'bold 34px Courier New';
|
|
ctx.shadowColor = '#00ff41';
|
|
ctx.shadowBlur = 10;
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(row.value, w - 32, y + 32);
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Row separator
|
|
if (i < rows.length - 1) {
|
|
ctx.strokeStyle = '#001a00';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(24, y + 48);
|
|
ctx.lineTo(w - 24, y + 48);
|
|
ctx.stroke();
|
|
}
|
|
});
|
|
|
|
// Last-updated timestamp (footer)
|
|
ctx.fillStyle = '#003300';
|
|
ctx.font = '11px Courier New';
|
|
ctx.textAlign = 'center';
|
|
const tsText = metrics.lastUpdated
|
|
? `SYNCED ${new Date(metrics.lastUpdated).toLocaleTimeString()} · 60s REFRESH`
|
|
: 'FETCHING DATA...';
|
|
ctx.fillText(tsText, w / 2, h - 10);
|
|
}
|
|
|
|
/* ── Gitea API fetch ────────────────────────────────────────── */
|
|
|
|
async function fetchMetrics() {
|
|
const baseUrl = Config.giteaUrl;
|
|
const token = Config.giteaToken;
|
|
const repo = Config.giteaRepo;
|
|
|
|
if (!baseUrl || !repo) return;
|
|
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = `token ${token}`;
|
|
|
|
try {
|
|
const [closedIssuesRes, mergedPrsRes, openPrsRes] = await Promise.all([
|
|
fetch(`${baseUrl}/repos/${repo}/issues?state=closed&type=issues&limit=1`, { headers }),
|
|
fetch(`${baseUrl}/repos/${repo}/pulls?state=closed&limit=1`, { headers }),
|
|
fetch(`${baseUrl}/repos/${repo}/pulls?state=open&limit=1`, { headers }),
|
|
]);
|
|
|
|
const issuesClosed = parseInt(closedIssuesRes.headers.get('X-Total-Count') || '0', 10);
|
|
const prsMerged = parseInt(mergedPrsRes.headers.get('X-Total-Count') || '0', 10);
|
|
const prsOpen = parseInt(openPrsRes.headers.get('X-Total-Count') || '0', 10);
|
|
const prsTotal = prsMerged + prsOpen;
|
|
|
|
metrics.issuesClosed = issuesClosed.toLocaleString();
|
|
metrics.prsMerged = prsMerged.toLocaleString();
|
|
metrics.successRate = prsTotal > 0
|
|
? `${Math.round(prsMerged / prsTotal * 100)}%`
|
|
: '--';
|
|
metrics.lastUpdated = Date.now();
|
|
|
|
drawPanel();
|
|
if (panelTexture) panelTexture.needsUpdate = true;
|
|
} catch (err) {
|
|
console.warn('[Metrics] fetch failed:', err);
|
|
}
|
|
}
|
|
|
|
/* ── Three.js setup ─────────────────────────────────────────── */
|
|
|
|
function buildBorderLines(w, h) {
|
|
const hw = w / 2;
|
|
const hh = h / 2;
|
|
const points = [
|
|
new THREE.Vector3(-hw, -hh, 0),
|
|
new THREE.Vector3( hw, -hh, 0),
|
|
new THREE.Vector3( hw, hh, 0),
|
|
new THREE.Vector3(-hw, hh, 0),
|
|
new THREE.Vector3(-hw, -hh, 0),
|
|
];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
|
const mat = new THREE.LineBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.85 });
|
|
return new THREE.Line(geo, mat);
|
|
}
|
|
|
|
export function initMetrics(scene) {
|
|
_scene = scene;
|
|
|
|
// Canvas texture
|
|
panelCanvas = document.createElement('canvas');
|
|
panelCanvas.width = TEX_W;
|
|
panelCanvas.height = TEX_H;
|
|
panelCtx = panelCanvas.getContext('2d');
|
|
|
|
drawPanel();
|
|
|
|
panelTexture = new THREE.CanvasTexture(panelCanvas);
|
|
|
|
// Panel mesh
|
|
const geo = new THREE.PlaneGeometry(PANEL_W, PANEL_H);
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
map: panelTexture,
|
|
transparent: true,
|
|
opacity: 0.92,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
panelMesh = new THREE.Mesh(geo, mat);
|
|
|
|
// Outline border
|
|
borderLines = buildBorderLines(PANEL_W, PANEL_H);
|
|
|
|
// Ambient glow from the panel surface
|
|
glowLight = new THREE.PointLight(0x00ff41, 1.0, 14);
|
|
glowLight.position.set(0, 0, 0.5);
|
|
|
|
panelGroup = new THREE.Group();
|
|
panelGroup.add(panelMesh);
|
|
panelGroup.add(borderLines);
|
|
panelGroup.add(glowLight);
|
|
panelGroup.position.set(PANEL_X, PANEL_Y, PANEL_Z);
|
|
|
|
scene.add(panelGroup);
|
|
|
|
// Fetch now, then every 60 s
|
|
fetchMetrics();
|
|
fetchTimer = setInterval(fetchMetrics, 60_000);
|
|
}
|
|
|
|
export function updateMetrics(time) {
|
|
if (!panelGroup) return;
|
|
|
|
// Gentle vertical float
|
|
panelGroup.position.y = PANEL_Y + Math.sin(time * 0.0007 + pulsePhase) * 0.25;
|
|
|
|
// Pulse border opacity
|
|
if (borderLines) {
|
|
borderLines.material.opacity = 0.55 + 0.3 * Math.sin(time * 0.0018 + pulsePhase);
|
|
}
|
|
|
|
// Pulse glow intensity
|
|
if (glowLight) {
|
|
glowLight.intensity = 0.7 + 0.5 * Math.sin(time * 0.0014 + pulsePhase);
|
|
}
|
|
}
|
|
|
|
export function disposeMetrics() {
|
|
if (fetchTimer) {
|
|
clearInterval(fetchTimer);
|
|
fetchTimer = null;
|
|
}
|
|
if (panelGroup && _scene) {
|
|
_scene.remove(panelGroup);
|
|
}
|
|
if (panelMesh) {
|
|
panelMesh.geometry.dispose();
|
|
panelMesh.material.dispose();
|
|
}
|
|
if (panelTexture) panelTexture.dispose();
|
|
if (borderLines) {
|
|
borderLines.geometry.dispose();
|
|
borderLines.material.dispose();
|
|
}
|
|
panelGroup = panelMesh = borderLines = panelTexture = glowLight = _scene = null;
|
|
}
|