diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte index 7012368..a77a971 100644 --- a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte @@ -19,168 +19,13 @@ import BibleModal from "$lib/components/faith/BibleModal.svelte"; import Toggle from "$lib/components/Toggle.svelte"; import LanguageToggle from "$lib/components/faith/LanguageToggle.svelte"; import StreakCounter from "$lib/components/faith/StreakCounter.svelte"; -import MysteryIcon from "$lib/components/faith/MysteryIcon.svelte"; +import RosarySvg from "./RosarySvg.svelte"; +import MysterySelector from "./MysterySelector.svelte"; +import MysteryImageColumn from "./MysteryImageColumn.svelte"; +import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, allMysteryImages, getLabels, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js"; +import { setupScrollSync } from "./rosaryScrollSync.js"; let { data } = $props(); -// Mystery variations for each type of rosary -const mysteries = { - freudenreich: [ - "Jesus, den du, o Jungfrau, vom Heiligen Geist empfangen hast.", - "Jesus, den du, o Jungfrau, zu Elisabeth getragen hast.", - "Jesus, den du, o Jungfrau, in Betlehem geboren hast.", - "Jesus, den du, o Jungfrau, im Tempel geopfert hast.", - "Jesus, den du, o Jungfrau, im Tempel wiedergefunden hast." - ], - schmerzhaften: [ - "Jesus, der für uns Blut geschwitzt hat.", - "Jesus, der für uns gegeisselt worden ist.", - "Jesus, der für uns mit Dornen gekrönt worden ist.", - "Jesus, der für uns das schwere Kreuz getragen hat.", - "Jesus, der für uns gekreuzigt worden ist." - ], - glorreichen: [ - "Jesus, der von den Toten auferstanden ist.", - "Jesus, der in den Himmel aufgefahren ist.", - "Jesus, der uns den Heiligen Geist gesandt hat.", - "Jesus, der dich, o Jungfrau, in den Himmel aufgenommen hat.", - "Jesus, der dich, o Jungfrau, im Himmel gekrönt hat." - ], - lichtreichen: [ - "Jesus, der von Johannes getauft worden ist.", - "Jesus, der sich bei der Hochzeit in Kana geoffenbart hat.", - "Jesus, der uns das Reich Gottes verkündet hat.", - "Jesus, der auf dem Berg verklärt worden ist.", - "Jesus, der uns die Eucharistie geschenkt hat." - ] -}; - -const mysteriesLatin = { - freudenreich: [ - "Jesus, quem, virgo, concepísti.", - "Jesus, quem visitándo Elísabeth portásti.", - "Jesus, quem, virgo, genuísti.", - "Jesus, quem in templo præsentásti.", - "Jesus, quem in templo invenisti." - ], - schmerzhaften: [ - "Jesus, qui pro nobis sánguinem sudavit.", - "Jesus, qui pro nobis flagellátus est.", - "Jesus, qui pro nobis spinis coronátus est.", - "Jesus, qui pro nobis crucem baiulávit.", - "Jesus, qui pro nobis crucifixus est." - ], - glorreichen: [ - "Jesus, qui resurréxit a mórtuis.", - "Jesus, qui ascendit in cælum.", - "Jesus, qui misit Spíritum Sanctum.", - "Jesus, qui te, virgo, in cælum assúmpsit.", - "Jesus, qui te, virgo, in cælo coronávit." - ], - lichtreichen: [ - "Jesus, qui a Ioánne baptizátus est.", - "Jesus, qui se in Cana revelávit.", - "Jesus, qui regnum Dei prædicávit.", - "Jesus, qui in monte transfigurátus est.", - "Jesus, Sacraméntum Altáris instítuit." - ] -}; - -// English mysteries (TODO: translate) -const mysteriesEnglish = { - freudenreich: [ - "Jesus, whom thou, O Virgin, didst conceive of the Holy Spirit.", - "Jesus, whom thou, O Virgin, didst carry to Elizabeth.", - "Jesus, whom thou, O Virgin, didst bring forth in Bethlehem.", - "Jesus, whom thou, O Virgin, didst present in the Temple.", - "Jesus, whom thou, O Virgin, didst find in the Temple." - ], - schmerzhaften: [ - "Jesus, who sweat blood for us.", - "Jesus, who was scourged for us.", - "Jesus, who was crowned with thorns for us.", - "Jesus, who carried the heavy cross for us.", - "Jesus, who was crucified for us." - ], - glorreichen: [ - "Jesus, who rose from the dead.", - "Jesus, who ascended into heaven.", - "Jesus, who sent us the Holy Spirit.", - "Jesus, who took thee, O Virgin, into heaven.", - "Jesus, who crowned thee, O Virgin, in heaven." - ], - lichtreichen: [ - "Jesus, who was baptized by John.", - "Jesus, who revealed Himself at the wedding in Cana.", - "Jesus, who proclaimed the Kingdom of God.", - "Jesus, who was transfigured on the mountain.", - "Jesus, who gave us the Eucharist." - ] -}; - -// Short titles for mysteries (for display in headings) -const mysteryTitles = { - freudenreich: [ - "Verkündigung", - "Heimsuchung", - "Geburt", - "Darstellung", - "Wiederfindung" - ], - schmerzhaften: [ - "Todesangst", - "Geisselung", - "Dornenkrönung", - "Kreuzweg", - "Kreuzigung" - ], - glorreichen: [ - "Auferstehung", - "Himmelfahrt", - "Geistsendung", - "Aufnahme Mariens", - "Krönung Mariens" - ], - lichtreichen: [ - "Taufe", - "Hochzeit zu Kana", - "Verkündigung des Reiches", - "Verklärung", - "Einsetzung der Eucharistie" - ] -}; - -// English short titles for mysteries (TODO: translate) -const mysteryTitlesEnglish = { - freudenreich: [ - "Annunciation", - "Visitation", - "Nativity", - "Presentation", - "Finding in the Temple" - ], - schmerzhaften: [ - "Agony in the Garden", - "Scourging", - "Crowning with Thorns", - "Carrying of the Cross", - "Crucifixion" - ], - glorreichen: [ - "Resurrection", - "Ascension", - "Descent of the Holy Spirit", - "Assumption of Mary", - "Coronation of Mary" - ], - lichtreichen: [ - "Baptism", - "Wedding at Cana", - "Proclamation of the Kingdom", - "Transfiguration", - "Institution of the Eucharist" - ] -}; - // Toggle for including Luminous mysteries (initialized from URL param or default) let includeLuminous = $state(data.initialLuminous); @@ -200,40 +45,7 @@ $effect(() => { // UI labels based on URL language (reactive) const isEnglish = $derived(data.lang === 'en'); -const labels = $derived({ - pageTitle: isEnglish ? 'Interactive Rosary' : 'Interaktiver Rosenkranz', - pageDescription: isEnglish - ? 'Interactive digital version of the Rosary for praying along. Scroll through the prayers and follow the visualization.' - : 'Interaktive digitale Version des Rosenkranzes zum Mitbeten. Scrolle durch die Gebete und folge der Visualisierung.', - mysteries: isEnglish ? 'Mysteries' : 'Geheimnisse', - today: isEnglish ? 'Today' : 'Heutige', - joyful: isEnglish ? 'Joyful' : 'Freudenreiche', - sorrowful: isEnglish ? 'Sorrowful' : 'Schmerzhaften', - glorious: isEnglish ? 'Glorious' : 'Glorreichen', - luminous: isEnglish ? 'Luminous' : 'Lichtreichen', - includeLuminous: isEnglish ? 'Include Luminous Mysteries' : 'Lichtreiche Geheimnisse einbeziehen', - showImages: isEnglish ? 'Show Images' : 'Bilder anzeigen', - beginning: isEnglish ? 'Beginning' : 'Anfang', - signOfCross: isEnglish ? '♱ Sign of the Cross' : '♱ Das Kreuzzeichen', - ourFather: isEnglish ? 'Our Father' : 'Vater unser', - hailMary: isEnglish ? 'Hail Mary' : 'Ave Maria', - faith: isEnglish ? 'Faith' : 'Glaube', - hope: isEnglish ? 'Hope' : 'Hoffnung', - love: isEnglish ? 'Love' : 'Liebe', - decade: isEnglish ? 'Decade' : 'Gesätz', - optional: isEnglish ? 'optional' : 'optional', - gloriaPatri: 'Gloria Patri', - fatimaPrayer: isEnglish ? 'Fatima Prayer' : 'Das Fatima Gebet', - conclusion: isEnglish ? 'Conclusion' : 'Abschluss', - finalPrayer: isEnglish ? 'Final Prayer' : 'Schlussgebet', - saintMichael: isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael', - footnoteSign: isEnglish ? 'Make the Sign of the Cross here' : 'Hier das Kreuzzeichen machen', - footnoteBow: isEnglish ? 'Bow the head here' : 'Hier den Kopf senken', - showBibleVerse: isEnglish ? 'Show Bible verse' : 'Bibelstelle anzeigen', - mysteryFaith: isEnglish ? 'Jesus, who may increase our faith' : 'Jesus, der in uns den Glauben vermehre', - mysteryHope: isEnglish ? 'Jesus, who may strengthen our hope' : 'Jesus, der in uns die Hoffnung stärke', - mysteryLove: isEnglish ? 'Jesus, who may kindle our love' : 'Jesus, der in uns die Liebe entzünde' -}); +const labels = $derived(getLabels(isEnglish)); // Save toggle states to localStorage whenever they change (but only after initial load) $effect(() => { @@ -247,37 +59,6 @@ $effect(() => { } }); -// Function to get the appropriate mystery for a given weekday -function getMysteryForWeekday(date, includeLuminous) { - const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc. - - if (includeLuminous) { - // With Luminous mysteries schedule - const schedule = { - 0: 'glorreichen', // Sunday - 1: 'freudenreich', // Monday - 2: 'schmerzhaften', // Tuesday - 3: 'glorreichen', // Wednesday - 4: 'lichtreichen', // Thursday - 5: 'schmerzhaften', // Friday - 6: 'freudenreich' // Saturday - }; - return schedule[dayOfWeek]; - } else { - // Without Luminous mysteries schedule - const schedule = { - 0: 'glorreichen', // Sunday - 1: 'freudenreich', // Monday - 2: 'schmerzhaften', // Tuesday - 3: 'glorreichen', // Wednesday - 4: 'freudenreich', // Thursday - 5: 'schmerzhaften', // Friday - 6: 'glorreichen' // Saturday - }; - return schedule[dayOfWeek]; - } -} - // Use server-computed initial values (supports no-JS via URL params) let selectedMystery = $state(data.initialMystery); let todaysMystery = $state(data.todaysMystery); @@ -324,38 +105,6 @@ let activeSection = $state("cross"); let sectionElements = {}; let svgContainer; -// Mystery images with captions (per mystery type, keyed by decade number 1-5) -const allMysteryImages = { - freudenreich: new Map([ - [1, { src: "/glaube/joyful/1-murilllo-annunciation.webp", artist: "Bartolomé Esteban Murillo", title: "The Annunciation", titleDe: "Die Verkündigung" }], - [2, { src: "/glaube/joyful/2-carl-bloch.the-visitation.1866.webp", artist: "Carl Bloch", title: "The Visitation", titleDe: "Die Heimsuchung", year: 1866 }], - [3, { src: "/glaube/joyful/3-adoration-of-the-shepards.webp", title: "Adoration of the Shepherds", titleDe: "Die Anbetung der Hirten" }], - [4, { src: "/glaube/joyful/4-vouet.presentation-in-the-temple.webp", artist: "Simon Vouet", title: "The Presentation in the Temple", titleDe: "Die Darstellung im Tempel" }], - [5, { src: "/glaube/joyful/5-carl-bloch.the-twelve-year-old-jesus-in-the-temple.1869.webp", artist: "Carl Bloch", title: "The Twelve-Year-Old Jesus in the Temple", titleDe: "Der zwölfjährige Jesus im Tempel", year: 1869 }], - ]), - schmerzhaften: new Map([ - [1, { src: "/glaube/sorrowful/1.carl-bloch.gethsemane.webp", artist: "Carl Bloch", title: "Gethsemane", titleDe: "Gethsemane", year: 1873 }], - [2, { src: "/glaube/sorrowful/2.wiliam-bouguereau.flagellation.webp", artist: "William-Adolphe Bouguereau", title: "The Flagellation of Our Lord Jesus Christ", titleDe: "Die Geisselung unseres Herrn Jesus Christus", year: 1880 }], - [3, { src: "/glaube/sorrowful/3.carl-bloch.mocking.webp", artist: "Carl Bloch", title: "The Mocking of Christ", titleDe: "Die Verspottung Christi", year: 1880 }], - [4, { src: "/glaube/sorrowful/4.lorenzo-lotto.carrying-the-cross.webp", artist: "Lorenzo Lotto", title: "Carrying the Cross", titleDe: "Die Kreuztragung", year: 1526 }], - [5, { src: "/glaube/sorrowful/5.alonso-cano.the-crucifixion.webp", artist: "Diego Velázquez", title: "Christ Crucified", titleDe: "Der gekreuzigte Christus", year: 1632 }], - ]), - glorreichen: new Map([ - [1, { src: "/glaube/glorious/1-carl-bloch.resurrection.webp", artist: "Carl Bloch", title: "The Resurrection", titleDe: "Die Auferstehung" }], - [2, { src: "/glaube/glorious/2-ascension.webp", title: "The Ascension", titleDe: "Die Himmelfahrt" }], - [3, { src: "/glaube/glorious/3-pentecost.webp", title: "Pentecost", titleDe: "Die Geistsendung" }], - [4, { src: "/glaube/glorious/4-giovanni-tiepolo.the-immaculate-conception.webp", artist: "Giovanni Battista Tiepolo", title: "The Immaculate Conception", titleDe: "Die Aufnahme Mariens in den Himmel" }], - [5, { src: "/glaube/glorious/5-diego-veazquez.coronation-mary.webp", artist: "Diego Velázquez", title: "Coronation of the Virgin", titleDe: "Die Krönung der Jungfrau", year: 1641 }], - ]), - lichtreichen: new Map([ - [1, { src: "/glaube/luminous/1-carl-bloch.the-baptism-of-christ.1870.webp", artist: "Carl Bloch", title: "The Baptism of Christ", titleDe: "Die Taufe Christi", year: 1870 }], - [2, { src: "/glaube/luminous/2-carl-bloch.the-wedding-at-cana.1870.webp", artist: "Carl Bloch", title: "The Wedding at Cana", titleDe: "Die Hochzeit zu Kana", year: 1870 }], - [3, { src: "/glaube/luminous/3-carl-bloch.the-sermon-on-the-mount.1877.jpg", artist: "Carl Bloch", title: "The Sermon on the Mount", titleDe: "Die Bergpredigt", year: 1877 }], - [4, { src: "/glaube/luminous/4-carl-bloch.transfiguration-of-christ.webp", artist: "Carl Bloch", title: "Transfiguration of Christ", titleDe: "Die Verklärung Christi" }], - [5, { src: "/glaube/luminous/5-carl-bloch.the-last-supper.webp", artist: "Carl Bloch", title: "The Last Supper", titleDe: "Das letzte Abendmahl" }], - ]), -}; - // Whether the rosary has mystery images (stable, doesn't change during scroll) const hasMysteryImages = $derived(showImages && (allMysteryImages[selectedMystery]?.size ?? 0) > 0); @@ -498,34 +247,6 @@ function handleCitationClick(reference, title = '', verseData = null) { showModal = true; } -// Map sections to their vertical positions in the SVG -const BEAD_SPACING = 22; -const DECADE_OFFSET = 10; -const sectionPositions = { - cross: 35, - lbead1: 75, - start1: 110, - start2: 135, - start3: 160, - lbead2: 195, - secret1: 270, - secret2: 560, - secret3: 840, - secret4: 1120, - secret5: 1400, - final_transition: 1685, - final_salve: 1720, - final_schlussgebet: 1745, - final_michael: 1770, - final_paternoster: 1805, - final_cross: 1900 -}; -// Center transition beads between last bead of decade d and first bead of decade d+1 -for (let d = 1; d < 5; d++) { - const lastBead = sectionPositions[`secret${d}`] + DECADE_OFFSET + 9 * BEAD_SPACING; - const nextFirst = sectionPositions[`secret${d + 1}`] + DECADE_OFFSET; - sectionPositions[`secret${d}_transition`] = Math.round((lastBead + nextFirst) / 2); -} const pos = sectionPositions; onMount(() => { @@ -566,337 +287,18 @@ onMount(() => { }; window.addEventListener('resize', onPipResize); - let scrollLock = null; // Track which side initiated the scroll: '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 - // Test both CSS property and actual scrollTo API support - const supportsNativeSmoothScroll = (() => { - if (!('scrollBehavior' in document.documentElement.style)) { - return false; - } - // Additional check: some browsers have the CSS property but not the JS API - 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); - }; - - // Set up Intersection Observer for scroll tracking (prayers -> SVG) - const options = { - root: null, - rootMargin: "-20% 0px -60% 0px", // Trigger when section is near top - threshold: 0 - }; - - const observer = new IntersectionObserver((entries) => { - 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; - activeSection = section; - - // Scroll SVG to keep active section visible at top - if (svgContainer && sectionPositions[activeSection] !== undefined) { - const svg = svgContainer.querySelector('svg'); - if (!svg) return; - - const svgYPosition = sectionPositions[activeSection]; - const viewBox = svg.viewBox.baseVal; - const svgHeight = svg.clientHeight; - const viewBoxHeight = viewBox.height; - - // Get CSS transform scale (3.5 on mobile, 1 on desktop) - const computedStyle = window.getComputedStyle(svg); - const matrix = new DOMMatrix(computedStyle.transform); - const cssScale = matrix.a || 1; - - // Convert SVG coordinates to pixel coordinates - const scale = (svgHeight / viewBoxHeight) * cssScale; - const pixelPosition = svgYPosition * scale; - - // Position with some padding to show context above - const targetScroll = pixelPosition - 100; - - setScrollLock('prayer'); - smoothScrollElement(svgContainer, Math.max(0, targetScroll)); - } - } - }); - }, options); - - // Observe all prayer sections - Object.values(sectionElements).forEach((el) => { - if (el) observer.observe(el); + // Bidirectional scroll sync between prayers, SVG, and image column + const cleanupScrollSync = setupScrollSync({ + getSvgContainer: () => svgContainer, + getSectionElements: () => sectionElements, + getMysteryImageContainer: () => mysteryImageContainer, + getActiveSection: () => activeSection, + setActiveSection: (s) => { activeSection = s; }, }); - // Detect when user scrolls past all prayers and snap SVG to bottom or top - const handleWindowScroll = () => { - if (scrollLock === 'svg' || scrollLock === 'click' || !svgContainer) return; - - const viewportHeight = window.innerHeight; - const scrollY = window.scrollY || window.pageYOffset; - const documentHeight = document.documentElement.scrollHeight; - - // Get the first and final prayer sections - const firstSection = sectionElements.cross; - const finalSection = sectionElements.final_cross; - if (!firstSection || !finalSection) return; - - const firstSectionRect = firstSection.getBoundingClientRect(); - const finalSectionRect = finalSection.getBoundingClientRect(); - - // Check if we're at the absolute top of the page - if (scrollY < 50) { - activeSection = 'cross'; - // Snap SVG and images to top instantly - if (svgContainer.scrollTop > 10) { - setScrollLock('prayer'); - svgContainer.scrollTop = 0; - } - if (mysteryImageContainer && mysteryImageContainer.scrollTop > 10) { - mysteryImageContainer.scrollTop = 0; - } - } - // Check if we're at the absolute bottom of the page - else if (scrollY + viewportHeight >= documentHeight - 50) { - // Scroll SVG to bottom - const maxScroll = svgContainer.scrollHeight - svgContainer.clientHeight; - if (svgContainer.scrollTop < maxScroll - 10) { // Only if not already at bottom - setScrollLock('prayer'); - smoothScrollElement(svgContainer, maxScroll); - } - } - // Check if we've scrolled above the first section (it's completely below viewport) - else if (firstSectionRect.top > viewportHeight * 0.6) { - // Scroll SVG to top - if (svgContainer.scrollTop > 10) { // Only if not already at top - setScrollLock('prayer'); - smoothScrollElement(svgContainer, 0); - } - if (mysteryImageContainer && mysteryImageContainer.scrollTop > 10) { - smoothScrollElement(mysteryImageContainer, 0); - } - } - // Check if we've scrolled past the final section (it's completely above viewport) - else if (finalSectionRect.bottom < viewportHeight * 0.4) { - // Scroll SVG to bottom - const maxScroll = svgContainer.scrollHeight - svgContainer.clientHeight; - if (svgContainer.scrollTop < maxScroll - 10) { // Only if not already at bottom - setScrollLock('prayer'); - smoothScrollElement(svgContainer, maxScroll); - } - } - }; - - window.addEventListener('scroll', handleWindowScroll, { passive: true }); - - // Debounce SVG scroll handler to avoid excessive updates - let svgScrollTimeout = null; - const handleSvgScroll = () => { - 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; - - // Get CSS transform scale (3.5 on mobile, 1 on desktop) - const computedStyle = window.getComputedStyle(svg); - const matrix = new DOMMatrix(computedStyle.transform); - const cssScale = matrix.a || 1; - - const scale = (svgHeight / viewBoxHeight) * cssScale; - - // Convert scroll position back to SVG coordinates - const svgY = scrollTop / scale; - - // Find the closest section based on scroll position - 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; - } - } - - // Scroll to the corresponding prayer section - if (closestSection !== activeSection && sectionElements[closestSection]) { - activeSection = closestSection; - setScrollLock('svg'); - - // Calculate scroll position with offset for spacing at top - const element = sectionElements[closestSection]; - const elementTop = element.getBoundingClientRect().top + window.scrollY; - const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3; // 3em in pixels - - smoothScrollTo(elementTop - offset); - } - }, 150); // Debounce by 150ms - }; - - if (svgContainer) { - svgContainer.addEventListener('scroll', handleSvgScroll); - } - - // Handle clicks on SVG elements to jump to prayers - const handleSvgClick = (e) => { - // Find the clicked element or its parent with a data-section attribute - let target = e.target; - while (target && target !== svgContainer) { - const section = target.dataset.section; - if (section && sectionElements[section]) { - // Update active section immediately - activeSection = section; - - // Lock scrolling for clicks - setScrollLock('click', 1500); - - // Scroll the SVG visualization to the clicked section - if (sectionPositions[section] !== undefined) { - const svg = svgContainer.querySelector('svg'); - if (svg) { - const svgYPosition = sectionPositions[section]; - const viewBox = svg.viewBox.baseVal; - const svgHeight = svg.clientHeight; - const viewBoxHeight = viewBox.height; - - // Get CSS transform scale (3.5 on mobile, 1 on desktop) - const computedStyle = window.getComputedStyle(svg); - const matrix = new DOMMatrix(computedStyle.transform); - const cssScale = matrix.a || 1; - - // Convert SVG coordinates to pixel coordinates - const scale = (svgHeight / viewBoxHeight) * cssScale; - const pixelPosition = svgYPosition * scale; - - // Position with some padding to show context above - 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); - // Make it clear elements are clickable - svg.style.cursor = 'pointer'; - } - return () => { - observer.disconnect(); - clearTimeout(scrollLockTimeout); - clearTimeout(svgScrollTimeout); - window.removeEventListener('scroll', handleWindowScroll); + cleanupScrollSync(); window.removeEventListener('resize', onPipResize); - if (svgContainer) { - svgContainer.removeEventListener('scroll', handleSvgScroll); - } - if (svg) { - svg.removeEventListener('click', handleSvgClick); - } }; }); @@ -952,7 +354,7 @@ onMount(() => { } /* Make SVG beads larger on mobile by scaling up and center it */ - .rosary-visualization svg { + .rosary-visualization :global(svg) { transform: scale(3.5); transform-origin: center top; } @@ -990,13 +392,6 @@ onMount(() => { display: none; } -.linear-rosary { - width: 100%; - height: auto; - display: block; - -webkit-tap-highlight-color: transparent; -} - /* Main content area with prayers */ .prayers-content { scroll-snap-type: y proximity; @@ -1068,55 +463,6 @@ onMount(() => { font-size: 0.95rem; } -/* Linear rosary bead styles */ -.rosary-visualization :global(.bead) { - fill: var(--nord10); - transition: all 0.3s ease; -} - -.rosary-visualization :global(.large-bead) { - fill: var(--nord12); - transition: all 0.3s ease; -} - -.rosary-visualization :global(.chain) { - stroke: var(--nord4); - stroke-width: 2; - fill: none; - opacity: 0.5; -} - -.rosary-visualization :global(.cross-symbol) { - fill: var(--nord4); - transition: all 0.3s ease; -} - -@media (prefers-color-scheme: light) { - .rosary-visualization :global(.cross-symbol) { - fill: var(--nord3); - } -} - -.rosary-visualization :global(.hitboxes) { - fill: transparent; -} - -/* Active states */ -.rosary-visualization :global(.active-bead) { - fill: var(--nord11) !important; - filter: drop-shadow(0 0 8px var(--nord11)); -} - -.rosary-visualization :global(.active-large-bead) { - fill: var(--nord13) !important; - filter: drop-shadow(0 0 10px var(--nord13)); -} - -.rosary-visualization :global(.cross-symbol.active-cross) { - fill: var(--nord11) !important; - filter: drop-shadow(0 0 10px var(--nord11)); -} - h1 { text-align: center; font-size: 3em; @@ -1148,171 +494,6 @@ h1 { gap: 0.5rem; } -/* Mystery selector grid */ -.mystery-selector { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1.5rem; - margin-bottom: 3rem; - max-width: 750px; - margin-left: auto; - margin-right: auto; -} - -.mystery-selector.four-mysteries { - grid-template-columns: repeat(2, 1fr); - max-width: 500px; -} - -@media (min-width: 800px) { - .mystery-selector.four-mysteries { - grid-template-columns: repeat(4, 1fr); - max-width: 900px; - } -} - -@media (max-width: 560px) { - .mystery-selector, - .mystery-selector.four-mysteries { - gap: 0.75rem; - margin-bottom: 1.5rem; - margin-inline: 0; - max-width: none; - } - .mystery-selector :global(svg) { - width: 48px; - height: 48px; - } - .mystery-button { - padding: 1rem 0.75rem; - gap: 0.5rem; - } - .mystery-button h3 { - font-size: 0.95rem; - } - .today-badge { - font-size: 0.7rem; - padding: 0.25rem 0.5rem; - top: 0.5rem; - right: 0.5rem; - } -} - -@media (max-width: 410px) { - .mystery-selector, - .mystery-selector.four-mysteries { - gap: 0.375rem; - margin-bottom: 0.75rem; - margin-inline: 0; - max-width: none; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .mystery-selector:not(.four-mysteries) { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .mystery-button { - padding: 0.25rem 0.15rem; - gap: 0.15rem; - border-radius: 6px; - } - .mystery-button h3 { - font-size: 0.55rem; - } - .today-badge { - font-size: 0.6rem; - padding: 0.15rem 0.35rem; - top: 0.25rem; - right: 0.25rem; - } -} - -@media (max-width: 350px) { - .mystery-selector, - .mystery-selector.four-mysteries { - grid-template-columns: 1fr; - } -} - -.mystery-button { - background: var(--nord1); - border: 2px solid transparent; - border-radius: 8px; - padding: 2rem 1.5rem; - cursor: pointer; - transition: all 0.3s ease; - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - position: relative; - text-decoration: none; - color: inherit; -} - -@media(prefers-color-scheme: light) { - .mystery-button { - background: var(--nord6); - } - .rosary-visualization :global(.chain) { - stroke: var(--nord3); - } -} - -.mystery-button:hover { - transform: translateY(-4px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); -} - -.mystery-button.selected { - border-color: var(--nord10); - transform: translateY(-4px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); -} - -.mystery-button:hover, -.mystery-button.selected { background: var(--nord4); } - - -.mystery-button h3 { - margin: 0; - font-size: 1.2rem; - color: var(--nord6); -} - -@media(prefers-color-scheme: light) { - .mystery-button h3 { - color: var(--nord0); - } -} - -.mystery-button.selected h3, -.mystery-button:hover h3 -{ - color: var(--nord10); - font-weight: 700; -} - -/* Today's mystery badge */ -.today-badge { - position: absolute; - top: 1rem; - right: 1rem; - background: var(--nord11); - color: white; - padding: 0.4rem 0.8rem; - border-radius: 4px; - font-size: 0.85rem; - font-weight: 600; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -/* Highlighted bead (orange for counting) */ -.rosary-visualization :global(.counted-bead) { - fill: var(--nord13) !important; - filter: drop-shadow(0 0 8px var(--nord13)); -} - /* Mystery description styling */ .decade-buttons { display: flex; @@ -1431,36 +612,6 @@ h1 { .mystery-image-column::-webkit-scrollbar { display: none; } - .mystery-image-pad { - height: calc(100vh - 5rem); - } - .mystery-image-pad[data-target="before"], - .mystery-image-pad[data-target="after"] { - height: 100vh; - } - .mystery-image-column figure { - margin: 0; - margin-right: 2rem; - } - .mystery-image-column img { - max-height: calc(100vh - 5rem); - width: auto; - max-width: 25vw; - object-fit: contain; - border-radius: 6px; - display: block; - } - .mystery-image-column figcaption { - font-size: 0.8rem; - color: var(--nord4); - margin-top: 0.4rem; - max-width: 25vw; - } -} -@media (min-width: 1200px) and (prefers-color-scheme: light) { - .mystery-image-column figcaption { - color: var(--nord2); - } } @@ -1475,62 +626,7 @@ h1 {