From 091c23a0bdb4b4538656572da86fef6a8d6951c1 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Wed, 11 Feb 2026 14:33:37 +0100 Subject: [PATCH] refactor: extract sub-components and modules from rosary +page.svelte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[rosary=rosaryLang]/+page.svelte | 1040 +---------------- .../MysteryImageColumn.svelte | 47 + .../MysterySelector.svelte | 219 ++++ .../[rosary=rosaryLang]/RosarySvg.svelte | 161 +++ .../[rosary=rosaryLang]/rosaryData.js | 288 +++++ .../[rosary=rosaryLang]/rosaryScrollSync.js | 331 ++++++ 6 files changed, 1064 insertions(+), 1022 deletions(-) create mode 100644 src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysteryImageColumn.svelte create mode 100644 src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysterySelector.svelte create mode 100644 src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/RosarySvg.svelte create mode 100644 src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryData.js create mode 100644 src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryScrollSync.js 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 {

{labels.mysteries}

- +
@@ -1561,98 +657,7 @@ h1 {
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {#each [1, 2, 3, 4, 5] as d} - {@const decadePos = pos[`secret${d}`]} - {@const transPos = pos[`secret${d}_transition`]} - - {#each Array(10) as _, i} - - {/each} - - {#if d < 5} - - {/if} - {/each} - - - - - - - - - - - - - - - - - - - - - - - - - - - - {#each [1, 2, 3, 4, 5] as d} - {@const decadePos = pos[`secret${d}`]} - - {/each} - - - {#each [1, 2, 3, 4] as d} - - {/each} - - - - - - - - - +
@@ -1862,16 +867,7 @@ h1 {
{#if hasMysteryImages} - {@const images = allMysteryImages[selectedMystery]} -
- {#each [...images.entries()] as [num, img], i} - {#if i > 0}
{/if} -
- {img.artist ? `${img.artist} — ` : ''}{isEnglish ? img.title : img.titleDe} -
{#if img.artist}{img.artist}, {/if}{isEnglish ? img.title : img.titleDe}{#if img.year}, {img.year}{/if}
-
- {/each} -
+ {/if}
diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysteryImageColumn.svelte b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysteryImageColumn.svelte new file mode 100644 index 0000000..d84d12c --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysteryImageColumn.svelte @@ -0,0 +1,47 @@ + + + +
+{#each [...images.entries()] as [num, img], i (num)} + {#if i > 0}
{/if} +
+ {img.artist ? `${img.artist} — ` : ''}{isEnglish ? img.title : img.titleDe} +
{#if img.artist}{img.artist}, {/if}{isEnglish ? img.title : img.titleDe}{#if img.year}, {img.year}{/if}
+
+{/each} +
diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysterySelector.svelte b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysterySelector.svelte new file mode 100644 index 0000000..8963992 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/MysterySelector.svelte @@ -0,0 +1,219 @@ + + + + diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/RosarySvg.svelte b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/RosarySvg.svelte new file mode 100644 index 0000000..818b96a --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/RosarySvg.svelte @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {#each [1, 2, 3, 4, 5] as d (d)} + {@const decadePos = pos[`secret${d}`]} + {@const transPos = pos[`secret${d}_transition`]} + + {#each Array(10) as _, i (i)} + + {/each} + + {#if d < 5} + + {/if} + {/each} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {#each [1, 2, 3, 4, 5] as d (d)} + {@const decadePos = pos[`secret${d}`]} + + {/each} + + + {#each [1, 2, 3, 4] as d (d)} + + {/each} + + + + + + + + + diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryData.js b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryData.js new file mode 100644 index 0000000..1c126b3 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryData.js @@ -0,0 +1,288 @@ +// Mystery variations for each type of rosary +export 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." + ] +}; + +export 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 +export 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) +export 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 +export 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" + ] +}; + +// UI labels based on language +export function getLabels(isEnglish) { + return { + 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' + }; +} + +// Get the appropriate mystery for a given weekday +export function getMysteryForWeekday(date, includeLuminous) { + const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc. + + if (includeLuminous) { + 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 { + 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]; + } +} + +// SVG layout constants +export const BEAD_SPACING = 22; +export const DECADE_OFFSET = 10; + +// Map sections to their vertical positions in the SVG +export 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); +} + +// Mystery images with captions (per mystery type, keyed by decade number 1-5) +export 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" }], + ]), +}; diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryScrollSync.js b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryScrollSync.js new file mode 100644 index 0000000..956d7a8 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/rosaryScrollSync.js @@ -0,0 +1,331 @@ +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); + } + }; +}