feat: Git commit heatmap on the Nexus floor (Fixes #201)
This commit is contained in:
384
app.js
384
app.js
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user