feat: redesign spectra lab with editorial brand aesthetic

This commit is contained in:
Chen Gu
2026-04-23 11:07:33 +08:00
parent 5a01965e16
commit 969f0738c4
4 changed files with 505 additions and 791 deletions

175
app.js
View File

@@ -1,168 +1,43 @@
const pointerGlow = document.getElementById('pointerGlow');
const themeToggle = document.getElementById('themeToggle');
const themeLabel = document.getElementById('themeLabel');
const sceneCards = document.querySelectorAll('.scene-card');
const counters = document.querySelectorAll('[data-counter]');
const sceneDetailTitle = document.getElementById('sceneDetailTitle');
const sceneDetailCopy = document.getElementById('sceneDetailCopy');
const particleCanvas = document.getElementById('particleCanvas');
const modeToggle = document.getElementById('modeToggle');
const modeLabel = document.getElementById('modeLabel');
const workItems = document.querySelectorAll('.work-item');
const workIndex = document.getElementById('workIndex');
const workTitle = document.getElementById('workTitle');
const workCopy = document.getElementById('workCopy');
const revealBlocks = document.querySelectorAll('.reveal');
const themes = [
{ className: '', label: 'Aurora' },
{ className: 'theme-ember', label: 'Ember' },
{ className: 'theme-tide', label: 'Tide' },
];
let themeIndex = 0;
function setTheme(index) {
document.body.classList.remove('theme-ember', 'theme-tide');
const theme = themes[index];
if (theme.className) {
document.body.classList.add(theme.className);
}
themeLabel.textContent = theme.label;
function applyMode() {
const isLight = document.body.classList.contains('light-mode');
modeToggle.textContent = isLight ? 'Dark Mode' : 'Light Mode';
modeLabel.textContent = isLight ? 'Editorial Light' : 'Editorial Dark';
}
function animateCounter(element) {
const target = Number(element.dataset.counter || 0);
const duration = 1400;
const start = performance.now();
function step(now) {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
element.textContent = Math.round(target * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function mountParticleField() {
if (!particleCanvas) return;
const ctx = particleCanvas.getContext('2d');
if (!ctx) return;
const particles = [];
const particleCount = 48;
let width = 0;
let height = 0;
function resize() {
width = window.innerWidth;
height = window.innerHeight;
particleCanvas.width = width * window.devicePixelRatio;
particleCanvas.height = height * window.devicePixelRatio;
particleCanvas.style.width = `${width}px`;
particleCanvas.style.height = `${height}px`;
ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
}
function seed() {
particles.length = 0;
for (let i = 0; i < particleCount; i += 1) {
particles.push({
x: Math.random() * width,
y: Math.random() * height,
r: Math.random() * 1.8 + 0.8,
vx: (Math.random() - 0.5) * 0.28,
vy: (Math.random() - 0.5) * 0.28,
});
}
}
function draw() {
ctx.clearRect(0, 0, width, height);
particles.forEach((particle, index) => {
particle.x += particle.vx;
particle.y += particle.vy;
if (particle.x < -20) particle.x = width + 20;
if (particle.x > width + 20) particle.x = -20;
if (particle.y < -20) particle.y = height + 20;
if (particle.y > height + 20) particle.y = -20;
ctx.beginPath();
ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.arc(particle.x, particle.y, particle.r, 0, Math.PI * 2);
ctx.fill();
for (let j = index + 1; j < particles.length; j += 1) {
const other = particles[j];
const dx = particle.x - other.x;
const dy = particle.y - other.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 130) {
ctx.beginPath();
ctx.strokeStyle = `rgba(130, 180, 255, ${0.12 * (1 - distance / 130)})`;
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(other.x, other.y);
ctx.stroke();
}
}
});
requestAnimationFrame(draw);
}
resize();
seed();
draw();
window.addEventListener('resize', () => {
resize();
seed();
});
}
window.addEventListener('pointermove', (event) => {
pointerGlow.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
modeToggle?.addEventListener('click', () => {
document.body.classList.toggle('light-mode');
applyMode();
});
sceneCards.forEach((card) => {
card.addEventListener('pointermove', (event) => {
const rect = card.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const rotateX = ((y / rect.height) - 0.5) * -8;
const rotateY = ((x / rect.width) - 0.5) * 10;
card.style.setProperty('--mx', `${(x / rect.width) * 100}%`);
card.style.setProperty('--my', `${(y / rect.height) * 100}%`);
card.style.transform = `translateY(-6px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
workItems.forEach((item) => {
item.addEventListener('click', () => {
workItems.forEach((button) => button.classList.remove('is-active'));
item.classList.add('is-active');
workIndex.textContent = item.dataset.index || '';
workTitle.textContent = item.dataset.title || '';
workCopy.textContent = item.dataset.copy || '';
});
card.addEventListener('pointerleave', () => {
card.style.transform = '';
});
card.addEventListener('click', () => {
sceneCards.forEach((item) => item.classList.remove('is-active'));
card.classList.add('is-active');
sceneDetailTitle.textContent = card.dataset.sceneTitle || '';
sceneDetailCopy.textContent = card.dataset.sceneCopy || '';
});
});
themeToggle.addEventListener('click', () => {
themeIndex = (themeIndex + 1) % themes.length;
setTheme(themeIndex);
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animateCounter(entry.target);
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.4 },
{ threshold: 0.18 },
);
counters.forEach((counter) => observer.observe(counter));
sceneCards[0]?.classList.add('is-active');
setTheme(themeIndex);
mountParticleField();
revealBlocks.forEach((block) => observer.observe(block));
applyMode();