/** * assets/js/rosary.js * * Draws an SVG rosary ring around the viewport edges. * * Layout: 60 beads distributed clockwise, starting at the bottom-center * with the crucifix, then going right → up the right edge → left along top * → down the left edge → back to bottom-center. * * Bead index mapping (60 beads total): * 0 = Crucifix (bottom center, stem) * 1 = large (Our Father, stem) * 2–4 = small (3 Hail Marys, stem) * 5 = large (Our Father, Decade 1) * 6–15 = small (10 HMs, Decade 1) * 16 = large (Our Father, Decade 2) * 17–26 = small (Decade 2) * 27 = large (Our Father, Decade 3) * 28–37 = small (Decade 3) * 38 = large (Our Father, Decade 4) * 39–48 = small (Decade 4) * 49 = large (Our Father, Decade 5) * 50–59 = small (Decade 5) * * Colors: * Unprayed : #555555 (dark gray) * Current : #FFD700 (gold, glow) * Prayed : #990000 (deep red) */ var RosaryRing = (function () { 'use strict'; var NS = 'http://www.w3.org/2000/svg'; var MARGIN = 28; // px from edge var R_SMALL = 6; var R_LARGE = 10; var R_CRUCIFIX = 12; var COLOR_UNPRAYED = '#555555'; var COLOR_CURRENT = '#FFD700'; var COLOR_PRAYED = '#990000'; var svg = null; var beadEls = []; // SVG elements, indexed 0-59 var polyline = null; var beadPositions = []; // [{x, y}] for each bead var _beadClickHandler = null; // registered by presenter.js // Which bead indices are "large" (Our Father) beads var LARGE_BEADS = new Set([1, 5, 16, 27, 38, 49]); var CRUCIFIX_BEADS = new Set([0]); // -------------------------------------------------------------------------- // Calculate bead positions around the viewport edges // -------------------------------------------------------------------------- function calcPositions(W, H) { var m = MARGIN; // Total path length around the rectangle (at margin inset) // Corners: (m,m), (W-m,m), (W-m,H-m), (m,H-m) var innerW = W - 2 * m; var innerH = H - 2 * m; var perimeter = 2 * (innerW + innerH); // The ring uses beads 0–59 (60 total). // Bead 0 (crucifix) is at the bottom-center. // We distribute beads clockwise starting from bottom-center, // going RIGHT first along the bottom edge. // Reference point for bead 0: bottom center of inset rectangle var startX = W / 2; var startY = H - m; var positions = []; for (var i = 0; i < 60; i++) { // Fraction of the perimeter for this bead (clockwise from bottom-center) var frac = i / 60; var dist = frac * perimeter; var pt = pointOnRect(dist, startX, startY, m, W, H, innerW, innerH, perimeter); positions.push(pt); } return positions; } /** * Given a distance along the perimeter (starting at bottom-center, clockwise), * return the {x, y} point on the inset rectangle. */ function pointOnRect(dist, startX, startY, m, W, H, innerW, innerH, perimeter) { var x, y; // Segment lengths from bottom-center going clockwise: // Segment A: bottom-center → bottom-right corner = W/2 - m // Segment B: bottom-right corner → top-right = H - 2m // Segment C: top-right → top-left = W - 2m // Segment D: top-left → bottom-left = H - 2m // Segment E: bottom-left → bottom-center = W/2 - m var segA = W / 2 - m; // → bottom-right var segB = H - 2 * m; // ↑ right edge var segC = W - 2 * m; // ← top edge var segD = H - 2 * m; // ↓ left edge var segE = W / 2 - m; // → back to bottom-center dist = ((dist % perimeter) + perimeter) % perimeter; // normalize if (dist <= segA) { // Bottom edge: left of center → right (going right from bottom-center) x = startX + dist; y = H - m; } else if (dist <= segA + segB) { // Right edge: bottom → top x = W - m; y = (H - m) - (dist - segA); } else if (dist <= segA + segB + segC) { // Top edge: right → left x = (W - m) - (dist - segA - segB); y = m; } else if (dist <= segA + segB + segC + segD) { // Left edge: top → bottom x = m; y = m + (dist - segA - segB - segC); } else { // Bottom edge: left → bottom-center x = m + (dist - segA - segB - segC - segD); y = H - m; } return { x: x, y: y }; } // -------------------------------------------------------------------------- // Build or rebuild the SVG // -------------------------------------------------------------------------- function build() { var container = document.getElementById('rosary-overlay'); if (!container) return; // Remove existing SVG if any container.innerHTML = ''; var W = window.innerWidth; var H = window.innerHeight; svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); svg.setAttribute('xmlns', NS); svg.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none'; // Define glow filter var defs = document.createElementNS(NS, 'defs'); var filter = document.createElementNS(NS, 'filter'); filter.setAttribute('id', 'glow'); filter.setAttribute('x', '-50%'); filter.setAttribute('y', '-50%'); filter.setAttribute('width', '200%'); filter.setAttribute('height', '200%'); var feGaussian = document.createElementNS(NS, 'feGaussianBlur'); feGaussian.setAttribute('stdDeviation', '3.5'); feGaussian.setAttribute('result', 'coloredBlur'); var feMerge = document.createElementNS(NS, 'feMerge'); var feMergeNode1 = document.createElementNS(NS, 'feMergeNode'); feMergeNode1.setAttribute('in', 'coloredBlur'); var feMergeNode2 = document.createElementNS(NS, 'feMergeNode'); feMergeNode2.setAttribute('in', 'SourceGraphic'); feMerge.appendChild(feMergeNode1); feMerge.appendChild(feMergeNode2); filter.appendChild(feGaussian); filter.appendChild(feMerge); defs.appendChild(filter); svg.appendChild(defs); beadPositions = calcPositions(W, H); // Draw chain (polyline) var pts = beadPositions.map(function (p) { return p.x + ',' + p.y; }); // Close the loop pts.push(pts[0]); polyline = document.createElementNS(NS, 'polyline'); polyline.setAttribute('points', pts.join(' ')); polyline.setAttribute('fill', 'none'); polyline.setAttribute('stroke', '#333'); polyline.setAttribute('stroke-width', '1.5'); polyline.setAttribute('opacity', '0.7'); svg.appendChild(polyline); // Draw beads beadEls = []; for (var i = 0; i < 60; i++) { var pos = beadPositions[i]; var isCrucifix = CRUCIFIX_BEADS.has(i); var isLarge = LARGE_BEADS.has(i); if (isCrucifix) { // Draw a simple cross symbol var g = document.createElementNS(NS, 'g'); g.setAttribute('transform', 'translate(' + pos.x + ',' + pos.y + ')'); // Beads are clickable — enable pointer events on this element g.style.pointerEvents = 'all'; g.style.cursor = 'pointer'; var vert = document.createElementNS(NS, 'line'); vert.setAttribute('x1', '0'); vert.setAttribute('y1', '-14'); vert.setAttribute('x2', '0'); vert.setAttribute('y2', '14'); vert.setAttribute('stroke', COLOR_UNPRAYED); vert.setAttribute('stroke-width', '3'); vert.setAttribute('stroke-linecap', 'round'); var horiz = document.createElementNS(NS, 'line'); horiz.setAttribute('x1', '-8'); horiz.setAttribute('y1', '-4'); horiz.setAttribute('x2', '8'); horiz.setAttribute('y2', '-4'); horiz.setAttribute('stroke', COLOR_UNPRAYED); horiz.setAttribute('stroke-width', '3'); horiz.setAttribute('stroke-linecap', 'round'); // Invisible hit-area circle for color tracking and clicks var hitCircle = document.createElementNS(NS, 'circle'); hitCircle.setAttribute('cx', '0'); hitCircle.setAttribute('cy', '0'); hitCircle.setAttribute('r', R_CRUCIFIX); hitCircle.setAttribute('fill', 'transparent'); g.appendChild(vert); g.appendChild(horiz); g.appendChild(hitCircle); g.dataset.beadIndex = i; g._vertLine = vert; g._horizLine = horiz; // Click: emit bead index to registered handler (function (beadIdx) { g.addEventListener('click', function () { if (_beadClickHandler) _beadClickHandler(beadIdx); }); })(i); svg.appendChild(g); beadEls.push(g); } else { var r = isLarge ? R_LARGE : R_SMALL; var circle = document.createElementNS(NS, 'circle'); circle.setAttribute('cx', pos.x); circle.setAttribute('cy', pos.y); circle.setAttribute('r', r); circle.setAttribute('fill', COLOR_UNPRAYED); circle.dataset.beadIndex = i; // Beads are clickable — enable pointer events on this element circle.style.pointerEvents = 'all'; circle.style.cursor = 'pointer'; // Click: emit bead index to registered handler (function (beadIdx) { circle.addEventListener('click', function () { if (_beadClickHandler) _beadClickHandler(beadIdx); }); })(i); svg.appendChild(circle); beadEls.push(circle); } } container.appendChild(svg); } // -------------------------------------------------------------------------- // applyColors — internal: paint all beads for the given state. // currentBeadIndex : bead being prayed NOW (gold glow), or null // lastPrayedBeadIndex: highest bead index prayed so far (all <= this are red) // -------------------------------------------------------------------------- function applyColors(currentBeadIndex, lastPrayedBeadIndex) { if (!svg) return; var last = (lastPrayedBeadIndex !== null && lastPrayedBeadIndex !== undefined) ? lastPrayedBeadIndex : -1; // Once the final bead of decade 5 (index 59) has been prayed and there is // no active bead, the cross turns gold and stays gold for the rest of the // presentation (litanies, novena prayer, closing slide). var allDecadesDone = (last >= 59) && (currentBeadIndex === null || currentBeadIndex === undefined); beadEls.forEach(function (el, i) { var isCrucifix = CRUCIFIX_BEADS.has(i); var color; var isActive = false; if (currentBeadIndex !== null && currentBeadIndex !== undefined) { // On a bead slide: gold = current, red = prayed, gray = future if (i === currentBeadIndex) { color = COLOR_CURRENT; isActive = true; } else if (i < currentBeadIndex) { color = COLOR_PRAYED; } else { color = COLOR_UNPRAYED; } } else { // Between-bead slide (Glory Be, Fatima, litany, etc.) if (isCrucifix && allDecadesDone) { // All 5 decades complete — crucifix glows gold color = COLOR_CURRENT; isActive = true; } else { color = (i <= last) ? COLOR_PRAYED : COLOR_UNPRAYED; } } if (isCrucifix) { el._vertLine.setAttribute('stroke', color); el._horizLine.setAttribute('stroke', color); if (isActive) { el.setAttribute('filter', 'url(#glow)'); } else { el.removeAttribute('filter'); } } else { el.setAttribute('fill', color); if (isActive) { el.setAttribute('filter', 'url(#glow)'); } else { el.removeAttribute('filter'); } } }); } // -------------------------------------------------------------------------- // update — public API. Saves state so a resize rebuild can restore it. // -------------------------------------------------------------------------- var _lastCurrent = null; var _lastPrayed = null; function update(currentBeadIndex, lastPrayedBeadIndex) { _lastCurrent = currentBeadIndex; _lastPrayed = lastPrayedBeadIndex; applyColors(currentBeadIndex, lastPrayedBeadIndex); } // -------------------------------------------------------------------------- // Initialize // -------------------------------------------------------------------------- function init() { build(); window.addEventListener('resize', function () { build(); applyColors(_lastCurrent, _lastPrayed); // restore state after SVG rebuild }); } // Run on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } function onBeadClick(fn) { _beadClickHandler = fn; } return { update: update, onBeadClick: onBeadClick }; })();