AI-for-Science / app.js
zli12321's picture
Deploy IntelligenceLab website
e57ffdb verified
Raw History Blame Contribute Delete
19.1 kB
(() => {
'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();
})();