Spaces:
Running
Running
| /* ══════════════════════════════════════════ | |
| RECOVER LAB — main.js | |
| Advanced interactions & animations | |
| ══════════════════════════════════════════ */ | |
| ; | |
| /* ── LOADER ── */ | |
| (function initLoader() { | |
| const loader = document.getElementById('loader'); | |
| const fill = document.getElementById('loaderFill'); | |
| if (!loader || !fill) return; | |
| let progress = 0; | |
| const tick = setInterval(() => { | |
| progress += Math.random() * 18 + 5; | |
| if (progress >= 100) { | |
| progress = 100; | |
| clearInterval(tick); | |
| setTimeout(() => loader.classList.add('hidden'), 300); | |
| } | |
| fill.style.width = progress + '%'; | |
| }, 60); | |
| })(); | |
| /* ── HERO CANVAS — particle network ── */ | |
| (function initCanvas() { | |
| const canvas = document.getElementById('heroCanvas'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| let W, H, particles = [], mouse = { x: -9999, y: -9999 }; | |
| const PARTICLE_COUNT = 80; | |
| const CONNECT_DIST = 120; | |
| const MOUSE_RADIUS = 160; | |
| function getAccent() { | |
| return getComputedStyle(document.documentElement) | |
| .getPropertyValue('--accent').trim() || '#00c2aa'; | |
| } | |
| class Particle { | |
| constructor() { this.reset(); } | |
| reset() { | |
| this.x = Math.random() * W; | |
| this.y = Math.random() * H; | |
| this.vx = (Math.random() - 0.5) * 0.4; | |
| this.vy = (Math.random() - 0.5) * 0.4; | |
| this.r = Math.random() * 2 + 1; | |
| this.base = { x: this.x, y: this.y }; | |
| } | |
| update() { | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| if (this.x < 0 || this.x > W) this.vx *= -1; | |
| if (this.y < 0 || this.y > H) this.vy *= -1; | |
| // mouse repulsion | |
| const dx = this.x - mouse.x; | |
| const dy = this.y - mouse.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist < MOUSE_RADIUS) { | |
| const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS; | |
| this.x += dx / dist * force * 2; | |
| this.y += dy / dist * force * 2; | |
| } | |
| } | |
| draw(accent) { | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); | |
| ctx.fillStyle = accent; | |
| ctx.globalAlpha = 0.5; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| function resize() { | |
| W = canvas.width = canvas.offsetWidth; | |
| H = canvas.height = canvas.offsetHeight; | |
| particles = Array.from({ length: PARTICLE_COUNT }, () => new Particle()); | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, W, H); | |
| const accent = getAccent(); | |
| for (let i = 0; i < particles.length; i++) { | |
| particles[i].update(); | |
| particles[i].draw(accent); | |
| for (let j = i + 1; j < particles.length; j++) { | |
| const dx = particles[i].x - particles[j].x; | |
| const dy = particles[i].y - particles[j].y; | |
| const d = Math.sqrt(dx * dx + dy * dy); | |
| if (d < CONNECT_DIST) { | |
| ctx.beginPath(); | |
| ctx.moveTo(particles[i].x, particles[i].y); | |
| ctx.lineTo(particles[j].x, particles[j].y); | |
| ctx.strokeStyle = accent; | |
| ctx.globalAlpha = (1 - d / CONNECT_DIST) * 0.2; | |
| ctx.lineWidth = 0.8; | |
| ctx.stroke(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| } | |
| requestAnimationFrame(draw); | |
| } | |
| window.addEventListener('resize', resize); | |
| canvas.addEventListener('mousemove', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| mouse.x = e.clientX - rect.left; | |
| mouse.y = e.clientY - rect.top; | |
| }); | |
| canvas.addEventListener('mouseleave', () => { mouse.x = -9999; mouse.y = -9999; }); | |
| resize(); | |
| draw(); | |
| })(); | |
| /* ── NAV SCROLL ── */ | |
| (function initNav() { | |
| const nav = document.getElementById('nav'); | |
| window.addEventListener('scroll', () => { | |
| nav.classList.toggle('scrolled', window.scrollY > 40); | |
| }, { passive: true }); | |
| // Hamburger | |
| const hamburger = document.getElementById('hamburger'); | |
| const navLinks = document.getElementById('navLinks'); | |
| if (!hamburger || !navLinks) return; | |
| hamburger.addEventListener('click', () => { | |
| navLinks.classList.toggle('open'); | |
| const spans = hamburger.querySelectorAll('span'); | |
| const isOpen = navLinks.classList.contains('open'); | |
| spans[0].style.transform = isOpen ? 'translateY(6.5px) rotate(45deg)' : ''; | |
| spans[1].style.opacity = isOpen ? '0' : ''; | |
| spans[2].style.transform = isOpen ? 'translateY(-6.5px) rotate(-45deg)' : ''; | |
| }); | |
| navLinks.querySelectorAll('a').forEach(a => | |
| a.addEventListener('click', () => { | |
| navLinks.classList.remove('open'); | |
| hamburger.querySelectorAll('span').forEach(s => { s.style.transform = ''; s.style.opacity = ''; }); | |
| }) | |
| ); | |
| })(); | |
| /* ── THEME PANEL ── */ | |
| (function initTheme() { | |
| const html = document.documentElement; | |
| if (!html.dataset.mode) html.dataset.mode = 'dark'; | |
| })(); | |
| /* ── SCROLL REVEAL ── */ | |
| (function initReveal() { | |
| const els = document.querySelectorAll('.reveal-up, .reveal-left, .reveal-right'); | |
| const obs = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) { | |
| e.target.classList.add('visible'); | |
| obs.unobserve(e.target); | |
| } | |
| }); | |
| }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }); | |
| els.forEach(el => { | |
| obs.observe(el); | |
| // Check if element is already in view | |
| const rect = el.getBoundingClientRect(); | |
| if (rect.top < window.innerHeight && rect.bottom > 0) { | |
| el.classList.add('visible'); | |
| obs.unobserve(el); | |
| } | |
| }); | |
| })(); | |
| /* ── COUNTER ANIMATION ── */ | |
| (function initCounters() { | |
| const stats = document.querySelectorAll('.hero-stat'); | |
| if (!stats.length) return; | |
| const obs = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (!e.isIntersecting) return; | |
| const el = e.target; | |
| const target = parseInt(el.dataset.count); | |
| const suffix = el.dataset.suffix || ''; | |
| const numEl = el.querySelector('.stat-n'); | |
| const prefix = numEl.textContent.startsWith('$') ? '$' : ''; | |
| let current = 0; | |
| const step = target / 40; | |
| const timer = setInterval(() => { | |
| current += step; | |
| if (current >= target) { | |
| current = target; | |
| clearInterval(timer); | |
| } | |
| numEl.textContent = prefix + Math.floor(current) + suffix; | |
| }, 30); | |
| obs.unobserve(el); | |
| }); | |
| }, { threshold: 0.5 }); | |
| stats.forEach(s => obs.observe(s)); | |
| })(); | |
| /* ── ACTIVE NAV LINK ON SCROLL ── */ | |
| (function initActiveNav() { | |
| const navLinks = document.querySelectorAll('.nav-link'); | |
| if (!navLinks.length) return; | |
| const page = window.location.pathname.split('/').pop() || 'index.html'; | |
| const pageMap = { | |
| 'index.html': ['#about', '#contact'], | |
| 'research.html': ['research.html'], | |
| 'team.html': ['team.html'], | |
| 'publications.html': ['publications.html'], | |
| 'news.html': ['news.html'] | |
| }; | |
| function setActiveByHref(href) { | |
| navLinks.forEach(l => l.classList.toggle('active', l.getAttribute('href') === href)); | |
| } | |
| if (page !== 'index.html') { | |
| const matches = pageMap[page]; | |
| if (matches && matches.length) setActiveByHref(matches[0]); | |
| return; | |
| } | |
| const sections = document.querySelectorAll('section[id]'); | |
| setActiveByHref('#about'); | |
| const obs = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) { | |
| const targetHref = `#${e.target.id}`; | |
| if (targetHref === '#about' || targetHref === '#contact') { | |
| setActiveByHref(targetHref); | |
| } | |
| } | |
| }); | |
| }, { threshold: 0.4 }); | |
| sections.forEach(s => obs.observe(s)); | |
| })(); | |
| /* ── CONTACT FORM ── */ | |
| (function initForm() { | |
| const form = document.getElementById('contactForm'); | |
| if (!form) return; | |
| form.addEventListener('submit', e => { | |
| e.preventDefault(); | |
| const formData = new FormData(form); | |
| const firstName = (formData.get('firstName') || '').toString().trim(); | |
| const lastName = (formData.get('lastName') || '').toString().trim(); | |
| const email = (formData.get('email') || '').toString().trim(); | |
| const inquiryType = (formData.get('inquiryType') || 'General Question').toString().trim(); | |
| const message = (formData.get('message') || '').toString().trim(); | |
| const sender = [firstName, lastName].filter(Boolean).join(' ') || 'Website visitor'; | |
| const subject = encodeURIComponent(`RECOVER Lab Inquiry: ${inquiryType}`); | |
| const body = encodeURIComponent( | |
| `Name: ${sender}\nEmail: ${email || 'Not provided'}\nInquiry Type: ${inquiryType}\n\nMessage:\n${message || 'No message provided.'}` | |
| ); | |
| const btn = form.querySelector('button[type="submit"]'); | |
| const orig = btn.textContent; | |
| btn.textContent = 'Opening Email Draft...'; | |
| btn.style.background = 'var(--accent-2)'; | |
| btn.disabled = true; | |
| window.location.href = `mailto:arunrajtpr19@gmail.com?subject=${subject}&body=${body}`; | |
| setTimeout(() => { | |
| btn.textContent = orig; | |
| btn.style.background = ''; | |
| btn.disabled = false; | |
| form.reset(); | |
| }, 3000); | |
| }); | |
| })(); | |
| /* ── PARALLAX HERO CONTENT ── */ | |
| (function initParallax() { | |
| const hero = document.getElementById('hero'); | |
| const content = hero ? hero.querySelector('.hero-content') : null; | |
| if (!content) return; | |
| window.addEventListener('scroll', () => { | |
| const scrollY = window.scrollY; | |
| if (scrollY < window.innerHeight) { | |
| content.style.transform = `translateY(${scrollY * 0.2}px)`; | |
| content.style.opacity = 1 - scrollY / (window.innerHeight * 0.75); | |
| } | |
| }, { passive: true }); | |
| })(); | |
| /* ── MAGNETIC BUTTONS ── */ | |
| (function initMagnetic() { | |
| document.querySelectorAll('.btn-glow, .btn-ghost').forEach(btn => { | |
| btn.addEventListener('mousemove', e => { | |
| const rect = btn.getBoundingClientRect(); | |
| const cx = rect.left + rect.width / 2; | |
| const cy = rect.top + rect.height / 2; | |
| const dx = (e.clientX - cx) * 0.2; | |
| const dy = (e.clientY - cy) * 0.2; | |
| btn.style.transform = `translate(${dx}px, ${dy}px) translateY(-2px)`; | |
| }); | |
| btn.addEventListener('mouseleave', () => { | |
| btn.style.transform = ''; | |
| }); | |
| }); | |
| })(); | |
| /* ── DATA RENDERING FUNCTIONS ── */ | |
| // Fetch JSON data from /data/ directory | |
| async function fetchJSON(filename) { | |
| try { | |
| const response = await fetch(`./data/${filename}`); | |
| if (!response.ok) throw new Error(`Failed to fetch ${filename}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error(`Error fetching ${filename}:`, error); | |
| return null; | |
| } | |
| } | |
| // Render team members from JSON | |
| async function renderTeamData() { | |
| const piGrid = document.querySelector('#teamPiGrid'); | |
| const researchersGrid = document.querySelector('#teamResearchersGrid'); | |
| const studentsGrid = document.querySelector('#teamStudentsGrid'); | |
| const researchersSection = document.querySelector('#teamResearchersSection'); | |
| if (!piGrid || !researchersGrid || !studentsGrid) return; | |
| const data = await fetchJSON('team.json'); | |
| if (!data) return; | |
| const piMembers = []; | |
| const studentMembers = []; | |
| const researcherMembers = []; | |
| data.forEach(member => { | |
| const role = (member.role || '').toLowerCase(); | |
| if (role.includes('principal investigator')) piMembers.push(member); | |
| else if (role.includes('student') || role.includes('candidate')) studentMembers.push(member); | |
| else researcherMembers.push(member); | |
| }); | |
| renderTeamGroup(piGrid, piMembers); | |
| renderTeamGroup(researchersGrid, researcherMembers); | |
| renderTeamGroup(studentsGrid, studentMembers); | |
| if (researchersSection) { | |
| researchersSection.style.display = researcherMembers.length ? '' : 'none'; | |
| } | |
| // Re-initialize scroll reveal for new elements | |
| initRevealForNewElements(); | |
| } | |
| function renderTeamGroup(grid, members) { | |
| grid.innerHTML = ''; | |
| if (!members.length) { | |
| grid.innerHTML = '<div class="member"><div class="member-info"><div class="member-focus">No entries available yet.</div></div></div>'; | |
| return; | |
| } | |
| members.forEach((member, idx) => { | |
| const delayClass = ['delay-1', 'delay-2', 'delay-3', 'delay-4'][idx % 4] || 'delay-1'; | |
| const linksHTML = (member.links || []).map(link => renderMemberLink(link)).join(''); | |
| const linksSection = linksHTML | |
| ? `<div class="member-links">${linksHTML}</div>` | |
| : `<div class="member-links-placeholder">Coming soon</div>`; | |
| const memberHTML = ` | |
| <div class="member reveal-up ${delayClass}"> | |
| <div class="member-img"> | |
| ${member.image ? `<img src="${member.image}" alt="${member.name}" class="member-photo" loading="lazy" />` : ''} | |
| <div class="member-initials-wrap${member.image ? ' is-fallback' : ''}">${member.initials}</div> | |
| <div class="member-glow"></div> | |
| </div> | |
| <div class="member-info"> | |
| <div class="member-name">${member.name}</div> | |
| <div class="member-role">${member.role}</div> | |
| <div class="member-focus">${member.focus}</div> | |
| ${linksSection} | |
| </div> | |
| </div> | |
| `; | |
| grid.innerHTML += memberHTML; | |
| }); | |
| } | |
| function renderMemberLink(link) { | |
| const label = (link.label || '').trim(); | |
| const url = (link.url || '').trim(); | |
| const icon = getMemberLinkIcon(label); | |
| const safeLabel = label || 'Profile'; | |
| if (!url) { | |
| return `<span class="mlink is-disabled" title="Add ${safeLabel} link in data/team.json" aria-disabled="true">${icon}<span class="sr-only">${safeLabel}</span></span>`; | |
| } | |
| const isMail = url.startsWith('mailto:'); | |
| const extraAttrs = isMail ? '' : ' target="_blank" rel="noopener noreferrer"'; | |
| return `<a href="${url}" class="mlink" aria-label="${safeLabel}" title="${safeLabel}"${extraAttrs}>${icon}<span class="sr-only">${safeLabel}</span></a>`; | |
| } | |
| function getMemberLinkIcon(label) { | |
| const key = label.toLowerCase().replace(/\s+/g, ''); | |
| const icons = { | |
| email: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3.8 6.5L12 12.8l8.2-6.3" fill="none" stroke="#EA4335" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 7.2v9.6h15V7.2" fill="#ffffff" stroke="#DADCE0" stroke-width="1.2"/><path d="M4.5 16.8l5.4-5" fill="none" stroke="#34A853" stroke-width="1.8" stroke-linecap="round"/><path d="M19.5 16.8l-5.4-5" fill="none" stroke="#4285F4" stroke-width="1.8" stroke-linecap="round"/><path d="M4.5 7.2l3.4 2.6" fill="none" stroke="#FBBC05" stroke-width="1.8" stroke-linecap="round"/></svg>', | |
| linkedin: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="3" fill="#0A66C2"/><circle cx="8" cy="8" r="1.3" fill="#ffffff"/><path d="M7 10.3h2v6.2H7zM11 10.3h1.9v.9c.5-.7 1.4-1.1 2.5-1.1 2 0 3.1 1.3 3.1 3.8v2.6h-2v-2.4c0-1.4-.5-2.1-1.6-2.1-1.2 0-1.9.8-1.9 2.1v2.4h-2z" fill="#ffffff"/></svg>', | |
| huggingface: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="#FFDA5A"/><circle cx="8.8" cy="11" r="1.1" fill="#3B2F2F"/><circle cx="15.2" cy="11" r="1.1" fill="#3B2F2F"/><path d="M8.7 14c.9 1 2 1.5 3.3 1.5s2.4-.5 3.3-1.5" fill="none" stroke="#3B2F2F" stroke-width="1.8" stroke-linecap="round"/><path d="M6.6 10.4c-.2-1.6.3-3 1.4-4.2M17.4 10.4c.2-1.6-.3-3-1.4-4.2" fill="none" stroke="#F59E0B" stroke-width="1.5" stroke-linecap="round"/></svg>', | |
| googlescholar: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4l8 4.2-8 4.2-8-4.2L12 4z" fill="#4285F4"/><path d="M7.2 10.4v4.1c0 1.7 2.1 3.1 4.8 3.1s4.8-1.4 4.8-3.1v-4.1L12 13z" fill="#7BAAF7"/><circle cx="17.8" cy="15.6" r="2.2" fill="#34A853"/><path d="M17.8 14.5v2.2M16.7 15.6H19" stroke="#ffffff" stroke-width="1.2" stroke-linecap="round"/></svg>' | |
| }; | |
| return icons[key] || '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>'; | |
| } | |
| (function initAssetFallbacks() { | |
| document.addEventListener('DOMContentLoaded', () => { | |
| document.addEventListener('error', event => { | |
| if (event.target.classList && event.target.classList.contains('member-photo')) { | |
| const wrap = event.target.closest('.member-img'); | |
| if (!wrap) return; | |
| event.target.remove(); | |
| const fallback = wrap.querySelector('.member-initials-wrap'); | |
| if (fallback) fallback.classList.remove('is-fallback'); | |
| } | |
| if (event.target.classList && event.target.classList.contains('carousel-image')) { | |
| event.target.classList.add('is-missing'); | |
| } | |
| }, true); | |
| }); | |
| })(); | |
| (function initCarousel() { | |
| document.addEventListener('DOMContentLoaded', () => { | |
| document.querySelectorAll('.js-carousel').forEach(carousel => { | |
| const track = carousel.querySelector('.carousel-track'); | |
| const prev = carousel.querySelector('.carousel-prev'); | |
| const next = carousel.querySelector('.carousel-next'); | |
| if (!track || !prev || !next) return; | |
| let index = 0; | |
| const interval = Number(carousel.dataset.interval || 5000); | |
| function getSlides() { | |
| return Array.from(track.children); | |
| } | |
| function updateCarousel() { | |
| track.style.transform = `translateX(-${index * 100}%)`; | |
| } | |
| prev.addEventListener('click', () => { | |
| const slides = getSlides(); | |
| if (!slides.length) return; | |
| index = (index - 1 + slides.length) % slides.length; | |
| updateCarousel(); | |
| }); | |
| next.addEventListener('click', () => { | |
| const slides = getSlides(); | |
| if (!slides.length) return; | |
| index = (index + 1) % slides.length; | |
| updateCarousel(); | |
| }); | |
| setInterval(() => { | |
| const slides = getSlides(); | |
| if (slides.length <= 1) return; | |
| index = (index + 1) % slides.length; | |
| updateCarousel(); | |
| }, interval); | |
| }); | |
| }); | |
| })(); | |
| // Render research projects from JSON | |
| async function renderResearchData() { | |
| const grid = document.querySelector('.research-grid'); | |
| if (!grid) return; | |
| const data = await fetchJSON('research.json'); | |
| if (!data) return; | |
| grid.innerHTML = ''; | |
| data.forEach((project, idx) => { | |
| const delayClass = ['delay-1', 'delay-2', 'delay-3', 'delay-4', 'delay-5', 'delay-6'][idx] || 'delay-1'; | |
| const pillsHTML = project.pills.map(pill => | |
| `<span class="rc-pill">${pill}</span>` | |
| ).join(''); | |
| const cardHTML = ` | |
| <div class="research-card reveal-up ${delayClass}" data-index="${project.index}"> | |
| <div class="rc-tag">${project.tag}</div> | |
| <h3 class="rc-title">${project.title}</h3> | |
| <p class="rc-desc">${project.desc}</p> | |
| <div class="rc-footer"> | |
| ${pillsHTML} | |
| </div> | |
| </div> | |
| `; | |
| grid.innerHTML += cardHTML; | |
| }); | |
| initRevealForNewElements(); | |
| } | |
| // Render publications from a single JSON source | |
| async function renderPublicationsData() { | |
| const journalsList = document.querySelector('#journalsList'); | |
| const conferencesList = document.querySelector('#conferencesList'); | |
| const otherOutputsList = document.querySelector('#otherOutputsList'); | |
| if (!journalsList || !conferencesList || !otherOutputsList) return; | |
| const data = await fetchJSON('publications.json'); | |
| if (!data) return; | |
| const groups = { | |
| journal: [], | |
| conference: [], | |
| other: [] | |
| }; | |
| data.forEach(pub => { | |
| if (pub.type === 'journal') groups.journal.push(pub); | |
| else if (pub.type === 'conference') groups.conference.push(pub); | |
| else groups.other.push(pub); | |
| }); | |
| renderPublicationGroup(journalsList, groups.journal); | |
| renderPublicationGroup(conferencesList, groups.conference); | |
| renderPublicationGroup(otherOutputsList, groups.other); | |
| initRevealForNewElements(); | |
| } | |
| function renderPublicationGroup(list, items) { | |
| list.innerHTML = ''; | |
| if (!items.length) { | |
| list.innerHTML = '<div class="pub-item"><div class="pub-body"><div class="pub-meta">No entries available yet.</div></div></div>'; | |
| return; | |
| } | |
| items.forEach((pub, idx) => { | |
| const delayClass = ['delay-1', 'delay-2', 'delay-3', 'delay-4'][idx % 4] || 'delay-1'; | |
| const linkHTML = pub.url | |
| ? `<a href="${pub.url}" class="pub-link" aria-label="View publication" target="_blank" rel="noopener noreferrer"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> | |
| </a>` | |
| : ''; | |
| const itemHTML = ` | |
| <div class="pub-item reveal-right ${delayClass}"> | |
| <div class="pub-year">${pub.year}</div> | |
| <div class="pub-body"> | |
| <div class="pub-title">${pub.title}</div> | |
| <div class="pub-meta">${pub.meta}</div> | |
| </div> | |
| ${linkHTML} | |
| </div> | |
| `; | |
| list.innerHTML += itemHTML; | |
| }); | |
| } | |
| async function renderPublicationHighlights() { | |
| const track = document.querySelector('#pubHighlightsTrack'); | |
| if (!track) return; | |
| const data = await fetchJSON('publication_highlights.json'); | |
| if (!data) return; | |
| track.innerHTML = ''; | |
| data.forEach((item, idx) => { | |
| const delayClass = ['delay-1', 'delay-2', 'delay-3', 'delay-4'][idx % 4] || 'delay-1'; | |
| const slideHTML = ` | |
| <article class="carousel-slide pub-highlight-slide reveal-up ${delayClass}"> | |
| <div class="pub-highlight-card"> | |
| <div class="pub-highlight-media"> | |
| <img src="${item.image}" alt="${item.alt}" class="carousel-image pub-highlight-image" /> | |
| <div class="carousel-fallback">Add \`${item.image}\`</div> | |
| </div> | |
| <div class="pub-highlight-summary"> | |
| <p>${item.summary}</p> | |
| </div> | |
| </div> | |
| </article> | |
| `; | |
| track.innerHTML += slideHTML; | |
| }); | |
| } | |
| // Render news items from JSON | |
| async function renderNewsData() { | |
| const grid = document.querySelector('.news-grid'); | |
| if (!grid) return; | |
| const data = await fetchJSON('news.json'); | |
| if (!data) return; | |
| grid.innerHTML = ''; | |
| data.forEach((news, idx) => { | |
| const delayClass = ['delay-1', 'delay-2', 'delay-3', 'delay-4'][idx] || 'delay-1'; | |
| const featuredClass = news.featured ? 'featured' : ''; | |
| const linkHTML = news.link | |
| ? `<a href="${news.link}" class="news-read" target="_blank" rel="noopener noreferrer">Read more →</a>` | |
| : ''; | |
| const cardHTML = ` | |
| <div class="news-card ${featuredClass} reveal-up ${delayClass}"> | |
| <div class="news-type">${news.type}</div> | |
| <div class="news-date">${news.date}</div> | |
| <h3 class="news-title">${news.title}</h3> | |
| <p class="news-body">${news.body}</p> | |
| ${linkHTML} | |
| </div> | |
| `; | |
| grid.innerHTML += cardHTML; | |
| }); | |
| initRevealForNewElements(); | |
| } | |
| // Re-initialize scroll reveal for dynamically added elements | |
| function initRevealForNewElements() { | |
| const els = document.querySelectorAll('.reveal-up, .reveal-left, .reveal-right'); | |
| const obs = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) { | |
| e.target.classList.add('visible'); | |
| obs.unobserve(e.target); | |
| } | |
| }); | |
| }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }); | |
| els.forEach(el => { | |
| if (!el.classList.contains('visible')) { | |
| obs.observe(el); | |
| const rect = el.getBoundingClientRect(); | |
| if (rect.top < window.innerHeight && rect.bottom > 0) { | |
| el.classList.add('visible'); | |
| obs.unobserve(el); | |
| } | |
| } | |
| }); | |
| } | |
| // Auto-initialize on specific pages | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| const path = window.location.pathname; | |
| if (path.includes('team.html')) await renderTeamData(); | |
| else if (path.includes('research.html')) await renderResearchData(); | |
| else if (path.includes('publications.html')) { | |
| await renderPublicationHighlights(); | |
| await renderPublicationsData(); | |
| } | |
| else if (path.includes('news.html')) await renderNewsData(); | |
| }); | |