Files
Rosary/assets/js/rosary.js
T
pguzman 663fde3909 Initial commit — Rosary Presenter App
Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy
Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads,
donate strip, and public session profiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:44:08 -07:00

365 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
* 24 = small (3 Hail Marys, stem)
* 5 = large (Our Father, Decade 1)
* 615 = small (10 HMs, Decade 1)
* 16 = large (Our Father, Decade 2)
* 1726 = small (Decade 2)
* 27 = large (Our Father, Decade 3)
* 2837 = small (Decade 3)
* 38 = large (Our Father, Decade 4)
* 3948 = small (Decade 4)
* 49 = large (Our Father, Decade 5)
* 5059 = 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 <circle> 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 059 (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 };
})();