(() => { 'use strict'; // Every message from the interest form and email links goes to all of these addresses. const CONTACT_EMAILS = ['zli12321@terpmail.umd.edu', 'zl22754@uga.edu']; const CONTACT_TEXT = CONTACT_EMAILS.join(' and '); const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const mailto = (subject, body) => { const params = [`subject=${encodeURIComponent(subject)}`]; if (body) params.push(`body=${encodeURIComponent(body)}`); return `mailto:${CONTACT_EMAILS.join(',')}?${params.join('&')}`; }; function initContact() { $$('[data-contact]').forEach((link) => { link.href = mailto(link.dataset.subject || 'AI for Science collaboration'); if (link.hasAttribute('data-show-address')) link.textContent = CONTACT_TEXT; }); $$('[data-year]').forEach((el) => { el.textContent = String(new Date().getFullYear()); }); } function initNav() { const nav = $('.nav'); const onScroll = () => nav.classList.toggle('is-scrolled', window.scrollY > 8); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); const toggle = $('.nav-toggle'); const menu = $('#mobile-menu'); const setOpen = (open) => { toggle.setAttribute('aria-expanded', String(open)); toggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); menu.hidden = !open; document.body.classList.toggle('menu-open', open); }; toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true')); $$('a', menu).forEach((a) => a.addEventListener('click', () => setOpen(false))); window.addEventListener('keydown', (e) => { if (e.key === 'Escape') setOpen(false); }); window.matchMedia('(min-width: 1101px)').addEventListener('change', (e) => { if (e.matches) setOpen(false); }); if (!('IntersectionObserver' in window)) return; const links = new Map($$('.nav-links a').map((a) => [a.getAttribute('href').slice(1), a])); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; links.forEach((a) => a.classList.remove('is-active')); const active = links.get(entry.target.dataset.nav || entry.target.id); if (active) active.classList.add('is-active'); }); }, { rootMargin: '-45% 0px -50% 0px' }); $$('main > section[id]').forEach((section) => observer.observe(section)); } function initReveal() { const items = $$('.reveal'); if (reduceMotion || !('IntersectionObserver' in window)) { items.forEach((el) => el.classList.add('is-visible')); return; } const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; entry.target.classList.add('is-visible'); observer.unobserve(entry.target); }); }, { rootMargin: '0px 0px -8% 0px', threshold: 0.12 }); items.forEach((el) => observer.observe(el)); } function initExplorer() { const root = $('#explorer'); if (!root) return; root.classList.add('is-enhanced'); const panels = $$('.explorer-panel', root); panels.forEach((panel) => { const stages = $$('.stage', panel); if (!stages.length) return; const tabs = document.createElement('div'); tabs.className = 'stage-tabs'; tabs.setAttribute('role', 'group'); tabs.setAttribute('aria-label', 'Project stages'); const buttons = stages.map((stage, i) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'stage-btn'; const num = document.createElement('span'); num.className = 'sb-num'; num.textContent = String(i + 1); const name = document.createElement('span'); name.textContent = $('.stage-title', stage).textContent; button.append(num, name); return button; }); const select = (index) => { buttons.forEach((b, i) => b.setAttribute('aria-pressed', String(i === index))); stages.forEach((s, i) => { s.hidden = i !== index; }); }; buttons.forEach((b, i) => b.addEventListener('click', () => select(i))); tabs.append(...buttons); panel.insertBefore(tabs, stages[0]); select(0); }); const toggles = $$('.ft-btn', root); const setField = (field) => { root.dataset.field = field; toggles.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.field === field))); panels.forEach((p) => { p.hidden = p.dataset.field !== field; }); }; toggles.forEach((b) => b.addEventListener('click', () => setField(b.dataset.field))); setField('bio'); } function initExamples() { const grid = $('#ex-grid'); const modal = $('#ex-modal'); const source = $('#examples-data'); if (!grid || !modal || !source) return; let examples; try { examples = JSON.parse(source.textContent); } catch (err) { return; } const cards = $$('.ex-card', grid); const more = $('#ex-more'); const extras = cards.filter((card) => card.classList.contains('is-extra')); if (more) { if (!extras.length) more.hidden = true; more.textContent = `Show ${extras.length} more demos`; more.addEventListener('click', () => { grid.classList.add('show-all'); more.setAttribute('aria-expanded', 'true'); if (extras[0]) extras[0].focus({ preventScroll: true }); }); } const sheet = $('.ex-sheet', modal); const closeButton = $('.icon-btn[data-ex-close]', modal); const field = (id) => document.getElementById(id); const fields = { domain: field('exm-domain'), title: field('exm-title'), tracks: field('exm-tracks'), img: field('exm-img'), desc: field('exm-desc'), why: field('exm-why'), software: field('exm-software'), artifacts: field('exm-artifacts'), axes: field('exm-axes'), outputs: field('exm-outputs'), cta: field('exm-cta'), live: field('exm-live'), }; let current = 0; let lastFocus = null; let liveView = null; const mountLive = (ex) => { if (liveView) { liveView.dispose(); liveView = null; } const live = window.ILLive; if (!fields.live || !live || !live.ready) { if (fields.live) fields.live.hidden = true; return; } fields.live.hidden = false; fields.live.style.setProperty('--dc', ex.color); liveView = live.mount(fields.live, ex.kind, { color: ex.color, hero: true }); if (!liveView) fields.live.hidden = true; }; const listItems = (ul, items, cls) => { ul.replaceChildren(...items.map((text) => { const li = document.createElement('li'); li.textContent = text; if (cls) li.className = cls; return li; })); }; const render = (index) => { current = (index + examples.length) % examples.length; const ex = examples[current]; sheet.style.setProperty('--dc', ex.color); fields.domain.textContent = ex.domain; fields.title.textContent = ex.title; listItems(fields.tracks, ex.tags, 'trk trk-rel'); fields.img.src = ex.img; fields.img.alt = ex.alt; fields.desc.textContent = ex.description; const lead = document.createElement('strong'); lead.textContent = 'In your research: '; fields.why.replaceChildren(lead, document.createTextNode(ex.why)); listItems(fields.software, ex.software); listItems(fields.artifacts, ex.artifacts); listItems(fields.axes, ex.axes); listItems(fields.outputs, ex.outputs); sheet.scrollTop = 0; if (!modal.hidden) mountLive(ex); }; const open = (index) => { lastFocus = document.activeElement; render(index); modal.hidden = false; mountLive(examples[current]); document.body.classList.add('modal-open'); closeButton.focus(); }; const close = () => { modal.hidden = true; if (liveView) { liveView.dispose(); liveView = null; } document.body.classList.remove('modal-open'); if (lastFocus) lastFocus.focus({ preventScroll: true }); }; document.addEventListener('il-live-ready', () => { if (!modal.hidden) mountLive(examples[current]); }); fields.cta.addEventListener('click', (e) => { e.preventDefault(); const form = $('#join-form'); if (form) form.elements.namedItem('interest').value = examples[current].title; close(); $('#join').scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth' }); const name = form && form.elements.namedItem('name'); if (name) setTimeout(() => name.focus({ preventScroll: true }), reduceMotion ? 0 : 600); }); cards.forEach((card) => { const index = Number(card.dataset.index); card.addEventListener('click', () => open(index)); card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(index); } }); }); $$('[data-ex-close]', modal).forEach((el) => el.addEventListener('click', close)); $('[data-ex-prev]', modal).addEventListener('click', () => render(current - 1)); $('[data-ex-next]', modal).addEventListener('click', () => render(current + 1)); document.addEventListener('keydown', (e) => { if (modal.hidden) return; if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'ArrowRight') { render(current + 1); } else if (e.key === 'ArrowLeft') { render(current - 1); } else if (e.key === 'Tab') { const focusables = $$('button, a[href]', sheet); const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }); } function initForm() { const form = $('#join-form'); if (!form) return; const note = $('#form-note'); const nameInput = form.elements.namedItem('name'); nameInput.addEventListener('input', () => nameInput.removeAttribute('aria-invalid')); form.addEventListener('submit', (e) => { e.preventDefault(); const data = new FormData(form); const value = (key) => String(data.get(key) || '').trim(); const name = value('name'); if (!name) { nameInput.setAttribute('aria-invalid', 'true'); note.textContent = 'Please add your name so we know who to reply to.'; nameInput.focus(); return; } const field = value('field') || 'Not specified'; const lines = [ `Name: ${name}`, `University & program: ${value('program') || '-'}`, `Field: ${field}`, `Research: ${value('topic') || '-'}`, '', 'How I use AI in my research today:', value('ai_use') || '-', '', `How I'd like to take part: ${data.getAll('involve').join(', ') || 'Not sure yet'}`, ]; if (value('interest')) lines.push(`Demo that caught my eye: ${value('interest')}`); const link = document.createElement('a'); link.href = mailto(`AI for Science collaboration: ${name} (${field})`, lines.join('\r\n')); link.hidden = true; document.body.append(link); link.click(); link.remove(); note.textContent = `Your email app should open with your message ready to send. If it doesn't, write to ${CONTACT_TEXT}.`; }); } function initMorph() { const canvas = $('#morph'); if (!canvas || !canvas.getContext) return; const ctx = canvas.getContext('2d'); const labels = $$('.morph-labels .ml'); // Rock-salt lattice: 5 x 5 x 5 sites, two alternating species. const SPACING = 0.42; const lattice = []; for (let i = 0; i < 5; i += 1) { for (let j = 0; j < 5; j += 1) { for (let k = 0; k < 5; k += 1) { lattice.push({ x: (i - 2) * SPACING, y: (j - 2) * SPACING, z: (k - 2) * SPACING, c: (i + j + k) % 2, r: 3.3 }); } } } const latticeEdges = []; for (let a = 0; a < lattice.length; a += 1) { for (let b = a + 1; b < lattice.length; b += 1) { const p = lattice[a]; const q = lattice[b]; const d = Math.abs(p.x - q.x) + Math.abs(p.y - q.y) + Math.abs(p.z - q.z); if (Math.abs(d - SPACING) < 1e-6) latticeEdges.push([a, b]); } } // Double helix: two backbones of 50 points plus 25 base pairs on the axis. const PER_STRAND = 50; const helix = []; const helixEdges = []; for (let s = 0; s < 2; s += 1) { for (let i = 0; i < PER_STRAND; i += 1) { const t = i / (PER_STRAND - 1); const angle = t * 2.1 * Math.PI * 2 + s * Math.PI; helix.push({ x: 0.6 * Math.cos(angle), y: (t - 0.5) * 2.1, z: 0.6 * Math.sin(angle), c: 2 + s, r: 3.4 }); if (i > 0) helixEdges.push([s * PER_STRAND + i - 1, s * PER_STRAND + i]); } } for (let i = 0; i < PER_STRAND; i += 2) { const a = helix[i]; const b = helix[PER_STRAND + i]; const mid = helix.length; helix.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, z: (a.z + b.z) / 2, c: 4, r: 2.3 }); helixEdges.push([i, mid], [mid, PER_STRAND + i]); } // Pair points by height so the morph flows within horizontal bands. const byHeight = (pts) => pts.map((_, i) => i).sort((a, b) => (pts[a].y - pts[b].y) || (Math.atan2(pts[a].z, pts[a].x) - Math.atan2(pts[b].z, pts[b].x))); const orderA = byHeight(lattice); const orderB = byHeight(helix); const slotA = []; const slotB = []; orderA.forEach((orig, k) => { slotA[orig] = k; }); orderB.forEach((orig, k) => { slotB[orig] = k; }); const points = orderA.map((orig, k) => ({ a: lattice[orig], b: helix[orderB[k]] })); const edgesA = latticeEdges.map(([p, q]) => [slotA[p], slotA[q]]); const edgesB = helixEdges.map(([p, q]) => [slotB[p], slotB[q]]); const RGB = [[15, 159, 117], [79, 70, 229], [15, 159, 117], [20, 184, 166], [245, 158, 11]]; const projected = points.map(() => ({ sx: 0, sy: 0, z: 0, p: 1 })); const depthOrder = points.map((_, i) => i); const smooth = (x) => x * x * (3 - 2 * x); const morphAt = (ms) => { const s = (ms / 1000) % 12; if (s < 3.6) return 0; if (s < 6) return smooth((s - 3.6) / 2.4); if (s < 9.6) return 1; return 1 - smooth((s - 9.6) / 2.4); }; let w = 0; let h = 0; let dpr = 1; let shown = -1; const resize = () => { const rect = canvas.getBoundingClientRect(); dpr = Math.min(window.devicePixelRatio || 1, 2); w = rect.width; h = rect.height; canvas.width = Math.max(1, Math.round(w * dpr)); canvas.height = Math.max(1, Math.round(h * dpr)); }; const draw = (now) => { if (w < 8 || h < 8) return; const m = reduceMotion ? 0 : morphAt(now); const rot = reduceMotion ? 0.6 : now * 0.00022; const tilt = 0.32; const cosR = Math.cos(rot); const sinR = Math.sin(rot); const cosT = Math.cos(tilt); const sinT = Math.sin(tilt); const size = Math.min(w, h); const scale = size * 0.34; const cx = w / 2; const cy = h * 0.47; const camera = 3.6; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); for (let k = 0; k < points.length; k += 1) { const { a, b } = points[k]; const x = a.x + (b.x - a.x) * m; const y = a.y + (b.y - a.y) * m; const z = a.z + (b.z - a.z) * m; const x1 = x * cosR + z * sinR; const z1 = -x * sinR + z * cosR; const y2 = y * cosT - z1 * sinT; const z2 = y * sinT + z1 * cosT; const persp = camera / (camera - z2); const pr = projected[k]; pr.sx = cx + x1 * scale * persp; pr.sy = cy - y2 * scale * persp; pr.z = z2; pr.p = persp; } const drawEdges = (edges, alpha, rgb) => { if (alpha < 0.02) return; ctx.lineWidth = 1.2; for (const [i, j] of edges) { const p = projected[i]; const q = projected[j]; const depth = Math.min(1, Math.max(0, ((p.z + q.z) / 2 + 1.2) / 2.4)); ctx.strokeStyle = `rgba(${rgb}, ${(alpha * (0.1 + 0.4 * depth)).toFixed(3)})`; ctx.beginPath(); ctx.moveTo(p.sx, p.sy); ctx.lineTo(q.sx, q.sy); ctx.stroke(); } }; drawEdges(edgesA, 1 - m, '79, 70, 229'); drawEdges(edgesB, m, '15, 159, 117'); depthOrder.sort((i, j) => projected[i].z - projected[j].z); const unit = size / 440; for (const k of depthOrder) { const pr = projected[k]; const { a, b } = points[k]; const ca = RGB[a.c]; const cb = RGB[b.c]; const depth = Math.min(1, Math.max(0, (pr.z + 1.2) / 2.4)); const radius = (a.r + (b.r - a.r) * m) * pr.p * unit; ctx.fillStyle = `rgba(${Math.round(ca[0] + (cb[0] - ca[0]) * m)}, ${Math.round(ca[1] + (cb[1] - ca[1]) * m)}, ${Math.round(ca[2] + (cb[2] - ca[2]) * m)}, ${(0.42 + 0.58 * depth).toFixed(3)})`; ctx.beginPath(); ctx.arc(pr.sx, pr.sy, radius, 0, Math.PI * 2); ctx.fill(); } const shape = m >= 0.5 ? 1 : 0; if (shape !== shown) { shown = shape; labels.forEach((label) => label.classList.toggle('is-on', Number(label.dataset.shape) === shape)); } }; let raf = 0; let inView = true; const loop = (now) => { draw(now); raf = requestAnimationFrame(loop); }; const start = () => { if (!raf && inView && !document.hidden && !reduceMotion) raf = requestAnimationFrame(loop); }; const stop = () => { cancelAnimationFrame(raf); raf = 0; }; resize(); draw(performance.now()); if ('ResizeObserver' in window) { new ResizeObserver(() => { resize(); if (!raf) draw(performance.now()); }).observe(canvas); } if (reduceMotion) return; if ('IntersectionObserver' in window) { new IntersectionObserver((entries) => { inView = entries[0].isIntersecting; if (inView) start(); else stop(); }).observe(canvas); } document.addEventListener('visibilitychange', () => { if (document.hidden) stop(); else start(); }); start(); } initContact(); initNav(); initReveal(); initExplorer(); initExamples(); initForm(); initMorph(); })();