From 86ec4a640ec3d9cee121235ad0b3484072563e0a Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 4 Dec 2025 21:13:20 +0100 Subject: [PATCH] feat: enhance interactive rosary with mobile support and counters - Add weekday-based mystery auto-selection with luminous mysteries toggle - Implement iOS-style toggle for including/excluding luminous mysteries - Add mystery selector buttons with visual feedback - Create CounterButton component for tracking Ave Maria progress - Add orange bead highlighting for prayer counting - Implement auto-scroll to next section after completing decade - Optimize mobile layout with responsive sidebar (20px-80px width) - Scale rosary visualization 3.5x on mobile for better visibility - Fix scroll synchronization accounting for CSS transform scale - Increase cross size for better visibility - Extract BenedictusMedal as reusable component - Add smooth scroll polyfills for better browser compatibility - Improve SVG interaction with click handlers and scroll locking --- src/lib/components/BenedictusMedal.svelte | 72 ++ src/lib/components/CounterButton.svelte | 62 ++ src/routes/glaube/rosenkranz/+page.svelte | 764 ++++++++++++++++++-- src/routes/glaube/rosenkranz/benedictus.svg | 108 +++ 4 files changed, 949 insertions(+), 57 deletions(-) create mode 100644 src/lib/components/BenedictusMedal.svelte create mode 100644 src/lib/components/CounterButton.svelte create mode 100644 src/routes/glaube/rosenkranz/benedictus.svg diff --git a/src/lib/components/BenedictusMedal.svelte b/src/lib/components/BenedictusMedal.svelte new file mode 100644 index 0000000..2da7412 --- /dev/null +++ b/src/lib/components/BenedictusMedal.svelte @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/components/CounterButton.svelte b/src/lib/components/CounterButton.svelte new file mode 100644 index 0000000..7ec19c4 --- /dev/null +++ b/src/lib/components/CounterButton.svelte @@ -0,0 +1,62 @@ + + + + + diff --git a/src/routes/glaube/rosenkranz/+page.svelte b/src/routes/glaube/rosenkranz/+page.svelte index 87c55dc..f3c1adc 100644 --- a/src/routes/glaube/rosenkranz/+page.svelte +++ b/src/routes/glaube/rosenkranz/+page.svelte @@ -9,6 +9,8 @@ import AveMaria from "$lib/components/prayers/AveMaria.svelte"; import GloriaPatri from "$lib/components/prayers/GloriaPatri.svelte"; import FatimaGebet from "$lib/components/prayers/FatimaGebet.svelte"; import SalveRegina from "$lib/components/prayers/SalveRegina.svelte"; +import BenedictusMedal from "$lib/components/BenedictusMedal.svelte"; +import CounterButton from "$lib/components/CounterButton.svelte"; // Mystery variations for each type of rosary const mysteries = { @@ -73,15 +75,107 @@ const mysteriesLatin = { ] }; -// Determine which mystery to use (for now using schmerzhaften) -let currentMysteries = mysteries.schmerzhaften; -let currentMysteriesLatin = mysteriesLatin.schmerzhaften; +// Toggle for including Luminous mysteries +let includeLuminous = true; + +// 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]; + } +} + +// Determine which mystery to use based on current weekday +let selectedMystery = getMysteryForWeekday(new Date(), includeLuminous); +let currentMysteries = mysteries[selectedMystery]; +let currentMysteriesLatin = mysteriesLatin[selectedMystery]; + +// Function to switch mysteries +function selectMystery(mysteryType) { + selectedMystery = mysteryType; + currentMysteries = mysteries[mysteryType]; + currentMysteriesLatin = mysteriesLatin[mysteryType]; +} + +// Function to handle toggle change +function handleToggleChange() { + // Recalculate the default mystery for today + const todaysMystery = getMysteryForWeekday(new Date(), includeLuminous); + // Update to today's mystery + selectMystery(todaysMystery); +} // Active section tracking let activeSection = "cross"; let sectionElements = {}; let svgContainer; +// Counter for tracking Ave Maria progress in each decade (0-10 for each) +let decadeCounters = { + secret1: 0, + secret2: 0, + secret3: 0, + secret4: 0, + secret5: 0 +}; + +// Function to advance the counter for a specific decade +function advanceDecade(decadeNum) { + const key = `secret${decadeNum}`; + if (decadeCounters[key] < 10) { + decadeCounters[key] += 1; + + // When we reach 10, auto-scroll to next section after a brief delay + // and reset the counter + if (decadeCounters[key] === 10) { + setTimeout(() => { + // Reset counter to clear highlighting + decadeCounters[key] = 0; + + // Determine next section + let nextSection; + if (decadeNum < 5) { + nextSection = `secret${decadeNum}_transition`; + } else { + nextSection = 'final_transition'; + } + + // Scroll to next section + const nextElement = sectionElements[nextSection]; + if (nextElement) { + const elementTop = nextElement.getBoundingClientRect().top + window.scrollY; + const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3; + window.scrollTo({ top: elementTop - offset, behavior: 'smooth' }); + } + }, 500); + } + } +} + // Map sections to their vertical positions in the SVG const sectionPositions = { cross: 35, @@ -90,7 +184,7 @@ const sectionPositions = { start2: 135, start3: 160, lbead2: 200, - secret1: 280, + secret1: 270, secret1_transition: 520, secret2: 560, secret2_transition: 800, @@ -103,7 +197,100 @@ const sectionPositions = { }; onMount(() => { - // Set up Intersection Observer for scroll tracking + 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 @@ -112,7 +299,7 @@ onMount(() => { const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { - if (entry.isIntersecting) { + if (entry.isIntersecting && scrollLock !== 'svg' && scrollLock !== 'click') { activeSection = entry.target.dataset.section; // Scroll SVG to keep active section visible at top @@ -125,17 +312,20 @@ onMount(() => { 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; + const scale = (svgHeight / viewBoxHeight) * cssScale; const pixelPosition = svgYPosition * scale; // Position with some padding to show context above const targetScroll = pixelPosition - 100; - svgContainer.scrollTo({ - top: Math.max(0, targetScroll), - behavior: 'smooth' - }); + setScrollLock('prayer'); + smoothScrollElement(svgContainer, Math.max(0, targetScroll)); } } }); @@ -146,7 +336,165 @@ onMount(() => { if (el) observer.observe(el); }); - return () => observer.disconnect(); + // 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; + + // Get the first and final prayer sections + const firstSection = sectionElements.cross; + const finalSection = sectionElements.final_transition; + if (!firstSection || !finalSection) return; + + const firstSectionRect = firstSection.getBoundingClientRect(); + const finalSectionRect = finalSection.getBoundingClientRect(); + + // Check if we've scrolled above the first section (it's completely below viewport) + if (firstSectionRect.top > viewportHeight * 0.6) { + // Scroll SVG to top + if (svgContainer.scrollTop > 10) { // Only if not already at top + smoothScrollElement(svgContainer, 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 + 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); + if (svgContainer) { + svgContainer.removeEventListener('scroll', handleSvgScroll); + } + if (svg) { + svg.removeEventListener('click', handleSvgClick); + } + }; }); Rosenkranz - Interaktiv @@ -362,86 +941,153 @@ h1 {

Interaktiver Rosenkranz

+ +
+ +

+ Die Geheimnisse werden automatisch nach dem Wochenplan ausgewählt. + {#if includeLuminous} + Mit lichtreichen Geheimnissen: Do=Lichtreich, andere Tage folgen dem traditionellen Plan. + {:else} + Traditioneller Plan ohne lichtreiche Geheimnisse. + {/if} + Sie können jederzeit manuell ein anderes Geheimnis wählen. +

+
+ + +
+ + + + + + + {#if includeLuminous} + + {/if} +
+
- - - - + - - + - + - - - + + + - + - - - - - - - - - - - C - S - S - M - + + {#each Array(10) as _, i} - + {/each} - + {#each Array(10) as _, i} - + {/each} - + {#each Array(10) as _, i} - + {/each} - + {#each Array(10) as _, i} - + {/each} - + {#each Array(10) as _, i} - + {/each} - +
@@ -454,7 +1100,8 @@ h1 { bind:this={sectionElements.cross} data-section="cross" > -

♱ Das Kreuzzeichen

+

Anfang

+

♱ Das Kreuzzeichen

Credo

@@ -466,7 +1113,7 @@ h1 { bind:this={sectionElements.lbead1} data-section="lbead1" > -

Vater unser

+

Vater unser

@@ -476,7 +1123,7 @@ h1 { bind:this={sectionElements.start1} data-section="start1" > -

Ave Maria

+

Ave Maria

-

Ave Maria

+

Ave Maria

-

Ave Maria

+

Ave Maria

+ + + advanceDecade(decadeNum)} />
diff --git a/src/routes/glaube/rosenkranz/benedictus.svg b/src/routes/glaube/rosenkranz/benedictus.svg new file mode 100644 index 0000000..3fce4b6 --- /dev/null +++ b/src/routes/glaube/rosenkranz/benedictus.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + Médaille de St Benoit + 2013-09-03T17:16:01 + Envers de la médaille de Saint Benoit. + http://openclipart.org/detail/182881/médaille-de-st-benoit-by-justin-ternet-182881 + + + Justin Ternet + + + + + benoit + benoît + catholic + catholique + croix + medal + médaille + religion + saint + + + + + + + + + + +