Files
the-nexus/modules/portals/commit-banners.js
Alexander Whitestone 75e51619df WIP: Claude Code progress on #414
Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
2026-03-24 18:22:39 -04:00

165 lines
4.8 KiB
JavaScript

/**
* commit-banners.js — Floating commit banner sprites
*
* Category: REAL
* Data source: Gitea commits API (via data/gitea.js — fetchNexusCommits)
*
* Loads the last N commits and renders each as a floating canvas-texture
* sprite near the island surface. Banners fade in, float gently, then fade
* out and respawn in sequence.
*/
import * as THREE from 'three';
import { fetchNexusCommits } from '../data/gitea.js';
let _scene = null;
/** @type {THREE.Sprite[]} */
const banners = [];
// ---------------------------------------------------------------------------
// Canvas texture
// ---------------------------------------------------------------------------
/**
* @param {string} hash 7-char short SHA
* @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);
}
// ---------------------------------------------------------------------------
// Banner lifecycle constants
// ---------------------------------------------------------------------------
const SPREAD_X = [-7, -3.5, 0, 3.5, 7];
const SPREAD_Y = [1.0, -1.5, 2.2, -0.8, 1.6];
const SPREAD_Z = [-1.5, -2.5, -1.0, -2.0, -1.8];
const FADE_IN_S = 0.5;
const VISIBLE_S = 10.0;
const FADE_OUT_S = 0.8;
const TOTAL_S = FADE_IN_S + VISIBLE_S + FADE_OUT_S;
const FLOAT_AMP = 0.18; // world-units peak-to-peak amplitude
// ---------------------------------------------------------------------------
// Module API
// ---------------------------------------------------------------------------
/**
* @param {THREE.Scene} scene
* @param {object} _state
* @param {object} _theme
*/
export async function init(scene, _state, _theme) {
_scene = scene;
const raw = await fetchNexusCommits(5);
const commits = raw.length > 0
? raw.map(c => ({
hash: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0],
}))
: [
{ 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' },
];
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);
const baseY = SPREAD_Y[i % SPREAD_Y.length];
sprite.position.set(
SPREAD_X[i % SPREAD_X.length],
baseY,
SPREAD_Z[i % SPREAD_Z.length]
);
sprite.userData = {
baseY,
floatPhase: (i / commits.length) * Math.PI * 2,
floatSpeed: 0.25 + i * 0.07,
// Stagger start so banners do not all appear simultaneously
startDelay: i * (TOTAL_S / commits.length),
zoomLabel: `Commit: ${commit.hash}`,
};
scene.add(sprite);
banners.push(sprite);
});
}
/**
* @param {number} elapsed Total seconds since start
* @param {number} _delta
*/
export function update(elapsed, _delta) {
for (const sprite of banners) {
const ud = sprite.userData;
// Position within the banner's own cyclic timeline
const localT = (elapsed - ud.startDelay) % TOTAL_S;
let opacity = 0;
if (localT < 0) {
opacity = 0;
} else if (localT < FADE_IN_S) {
opacity = localT / FADE_IN_S;
} else if (localT < FADE_IN_S + VISIBLE_S) {
opacity = 1;
} else {
const fadeOutT = localT - FADE_IN_S - VISIBLE_S;
opacity = Math.max(0, 1 - fadeOutT / FADE_OUT_S);
}
sprite.material.opacity = opacity;
// Vertical float
sprite.position.y =
ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * FLOAT_AMP;
}
}
export function dispose() {
for (const sprite of banners) {
_scene.remove(sprite);
if (sprite.material.map) sprite.material.map.dispose();
sprite.material.dispose();
}
banners.length = 0;
}