Files
pguzman 76a5061fba Custom sessions now draw their own bead ring matching the builder sequence
present.php extracts the ordered bead types (small/large/crucifix) from
custom session slides and passes them as CUSTOM_BEADS to the JS layer.

rosary.js reads CUSTOM_BEADS on init: if present, it draws exactly those
N beads (with their correct types) instead of the standard 60-bead ring.
The three hardcoded 60s and the fixed type Sets are now dynamic so any
sequence length works. Standard sessions are unchanged (CUSTOM_BEADS=null).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:34:24 -07:00

386 lines
15 KiB
JavaScript
Raw Permalink 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 elements, one per bead
var polyline = null;
var beadPositions = []; // [{x, y}] for each bead
var _beadClickHandler = null; // registered by presenter.js
// Bead configuration — set by configure(), either from CUSTOM_BEADS or standard rosary
var TOTAL_BEADS = 60;
var LARGE_BEADS = new Set([1, 5, 16, 27, 38, 49]);
var CRUCIFIX_BEADS = new Set([0]);
// Read window.CUSTOM_BEADS (set by present.php for builder sessions).
// CUSTOM_BEADS is an ordered array of bead types, e.g. ['crucifix','large','small',...].
// If null/absent, fall back to the standard 60-bead rosary layout.
function configure() {
var cb = window.CUSTOM_BEADS;
if (cb && cb.length) {
TOTAL_BEADS = cb.length;
LARGE_BEADS = new Set();
CRUCIFIX_BEADS = new Set();
cb.forEach(function (type, i) {
if (type === 'large') LARGE_BEADS.add(i);
if (type === 'crucifix') CRUCIFIX_BEADS.add(i);
});
} else {
TOTAL_BEADS = 60;
LARGE_BEADS = new Set([1, 5, 16, 27, 38, 49]);
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 < TOTAL_BEADS; i++) {
// Fraction of the perimeter for this bead (clockwise from bottom-center)
var frac = i / TOTAL_BEADS;
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 < TOTAL_BEADS; 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 has been prayed and there is no active bead,
// the last element turns gold and stays gold for the rest of the presentation.
var allDecadesDone = (last >= TOTAL_BEADS - 1) &&
(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() {
configure();
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 };
})();