/**
|
* Glossary Navigation Active State Manager
|
* Handles highlighting active terms as they scroll into view
|
* and syncing navigation with scroll position
|
*/
|
class GlossaryNavigator {
|
constructor(glossarySelector = 'dl.glossary', navSelector = 'nav.glossary-index') {
|
this.glossary = document.querySelector(glossarySelector);
|
this.nav = document.querySelector(navSelector);
|
|
if (!this.glossary || !this.nav) return;
|
|
this.terms = this.glossary.querySelectorAll('dt[id]');
|
this.navList = this.nav.querySelector('ul');
|
this.activeClass = 'active';
|
this.currentActive = null;
|
|
this.init();
|
this.setupResizeHandler();
|
}
|
|
init() {
|
// Set up Intersection Observer with screen-size appropriate margins
|
const observerOptions = {
|
root: null, // viewport
|
rootMargin: this.getRootMargin(),
|
threshold: 0
|
};
|
|
this.observer = new IntersectionObserver(
|
(entries) => this.handleIntersection(entries),
|
observerOptions
|
);
|
|
// Observe all terms
|
this.terms.forEach(term => this.observer.observe(term));
|
|
// Also handle manual scroll for edge cases
|
this.handleScroll = this.debounce(() => this.checkActiveTerm(), 100);
|
window.addEventListener('scroll', this.handleScroll, { passive: true });
|
}
|
|
getRootMargin() {
|
// On larger screens: centered (50% from top and bottom)
|
return '-50% 0px -50% 0px';
|
}
|
|
setupResizeHandler() {
|
let resizeTimer;
|
window.addEventListener('resize', () => {
|
clearTimeout(resizeTimer);
|
resizeTimer = setTimeout(() => {
|
// Reinitialize observer with new margins on resize
|
this.reinitialize();
|
}, 250);
|
});
|
}
|
|
reinitialize() {
|
// Disconnect old observer
|
if (this.observer) {
|
this.observer.disconnect();
|
}
|
|
// Create new observer with updated margins
|
this.init();
|
}
|
|
handleIntersection(entries) {
|
// Find the entry that's intersecting
|
const intersecting = entries.find(entry => entry.isIntersecting);
|
|
if (intersecting) {
|
this.setActive(intersecting.target);
|
}
|
}
|
|
checkActiveTerm() {
|
// Fallback method to find which term is in the trigger zone
|
const remInPixels = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const margin = remInPixels * 4;
|
|
let closestTerm = null;
|
let closestDistance = Infinity;
|
|
this.terms.forEach(term => {
|
const rect = term.getBoundingClientRect();
|
|
const isInZone = rect.top + rect.height / 2 >= 0 && rect.top + rect.height / 2 <= window.innerHeight;
|
|
if (isInZone) {
|
// Find closest to the trigger point
|
const triggerPoint = window.innerHeight / 2;
|
|
const distance = Math.abs(rect.top - triggerPoint);
|
|
if (distance < closestDistance) {
|
closestDistance = distance;
|
closestTerm = term;
|
}
|
}
|
});
|
|
if (closestTerm) {
|
this.setActive(closestTerm);
|
}
|
}
|
|
setActive(term) {
|
if (this.currentActive === term) return;
|
|
// Remove active class from previous term
|
if (this.currentActive) {
|
this.currentActive.classList.remove(this.activeClass);
|
}
|
|
// Add active class to current term
|
term.classList.add(this.activeClass);
|
this.currentActive = term;
|
|
// Update navigation
|
this.updateNavigation(term.id);
|
}
|
|
updateNavigation(termId) {
|
// Remove active from all nav links
|
const navLinks = this.nav.querySelectorAll('a');
|
navLinks.forEach(link => link.classList.remove(this.activeClass));
|
|
// Find and activate corresponding nav link
|
const activeLink = this.nav.querySelector(`a[href="#${termId}"]`);
|
|
if (activeLink) {
|
activeLink.classList.add(this.activeClass);
|
|
// Scroll the nav list to center the active link
|
this.centerNavItem(activeLink);
|
}
|
}
|
|
centerNavItem(link) {
|
const listRect = this.navList.getBoundingClientRect();
|
const linkRect = link.getBoundingClientRect();
|
|
// Calculate position to center the link in the nav container
|
const scrollTop = this.navList.scrollTop;
|
const linkOffset = linkRect.top - listRect.top;
|
const centerOffset = (listRect.height / 2) - (linkRect.height / 2);
|
|
this.navList.scrollTo({
|
top: scrollTop + linkOffset - centerOffset,
|
behavior: 'smooth'
|
});
|
}
|
|
debounce(func, wait) {
|
let timeout;
|
return function executedFunction(...args) {
|
const later = () => {
|
clearTimeout(timeout);
|
func(...args);
|
};
|
clearTimeout(timeout);
|
timeout = setTimeout(later, wait);
|
};
|
}
|
|
destroy() {
|
if (this.observer) {
|
this.observer.disconnect();
|
}
|
window.removeEventListener('scroll', this.handleScroll);
|
}
|
}
|
|
|
// Initialize when DOM is ready
|
if (document.readyState === 'loading') {
|
document.addEventListener('DOMContentLoaded', () => {
|
new GlossaryNavigator();
|
});
|
} else {
|
new GlossaryNavigator();
|
}
|