/**
* CODEBASE-TO-COURSE — COMPLETE JS ENGINE
* Copy this file verbatim into the course output directory.
* Never regenerate it. It handles all interactivity generically.
*
* Engines included:
* - Navigation & progress bar
* - Scroll-triggered reveal animations
* - Keyboard navigation
* - Glossary tooltips
* - Quiz (multiple-choice & scenario)
* - Drag-and-drop matching
* - Group chat animation
* - Data flow / message flow animation
* - Architecture diagram
* - "Spot the bug" challenge
* - Layer toggle
*/
(function () {
'use strict';
/* ── HELPERS ──────────────────────────────────────────────── */
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }
/* ── NAVIGATION & PROGRESS BAR ────────────────────────────── */
const progressBar = $('#progress-bar');
const navDots = $$('.nav-dot');
const modules = $$('.module');
function updateProgress() {
if (!progressBar) return;
const scrollTop = window.scrollY;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const pct = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
progressBar.style.width = pct + '%';
progressBar.setAttribute('aria-valuenow', Math.round(pct));
updateNavDots();
}
function updateNavDots() {
const scrollMid = window.scrollY + window.innerHeight / 2;
modules.forEach((mod, i) => {
const dot = navDots[i];
if (!dot) return;
const top = mod.offsetTop;
const bottom = top + mod.offsetHeight;
if (scrollMid >= top && scrollMid < bottom) {
dot.classList.add('active');
dot.classList.remove('visited');
} else if (window.scrollY + window.innerHeight > top) {
dot.classList.remove('active');
dot.classList.add('visited');
} else {
dot.classList.remove('active', 'visited');
}
});
}
window.addEventListener('scroll', () => requestAnimationFrame(updateProgress), { passive: true });
updateProgress();
// Nav dot click → scroll to module
navDots.forEach(dot => {
dot.addEventListener('click', () => {
const target = $('#' + dot.dataset.target);
if (target) target.scrollIntoView({ behavior: 'smooth' });
});
});
/* ── KEYBOARD NAVIGATION ───────────────────────────────────── */
function currentModuleIndex() {
const scrollMid = window.scrollY + window.innerHeight / 2;
for (let i = 0; i < modules.length; i++) {
const top = modules[i].offsetTop;
const bottom = top + modules[i].offsetHeight;
if (scrollMid >= top && scrollMid < bottom) return i;
}
return 0;
}
document.addEventListener('keydown', e => {
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
const next = modules[currentModuleIndex() + 1];
if (next) { next.scrollIntoView({ behavior: 'smooth' }); e.preventDefault(); }
}
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
const prev = modules[currentModuleIndex() - 1];
if (prev) { prev.scrollIntoView({ behavior: 'smooth' }); e.preventDefault(); }
}
});
/* ── SCROLL-TRIGGERED REVEAL ───────────────────────────────── */
const revealObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
revealObserver.unobserve(entry.target);
}
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
$$('.animate-in').forEach(el => revealObserver.observe(el));
// Stagger children
$$('.stagger-children').forEach(parent => {
Array.from(parent.children).forEach((child, i) => {
child.style.setProperty('--stagger-index', i);
});
});
/* ── GLOSSARY TOOLTIPS ─────────────────────────────────────── */
let activeTooltip = null;
function positionTooltip(term, tip) {
const rect = term.getBoundingClientRect();
const tipWidth = Math.min(320, Math.max(200, window.innerWidth * 0.8));
let left = rect.left + rect.width / 2 - tipWidth / 2;
left = Math.max(8, Math.min(left, window.innerWidth - tipWidth - 8));
tip.style.left = left + 'px';
tip.style.width = tipWidth + 'px';
document.body.appendChild(tip);
const tipHeight = tip.offsetHeight;
if (rect.top - tipHeight - 12 < 0) {
tip.style.top = (rect.bottom + 8) + 'px';
tip.classList.add('flip');
} else {
tip.style.top = (rect.top - tipHeight - 8) + 'px';
tip.classList.remove('flip');
}
}
function showTooltip(term, tip) {
if (activeTooltip && activeTooltip !== tip) {
activeTooltip.classList.remove('visible');
activeTooltip.remove();
}
positionTooltip(term, tip);
requestAnimationFrame(() => tip.classList.add('visible'));
activeTooltip = tip;
}
function hideTooltip(tip) {
tip.classList.remove('visible');
setTimeout(() => { if (!tip.classList.contains('visible')) tip.remove(); }, 150);
if (activeTooltip === tip) activeTooltip = null;
}
$$('.term').forEach(term => {
const tip = document.createElement('span');
tip.className = 'term-tooltip';
tip.textContent = term.dataset.definition;
term.addEventListener('mouseenter', () => showTooltip(term, tip));
term.addEventListener('mouseleave', () => hideTooltip(tip));
term.addEventListener('click', e => {
e.stopPropagation();
tip.classList.contains('visible') ? hideTooltip(tip) : showTooltip(term, tip);
});
});
document.addEventListener('click', () => {
if (activeTooltip) { activeTooltip.classList.remove('visible'); activeTooltip.remove(); activeTooltip = null; }
});
/* ── QUIZ ENGINE ───────────────────────────────────────────── */
window.selectOption = function (btn) {
const block = btn.closest('.quiz-question-block');
$$('.quiz-option', block).forEach(o => o.classList.remove('selected'));
btn.classList.add('selected');
};
window.checkQuiz = function (containerId) {
const container = $('#' + containerId);
if (!container) return;
$$('.quiz-question-block', container).forEach(q => {
const selected = $('.quiz-option.selected', q);
const feedback = $('.quiz-feedback', q);
const correct = q.dataset.correct;
const rightExp = q.dataset.explanationRight || '';
const wrongExp = q.dataset.explanationWrong || '';
if (!selected) {
feedback.textContent = 'Pick an answer first!';
feedback.className = 'quiz-feedback show warning';
return;
}
$$('.quiz-option', q).forEach(o => o.disabled = true);
if (selected.dataset.value === correct) {
selected.classList.add('correct');
feedback.innerHTML = 'Exactly! ' + rightExp;
feedback.className = 'quiz-feedback show success';
} else {
selected.classList.add('incorrect');
const correctBtn = $(`.quiz-option[data-value="${correct}"]`, q);
if (correctBtn) correctBtn.classList.add('correct');
feedback.innerHTML = 'Not quite. ' + wrongExp;
feedback.className = 'quiz-feedback show error';
}
});
};
window.resetQuiz = function (containerId) {
const container = $('#' + containerId);
if (!container) return;
$$('.quiz-option', container).forEach(o => {
o.classList.remove('selected', 'correct', 'incorrect');
o.disabled = false;
});
$$('.quiz-feedback', container).forEach(f => { f.className = 'quiz-feedback'; f.textContent = ''; });
};
/* ── DRAG-AND-DROP ENGINE ──────────────────────────────────── */
function initDnD(containerEl) {
if (!containerEl) return;
const chips = $$('.dnd-chip', containerEl);
const zones = $$('.dnd-zone', containerEl);
// Mouse (HTML5 Drag API)
chips.forEach(chip => {
chip.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', chip.dataset.answer);
chip.classList.add('dragging');
});
chip.addEventListener('dragend', () => chip.classList.remove('dragging'));
});
zones.forEach(zone => {
const target = $('.dnd-zone-target', zone);
if (!target) return;
target.addEventListener('dragover', e => { e.preventDefault(); target.classList.add('drag-over'); });
target.addEventListener('dragleave', () => target.classList.remove('drag-over'));
target.addEventListener('drop', e => {
e.preventDefault();
target.classList.remove('drag-over');
const answer = e.dataTransfer.getData('text/plain');
const chip = $(`.dnd-chip[data-answer="${answer}"]`, containerEl);
if (!chip) return;
target.textContent = chip.textContent;
target.dataset.placed = answer;
chip.classList.add('placed');
});
});
// Touch
chips.forEach(chip => {
chip.addEventListener('touchstart', e => {
e.preventDefault();
const touch = e.touches[0];
const ghost = chip.cloneNode(true);
ghost.classList.add('touch-ghost');
ghost.style.cssText = `position:fixed;z-index:9999;pointer-events:none;left:${touch.clientX - 40}px;top:${touch.clientY - 20}px;`;
document.body.appendChild(ghost);
chip._ghost = ghost;
chip._answer = chip.dataset.answer;
}, { passive: false });
chip.addEventListener('touchmove', e => {
e.preventDefault();
const touch = e.touches[0];
if (chip._ghost) {
chip._ghost.style.left = (touch.clientX - 40) + 'px';
chip._ghost.style.top = (touch.clientY - 20) + 'px';
}
zones.forEach(z => { const t = $('.dnd-zone-target', z); if (t) t.classList.remove('drag-over'); });
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const zt = el && el.closest('.dnd-zone-target');
if (zt) zt.classList.add('drag-over');
}, { passive: false });
chip.addEventListener('touchend', e => {
if (chip._ghost) { chip._ghost.remove(); chip._ghost = null; }
const touch = e.changedTouches[0];
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const zt = el && el.closest('.dnd-zone-target');
if (zt) {
zt.textContent = chip.textContent;
zt.dataset.placed = chip._answer;
chip.classList.add('placed');
}
zones.forEach(z => { const t = $('.dnd-zone-target', z); if (t) t.classList.remove('drag-over'); });
});
});
}
window.checkDnD = function (containerId) {
const container = $('#' + containerId);
if (!container) return;
$$('.dnd-zone', container).forEach(zone => {
const target = $('.dnd-zone-target', zone);
if (!target || !target.dataset.placed) return;
if (target.dataset.placed === zone.dataset.correct) {
target.classList.add('correct-placed');
} else {
target.classList.add('incorrect-placed');
}
});
};
window.resetDnD = function (containerId) {
const container = $('#' + containerId);
if (!container) return;
$$('.dnd-zone-target', container).forEach(t => {
t.textContent = 'Drop here';
delete t.dataset.placed;
t.classList.remove('correct-placed', 'incorrect-placed');
});
$$('.dnd-chip', container).forEach(c => c.classList.remove('placed', 'dragging'));
};
// Auto-init all dnd containers
$$('.dnd-container').forEach(el => initDnD(el));
/* ── GROUP CHAT ENGINE ─────────────────────────────────────── */
function initChat(containerEl) {
if (!containerEl) return;
const messages = $$('.chat-message', containerEl);
const typingEl = $('.chat-typing', containerEl);
const typingAvEl = $('#' + containerEl.id + '-typing-avatar') || $('.chat-avatar', typingEl);
const progressEl = $('.chat-progress', containerEl);
let index = 0;
// Build actor map from messages
const actors = {};
messages.forEach(msg => {
const sender = msg.dataset.sender;
const avatar = $('.chat-avatar', msg);
if (avatar && !actors[sender]) {
actors[sender] = { initial: avatar.textContent.trim(), style: avatar.style.background };
}
});
function updateProgress() {
if (progressEl) progressEl.textContent = index + ' / ' + messages.length + ' messages';
}
function showNext() {
if (index >= messages.length) return;
const msg = messages[index];
const sender = msg.dataset.sender;
if (typingEl && actors[sender]) {
if (typingAvEl) {
typingAvEl.textContent = actors[sender].initial;
typingAvEl.style.background = actors[sender].style;
}
typingEl.style.display = 'flex';
}
setTimeout(() => {
if (typingEl) typingEl.style.display = 'none';
msg.style.display = 'flex';
msg.style.animation = 'fadeSlideUp 0.3s var(--ease-out)';
index++;
updateProgress();
}, 800);
}
function showAll() {
const iv = setInterval(() => {
if (index >= messages.length) { clearInterval(iv); return; }
showNext();
}, 1200);
}
function reset() {
index = 0;
messages.forEach(m => { m.style.display = 'none'; m.style.animation = ''; });
if (typingEl) typingEl.style.display = 'none';
updateProgress();
}
// Bind controls
const nextBtn = $('.chat-next-btn', containerEl);
const allBtn = $('.chat-all-btn', containerEl);
const resetBtn = $('.chat-reset-btn', containerEl);
if (nextBtn) nextBtn.addEventListener('click', showNext);
if (allBtn) allBtn.addEventListener('click', showAll);
if (resetBtn) resetBtn.addEventListener('click', reset);
updateProgress();
}
$$('.chat-window').forEach(el => initChat(el));
/* ── FLOW ANIMATION ENGINE ─────────────────────────────────── */
function initFlow(containerEl) {
if (!containerEl) return;
const stepsData = JSON.parse(containerEl.dataset.steps || '[]');
const labelEl = $('.flow-step-label', containerEl);
const progressEl = $('.flow-progress', containerEl);
const packet = $('.flow-packet', containerEl);
let step = 0;
function updateProgress() {
if (progressEl) progressEl.textContent = 'Step ' + step + ' / ' + stepsData.length;
}
function animatePacket(fromId, toId) {
if (!packet) return;
const fromEl = $('#' + fromId);
const toEl = $('#' + toId);
if (!fromEl || !toEl) return;
const fromR = fromEl.getBoundingClientRect();
const toR = toEl.getBoundingClientRect();
const contR = containerEl.getBoundingClientRect();
const fx = fromR.left + fromR.width / 2 - contR.left;
const fy = fromR.top + fromR.height / 2 - contR.top;
const tx = toR.left + toR.width / 2 - contR.left;
const ty = toR.top + toR.height / 2 - contR.top;
packet.style.setProperty('--packet-from-x', fx + 'px');
packet.style.setProperty('--packet-from-y', fy + 'px');
packet.style.setProperty('--packet-to-x', tx + 'px');
packet.style.setProperty('--packet-to-y', ty + 'px');
packet.style.display = 'block';
packet.style.animation = 'none';
packet.offsetHeight; // reflow
packet.style.animation = 'packetMove 0.8s var(--ease-in-out) forwards';
setTimeout(() => { packet.style.display = 'none'; }, 850);
}
function next() {
if (step >= stepsData.length) return;
const s = stepsData[step];
$$('.flow-actor', containerEl).forEach(a => a.classList.remove('active'));
if (s.highlight) {
const hEl = $('#' + s.highlight, containerEl) || $('#flow-' + s.highlight);
if (hEl) hEl.classList.add('active');
}
if (s.packet && s.from && s.to) animatePacket('flow-' + s.from, 'flow-' + s.to);
if (labelEl) labelEl.textContent = s.label || '';
step++;
updateProgress();
}
function reset() {
step = 0;
$$('.flow-actor', containerEl).forEach(a => a.classList.remove('active'));
if (labelEl) labelEl.textContent = 'Click "Next Step" to begin';
if (packet) packet.style.display = 'none';
updateProgress();
}
const nextBtn = $('.flow-next-btn', containerEl);
const resetBtn = $('.flow-reset-btn', containerEl);
if (nextBtn) nextBtn.addEventListener('click', next);
if (resetBtn) resetBtn.addEventListener('click', reset);
updateProgress();
}
$$('.flow-animation').forEach(el => initFlow(el));
/* ── ARCHITECTURE DIAGRAM ──────────────────────────────────── */
$$('.arch-component').forEach(comp => {
comp.addEventListener('click', function () {
const diagram = this.closest('.arch-diagram');
$$('.arch-component', diagram).forEach(c => c.classList.remove('active'));
this.classList.add('active');
const descEl = $('.arch-description', diagram);
if (descEl) descEl.textContent = this.dataset.desc || '';
});
});
/* ── BUG CHALLENGE ─────────────────────────────────────────── */
window.checkBugLine = function (el, isCorrect) {
const challenge = el.closest('.bug-challenge');
const feedback = $('.bug-feedback', challenge);
if (isCorrect) {
el.classList.add('correct');
feedback.innerHTML = 'Found it! ' + (el.dataset.explanation || '');
feedback.className = 'bug-feedback show success';
$$('.bug-line', challenge).forEach(l => l.style.pointerEvents = 'none');
} else {
el.classList.add('incorrect');
feedback.innerHTML = (el.dataset.hint || 'Not this line — keep looking...');
feedback.className = 'bug-feedback show error';
setTimeout(() => {
el.classList.remove('incorrect');
feedback.className = 'bug-feedback';
}, 1800);
}
};
/* ── LAYER TOGGLE ──────────────────────────────────────────── */
window.showLayer = function (layerId, btn) {
const tab = btn || (window.event && window.event.currentTarget);
if (!tab || !tab.closest) return;
const demo = tab.closest('.layer-demo');
if (!demo) return;
$$('.layer', demo).forEach(l => { l.style.display = 'none'; });
$$('.layer-tab', demo).forEach(t => t.classList.remove('active'));
const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, '');
let layer = demo.querySelector('#' + esc);
if (!layer) layer = demo.querySelector('#layer-' + esc);
if (layer) layer.style.display = 'block';
tab.classList.add('active');
};
})();