javascript
exercises
exercises.js⚡javascript
/**
* 19.3 Intersection Observer - Exercises
*
* Practice implementing IntersectionObserver patterns
*/
// ============================================
// EXERCISE 1: Advanced Lazy Loader
// ============================================
/**
* Create an advanced lazy loader that:
* - Loads images, videos, and iframes
* - Shows loading placeholders
* - Handles load errors gracefully
* - Supports priority loading
*
* Requirements:
* - Different rootMargin for high/low priority
* - Retry failed loads
* - Emit events for load states
*/
class AdvancedLazyLoader {
// Your implementation here
}
/*
// SOLUTION:
class AdvancedLazyLoader {
constructor(options = {}) {
this.options = {
highPriorityMargin: options.highPriorityMargin || '200px',
lowPriorityMargin: options.lowPriorityMargin || '50px',
retryAttempts: options.retryAttempts || 3,
retryDelay: options.retryDelay || 1000
};
this.loadingItems = new Map();
this.listeners = new Map();
// High priority observer (larger margin)
this.highPriorityObserver = new IntersectionObserver(
(entries) => this.handleIntersection(entries, 'high'),
{ rootMargin: this.options.highPriorityMargin, threshold: 0 }
);
// Low priority observer
this.lowPriorityObserver = new IntersectionObserver(
(entries) => this.handleIntersection(entries, 'low'),
{ rootMargin: this.options.lowPriorityMargin, threshold: 0 }
);
}
handleIntersection(entries, priority) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadElement(entry.target, priority);
this.getObserver(priority).unobserve(entry.target);
}
});
}
getObserver(priority) {
return priority === 'high' ?
this.highPriorityObserver :
this.lowPriorityObserver;
}
observe(element, priority = 'low') {
const type = this.getElementType(element);
if (!type) return;
element.dataset.lazyType = type;
element.classList.add('lazy', 'lazy-pending');
this.getObserver(priority).observe(element);
this.emit('queued', { element, type, priority });
}
getElementType(element) {
const tagName = element.tagName.toLowerCase();
if (tagName === 'img' && element.dataset.src) return 'image';
if (tagName === 'video' && element.dataset.src) return 'video';
if (tagName === 'iframe' && element.dataset.src) return 'iframe';
if (element.dataset.backgroundImage) return 'background';
return null;
}
async loadElement(element, priority) {
const type = element.dataset.lazyType;
const src = element.dataset.src || element.dataset.backgroundImage;
element.classList.remove('lazy-pending');
element.classList.add('lazy-loading');
this.emit('loading', { element, type, src });
let attempts = 0;
let success = false;
while (attempts < this.options.retryAttempts && !success) {
attempts++;
try {
await this.load(element, type, src);
success = true;
element.classList.remove('lazy-loading');
element.classList.add('lazy-loaded');
this.emit('loaded', { element, type, src, attempts });
} catch (error) {
if (attempts < this.options.retryAttempts) {
await this.delay(this.options.retryDelay * attempts);
} else {
element.classList.remove('lazy-loading');
element.classList.add('lazy-error');
this.emit('error', { element, type, src, error, attempts });
}
}
}
}
load(element, type, src) {
return new Promise((resolve, reject) => {
switch (type) {
case 'image':
const img = new Image();
img.onload = () => {
element.src = src;
if (element.dataset.srcset) {
element.srcset = element.dataset.srcset;
}
resolve();
};
img.onerror = reject;
img.src = src;
break;
case 'video':
element.src = src;
element.onloadeddata = resolve;
element.onerror = reject;
element.load();
break;
case 'iframe':
element.onload = resolve;
element.onerror = reject;
element.src = src;
break;
case 'background':
const bgImg = new Image();
bgImg.onload = () => {
element.style.backgroundImage = `url(${src})`;
resolve();
};
bgImg.onerror = reject;
bgImg.src = src;
break;
default:
reject(new Error(`Unknown type: ${type}`));
}
});
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return this;
}
emit(event, data) {
const listeners = this.listeners.get(event) || [];
listeners.forEach(cb => cb(data));
}
disconnect() {
this.highPriorityObserver.disconnect();
this.lowPriorityObserver.disconnect();
}
}
*/
// ============================================
// EXERCISE 2: Section Navigator
// ============================================
/**
* Create a section navigator that:
* - Tracks which section is currently in view
* - Updates navigation highlighting
* - Supports smooth scrolling to sections
*
* Requirements:
* - Handle multiple sections visible
* - Emit section change events
* - Track scroll direction
*/
class SectionNavigator {
// Your implementation here
}
/*
// SOLUTION:
class SectionNavigator {
constructor(options = {}) {
this.options = {
sectionSelector: options.sectionSelector || 'section[id]',
navSelector: options.navSelector || 'nav a',
activeClass: options.activeClass || 'active',
offset: options.offset || 100,
smoothScroll: options.smoothScroll !== false
};
this.sections = [];
this.currentSection = null;
this.lastScrollTop = 0;
this.scrollDirection = null;
this.listeners = new Map();
this.init();
}
init() {
// Get all sections
this.sections = Array.from(
document.querySelectorAll(this.options.sectionSelector)
).map(section => ({
id: section.id,
element: section
}));
// Create observer
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
rootMargin: `-${this.options.offset}px 0px -50% 0px`,
threshold: 0
}
);
// Observe all sections
this.sections.forEach(({ element }) => {
this.observer.observe(element);
});
// Setup nav click handlers
this.setupNavigation();
// Track scroll direction
this.trackScrollDirection();
}
handleIntersection(entries) {
// Find the most visible section
const visibleSections = [];
entries.forEach(entry => {
const section = this.sections.find(s => s.element === entry.target);
if (section) {
section.isIntersecting = entry.isIntersecting;
section.intersectionRatio = entry.intersectionRatio;
}
});
// Get currently intersecting sections
this.sections.forEach(section => {
if (section.isIntersecting) {
visibleSections.push(section);
}
});
if (visibleSections.length > 0) {
// Choose based on scroll direction
let newSection;
if (this.scrollDirection === 'up') {
newSection = visibleSections[visibleSections.length - 1];
} else {
newSection = visibleSections[0];
}
if (newSection.id !== this.currentSection) {
const previousSection = this.currentSection;
this.currentSection = newSection.id;
this.updateNavigation();
this.emit('sectionChange', {
current: newSection.id,
previous: previousSection,
direction: this.scrollDirection,
visibleSections: visibleSections.map(s => s.id)
});
}
}
}
setupNavigation() {
const navLinks = document.querySelectorAll(this.options.navSelector);
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
const href = link.getAttribute('href');
if (href && href.startsWith('#')) {
e.preventDefault();
this.scrollTo(href.slice(1));
}
});
});
}
trackScrollDirection() {
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollTop = window.scrollY;
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
this.lastScrollTop = scrollTop;
ticking = false;
});
ticking = true;
}
}, { passive: true });
}
updateNavigation() {
const navLinks = document.querySelectorAll(this.options.navSelector);
navLinks.forEach(link => {
const href = link.getAttribute('href');
if (href === `#${this.currentSection}`) {
link.classList.add(this.options.activeClass);
} else {
link.classList.remove(this.options.activeClass);
}
});
}
scrollTo(sectionId) {
const section = this.sections.find(s => s.id === sectionId);
if (!section) return;
const top = section.element.offsetTop - this.options.offset;
if (this.options.smoothScroll) {
window.scrollTo({
top,
behavior: 'smooth'
});
} else {
window.scrollTo(0, top);
}
this.emit('scrollTo', { sectionId });
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return this;
}
emit(event, data) {
const listeners = this.listeners.get(event) || [];
listeners.forEach(cb => cb(data));
}
getCurrentSection() {
return this.currentSection;
}
destroy() {
this.observer.disconnect();
}
}
*/
// ============================================
// EXERCISE 3: Viewport Analytics
// ============================================
/**
* Create a viewport analytics system:
* - Track time each element is in viewport
* - Calculate engagement metrics
* - Generate reports
*
* Requirements:
* - Track percentage viewed over time
* - Support custom engagement thresholds
* - Aggregate data for reporting
*/
class ViewportAnalytics {
// Your implementation here
}
/*
// SOLUTION:
class ViewportAnalytics {
constructor(options = {}) {
this.options = {
visibilityThreshold: options.visibilityThreshold || 0.5,
engagementThreshold: options.engagementThreshold || 5000,
sampleInterval: options.sampleInterval || 100
};
this.elements = new Map();
this.intervals = new Map();
// Create fine-grained threshold array
const thresholds = Array.from({ length: 11 }, (_, i) => i / 10);
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{ threshold: thresholds }
);
}
track(element, id = null) {
const elementId = id || element.id || `element-${this.elements.size}`;
this.elements.set(element, {
id: elementId,
totalViewTime: 0,
maxVisibility: 0,
viewCount: 0,
engagementReached: false,
visibilityHistory: [],
firstSeen: null,
lastSeen: null
});
this.observer.observe(element);
}
handleIntersection(entries) {
const now = Date.now();
entries.forEach(entry => {
const data = this.elements.get(entry.target);
if (!data) return;
const visibility = entry.intersectionRatio;
if (entry.isIntersecting) {
// Record first seen
if (!data.firstSeen) {
data.firstSeen = now;
}
// Start or continue tracking
if (!this.intervals.has(entry.target)) {
data.viewCount++;
this.startTracking(entry.target, data);
}
// Update max visibility
data.maxVisibility = Math.max(data.maxVisibility, visibility);
} else {
// Stop tracking when not visible
this.stopTracking(entry.target, data, now);
}
});
}
startTracking(element, data) {
const startTime = Date.now();
const interval = setInterval(() => {
data.totalViewTime += this.options.sampleInterval;
// Check engagement threshold
if (!data.engagementReached &&
data.totalViewTime >= this.options.engagementThreshold) {
data.engagementReached = true;
console.log(`Engagement reached for ${data.id}`);
}
}, this.options.sampleInterval);
this.intervals.set(element, interval);
}
stopTracking(element, data, timestamp) {
const interval = this.intervals.get(element);
if (interval) {
clearInterval(interval);
this.intervals.delete(element);
data.lastSeen = timestamp;
}
}
getMetrics(element) {
const data = this.elements.get(element);
if (!data) return null;
return {
id: data.id,
totalViewTime: data.totalViewTime,
maxVisibility: Math.round(data.maxVisibility * 100),
viewCount: data.viewCount,
engagementReached: data.engagementReached,
averageViewDuration: data.viewCount > 0 ?
Math.round(data.totalViewTime / data.viewCount) : 0,
firstSeen: data.firstSeen,
lastSeen: data.lastSeen
};
}
getAllMetrics() {
const metrics = [];
this.elements.forEach((data, element) => {
metrics.push(this.getMetrics(element));
});
return metrics;
}
generateReport() {
const metrics = this.getAllMetrics();
const report = {
timestamp: new Date().toISOString(),
totalElements: metrics.length,
summary: {
averageViewTime: 0,
engagementRate: 0,
averageMaxVisibility: 0,
totalViews: 0
},
elements: metrics
};
if (metrics.length > 0) {
report.summary.averageViewTime = Math.round(
metrics.reduce((sum, m) => sum + m.totalViewTime, 0) / metrics.length
);
report.summary.engagementRate = Math.round(
(metrics.filter(m => m.engagementReached).length / metrics.length) * 100
);
report.summary.averageMaxVisibility = Math.round(
metrics.reduce((sum, m) => sum + m.maxVisibility, 0) / metrics.length
);
report.summary.totalViews = metrics.reduce((sum, m) => sum + m.viewCount, 0);
}
return report;
}
reset(element = null) {
if (element) {
const data = this.elements.get(element);
if (data) {
this.stopTracking(element, data, Date.now());
data.totalViewTime = 0;
data.maxVisibility = 0;
data.viewCount = 0;
data.engagementReached = false;
}
} else {
this.elements.forEach((data, el) => {
this.stopTracking(el, data, Date.now());
});
this.elements.clear();
}
}
disconnect() {
this.intervals.forEach(interval => clearInterval(interval));
this.intervals.clear();
this.observer.disconnect();
}
}
*/
// ============================================
// EXERCISE 4: Reveal Animation Manager
// ============================================
/**
* Create a reveal animation manager:
* - Support multiple animation types
* - Stagger animations for lists
* - Handle animation sequences
*
* Requirements:
* - Configure animation per element
* - Support direction-aware animations
* - Clean up after animations complete
*/
class RevealAnimationManager {
// Your implementation here
}
/*
// SOLUTION:
class RevealAnimationManager {
constructor(options = {}) {
this.options = {
rootMargin: options.rootMargin || '0px 0px -10% 0px',
threshold: options.threshold || 0.1,
defaultAnimation: options.defaultAnimation || 'fadeInUp',
defaultDuration: options.defaultDuration || 600,
defaultDelay: options.defaultDelay || 0,
staggerDelay: options.staggerDelay || 100
};
this.animations = {
fadeIn: { opacity: [0, 1] },
fadeInUp: { opacity: [0, 1], transform: ['translateY(30px)', 'translateY(0)'] },
fadeInDown: { opacity: [0, 1], transform: ['translateY(-30px)', 'translateY(0)'] },
fadeInLeft: { opacity: [0, 1], transform: ['translateX(-30px)', 'translateX(0)'] },
fadeInRight: { opacity: [0, 1], transform: ['translateX(30px)', 'translateX(0)'] },
scaleIn: { opacity: [0, 1], transform: ['scale(0.8)', 'scale(1)'] },
slideInUp: { transform: ['translateY(100%)', 'translateY(0)'] },
rotateIn: { opacity: [0, 1], transform: ['rotate(-10deg)', 'rotate(0)'] }
};
this.groups = new Map();
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
rootMargin: this.options.rootMargin,
threshold: this.options.threshold
}
);
}
register(element, options = {}) {
const config = {
animation: options.animation || element.dataset.animation || this.options.defaultAnimation,
duration: parseInt(options.duration || element.dataset.duration) || this.options.defaultDuration,
delay: parseInt(options.delay || element.dataset.delay) || this.options.defaultDelay,
group: options.group || element.dataset.group || null,
once: options.once !== false && element.dataset.once !== 'false'
};
element._revealConfig = config;
// Set initial state
this.setInitialState(element, config);
// Track groups for staggering
if (config.group) {
if (!this.groups.has(config.group)) {
this.groups.set(config.group, []);
}
this.groups.get(config.group).push(element);
}
this.observer.observe(element);
}
setInitialState(element, config) {
const animation = this.animations[config.animation];
if (!animation) return;
element.style.opacity = '0';
element.style.visibility = 'hidden';
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const config = entry.target._revealConfig;
if (config.group) {
this.animateGroup(config.group);
} else {
this.animateElement(entry.target, config);
}
if (config.once) {
this.observer.unobserve(entry.target);
}
}
});
}
animateElement(element, config) {
const animation = this.animations[config.animation];
if (!animation) return;
element.style.visibility = 'visible';
const keyframes = Object.entries(animation).reduce((acc, [prop, values]) => {
if (!acc[0]) {
acc[0] = {};
acc[1] = {};
}
acc[0][prop] = values[0];
acc[1][prop] = values[1];
return acc;
}, []);
const animationInstance = element.animate(keyframes, {
duration: config.duration,
delay: config.delay,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards'
});
animationInstance.onfinish = () => {
// Clean up inline styles
element.style.opacity = '';
element.style.transform = '';
element.style.visibility = '';
element.classList.add('revealed');
};
}
animateGroup(groupId) {
const elements = this.groups.get(groupId);
if (!elements) return;
// Filter to only visible elements that haven't been animated
const toAnimate = elements.filter(el =>
!el.classList.contains('revealed') &&
el.style.visibility !== 'visible'
);
toAnimate.forEach((element, index) => {
const config = { ...element._revealConfig };
config.delay += index * this.options.staggerDelay;
this.animateElement(element, config);
});
}
addAnimation(name, keyframes) {
this.animations[name] = keyframes;
}
reset(element = null) {
if (element) {
element.classList.remove('revealed');
this.setInitialState(element, element._revealConfig);
this.observer.observe(element);
} else {
document.querySelectorAll('.revealed').forEach(el => {
this.reset(el);
});
}
}
disconnect() {
this.observer.disconnect();
this.groups.clear();
}
}
*/
// ============================================
// EXERCISE 5: Smart Prefetcher
// ============================================
/**
* Create a smart content prefetcher:
* - Prefetch links when they're about to enter viewport
* - Prioritize based on link importance
* - Limit concurrent prefetches
*
* Requirements:
* - Use IntersectionObserver for detection
* - Respect browser's network conditions
* - Cancel prefetches when navigating away
*/
class SmartPrefetcher {
// Your implementation here
}
/*
// SOLUTION:
class SmartPrefetcher {
constructor(options = {}) {
this.options = {
rootMargin: options.rootMargin || '200px',
maxConcurrent: options.maxConcurrent || 3,
prefetchTimeout: options.prefetchTimeout || 10000,
ignorePaths: options.ignorePaths || ['/logout', '/api/']
};
this.prefetched = new Set();
this.pending = new Map();
this.queue = [];
this.activeCount = 0;
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
rootMargin: this.options.rootMargin,
threshold: 0
}
);
// Listen for navigation to cancel prefetches
window.addEventListener('beforeunload', () => this.cancelAll());
}
observe(links = null) {
const linkElements = links || document.querySelectorAll('a[href]');
linkElements.forEach(link => {
const href = link.getAttribute('href');
// Only prefetch valid same-origin links
if (this.shouldPrefetch(href)) {
link._prefetchPriority = this.calculatePriority(link);
this.observer.observe(link);
}
});
}
shouldPrefetch(href) {
if (!href) return false;
if (href.startsWith('#')) return false;
if (href.startsWith('javascript:')) return false;
if (this.prefetched.has(href)) return false;
// Check ignored paths
for (const path of this.options.ignorePaths) {
if (href.includes(path)) return false;
}
// Check if same origin
try {
const url = new URL(href, window.location.origin);
return url.origin === window.location.origin;
} catch {
return false;
}
}
calculatePriority(link) {
let priority = 1;
// Higher priority for prominent links
if (link.matches('nav a, header a')) priority += 2;
if (link.matches('.cta, .primary, [data-prefetch="high"]')) priority += 3;
if (link.matches('footer a, aside a')) priority -= 1;
return priority;
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const href = entry.target.getAttribute('href');
const priority = entry.target._prefetchPriority || 1;
this.queuePrefetch(href, priority);
this.observer.unobserve(entry.target);
}
});
}
queuePrefetch(href, priority) {
if (this.prefetched.has(href) || this.pending.has(href)) return;
// Check network conditions
if (!this.canPrefetch()) {
console.log('Skipping prefetch due to network conditions');
return;
}
this.queue.push({ href, priority });
this.queue.sort((a, b) => b.priority - a.priority);
this.processQueue();
}
canPrefetch() {
// Check if save-data is enabled
if ('connection' in navigator) {
const conn = navigator.connection;
if (conn.saveData) return false;
if (conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g') return false;
}
return true;
}
processQueue() {
while (this.queue.length > 0 && this.activeCount < this.options.maxConcurrent) {
const { href } = this.queue.shift();
this.prefetch(href);
}
}
async prefetch(href) {
if (this.prefetched.has(href) || this.pending.has(href)) return;
this.activeCount++;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.options.prefetchTimeout);
this.pending.set(href, { controller, timeoutId });
try {
// Use link prefetch if supported
if (this.supportsPrefetch()) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = href;
document.head.appendChild(link);
await new Promise((resolve, reject) => {
link.onload = resolve;
link.onerror = reject;
setTimeout(resolve, 100); // Fallback
});
} else {
// Fallback to fetch
await fetch(href, {
method: 'GET',
credentials: 'same-origin',
signal: controller.signal,
headers: {
'Purpose': 'prefetch'
}
});
}
this.prefetched.add(href);
console.log('Prefetched:', href);
} catch (error) {
if (error.name !== 'AbortError') {
console.warn('Prefetch failed:', href, error.message);
}
} finally {
clearTimeout(timeoutId);
this.pending.delete(href);
this.activeCount--;
this.processQueue();
}
}
supportsPrefetch() {
const link = document.createElement('link');
return link.relList && link.relList.supports && link.relList.supports('prefetch');
}
cancelAll() {
this.queue = [];
this.pending.forEach(({ controller, timeoutId }) => {
clearTimeout(timeoutId);
controller.abort();
});
this.pending.clear();
}
getStats() {
return {
prefetched: this.prefetched.size,
pending: this.pending.size,
queued: this.queue.length
};
}
disconnect() {
this.cancelAll();
this.observer.disconnect();
}
}
*/
// ============================================
// TEST UTILITIES
// ============================================
console.log('=== IntersectionObserver Exercises ===');
console.log('');
console.log('Exercises:');
console.log('1. AdvancedLazyLoader - Multi-type lazy loading with retry');
console.log('2. SectionNavigator - Scroll-aware navigation');
console.log('3. ViewportAnalytics - Track viewport metrics');
console.log('4. RevealAnimationManager - Scroll-triggered animations');
console.log('5. SmartPrefetcher - Intelligent link prefetching');
console.log('');
console.log('These exercises require a browser environment.');
console.log('Uncomment solutions to see implementations.');
// Export for browser use
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
AdvancedLazyLoader,
SectionNavigator,
ViewportAnalytics,
RevealAnimationManager,
SmartPrefetcher,
};
}