663fde3909
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>
365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
/**
|
||
* 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 <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 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 };
|
||
})();
|