Initial commit

This commit is contained in:
2026-03-07 01:09:00 -08:00
commit af7d63717e
102 changed files with 68739 additions and 0 deletions

686
app/ui-now.js Normal file
View File

@@ -0,0 +1,686 @@
(function () {
const {
DAY_IN_MS,
getDateKey,
getMoonPhaseName,
getDecanForDate,
calcPlanetaryHoursForDayAndLocation
} = window.TarotCalc;
const { resolveTarotCardImage, getTarotCardDisplayName } = window.TarotCardImages || {};
let moonCountdownCache = null;
let decanCountdownCache = null;
let nowLightboxOverlayEl = null;
let nowLightboxImageEl = null;
let nowLightboxZoomed = false;
const LIGHTBOX_ZOOM_SCALE = 6.66;
const PLANETARY_BODIES = [
{ id: "sol", astronomyBody: "Sun", fallbackName: "Sun", fallbackSymbol: "☉︎" },
{ id: "luna", astronomyBody: "Moon", fallbackName: "Moon", fallbackSymbol: "☾︎" },
{ id: "mercury", astronomyBody: "Mercury", fallbackName: "Mercury", fallbackSymbol: "☿︎" },
{ id: "venus", astronomyBody: "Venus", fallbackName: "Venus", fallbackSymbol: "♀︎" },
{ id: "mars", astronomyBody: "Mars", fallbackName: "Mars", fallbackSymbol: "♂︎" },
{ id: "jupiter", astronomyBody: "Jupiter", fallbackName: "Jupiter", fallbackSymbol: "♃︎" },
{ id: "saturn", astronomyBody: "Saturn", fallbackName: "Saturn", fallbackSymbol: "♄︎" },
{ id: "uranus", astronomyBody: "Uranus", fallbackName: "Uranus", fallbackSymbol: "♅︎" },
{ id: "neptune", astronomyBody: "Neptune", fallbackName: "Neptune", fallbackSymbol: "♆︎" },
{ id: "pluto", astronomyBody: "Pluto", fallbackName: "Pluto", fallbackSymbol: "♇︎" }
];
function resetNowLightboxZoom() {
if (!nowLightboxImageEl) {
return;
}
nowLightboxZoomed = false;
nowLightboxImageEl.style.transform = "scale(1)";
nowLightboxImageEl.style.transformOrigin = "center center";
nowLightboxImageEl.style.cursor = "zoom-in";
}
function updateNowLightboxZoomOrigin(clientX, clientY) {
if (!nowLightboxZoomed || !nowLightboxImageEl) {
return;
}
const rect = nowLightboxImageEl.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
const x = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
const y = Math.min(100, Math.max(0, ((clientY - rect.top) / rect.height) * 100));
nowLightboxImageEl.style.transformOrigin = `${x}% ${y}%`;
}
function isNowLightboxPointOnCard(clientX, clientY) {
if (!nowLightboxImageEl) {
return false;
}
const rect = nowLightboxImageEl.getBoundingClientRect();
const naturalWidth = nowLightboxImageEl.naturalWidth;
const naturalHeight = nowLightboxImageEl.naturalHeight;
if (!rect.width || !rect.height || !naturalWidth || !naturalHeight) {
return true;
}
const frameAspect = rect.width / rect.height;
const imageAspect = naturalWidth / naturalHeight;
let renderWidth = rect.width;
let renderHeight = rect.height;
if (imageAspect > frameAspect) {
renderHeight = rect.width / imageAspect;
} else {
renderWidth = rect.height * imageAspect;
}
const left = rect.left + (rect.width - renderWidth) / 2;
const top = rect.top + (rect.height - renderHeight) / 2;
const right = left + renderWidth;
const bottom = top + renderHeight;
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom;
}
function ensureNowImageLightbox() {
if (nowLightboxOverlayEl && nowLightboxImageEl) {
return;
}
nowLightboxOverlayEl = document.createElement("div");
nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
nowLightboxOverlayEl.style.position = "fixed";
nowLightboxOverlayEl.style.inset = "0";
nowLightboxOverlayEl.style.background = "rgba(0, 0, 0, 0.82)";
nowLightboxOverlayEl.style.display = "none";
nowLightboxOverlayEl.style.alignItems = "center";
nowLightboxOverlayEl.style.justifyContent = "center";
nowLightboxOverlayEl.style.zIndex = "9999";
nowLightboxOverlayEl.style.padding = "0";
const image = document.createElement("img");
image.alt = "Now card enlarged image";
image.style.maxWidth = "100vw";
image.style.maxHeight = "100vh";
image.style.width = "100vw";
image.style.height = "100vh";
image.style.objectFit = "contain";
image.style.borderRadius = "0";
image.style.boxShadow = "none";
image.style.border = "none";
image.style.cursor = "zoom-in";
image.style.transform = "scale(1)";
image.style.transformOrigin = "center center";
image.style.transition = "transform 120ms ease-out";
image.style.userSelect = "none";
nowLightboxImageEl = image;
nowLightboxOverlayEl.appendChild(image);
const closeLightbox = () => {
if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
return;
}
nowLightboxOverlayEl.style.display = "none";
nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
nowLightboxImageEl.removeAttribute("src");
resetNowLightboxZoom();
};
nowLightboxOverlayEl.addEventListener("click", (event) => {
if (event.target === nowLightboxOverlayEl) {
closeLightbox();
}
});
nowLightboxImageEl.addEventListener("click", (event) => {
event.stopPropagation();
if (!isNowLightboxPointOnCard(event.clientX, event.clientY)) {
closeLightbox();
return;
}
if (!nowLightboxZoomed) {
nowLightboxZoomed = true;
nowLightboxImageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`;
nowLightboxImageEl.style.cursor = "zoom-out";
updateNowLightboxZoomOrigin(event.clientX, event.clientY);
return;
}
resetNowLightboxZoom();
});
nowLightboxImageEl.addEventListener("mousemove", (event) => {
updateNowLightboxZoomOrigin(event.clientX, event.clientY);
});
nowLightboxImageEl.addEventListener("mouseleave", () => {
if (nowLightboxZoomed) {
nowLightboxImageEl.style.transformOrigin = "center center";
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeLightbox();
}
});
document.body.appendChild(nowLightboxOverlayEl);
}
function openNowImageLightbox(src, altText) {
if (!src) {
return;
}
ensureNowImageLightbox();
if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
return;
}
nowLightboxImageEl.src = src;
nowLightboxImageEl.alt = altText || "Now card enlarged image";
resetNowLightboxZoom();
nowLightboxOverlayEl.style.display = "flex";
nowLightboxOverlayEl.setAttribute("aria-hidden", "false");
}
function getDisplayTarotName(cardName, trumpNumber) {
if (!cardName) {
return "";
}
if (typeof getTarotCardDisplayName !== "function") {
return cardName;
}
if (Number.isFinite(Number(trumpNumber))) {
return getTarotCardDisplayName(cardName, { trumpNumber: Number(trumpNumber) }) || cardName;
}
return getTarotCardDisplayName(cardName) || cardName;
}
function bindNowCardLightbox(imageEl) {
if (!(imageEl instanceof HTMLImageElement) || imageEl.dataset.lightboxBound === "true") {
return;
}
imageEl.dataset.lightboxBound = "true";
imageEl.style.cursor = "zoom-in";
imageEl.title = "Click to enlarge";
imageEl.addEventListener("click", () => {
const src = imageEl.getAttribute("src");
if (!src || imageEl.style.display === "none") {
return;
}
openNowImageLightbox(src, imageEl.alt || "Now card enlarged image");
});
}
function normalizeLongitude(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return null;
}
return ((numeric % 360) + 360) % 360;
}
function getSortedSigns(signs) {
if (!Array.isArray(signs)) {
return [];
}
return [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
}
function getSignForLongitude(longitude, signs) {
const normalized = normalizeLongitude(longitude);
if (normalized === null) {
return null;
}
const sortedSigns = getSortedSigns(signs);
if (!sortedSigns.length) {
return null;
}
const signIndex = Math.min(sortedSigns.length - 1, Math.floor(normalized / 30));
const sign = sortedSigns[signIndex] || null;
if (!sign) {
return null;
}
return {
sign,
degreeInSign: normalized - signIndex * 30,
absoluteLongitude: normalized
};
}
function getSabianSymbolForLongitude(longitude, sabianSymbols) {
const normalized = normalizeLongitude(longitude);
if (normalized === null || !Array.isArray(sabianSymbols) || !sabianSymbols.length) {
return null;
}
const absoluteDegree = Math.floor(normalized) + 1;
return sabianSymbols.find((entry) => Number(entry?.absoluteDegree) === absoluteDegree) || null;
}
function calculatePlanetPositions(referenceData, now) {
if (!window.Astronomy || !referenceData) {
return [];
}
const positions = [];
PLANETARY_BODIES.forEach((body) => {
try {
const geoVector = window.Astronomy.GeoVector(body.astronomyBody, now, true);
const ecliptic = window.Astronomy.Ecliptic(geoVector);
const signInfo = getSignForLongitude(ecliptic?.elon, referenceData.signs);
if (!signInfo?.sign) {
return;
}
const planetInfo = referenceData.planets?.[body.id] || null;
const symbol = planetInfo?.symbol || body.fallbackSymbol;
const name = planetInfo?.name || body.fallbackName;
positions.push({
id: body.id,
symbol,
name,
longitude: signInfo.absoluteLongitude,
sign: signInfo.sign,
degreeInSign: signInfo.degreeInSign,
label: `${symbol} ${name}: ${signInfo.sign.symbol} ${signInfo.sign.name} ${signInfo.degreeInSign.toFixed(1)}°`
});
} catch {
}
});
return positions;
}
function updateNowStats(referenceData, elements, now) {
const planetPositions = calculatePlanetPositions(referenceData, now);
if (elements.nowStatsPlanetsEl) {
elements.nowStatsPlanetsEl.replaceChildren();
if (!planetPositions.length) {
elements.nowStatsPlanetsEl.textContent = "--";
} else {
planetPositions.forEach((position) => {
const item = document.createElement("div");
item.className = "now-stats-planet";
item.textContent = position.label;
elements.nowStatsPlanetsEl.appendChild(item);
});
}
}
if (elements.nowStatsSabianEl) {
const sunPosition = planetPositions.find((entry) => entry.id === "sol") || null;
const moonPosition = planetPositions.find((entry) => entry.id === "luna") || null;
const sunSabianSymbol = sunPosition
? getSabianSymbolForLongitude(sunPosition.longitude, referenceData.sabianSymbols)
: null;
const moonSabianSymbol = moonPosition
? getSabianSymbolForLongitude(moonPosition.longitude, referenceData.sabianSymbols)
: null;
const sunLine = sunSabianSymbol?.phrase
? `Sun Sabian ${sunSabianSymbol.absoluteDegree}: ${sunSabianSymbol.phrase}`
: "Sun Sabian: --";
const moonLine = moonSabianSymbol?.phrase
? `Moon Sabian ${moonSabianSymbol.absoluteDegree}: ${moonSabianSymbol.phrase}`
: "Moon Sabian: --";
elements.nowStatsSabianEl.textContent = `${sunLine}\n${moonLine}`;
}
}
function formatCountdown(ms, mode) {
if (!Number.isFinite(ms) || ms <= 0) {
if (mode === "hours") {
return "0.0 hours";
}
if (mode === "seconds") {
return "0s";
}
return "0m";
}
if (mode === "hours") {
return `${(ms / 3600000).toFixed(1)} hours`;
}
if (mode === "seconds") {
return `${Math.floor(ms / 1000)}s`;
}
return `${Math.floor(ms / 60000)}m`;
}
function parseMonthDay(monthDay) {
const [month, day] = String(monthDay || "").split("-").map(Number);
return { month, day };
}
function getCurrentPhaseName(date) {
return getMoonPhaseName(window.SunCalc.getMoonIllumination(date).phase);
}
function findNextMoonPhaseTransition(now) {
const currentPhase = getCurrentPhaseName(now);
const stepMs = 15 * 60 * 1000;
const maxMs = 40 * DAY_IN_MS;
let previousTime = now.getTime();
let previousPhase = currentPhase;
for (let t = previousTime + stepMs; t <= previousTime + maxMs; t += stepMs) {
const phaseName = getCurrentPhaseName(new Date(t));
if (phaseName !== previousPhase) {
let low = previousTime;
let high = t;
while (high - low > 1000) {
const mid = Math.floor((low + high) / 2);
const midPhase = getCurrentPhaseName(new Date(mid));
if (midPhase === currentPhase) {
low = mid;
} else {
high = mid;
}
}
const transitionAt = new Date(high);
const nextPhase = getCurrentPhaseName(new Date(high + 1000));
return {
fromPhase: currentPhase,
nextPhase,
changeAt: transitionAt
};
}
previousTime = t;
previousPhase = phaseName;
}
return null;
}
function getSignStartDate(now, sign) {
const { month: startMonth, day: startDay } = parseMonthDay(sign.start);
const { month: endMonth } = parseMonthDay(sign.end);
const wrapsYear = startMonth > endMonth;
let year = now.getFullYear();
const nowMonth = now.getMonth() + 1;
const nowDay = now.getDate();
if (wrapsYear && (nowMonth < startMonth || (nowMonth === startMonth && nowDay < startDay))) {
year -= 1;
}
return new Date(year, startMonth - 1, startDay);
}
function getNextSign(signs, currentSign) {
const sorted = [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
const index = sorted.findIndex((entry) => entry.id === currentSign.id);
if (index < 0) {
return null;
}
return sorted[(index + 1) % sorted.length] || null;
}
function getDecanByIndex(decansBySign, signId, index) {
const signDecans = decansBySign[signId] || [];
return signDecans.find((entry) => entry.index === index) || null;
}
function findNextDecanTransition(now, signs, decansBySign) {
const currentInfo = getDecanForDate(now, signs, decansBySign);
if (!currentInfo?.sign) {
return null;
}
const currentIndex = currentInfo.decan?.index || 1;
const signStart = getSignStartDate(now, currentInfo.sign);
if (currentIndex < 3) {
const changeAt = new Date(signStart.getTime() + currentIndex * 10 * DAY_IN_MS);
const nextDecan = getDecanByIndex(decansBySign, currentInfo.sign.id, currentIndex + 1);
const nextLabel = nextDecan?.tarotMinorArcana || `${currentInfo.sign.name} Decan ${currentIndex + 1}`;
return {
key: `${currentInfo.sign.id}-${currentIndex}`,
changeAt,
nextLabel
};
}
const nextSign = getNextSign(signs, currentInfo.sign);
if (!nextSign) {
return null;
}
const { month: nextMonth, day: nextDay } = parseMonthDay(nextSign.start);
let year = now.getFullYear();
let changeAt = new Date(year, nextMonth - 1, nextDay);
if (changeAt.getTime() <= now.getTime()) {
changeAt = new Date(year + 1, nextMonth - 1, nextDay);
}
const nextDecan = getDecanByIndex(decansBySign, nextSign.id, 1);
return {
key: `${currentInfo.sign.id}-${currentIndex}`,
changeAt,
nextLabel: nextDecan?.tarotMinorArcana || `${nextSign.name} Decan 1`
};
}
function setNowCardImage(imageEl, cardName, fallbackLabel, trumpNumber) {
if (!imageEl) {
return;
}
bindNowCardLightbox(imageEl);
if (!cardName || typeof resolveTarotCardImage !== "function") {
imageEl.style.display = "none";
imageEl.removeAttribute("src");
return;
}
const src = resolveTarotCardImage(cardName);
if (!src) {
imageEl.style.display = "none";
imageEl.removeAttribute("src");
return;
}
imageEl.src = src;
const displayName = getDisplayTarotName(cardName, trumpNumber);
imageEl.alt = `${fallbackLabel}: ${displayName}`;
imageEl.style.display = "block";
}
function updateNowPanel(referenceData, geo, elements, timeFormat = "minutes") {
if (!referenceData || !geo || !elements) {
return { dayKey: getDateKey(new Date()), skyRefreshKey: "" };
}
const now = new Date();
const dayKey = getDateKey(now);
const todayHours = calcPlanetaryHoursForDayAndLocation(now, geo);
const yesterday = new Date(now.getTime() - DAY_IN_MS);
const yesterdayHours = calcPlanetaryHoursForDayAndLocation(yesterday, geo);
const tomorrow = new Date(now.getTime() + DAY_IN_MS);
const tomorrowHours = calcPlanetaryHoursForDayAndLocation(tomorrow, geo);
const allHours = [...yesterdayHours, ...todayHours, ...tomorrowHours].sort(
(a, b) => a.start.getTime() - b.start.getTime()
);
const currentHour = allHours.find((entry) => now >= entry.start && now < entry.end);
const currentHourSkyKey = currentHour
? `${currentHour.planetId}-${currentHour.start.toISOString()}`
: "no-hour";
if (currentHour) {
const planet = referenceData.planets[currentHour.planetId];
elements.nowHourEl.textContent = planet
? `${planet.symbol} ${planet.name}`
: currentHour.planetId;
if (elements.nowHourTarotEl) {
const hourCardName = planet?.tarot?.majorArcana || "";
const hourTrumpNumber = planet?.tarot?.number;
elements.nowHourTarotEl.textContent = hourCardName
? getDisplayTarotName(hourCardName, hourTrumpNumber)
: "--";
}
const msLeft = Math.max(0, currentHour.end.getTime() - now.getTime());
elements.nowCountdownEl.textContent = formatCountdown(msLeft, timeFormat);
if (elements.nowHourNextEl) {
const nextHour = allHours.find(
(entry) => entry.start.getTime() >= currentHour.end.getTime() - 1000
);
if (nextHour) {
const nextPlanet = referenceData.planets[nextHour.planetId];
elements.nowHourNextEl.textContent = nextPlanet
? `> ${nextPlanet.name}`
: `> ${nextHour.planetId}`;
} else {
elements.nowHourNextEl.textContent = "> --";
}
}
setNowCardImage(
elements.nowHourCardEl,
planet?.tarot?.majorArcana,
"Current planetary hour card",
planet?.tarot?.number
);
} else {
elements.nowHourEl.textContent = "--";
elements.nowCountdownEl.textContent = "--";
if (elements.nowHourTarotEl) {
elements.nowHourTarotEl.textContent = "--";
}
if (elements.nowHourNextEl) {
elements.nowHourNextEl.textContent = "> --";
}
setNowCardImage(elements.nowHourCardEl, null, "Current planetary hour card");
}
const moonIllum = window.SunCalc.getMoonIllumination(now);
const moonPhase = getMoonPhaseName(moonIllum.phase);
const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess";
elements.nowMoonEl.textContent = `${moonPhase} (${Math.round(moonIllum.fraction * 100)}%)`;
elements.nowMoonTarotEl.textContent = getDisplayTarotName(moonTarot, referenceData.planets.luna?.tarot?.number);
setNowCardImage(
elements.nowMoonCardEl,
moonTarot,
"Current moon phase card",
referenceData.planets.luna?.tarot?.number
);
if (!moonCountdownCache || moonCountdownCache.fromPhase !== moonPhase || now >= moonCountdownCache.changeAt) {
moonCountdownCache = findNextMoonPhaseTransition(now);
}
if (elements.nowMoonCountdownEl) {
if (moonCountdownCache?.changeAt) {
const remaining = moonCountdownCache.changeAt.getTime() - now.getTime();
elements.nowMoonCountdownEl.textContent = formatCountdown(remaining, timeFormat);
if (elements.nowMoonNextEl) {
elements.nowMoonNextEl.textContent = `> ${moonCountdownCache.nextPhase}`;
}
} else {
elements.nowMoonCountdownEl.textContent = "--";
if (elements.nowMoonNextEl) {
elements.nowMoonNextEl.textContent = "> --";
}
}
}
const sunInfo = getDecanForDate(now, referenceData.signs, referenceData.decansBySign);
const decanSkyKey = sunInfo?.sign
? `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`
: "no-decan";
if (sunInfo?.sign) {
const signStartDate = getSignStartDate(now, sunInfo.sign);
const daysSinceSignStart = (now.getTime() - signStartDate.getTime()) / DAY_IN_MS;
const signDegree = Math.min(29.9, Math.max(0, daysSinceSignStart));
const signMajorName = getDisplayTarotName(sunInfo.sign.tarot.majorArcana, sunInfo.sign.tarot.trumpNumber);
elements.nowDecanEl.textContent = `${sunInfo.sign.symbol} ${sunInfo.sign.name} · ${signMajorName} (${signDegree.toFixed(1)}°)`;
const currentDecanKey = `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`;
if (!decanCountdownCache || decanCountdownCache.key !== currentDecanKey || now >= decanCountdownCache.changeAt) {
decanCountdownCache = findNextDecanTransition(now, referenceData.signs, referenceData.decansBySign);
}
if (sunInfo.decan) {
const decanCardName = sunInfo.decan.tarotMinorArcana;
elements.nowDecanTarotEl.textContent = getDisplayTarotName(decanCardName);
setNowCardImage(elements.nowDecanCardEl, sunInfo.decan.tarotMinorArcana, "Current decan card");
} else {
const signTarotName = sunInfo.sign.tarot?.majorArcana || "--";
elements.nowDecanTarotEl.textContent = signTarotName === "--"
? "--"
: getDisplayTarotName(signTarotName, sunInfo.sign.tarot?.trumpNumber);
setNowCardImage(
elements.nowDecanCardEl,
sunInfo.sign.tarot?.majorArcana,
"Current decan card",
sunInfo.sign.tarot?.trumpNumber
);
}
if (elements.nowDecanCountdownEl) {
if (decanCountdownCache?.changeAt) {
const remaining = decanCountdownCache.changeAt.getTime() - now.getTime();
elements.nowDecanCountdownEl.textContent = formatCountdown(remaining, timeFormat);
if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = `> ${getDisplayTarotName(decanCountdownCache.nextLabel)}`;
}
} else {
elements.nowDecanCountdownEl.textContent = "--";
if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = "> --";
}
}
}
} else {
elements.nowDecanEl.textContent = "--";
elements.nowDecanTarotEl.textContent = "--";
setNowCardImage(elements.nowDecanCardEl, null, "Current decan card");
if (elements.nowDecanCountdownEl) {
elements.nowDecanCountdownEl.textContent = "--";
}
if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = "> --";
}
}
updateNowStats(referenceData, elements, now);
return {
dayKey,
skyRefreshKey: `${currentHourSkyKey}|${decanSkyKey}|${moonPhase}`
};
}
window.TarotNowUi = {
updateNowPanel
};
})();