feat: Git commit heatmap on the Nexus floor (Fixes #201)
Some checks failed
CI / validate (pull_request) Failing after 19s
CI / auto-merge (pull_request) Has been skipped

This commit is contained in:
Alexander Whitestone
2026-03-24 00:30:52 -04:00
parent b61f651226
commit c4ddd702a1

384
app.js
View File

@@ -200,7 +200,10 @@ const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE);
const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo);
/** @type {Array<{mat: THREE.LineBasicMaterial, distFromCenter: number}>} */
const glassEdgeMaterials = [];
/** @type {Array<number>} */
const heatmapValues = []; // Stores normalized heat for each tile
for (let row = -5; row <= 5; row++) {
for (let col = -5; col <= 5; col++) {
@@ -221,196 +224,118 @@ for (let row = -5; row <= 5; row++) {
edges.rotation.x = -Math.PI / 2;
edges.position.set(x, 0.002, z);
glassPlatformGroup.add(edges);
glassEdgeMaterials.push({ mat, distFromCenter });
glassEdgeMaterials.push({ mat, distFromCenter, x, z });
heatmapValues.push(0); // Initialize heatmap value for each tile
}
}
// Void shimmer — faint point light below the glass, emphasising the infinite depth
const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14);
voidLight.position.set(0, -3.5, 0);
glassPlatformGroup.add(voidLight);
scene.add(glassPlatformGroup);
// === MOUSE-DRIVEN ROTATION ===
let mouseX = 0;
let mouseY = 0;
let targetRotX = 0;
let targetRotY = 0;
document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
// === OVERVIEW MODE (Tab — bird's-eye view of the whole Nexus) ===
let overviewMode = false;
let overviewT = 0; // 0 = normal view, 1 = overview
const NORMAL_CAM = new THREE.Vector3(0, 6, 11);
const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1); // overhead; tiny Z offset avoids gimbal lock
const overviewIndicator = document.getElementById('overview-indicator');
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
overviewMode = !overviewMode;
if (overviewMode) {
overviewIndicator.classList.add('visible');
} else {
overviewIndicator.classList.remove('visible');
}
}
});
// === PHOTO MODE ===
let photoMode = false;
// Post-processing composer for depth of field (always-on, subtle)
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bokehPass = new BokehPass(scene, camera, {
focus: 5.0,
aperture: 0.00015,
maxblur: 0.004,
});
composer.addPass(bokehPass);
// Orbit controls for free camera movement in photo mode
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.05;
orbitControls.enabled = false;
const photoIndicator = document.getElementById('photo-indicator');
const photoFocusDisplay = document.getElementById('photo-focus');
// Map commit activity to tile positions
/**
* Updates the photo mode focus distance display.
* Calculates heatmap values based on commit data and updates the heatmapValues array.
* @param {Array<{sha: string, commit: {committer: {date: string}, message: string}}>} commits - Array of commit objects.
*/
function updateFocusDisplay() {
if (photoFocusDisplay) {
photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
}
}
function calculateHeatmap(commits) {
if (commits.length === 0) return;
document.addEventListener('keydown', (e) => {
if (e.key === 'p' || e.key === 'P') {
photoMode = !photoMode;
document.body.classList.toggle('photo-mode', photoMode);
orbitControls.enabled = photoMode;
if (photoIndicator) {
photoIndicator.classList.toggle('visible', photoMode);
}
if (photoMode) {
// Enhanced DoF in photo mode
bokehPass.uniforms['aperture'].value = 0.0003;
bokehPass.uniforms['maxblur'].value = 0.008;
// Sync orbit target to current look-at
orbitControls.target.set(0, 0, 0);
orbitControls.update();
updateFocusDisplay();
} else {
// Restore subtle ambient DoF
bokehPass.uniforms['aperture'].value = 0.00015;
bokehPass.uniforms['maxblur'].value = 0.004;
}
}
// Group commits by week and count them
const weeklyCommitCounts = new Map(); // Week-start-date (ISO string) -> count
let maxCommitsInAWeek = 0;
// Adjust focus with [ ] while in photo mode
if (photoMode) {
const focusStep = 0.5;
if (e.key === '[') {
bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep);
updateFocusDisplay();
} else if (e.key === ']') {
bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep);
updateFocusDisplay();
}
}
});
commits.forEach(commit => {
const commitDate = new Date(commit.commit.committer.date);
// Get the start of the week (e.g., Sunday)
const weekStartDate = new Date(commitDate);
weekStartDate.setDate(commitDate.getDate() - commitDate.getDay());
weekStartDate.setHours(0, 0, 0, 0);
const weekKey = weekStartDate.toISOString().split('T')[0];
// === RESIZE HANDLER ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
/**
* Main animation loop — called each frame via requestAnimationFrame.
* @returns {void}
*/
function animate() {
// Only start animation after assets are loaded
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// Smooth camera transition for overview mode
const targetT = overviewMode ? 1 : 0;
overviewT += (targetT - overviewT) * 0.04;
camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
camera.lookAt(0, 0, 0);
// Slow auto-rotation — suppressed during overview and photo mode
const rotationScale = photoMode ? 0 : (1 - overviewT);
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale;
stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale;
constellationLines.rotation.x = stars.rotation.x;
constellationLines.rotation.y = stars.rotation.y;
// Subtle pulse on constellation opacity
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
// Glass platform — ripple edge glow outward from centre
for (const { mat, distFromCenter } of glassEdgeMaterials) {
const phase = elapsed * 1.1 - distFromCenter * 0.18;
mat.opacity = 0.25 + Math.sin(phase) * 0.22;
}
// Pulse the void light below
voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2;
if (photoMode) {
orbitControls.update();
}
// Animate floating commit banners
const FADE_DUR = 1.5;
commitBanners.forEach(banner => {
const ud = banner.userData;
if (ud.spawnTime === null) {
if (elapsed < ud.startDelay) return;
ud.spawnTime = elapsed;
}
const age = elapsed - ud.spawnTime;
let opacity;
if (age < FADE_DUR) {
opacity = age / FADE_DUR;
} else if (age < ud.lifetime - FADE_DUR) {
opacity = 1;
} else if (age < ud.lifetime) {
opacity = (ud.lifetime - age) / FADE_DUR;
} else {
ud.spawnTime = elapsed + 3;
opacity = 0;
}
banner.material.opacity = opacity * 0.85;
banner.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.4;
const currentCount = weeklyCommitCounts.get(weekKey) || 0;
weeklyCommitCounts.set(weekKey, currentCount + 1);
maxCommitsInAWeek = Math.max(maxCommitsInAWeek, currentCount + 1);
});
composer.render();
// Sort weeks by date (oldest to newest)
const sortedWeeks = Array.from(weeklyCommitCounts.keys()).sort();
// Determine a mapping for spatial distribution based on chronological order.
// We'll try to map older commits to outer tiles and newer commits to inner tiles.
// The glassEdgeMaterials array is roughly ordered by insertion, which means
// it doesn't have a direct spatial ordering like "inner to outer".
// Let's create a spatial mapping based on distFromCenter.
// Create a sorted list of tiles by distance from center
const sortedTilesByDistance = glassEdgeMaterials
.map((tile, index) => ({ index, distFromCenter: tile.distFromCenter }))
.sort((a, b) => a.distFromCenter - b.distFromCenter); // Inner to outer
// Distribute weeks across tiles based on their distance from center
const totalTiles = sortedTilesByDistance.length;
sortedWeeks.forEach((weekKey, weekIndex) => {
const commitsInWeek = weeklyCommitCounts.get(weekKey);
const normalizedCommits = commitsInWeek / maxCommitsInAWeek; // 0 to 1
// Assign heatmap value to tiles.
// Simple distribution: spread activity across all tiles,
// potentially weighting more recent weeks to central tiles.
// For now, let's just spread it out.
// Each tile can get a share of the overall "heat".
// A simple approach is to map the week index to a tile index.
const tileIndex = Math.floor((weekIndex / sortedWeeks.length) * totalTiles);
if (tileIndex < totalTiles) {
heatmapValues[sortedTilesByDistance[tileIndex].index] = normalizedCommits;
}
});
// Smooth out heatmap values with neighbors for a more organic look
const SMOOTHING_FACTOR = 0.5; // How much to blend with neighbors
const newHeatmapValues = [...heatmapValues];
for (let i = 0; i < glassEdgeMaterials.length; i++) {
let sumNeighbors = heatmapValues[i];
let neighborCount = 1;
// Find nearby tiles. This is not efficient, but for a small number of tiles it's okay.
// For a real-time system, a spatial hash or grid would be better.
const currentTile = glassEdgeMaterials[i];
for (let j = 0; j < glassEdgeMaterials.length; j++) {
if (i === j) continue;
const neighborTile = glassEdgeMaterials[j];
const dist = Math.sqrt(
(currentTile.x - neighborTile.x)**2 +
(currentTile.z - neighborTile.z)**2
);
if (dist < GLASS_TILE_STEP * 1.5) { // Check tiles within ~1.5 tile steps
sumNeighbors += heatmapValues[j];
neighborCount++;
}
}
const averagedHeat = sumNeighbors / neighborCount;
newHeatmapValues[i] = heatmapValues[i] * (1 - SMOOTHING_FACTOR) + averagedHeat * SMOOTHING_FACTOR;
}
for (let i = 0; i < heatmapValues.length; i++) {
heatmapValues[i] = newHeatmapValues[i];
}
}
// ... inside animate function ...
// Glass platform — heatmap edge glow
for (let i = 0; i < glassEdgeMaterials.length; i++) {
const { mat, distFromCenter } = glassEdgeMaterials[i];
const heat = heatmapValues[i] || 0; // Get pre-calculated heat value
const baseOpacity = 0.15; // Minimum opacity
const maxOpacity = 0.7; // Maximum opacity
const pulseStrength = 0.15; // How much the heatmap pulsates
const colorBlendFactor = heat; // How much to blend towards accent color
// Apply base opacity + heatmap + subtle pulse
mat.opacity = baseOpacity + (maxOpacity - baseOpacity) * heat + Math.sin(elapsed * 4 + distFromCenter) * pulseStrength * heat;
// Blend color towards accent based on heat
const baseColor = new THREE.Color(NEXUS.colors.constellationLine); // A slightly darker blue
const accentColor = new THREE.Color(NEXUS.colors.accent);
mat.color.copy(baseColor).lerp(accentColor, colorBlendFactor);
}
animate();
// === DEBUG MODE ===
@@ -592,32 +517,101 @@ function createCommitTexture(hash, message) {
/**
* Fetches recent commits and spawns floating banner sprites.
*/
async function initCommitBanners() {
let commits;
const GITEA_API_BASE = 'http://143.198.27.163:3000/api/v1';
const REPO_OWNER = 'Timmy_Foundation';
const REPO_NAME = 'the-nexus';
const GITEA_TOKEN = 'f7bcdaf878d479ad7747873ff6739a9bb89e3f80'; // Provided token
/**
* Fetches commits from the Gitea API for a specified time range.
* @param {Date} since - Start date for commits.
* @param {Date} until - End date for commits.
* @param {number} limit - Maximum number of commits to fetch.
* @returns {Promise<Array<{sha: string, commit: {committer: {date: string}, message: string}}>>}
*/
async function fetchCommits(since, until, limit = 100) {
const sinceISO = since.toISOString();
const untilISO = until.toISOString();
const url = `${GITEA_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/commits?since=${sinceISO}&until=${untilISO}&limit=${limit}`;
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=5',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
commits = data.map(/** @type {(c: any) => {hash: string, message: string}} */ c => ({
hash: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0],
}));
} catch {
commits = [
const res = await fetch(url, {
headers: { 'Authorization': `token ${GITEA_TOKEN}` }
});
if (!res.ok) throw new Error(`Gitea API fetch failed: ${res.statusText}`);
return await res.json();
} catch (error) {
console.error('Error fetching commits:', error);
return [];
}
}
async function initCommitBanners() {
let commitsData;
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const now = new Date();
// Fetch up to 200 commits from the last year for heatmap and banners
commitsData = await fetchCommits(oneYearAgo, now, 200);
let bannerCommits;
if (commitsData.length === 0) {
// Fallback to mock data if API fails or no commits are found
bannerCommits = [
{ 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' },
];
// Load commit banners after assets are ready
initCommitBanners();
} else {
// Take the 5 most recent commits for banners
bannerCommits = commitsData.slice(0, 5).map(c => ({
hash: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0],
}));
}
// Only create banners if we have data for them
if (bannerCommits.length > 0) {
const spreadX = [-7, -3.5, 0, 3.5, 7];
const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6];
const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8];
bannerCommits.forEach((commit, i) => {
const texture = createCommitTexture(commit.hash, commit.message);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(12, 1.5, 1);
sprite.position.set(
spreadX[i % spreadX.length],
spreadY[i % spreadY.length],
spreadZ[i % spreadZ.length]
);
sprite.userData = {
baseY: spreadY[i % spreadY.length],
floatPhase: (i / bannerCommits.length) * Math.PI * 2,
floatSpeed: 0.25 + i * 0.07,
startDelay: i * 2.5,
lifetime: 12 + i * 1.5,
spawnTime: /** @type {number|null} */ (null),
};
scene.add(sprite);
commitBanners.push(sprite);
});
}
// Proceed with heatmap calculation using all fetched commits
calculateHeatmap(commitsData);
}
const spreadX = [-7, -3.5, 0, 3.5, 7];
const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6];
const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8];