diff --git a/app.js b/app.js index 485320f..5d61a83 100644 --- a/app.js +++ b/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} */ +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>} + */ +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];