All checks were successful
CI / update (push) Successful in 3m9s
Break the 1889-line rosary page into focused modules: - rosaryData.js: mystery data, labels, weekday schedule, SVG positions - rosaryScrollSync.js: bidirectional scroll sync (prayers ↔ SVG ↔ images) - RosarySvg.svelte: SVG bead visualization - MysterySelector.svelte: mystery picker grid - MysteryImageColumn.svelte: desktop image column Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
332 lines
10 KiB
JavaScript
332 lines
10 KiB
JavaScript
import { sectionPositions } from './rosaryData.js';
|
|
|
|
/**
|
|
* Sets up bidirectional scroll synchronization between the prayer sections,
|
|
* the SVG rosary visualization, and the mystery image column.
|
|
*
|
|
* @param {object} opts
|
|
* @param {() => HTMLElement} opts.getSvgContainer - getter for the SVG scroll container
|
|
* @param {() => object} opts.getSectionElements - getter for the section-name → DOM element map
|
|
* @param {() => HTMLElement} opts.getMysteryImageContainer - getter for the image column container
|
|
* @param {() => string} opts.getActiveSection - getter for current active section
|
|
* @param {(s: string) => void} opts.setActiveSection - setter for active section
|
|
* @returns {() => void} cleanup function
|
|
*/
|
|
export function setupScrollSync({
|
|
getSvgContainer,
|
|
getSectionElements,
|
|
getMysteryImageContainer,
|
|
getActiveSection,
|
|
setActiveSection,
|
|
}) {
|
|
let scrollLock = null; // 'prayer', 'svg', or 'click'
|
|
let scrollLockTimeout = null;
|
|
|
|
const setScrollLock = (source, duration = 1000) => {
|
|
scrollLock = source;
|
|
clearTimeout(scrollLockTimeout);
|
|
scrollLockTimeout = setTimeout(() => {
|
|
scrollLock = null;
|
|
}, duration);
|
|
};
|
|
|
|
// Check if browser supports smooth scrolling
|
|
const supportsNativeSmoothScroll = (() => {
|
|
if (!('scrollBehavior' in document.documentElement.style)) {
|
|
return false;
|
|
}
|
|
try {
|
|
const testElement = document.createElement('div');
|
|
testElement.scrollTo({ top: 0, behavior: 'smooth' });
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
// Smooth scroll polyfill for window scrolling
|
|
const smoothScrollTo = (targetY, duration = 500) => {
|
|
if (supportsNativeSmoothScroll) {
|
|
try {
|
|
window.scrollTo({ top: targetY, behavior: 'smooth' });
|
|
return;
|
|
} catch (e) {
|
|
// Fall through to polyfill
|
|
}
|
|
}
|
|
|
|
const startY = window.scrollY || window.pageYOffset;
|
|
const distance = targetY - startY;
|
|
const startTime = performance.now();
|
|
|
|
const easeInOutQuad = (t) => {
|
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
};
|
|
|
|
const scroll = (currentTime) => {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const ease = easeInOutQuad(progress);
|
|
window.scrollTo(0, startY + distance * ease);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(scroll);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(scroll);
|
|
};
|
|
|
|
// Smooth scroll polyfill for element scrolling (for SVG container)
|
|
const smoothScrollElement = (element, targetY, duration = 500) => {
|
|
if (supportsNativeSmoothScroll) {
|
|
try {
|
|
element.scrollTo({ top: targetY, behavior: 'smooth' });
|
|
return;
|
|
} catch (e) {
|
|
// Fall through to polyfill
|
|
}
|
|
}
|
|
|
|
const startY = element.scrollTop;
|
|
const distance = targetY - startY;
|
|
const startTime = performance.now();
|
|
|
|
const easeInOutQuad = (t) => {
|
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
};
|
|
|
|
const scroll = (currentTime) => {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const ease = easeInOutQuad(progress);
|
|
element.scrollTop = startY + distance * ease;
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(scroll);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(scroll);
|
|
};
|
|
|
|
// Helper: convert SVG section position to pixel scroll target
|
|
function svgSectionToPixel(svg, section) {
|
|
const svgYPosition = sectionPositions[section];
|
|
if (svgYPosition === undefined) return null;
|
|
const viewBox = svg.viewBox.baseVal;
|
|
const svgHeight = svg.clientHeight;
|
|
const viewBoxHeight = viewBox.height;
|
|
|
|
const computedStyle = window.getComputedStyle(svg);
|
|
const matrix = new DOMMatrix(computedStyle.transform);
|
|
const cssScale = matrix.a || 1;
|
|
|
|
const scale = (svgHeight / viewBoxHeight) * cssScale;
|
|
return svgYPosition * scale;
|
|
}
|
|
|
|
// Set up Intersection Observer for scroll tracking (prayers → SVG)
|
|
const observer = new IntersectionObserver((entries) => {
|
|
const svgContainer = getSvgContainer();
|
|
const sectionElements = getSectionElements();
|
|
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting && scrollLock !== 'svg' && scrollLock !== 'click') {
|
|
// Skip observer updates when at the top — handleWindowScroll handles this
|
|
const scrollY = window.scrollY || window.pageYOffset;
|
|
if (scrollY < 50) return;
|
|
|
|
const section = entry.target.dataset.section;
|
|
setActiveSection(section);
|
|
|
|
// Scroll SVG to keep active section visible at top
|
|
if (svgContainer && sectionPositions[section] !== undefined) {
|
|
const svg = svgContainer.querySelector('svg');
|
|
if (!svg) return;
|
|
|
|
const pixelPosition = svgSectionToPixel(svg, section);
|
|
if (pixelPosition === null) return;
|
|
const targetScroll = pixelPosition - 100;
|
|
|
|
setScrollLock('prayer');
|
|
smoothScrollElement(svgContainer, Math.max(0, targetScroll));
|
|
}
|
|
}
|
|
});
|
|
}, {
|
|
root: null,
|
|
rootMargin: "-20% 0px -60% 0px",
|
|
threshold: 0
|
|
});
|
|
|
|
// Observe all prayer sections
|
|
const sectionElements = getSectionElements();
|
|
Object.values(sectionElements).forEach((el) => {
|
|
if (el) observer.observe(el);
|
|
});
|
|
|
|
// Detect when user scrolls past all prayers and snap SVG to bottom or top
|
|
const handleWindowScroll = () => {
|
|
const svgContainer = getSvgContainer();
|
|
const sectionElements = getSectionElements();
|
|
const mysteryImageContainer = getMysteryImageContainer();
|
|
if (scrollLock === 'svg' || scrollLock === 'click' || !svgContainer) return;
|
|
|
|
const viewportHeight = window.innerHeight;
|
|
const scrollY = window.scrollY || window.pageYOffset;
|
|
const documentHeight = document.documentElement.scrollHeight;
|
|
|
|
const firstSection = sectionElements.cross;
|
|
const finalSection = sectionElements.final_cross;
|
|
if (!firstSection || !finalSection) return;
|
|
|
|
const firstSectionRect = firstSection.getBoundingClientRect();
|
|
const finalSectionRect = finalSection.getBoundingClientRect();
|
|
|
|
if (scrollY < 50) {
|
|
setActiveSection('cross');
|
|
if (svgContainer.scrollTop > 10) {
|
|
setScrollLock('prayer');
|
|
svgContainer.scrollTop = 0;
|
|
}
|
|
if (mysteryImageContainer && mysteryImageContainer.scrollTop > 10) {
|
|
mysteryImageContainer.scrollTop = 0;
|
|
}
|
|
}
|
|
else if (scrollY + viewportHeight >= documentHeight - 50) {
|
|
const maxScroll = svgContainer.scrollHeight - svgContainer.clientHeight;
|
|
if (svgContainer.scrollTop < maxScroll - 10) {
|
|
setScrollLock('prayer');
|
|
smoothScrollElement(svgContainer, maxScroll);
|
|
}
|
|
}
|
|
else if (firstSectionRect.top > viewportHeight * 0.6) {
|
|
if (svgContainer.scrollTop > 10) {
|
|
setScrollLock('prayer');
|
|
smoothScrollElement(svgContainer, 0);
|
|
}
|
|
if (mysteryImageContainer && mysteryImageContainer.scrollTop > 10) {
|
|
smoothScrollElement(mysteryImageContainer, 0);
|
|
}
|
|
}
|
|
else if (finalSectionRect.bottom < viewportHeight * 0.4) {
|
|
const maxScroll = svgContainer.scrollHeight - svgContainer.clientHeight;
|
|
if (svgContainer.scrollTop < maxScroll - 10) {
|
|
setScrollLock('prayer');
|
|
smoothScrollElement(svgContainer, maxScroll);
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('scroll', handleWindowScroll, { passive: true });
|
|
|
|
// Debounce SVG scroll handler to avoid excessive updates
|
|
let svgScrollTimeout = null;
|
|
const handleSvgScroll = () => {
|
|
const svgContainer = getSvgContainer();
|
|
const sectionElements = getSectionElements();
|
|
if (scrollLock === 'prayer' || scrollLock === 'click' || !svgContainer) return;
|
|
|
|
clearTimeout(svgScrollTimeout);
|
|
svgScrollTimeout = setTimeout(() => {
|
|
const svg = svgContainer.querySelector('svg');
|
|
if (!svg) return;
|
|
|
|
const scrollTop = svgContainer.scrollTop;
|
|
const viewBox = svg.viewBox.baseVal;
|
|
const svgHeight = svg.clientHeight;
|
|
const viewBoxHeight = viewBox.height;
|
|
|
|
const computedStyle = window.getComputedStyle(svg);
|
|
const matrix = new DOMMatrix(computedStyle.transform);
|
|
const cssScale = matrix.a || 1;
|
|
|
|
const scale = (svgHeight / viewBoxHeight) * cssScale;
|
|
const svgY = scrollTop / scale;
|
|
|
|
let closestSection = 'cross';
|
|
let closestDistance = Infinity;
|
|
|
|
for (const [section, position] of Object.entries(sectionPositions)) {
|
|
const distance = Math.abs(svgY - position);
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
closestSection = section;
|
|
}
|
|
}
|
|
|
|
if (closestSection !== getActiveSection() && sectionElements[closestSection]) {
|
|
setActiveSection(closestSection);
|
|
setScrollLock('svg');
|
|
|
|
const element = sectionElements[closestSection];
|
|
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
|
const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3;
|
|
|
|
smoothScrollTo(elementTop - offset);
|
|
}
|
|
}, 150);
|
|
};
|
|
|
|
const svgContainer = getSvgContainer();
|
|
if (svgContainer) {
|
|
svgContainer.addEventListener('scroll', handleSvgScroll);
|
|
}
|
|
|
|
// Handle clicks on SVG elements to jump to prayers
|
|
const handleSvgClick = (e) => {
|
|
const svgContainer = getSvgContainer();
|
|
const sectionElements = getSectionElements();
|
|
let target = e.target;
|
|
while (target && target !== svgContainer) {
|
|
const section = target.dataset.section;
|
|
if (section && sectionElements[section]) {
|
|
setActiveSection(section);
|
|
setScrollLock('click', 1500);
|
|
|
|
// Scroll the SVG visualization to the clicked section
|
|
if (sectionPositions[section] !== undefined) {
|
|
const svg = svgContainer.querySelector('svg');
|
|
if (svg) {
|
|
const pixelPosition = svgSectionToPixel(svg, section);
|
|
if (pixelPosition !== null) {
|
|
const targetScroll = pixelPosition - 100;
|
|
smoothScrollElement(svgContainer, Math.max(0, targetScroll));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scroll the prayers to the corresponding section
|
|
const element = sectionElements[section];
|
|
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
|
const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3;
|
|
|
|
smoothScrollTo(elementTop - offset);
|
|
|
|
break;
|
|
}
|
|
target = target.parentElement;
|
|
}
|
|
};
|
|
|
|
const svg = svgContainer?.querySelector('svg');
|
|
if (svg) {
|
|
svg.addEventListener('click', handleSvgClick);
|
|
svg.style.cursor = 'pointer';
|
|
}
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
clearTimeout(scrollLockTimeout);
|
|
clearTimeout(svgScrollTimeout);
|
|
window.removeEventListener('scroll', handleWindowScroll);
|
|
if (svgContainer) {
|
|
svgContainer.removeEventListener('scroll', handleSvgScroll);
|
|
}
|
|
if (svg) {
|
|
svg.removeEventListener('click', handleSvgClick);
|
|
}
|
|
};
|
|
}
|