added overlay function for tarot cards

This commit is contained in:
2026-03-08 03:52:25 -07:00
parent 84b340d7d1
commit 78abb582dd
17 changed files with 4050 additions and 1175 deletions

View File

@@ -254,6 +254,7 @@
decansJson,
sabianJson,
planetScienceJson,
gematriaCiphersJson,
iChingJson,
calendarMonthsJson,
celestialHolidaysJson,
@@ -269,6 +270,7 @@
fetchJson(`${DATA_ROOT}/decans.json`),
fetchJson(`${DATA_ROOT}/sabian-symbols.json`),
fetchJson(`${DATA_ROOT}/planet-science.json`),
fetchJson(`${DATA_ROOT}/gematria-ciphers.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/i-ching.json`),
fetchJson(`${DATA_ROOT}/calendar-months.json`),
fetchJson(`${DATA_ROOT}/celestial-holidays.json`),
@@ -287,6 +289,9 @@
const planetScience = Array.isArray(planetScienceJson?.planets)
? planetScienceJson.planets
: [];
const gematriaCiphers = gematriaCiphersJson && typeof gematriaCiphersJson === "object"
? gematriaCiphersJson
: {};
const iChing = {
trigrams: Array.isArray(iChingJson?.trigrams) ? iChingJson.trigrams : [],
hexagrams: Array.isArray(iChingJson?.hexagrams) ? iChingJson.hexagrams : [],
@@ -352,6 +357,7 @@
decansBySign: groupDecansBySign(decans),
sabianSymbols,
planetScience,
gematriaCiphers,
iChing,
calendarMonths,
celestialHolidays,

View File

@@ -3,79 +3,21 @@
(function () {
"use strict";
// ----- shared utilities (mirrored from ui-quiz.js since they aren't exported) -----
const quizPluginHelpers = window.QuizPluginHelpers || {};
const {
normalizeOption,
normalizeKey,
toUniqueOptionList,
makeTemplate
} = quizPluginHelpers;
function normalizeOption(value) {
return String(value || "").trim();
}
function normalizeKey(value) {
return normalizeOption(value).toLowerCase();
}
function toUniqueOptionList(values) {
const seen = new Set();
const unique = [];
(values || []).forEach((value) => {
const formatted = normalizeOption(value);
if (!formatted) return;
const key = normalizeKey(formatted);
if (seen.has(key)) return;
seen.add(key);
unique.push(formatted);
});
return unique;
}
function shuffle(list) {
const clone = list.slice();
for (let i = clone.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[clone[i], clone[j]] = [clone[j], clone[i]];
}
return clone;
}
function buildOptions(correctValue, poolValues) {
const correct = normalizeOption(correctValue);
if (!correct) return null;
const uniquePool = toUniqueOptionList(poolValues || []);
if (!uniquePool.some((v) => normalizeKey(v) === normalizeKey(correct))) {
uniquePool.push(correct);
}
const distractors = uniquePool.filter((v) => normalizeKey(v) !== normalizeKey(correct));
if (distractors.length < 3) return null;
const selected = shuffle(distractors).slice(0, 3);
const options = shuffle([correct, ...selected]);
const correctIndex = options.findIndex((v) => normalizeKey(v) === normalizeKey(correct));
if (correctIndex < 0 || options.length < 4) return null;
return { options, correctIndex };
}
/**
* Build a validated quiz question template.
* Returns null if there aren't enough distractors for a 4-choice question.
*/
function makeTemplate(key, categoryId, category, prompt, answer, pool) {
const correctStr = normalizeOption(answer);
const promptStr = normalizeOption(prompt);
if (!key || !categoryId || !promptStr || !correctStr) return null;
const uniquePool = toUniqueOptionList(pool || []);
if (!uniquePool.some((v) => normalizeKey(v) === normalizeKey(correctStr))) {
uniquePool.push(correctStr);
}
const distractorCount = uniquePool.filter((v) => normalizeKey(v) !== normalizeKey(correctStr)).length;
if (distractorCount < 3) return null;
return {
key,
categoryId,
category,
promptByDifficulty: promptStr,
answerByDifficulty: correctStr,
poolByDifficulty: uniquePool
};
if (
typeof normalizeOption !== "function"
|| typeof normalizeKey !== "function"
|| typeof toUniqueOptionList !== "function"
|| typeof makeTemplate !== "function"
) {
throw new Error("QuizPluginHelpers must load before quiz-calendars.js");
}
function ordinal(n) {

873
app/quiz-connections.js Normal file
View File

@@ -0,0 +1,873 @@
/* quiz-connections.js — Dynamic quiz category plugin for dataset relations */
(function () {
"use strict";
const { getTarotCardDisplayName } = window.TarotCardImages || {};
const quizPluginHelpers = window.QuizPluginHelpers || {};
const {
normalizeOption,
normalizeKey,
buildTemplatesFromSpec,
buildTemplatesFromVariants
} = quizPluginHelpers;
if (
typeof normalizeOption !== "function"
|| typeof normalizeKey !== "function"
|| typeof buildTemplatesFromSpec !== "function"
|| typeof buildTemplatesFromVariants !== "function"
) {
throw new Error("QuizPluginHelpers must load before quiz-connections.js");
}
const cache = {
referenceData: null,
magickDataset: null,
templates: [],
templatesByCategory: new Map()
};
function toTitleCase(value) {
const text = normalizeOption(value).toLowerCase();
if (!text) {
return "";
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
function labelFromId(value) {
const id = normalizeOption(value);
if (!id) {
return "";
}
return id
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : ""))
.join(" ");
}
function formatHebrewLetterLabel(entry, fallbackId = "") {
if (entry?.name && entry?.char) {
return `${entry.name} (${entry.char})`;
}
if (entry?.name) {
return entry.name;
}
if (entry?.char) {
return entry.char;
}
return labelFromId(fallbackId);
}
function slugifyId(value) {
return normalizeKey(value)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function formatHexagramLabel(entry) {
const number = Number(entry?.number);
const name = normalizeOption(entry?.name);
if (Number.isFinite(number) && name) {
return `Hexagram ${number}: ${name}`;
}
if (Number.isFinite(number)) {
return `Hexagram ${number}`;
}
return name || "Hexagram";
}
function formatTrigramLabel(trigram) {
const name = normalizeOption(trigram?.name);
const element = normalizeOption(trigram?.element);
if (name && element) {
return `${name} (${element})`;
}
return name || element;
}
function formatTarotLabel(value) {
const raw = normalizeOption(value);
if (!raw) {
return "";
}
const keyMatch = raw.match(/^key\s*(\d{1,2})\s*:\s*(.+)$/i);
if (keyMatch) {
const trumpNumber = Number(keyMatch[1]);
const fallbackName = normalizeOption(keyMatch[2]);
if (typeof getTarotCardDisplayName === "function") {
const displayName = normalizeOption(getTarotCardDisplayName(fallbackName, { trumpNumber }));
if (displayName) {
return displayName;
}
}
return fallbackName;
}
if (typeof getTarotCardDisplayName === "function") {
const displayName = normalizeOption(getTarotCardDisplayName(raw));
if (displayName) {
return displayName;
}
}
return raw.replace(/\bPentacles\b/gi, "Disks");
}
function getPlanetLabelById(planetId, planetsById) {
const key = normalizeKey(planetId);
const label = planetsById?.[key]?.name;
if (label) {
return normalizeOption(label);
}
if (key === "primum-mobile") {
return "Primum Mobile";
}
if (key === "olam-yesodot") {
return "Earth / Elements";
}
return normalizeOption(labelFromId(planetId));
}
function formatPathLetter(path) {
const transliteration = normalizeOption(path?.hebrewLetter?.transliteration);
const glyph = normalizeOption(path?.hebrewLetter?.char);
if (transliteration && glyph) {
return `${transliteration} (${glyph})`;
}
return transliteration || glyph;
}
function getSephiraName(numberValue, idValue, sephiraByNumber, sephiraById) {
const numberKey = Number(numberValue);
if (Number.isFinite(numberKey) && sephiraByNumber.has(Math.trunc(numberKey))) {
return sephiraByNumber.get(Math.trunc(numberKey));
}
const idKey = normalizeKey(idValue);
if (idKey && sephiraById.has(idKey)) {
return sephiraById.get(idKey);
}
if (Number.isFinite(numberKey)) {
return `Sephira ${Math.trunc(numberKey)}`;
}
return labelFromId(idValue);
}
function toRomanNumeral(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return String(value || "");
}
const lookup = [
[1000, "M"],
[900, "CM"],
[500, "D"],
[400, "CD"],
[100, "C"],
[90, "XC"],
[50, "L"],
[40, "XL"],
[10, "X"],
[9, "IX"],
[5, "V"],
[4, "IV"],
[1, "I"]
];
let current = Math.trunc(numeric);
let result = "";
lookup.forEach(([size, symbol]) => {
while (current >= size) {
result += symbol;
current -= size;
}
});
return result || String(Math.trunc(numeric));
}
function formatDecanLabel(decan, signNameById) {
const signName = signNameById.get(normalizeKey(decan?.signId)) || labelFromId(decan?.signId);
const index = Number(decan?.index);
if (!signName || !Number.isFinite(index)) {
return "";
}
return `${signName} Decan ${toRomanNumeral(index)}`;
}
function buildEnglishGematriaCipherGroups(englishLetters, gematriaCiphers) {
const ciphers = Array.isArray(gematriaCiphers?.ciphers) ? gematriaCiphers.ciphers : [];
return ciphers
.map((cipher) => {
const cipherId = normalizeOption(cipher?.id);
const cipherName = normalizeOption(cipher?.name || labelFromId(cipherId));
const slug = slugifyId(cipherId || cipherName);
const values = Array.isArray(cipher?.values) ? cipher.values : [];
if (!slug || !cipherName || !values.length) {
return null;
}
const categoryId = `english-gematria-${slug}`;
const category = `English Gematria: ${cipherName}`;
const rows = englishLetters
.filter((entry) => normalizeOption(entry?.letter) && Number.isFinite(Number(entry?.index)))
.map((entry) => {
const position = Math.trunc(Number(entry.index));
const value = values[position - 1];
if (!Number.isFinite(Number(value))) {
return null;
}
return {
id: normalizeOption(entry.letter),
letter: normalizeOption(entry.letter),
value: String(Math.trunc(Number(value))),
cipherName
};
})
.filter(Boolean);
if (rows.length < 4) {
return null;
}
return {
entries: rows,
variants: [
{
keyPrefix: categoryId,
categoryId,
category,
getKey: (entry) => entry.id,
getPrompt: (entry) => `In the ${entry.cipherName} cipher, ${entry.letter} has a value of`,
getAnswer: (entry) => entry.value
}
]
};
})
.filter(Boolean);
}
function buildAutomatedConnectionTemplates(referenceData, magickDataset) {
if (cache.referenceData === referenceData && cache.magickDataset === magickDataset) {
return cache.templates;
}
const planetsById = referenceData?.planets && typeof referenceData.planets === "object"
? referenceData.planets
: {};
const planets = Object.values(planetsById).filter(Boolean);
const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
const gematriaCiphers = referenceData?.gematriaCiphers && typeof referenceData.gematriaCiphers === "object"
? referenceData.gematriaCiphers
: {};
const decansBySign = referenceData?.decansBySign && typeof referenceData.decansBySign === "object"
? referenceData.decansBySign
: {};
const signNameById = new Map(
signs
.filter((entry) => normalizeOption(entry?.id) && normalizeOption(entry?.name))
.map((entry) => [normalizeKey(entry.id), normalizeOption(entry.name)])
);
const grouped = magickDataset?.grouped || {};
const alphabets = grouped.alphabets || {};
const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : [];
const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : [];
const hebrewById = new Map(
hebrewLetters
.filter((entry) => normalizeOption(entry?.hebrewLetterId))
.map((entry) => [normalizeKey(entry.hebrewLetterId), entry])
);
const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {};
const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : [];
const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : [];
const sephiraByNumber = new Map(
treeSephiroth
.filter((entry) => Number.isFinite(Number(entry?.number)) && normalizeOption(entry?.name))
.map((entry) => [Math.trunc(Number(entry.number)), normalizeOption(entry.name)])
);
const sephiraByTreeId = new Map(
treeSephiroth
.filter((entry) => normalizeOption(entry?.sephiraId) && normalizeOption(entry?.name))
.map((entry) => [normalizeKey(entry.sephiraId), normalizeOption(entry.name)])
);
const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object"
? grouped.kabbalah.sephirot
: {};
const cube = grouped?.kabbalah?.cube && typeof grouped.kabbalah.cube === "object"
? grouped.kabbalah.cube
: {};
const cubeWalls = Array.isArray(cube?.walls) ? cube.walls : [];
const cubeEdges = Array.isArray(cube?.edges) ? cube.edges : [];
const cubeCenter = cube?.center && typeof cube.center === "object" ? cube.center : null;
const playingCardsData = grouped?.["playing-cards-52"];
const playingCards = Array.isArray(playingCardsData)
? playingCardsData
: (Array.isArray(playingCardsData?.entries) ? playingCardsData.entries : []);
const flattenDecans = Object.values(decansBySign).flatMap((entries) => Array.isArray(entries) ? entries : []);
const iChing = referenceData?.iChing;
const trigrams = Array.isArray(iChing?.trigrams) ? iChing.trigrams : [];
const hexagrams = Array.isArray(iChing?.hexagrams) ? iChing.hexagrams : [];
const correspondences = Array.isArray(iChing?.correspondences?.tarotToTrigram)
? iChing.correspondences.tarotToTrigram
: [];
const trigramByKey = new Map(
trigrams
.map((trigram) => [normalizeKey(trigram?.name), trigram])
.filter(([key]) => Boolean(key))
);
const hexagramRows = hexagrams
.map((entry) => {
const upper = trigramByKey.get(normalizeKey(entry?.upperTrigram)) || null;
const lower = trigramByKey.get(normalizeKey(entry?.lowerTrigram)) || null;
return {
...entry,
number: Number(entry?.number),
hexagramLabel: formatHexagramLabel(entry),
upperLabel: formatTrigramLabel(upper || { name: entry?.upperTrigram }),
lowerLabel: formatTrigramLabel(lower || { name: entry?.lowerTrigram })
};
})
.filter((entry) => Number.isFinite(entry.number) && entry.hexagramLabel);
const tarotCorrespondenceRows = correspondences
.map((entry, index) => {
const trigram = trigramByKey.get(normalizeKey(entry?.trigram)) || null;
return {
id: `${index}-${normalizeKey(entry?.tarot)}-${normalizeKey(entry?.trigram)}`,
tarotLabel: formatTarotLabel(entry?.tarot),
trigramLabel: formatTrigramLabel(trigram || { name: entry?.trigram })
};
})
.filter((entry) => entry.tarotLabel && entry.trigramLabel);
const englishGematriaGroups = buildEnglishGematriaCipherGroups(englishLetters, gematriaCiphers);
const hebrewNumerologyRows = hebrewLetters
.filter((entry) => normalizeOption(entry?.name) && normalizeOption(entry?.char) && Number.isFinite(Number(entry?.numerology)))
.map((entry) => ({
id: normalizeOption(entry.hebrewLetterId || entry.name),
glyphLabel: `${normalizeOption(entry.name)} (${normalizeOption(entry.char)})`,
char: normalizeOption(entry.char),
value: String(Math.trunc(Number(entry.numerology)))
}));
const englishHebrewRows = englishLetters
.map((entry) => {
const hebrew = hebrewById.get(normalizeKey(entry?.hebrewLetterId)) || null;
if (!normalizeOption(entry?.letter) || !hebrew) {
return null;
}
return {
id: normalizeOption(entry.letter),
letter: normalizeOption(entry.letter),
hebrewLabel: formatHebrewLetterLabel(hebrew, entry?.hebrewLetterId),
hebrewChar: normalizeOption(hebrew?.char)
};
})
.filter(Boolean);
const kabbalahPathRows = treePaths
.map((path) => {
const pathNumber = Number(path?.pathNumber);
if (!Number.isFinite(pathNumber)) {
return null;
}
const pathNumberLabel = String(Math.trunc(pathNumber));
const fromName = getSephiraName(path?.connects?.from, path?.connectIds?.from, sephiraByNumber, sephiraByTreeId);
const toName = getSephiraName(path?.connects?.to, path?.connectIds?.to, sephiraByNumber, sephiraByTreeId);
const letterLabel = formatPathLetter(path);
const tarotLabel = formatTarotLabel(path?.tarot?.card);
return {
id: pathNumberLabel,
pathNumberLabel,
pathPairLabel: fromName && toName ? `${fromName}${toName}` : "",
fromName,
toName,
letterLabel,
tarotLabel
};
})
.filter(Boolean);
const sephirotRows = Object.values(sephirotById || {})
.map((sephira) => {
const sephiraName = normalizeOption(sephira?.name?.roman || sephira?.name?.en);
const planetLabel = getPlanetLabelById(sephira?.planetId, planetsById);
if (!sephiraName || !planetLabel) {
return null;
}
return {
id: normalizeOption(sephira?.id || sephiraName),
sephiraName,
planetLabel
};
})
.filter(Boolean);
const decanRows = flattenDecans
.map((decan) => {
const id = normalizeOption(decan?.id);
const tarotLabel = formatTarotLabel(decan?.tarotMinorArcana);
const decanLabel = formatDecanLabel(decan, signNameById);
const rulerLabel = getPlanetLabelById(decan?.rulerPlanetId, planetsById);
if (!id || !tarotLabel) {
return null;
}
return {
id,
tarotLabel,
decanLabel,
rulerLabel
};
})
.filter(Boolean);
const cubeTarotRows = [
...cubeWalls.map((wall) => {
const wallName = normalizeOption(wall?.name || labelFromId(wall?.id));
const locationLabel = wallName ? `${wallName} Wall` : "";
const tarotLabel = formatTarotLabel(wall?.associations?.tarotCard);
return tarotLabel && locationLabel
? { id: normalizeOption(wall?.id || wallName), tarotLabel, locationLabel }
: null;
}),
...cubeEdges.map((edge) => {
const edgeName = normalizeOption(edge?.name || labelFromId(edge?.id));
const locationLabel = edgeName ? `${edgeName} Edge` : "";
const hebrew = hebrewById.get(normalizeKey(edge?.hebrewLetterId)) || null;
const tarotLabel = formatTarotLabel(hebrew?.tarot?.card);
return tarotLabel && locationLabel
? { id: normalizeOption(edge?.id || edgeName), tarotLabel, locationLabel }
: null;
}),
(() => {
const tarotLabel = formatTarotLabel(cubeCenter?.associations?.tarotCard || cubeCenter?.tarotCard);
return tarotLabel ? { id: "center", tarotLabel, locationLabel: "Center" } : null;
})()
].filter(Boolean);
const cubeHebrewRows = [
...cubeWalls.map((wall) => {
const wallName = normalizeOption(wall?.name || labelFromId(wall?.id));
const locationLabel = wallName ? `${wallName} Wall` : "";
const hebrew = hebrewById.get(normalizeKey(wall?.hebrewLetterId)) || null;
const hebrewLabel = formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId);
return locationLabel && hebrewLabel
? { id: normalizeOption(wall?.id || wallName), locationLabel, hebrewLabel }
: null;
}),
...cubeEdges.map((edge) => {
const edgeName = normalizeOption(edge?.name || labelFromId(edge?.id));
const locationLabel = edgeName ? `${edgeName} Edge` : "";
const hebrew = hebrewById.get(normalizeKey(edge?.hebrewLetterId)) || null;
const hebrewLabel = formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId);
return locationLabel && hebrewLabel
? { id: normalizeOption(edge?.id || edgeName), locationLabel, hebrewLabel }
: null;
}),
(() => {
const hebrew = hebrewById.get(normalizeKey(cubeCenter?.hebrewLetterId)) || null;
const hebrewLabel = formatHebrewLetterLabel(hebrew, cubeCenter?.hebrewLetterId);
return hebrewLabel ? { id: "center", locationLabel: "Center", hebrewLabel } : null;
})()
].filter(Boolean);
const playingCardRows = playingCards
.map((entry) => {
const cardId = normalizeOption(entry?.id);
const rankLabel = normalizeOption(entry?.rankLabel || entry?.rank);
const suitLabel = normalizeOption(entry?.suitLabel || labelFromId(entry?.suit));
const tarotLabel = formatTarotLabel(entry?.tarotCard);
const playingCardLabel = rankLabel && suitLabel ? `${rankLabel} of ${suitLabel}` : "";
return cardId && playingCardLabel && tarotLabel
? { id: cardId, playingCardLabel, tarotLabel }
: null;
})
.filter(Boolean);
const specGroups = [
...englishGematriaGroups,
{
entries: hebrewNumerologyRows,
variants: [
{
keyPrefix: "hebrew-number",
categoryId: "hebrew-numerology",
category: "Hebrew Gematria",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.glyphLabel} has a gematria value of`,
getAnswer: (entry) => entry.value
}
]
},
{
entries: englishHebrewRows,
variants: [
{
keyPrefix: "english-hebrew",
categoryId: "english-hebrew-mapping",
category: "Alphabet Mapping",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.letter} maps to which Hebrew letter`,
getAnswer: (entry) => entry.hebrewLabel,
inverse: {
keyPrefix: "hebrew-english",
getUniquenessKey: (entry) => entry.hebrewLabel,
getKey: (entry) => entry.hebrewChar || entry.hebrewLabel,
getPrompt: (entry) => `${entry.hebrewLabel} maps to which English letter`,
getAnswer: (entry) => entry.letter
}
}
]
},
{
entries: signs.filter((entry) => entry?.name && entry?.rulingPlanetId),
variants: [
{
keyPrefix: "zodiac-ruler",
categoryId: "zodiac-rulers",
category: "Zodiac Rulers",
getKey: (entry) => entry.id || entry.name,
getPrompt: (entry) => `${entry.name} is ruled by`,
getAnswer: (entry) => getPlanetLabelById(entry.rulingPlanetId, planetsById)
}
]
},
{
entries: signs.filter((entry) => entry?.name && entry?.element),
variants: [
{
keyPrefix: "zodiac-element",
categoryId: "zodiac-elements",
category: "Zodiac Elements",
getKey: (entry) => entry.id || entry.name,
getPrompt: (entry) => `${entry.name} is`,
getAnswer: (entry) => normalizeOption(entry.element).replace(/^./, (char) => char.toUpperCase())
}
]
},
{
entries: planets.filter((entry) => entry?.name && entry?.weekday),
variants: [
{
keyPrefix: "planet-weekday",
categoryId: "planetary-weekdays",
category: "Planetary Weekdays",
getKey: (entry) => entry.id || entry.name,
getPrompt: (entry) => `${entry.name} corresponds to`,
getAnswer: (entry) => normalizeOption(entry.weekday),
inverse: {
keyPrefix: "weekday-planet",
getUniquenessKey: (entry) => normalizeOption(entry.weekday),
getKey: (entry) => normalizeOption(entry.weekday),
getPrompt: (entry) => `${normalizeOption(entry.weekday)} corresponds to which planet`,
getAnswer: (entry) => normalizeOption(entry.name)
}
}
]
},
{
entries: signs.filter((entry) => entry?.name && (entry?.tarot?.majorArcana || entry?.tarot?.card)),
variants: [
{
keyPrefix: "zodiac-tarot",
categoryId: "zodiac-tarot",
category: "Zodiac ↔ Tarot",
getKey: (entry) => entry.id || entry.name,
getPrompt: (entry) => `${entry.name} corresponds to`,
getAnswer: (entry) => formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card),
inverse: {
keyPrefix: "tarot-zodiac",
getUniquenessKey: (entry) => formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card),
getKey: (entry) => formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card),
getPrompt: (entry) => `${formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card)} corresponds to which zodiac sign`,
getAnswer: (entry) => normalizeOption(entry.name)
}
}
]
},
{
entries: kabbalahPathRows.filter((entry) => entry.fromName && entry.toName),
variants: [
{
keyPrefix: "kabbalah-path-between",
categoryId: "kabbalah-path-between-sephirot",
category: "Kabbalah Paths",
getKey: (entry) => entry.id,
getPrompt: (entry) => `What path connects ${entry.fromName} and ${entry.toName}`,
getAnswer: (entry) => entry.pathNumberLabel
}
]
},
{
entries: kabbalahPathRows.filter((entry) => entry.letterLabel),
variants: [
{
keyPrefix: "kabbalah-path-letter",
categoryId: "kabbalah-path-letter",
category: "Kabbalah Paths",
getKey: (entry) => entry.id,
getPrompt: (entry) => `Path ${entry.pathNumberLabel} carries which Hebrew letter`,
getAnswer: (entry) => entry.letterLabel,
inverse: {
keyPrefix: "kabbalah-letter-path-number",
getUniquenessKey: (entry) => entry.letterLabel,
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.letterLabel} belongs to which path`,
getAnswer: (entry) => entry.pathNumberLabel
}
}
]
},
{
entries: kabbalahPathRows.filter((entry) => entry.tarotLabel),
variants: [
{
keyPrefix: "kabbalah-path-tarot",
categoryId: "kabbalah-path-tarot",
category: "Kabbalah ↔ Tarot",
getKey: (entry) => entry.id,
getPrompt: (entry) => `Path ${entry.pathNumberLabel} corresponds to which Tarot trump`,
getAnswer: (entry) => entry.tarotLabel,
inverse: {
keyPrefix: "tarot-trump-path",
getUniquenessKey: (entry) => entry.tarotLabel,
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.tarotLabel} is on which path`,
getAnswer: (entry) => entry.pathNumberLabel
}
}
]
},
{
entries: sephirotRows,
variants: [
{
keyPrefix: "sephirot-planet",
categoryId: "sephirot-planets",
category: "Sephirot ↔ Planet",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.sephiraName} corresponds to which planet`,
getAnswer: (entry) => entry.planetLabel
}
]
},
{
entries: decanRows.filter((entry) => entry.decanLabel),
variants: [
{
keyPrefix: "tarot-decan-sign",
categoryId: "tarot-decan-sign",
category: "Tarot Decans",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.tarotLabel} belongs to which decan`,
getAnswer: (entry) => entry.decanLabel,
inverse: {
keyPrefix: "decan-tarot-card",
getUniquenessKey: (entry) => entry.decanLabel,
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.decanLabel} corresponds to which Tarot card`,
getAnswer: (entry) => entry.tarotLabel
}
}
]
},
{
entries: decanRows.filter((entry) => entry.rulerLabel),
variants: [
{
keyPrefix: "tarot-decan-ruler",
categoryId: "tarot-decan-ruler",
category: "Tarot Decans",
getKey: (entry) => entry.id,
getPrompt: (entry) => `The decan of ${entry.tarotLabel} is ruled by`,
getAnswer: (entry) => entry.rulerLabel
}
]
},
{
entries: cubeTarotRows,
variants: [
{
keyPrefix: "tarot-cube-location",
categoryId: "tarot-cube-location",
category: "Tarot ↔ Cube",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.tarotLabel} is on which Cube location`,
getAnswer: (entry) => entry.locationLabel,
inverse: {
keyPrefix: "cube-location-tarot",
getUniquenessKey: (entry) => entry.locationLabel,
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.locationLabel} corresponds to which Tarot card`,
getAnswer: (entry) => entry.tarotLabel
}
}
]
},
{
entries: cubeHebrewRows,
variants: [
{
keyPrefix: "cube-hebrew-letter",
categoryId: "cube-hebrew-letter",
category: "Cube ↔ Hebrew",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.locationLabel} corresponds to which Hebrew letter`,
getAnswer: (entry) => entry.hebrewLabel,
inverse: {
keyPrefix: "hebrew-cube-location",
getUniquenessKey: (entry) => entry.hebrewLabel,
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.hebrewLabel} is on which Cube location`,
getAnswer: (entry) => entry.locationLabel
}
}
]
},
{
entries: playingCardRows,
variants: [
{
keyPrefix: "playing-card-tarot",
categoryId: "playing-card-tarot",
category: "Playing Card ↔ Tarot",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.playingCardLabel} maps to which Tarot card`,
getAnswer: (entry) => entry.tarotLabel,
inverse: {
keyPrefix: "tarot-playing-card",
getUniquenessKey: (entry) => entry.tarotLabel,
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.tarotLabel} maps to which playing card`,
getAnswer: (entry) => entry.playingCardLabel
}
}
]
},
{
entries: hexagramRows.filter((entry) => normalizeOption(entry.planetaryInfluence)),
variants: [
{
keyPrefix: "iching-planet",
categoryId: "iching-planetary-influence",
category: "I Ching ↔ Planet",
getKey: (entry) => String(entry.number),
getPrompt: (entry) => `${entry.hexagramLabel} corresponds to which planetary influence`,
getAnswer: (entry) => normalizeOption(entry.planetaryInfluence)
}
]
},
{
entries: hexagramRows,
variants: [
{
keyPrefix: "iching-upper-trigram",
categoryId: "iching-trigrams",
category: "I Ching Trigrams",
getKey: (entry) => `${entry.number}-upper`,
getPrompt: (entry) => `What is the upper trigram of ${entry.hexagramLabel}`,
getAnswer: (entry) => entry.upperLabel
},
{
keyPrefix: "iching-lower-trigram",
categoryId: "iching-trigrams",
category: "I Ching Trigrams",
getKey: (entry) => `${entry.number}-lower`,
getPrompt: (entry) => `What is the lower trigram of ${entry.hexagramLabel}`,
getAnswer: (entry) => entry.lowerLabel
}
]
},
{
entries: tarotCorrespondenceRows,
variants: [
{
keyPrefix: "iching-tarot-trigram",
categoryId: "iching-tarot-correspondence",
category: "I Ching ↔ Tarot",
getKey: (entry) => entry.id,
getPrompt: (entry) => `${entry.tarotLabel} corresponds to which I Ching trigram`,
getAnswer: (entry) => entry.trigramLabel
}
]
}
];
const templates = specGroups.flatMap((specGroup) => {
if (Array.isArray(specGroup.variants) && specGroup.variants.length > 1) {
return buildTemplatesFromVariants(specGroup);
}
const [variant] = specGroup.variants || [];
return buildTemplatesFromSpec({
entries: specGroup.entries,
categoryId: variant?.categoryId,
category: variant?.category,
keyPrefix: variant?.keyPrefix,
getKey: variant?.getKey,
getPrompt: variant?.getPrompt,
getAnswer: variant?.getAnswer
});
});
const templatesByCategory = new Map();
templates.forEach((template) => {
if (!templatesByCategory.has(template.categoryId)) {
templatesByCategory.set(template.categoryId, []);
}
templatesByCategory.get(template.categoryId).push(template);
});
cache.referenceData = referenceData;
cache.magickDataset = magickDataset;
cache.templates = templates;
cache.templatesByCategory = templatesByCategory;
return templates;
}
function buildConnectionTemplates(referenceData, magickDataset) {
return buildAutomatedConnectionTemplates(referenceData, magickDataset).slice();
}
function registerConnectionQuizCategories() {
const { registerQuizCategory } = window.QuizSectionUi || {};
if (typeof registerQuizCategory !== "function") {
return;
}
registerQuizCategory("quiz-connections", "Connection Quizzes", buildConnectionTemplates);
}
registerConnectionQuizCategories();
window.QuizConnectionsPlugin = {
registerConnectionQuizCategories,
buildAutomatedConnectionTemplates,
buildConnectionTemplates
};
})();

168
app/quiz-plugin-helpers.js Normal file
View File

@@ -0,0 +1,168 @@
/* quiz-plugin-helpers.js — Shared utilities for dynamic quiz plugins */
(function () {
"use strict";
function normalizeOption(value) {
return String(value || "").trim();
}
function normalizeKey(value) {
return normalizeOption(value).toLowerCase();
}
function toUniqueOptionList(values) {
const seen = new Set();
const unique = [];
(values || []).forEach((value) => {
const formatted = normalizeOption(value);
if (!formatted) {
return;
}
const key = normalizeKey(formatted);
if (seen.has(key)) {
return;
}
seen.add(key);
unique.push(formatted);
});
return unique;
}
function makeTemplate(key, categoryId, category, prompt, answer, pool) {
const promptText = normalizeOption(prompt);
const answerText = normalizeOption(answer);
const optionPool = toUniqueOptionList(pool || []);
if (!key || !categoryId || !category || !promptText || !answerText) {
return null;
}
if (!optionPool.some((value) => normalizeKey(value) === normalizeKey(answerText))) {
optionPool.push(answerText);
}
const distractorCount = optionPool.filter((value) => normalizeKey(value) !== normalizeKey(answerText)).length;
if (distractorCount < 3) {
return null;
}
return {
key,
categoryId,
category,
promptByDifficulty: promptText,
answerByDifficulty: answerText,
poolByDifficulty: optionPool
};
}
function buildTemplatesFromSpec(spec) {
const rows = Array.isArray(spec?.entries) ? spec.entries : [];
const categoryId = normalizeOption(spec?.categoryId);
const category = normalizeOption(spec?.category);
const keyPrefix = normalizeOption(spec?.keyPrefix);
const getPrompt = spec?.getPrompt;
const getAnswer = spec?.getAnswer;
const getKey = spec?.getKey;
if (!rows.length || !categoryId || !category || !keyPrefix) {
return [];
}
if (typeof getPrompt !== "function" || typeof getAnswer !== "function") {
return [];
}
const pool = toUniqueOptionList(rows.map((entry) => getAnswer(entry)).filter(Boolean));
if (pool.length < 4) {
return [];
}
return rows
.map((entry, index) => {
const keyValue = typeof getKey === "function" ? getKey(entry, index) : String(index);
return makeTemplate(
`${keyPrefix}:${keyValue}`,
categoryId,
category,
getPrompt(entry),
getAnswer(entry),
pool
);
})
.filter(Boolean);
}
function buildTemplatesFromVariants(spec) {
const variants = Array.isArray(spec?.variants) ? spec.variants : [];
if (!variants.length) {
return [];
}
return variants.flatMap((variant) => {
const forwardTemplates = buildTemplatesFromSpec({
entries: spec.entries,
categoryId: variant.categoryId || spec.categoryId,
category: variant.category || spec.category,
keyPrefix: variant.keyPrefix || spec.keyPrefix,
getKey: variant.getKey || spec.getKey,
getPrompt: variant.getPrompt,
getAnswer: variant.getAnswer
});
const inverse = variant.inverse;
if (!inverse || typeof inverse.getPrompt !== "function" || typeof inverse.getAnswer !== "function") {
return forwardTemplates;
}
const entries = Array.isArray(spec.entries) ? spec.entries : [];
const rows = entries.filter((entry) => {
const uniquenessValue = typeof inverse.getUniquenessKey === "function"
? inverse.getUniquenessKey(entry)
: variant.getAnswer(entry);
return normalizeOption(uniquenessValue) && normalizeOption(inverse.getAnswer(entry));
});
const occurrenceCountByKey = new Map();
rows.forEach((entry) => {
const uniquenessValue = typeof inverse.getUniquenessKey === "function"
? inverse.getUniquenessKey(entry)
: variant.getAnswer(entry);
const key = normalizeKey(uniquenessValue);
occurrenceCountByKey.set(key, (occurrenceCountByKey.get(key) || 0) + 1);
});
const uniqueRows = rows.filter((entry) => {
const uniquenessValue = typeof inverse.getUniquenessKey === "function"
? inverse.getUniquenessKey(entry)
: variant.getAnswer(entry);
return occurrenceCountByKey.get(normalizeKey(uniquenessValue)) === 1;
});
const inverseTemplates = buildTemplatesFromSpec({
entries: uniqueRows,
categoryId: inverse.categoryId || variant.categoryId || spec.categoryId,
category: inverse.category || variant.category || spec.category,
keyPrefix: inverse.keyPrefix || `${variant.keyPrefix || spec.keyPrefix}-reverse`,
getKey: inverse.getKey || variant.getKey || spec.getKey,
getPrompt: inverse.getPrompt,
getAnswer: inverse.getAnswer
});
return [...forwardTemplates, ...inverseTemplates];
});
}
window.QuizPluginHelpers = {
normalizeOption,
normalizeKey,
toUniqueOptionList,
makeTemplate,
buildTemplatesFromSpec,
buildTemplatesFromVariants
};
})();

View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stellarium NOW Wrapper</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
body {
position: relative;
font-family: sans-serif;
}
#sky-shell {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.08), transparent 36%),
radial-gradient(circle at 20% 12%, rgba(120, 160, 255, 0.12), transparent 28%),
#000;
}
#sky-embed {
position: absolute;
inset: 0;
width: 100%;
height: 106%;
top: -2%;
border: 0;
background: #000;
}
#sky-shell::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.08), transparent 42%),
linear-gradient(to bottom, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.38));
z-index: 1;
}
#sky-shell::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 30%;
pointer-events: none;
background:
linear-gradient(
to top,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 1) 14%,
rgba(0, 0, 0, 0.94) 34%,
rgba(0, 0, 0, 0.74) 56%,
rgba(0, 0, 0, 0.24) 80%,
rgba(0, 0, 0, 0) 100%
);
z-index: 2;
}
</style>
</head>
<body>
<div id="sky-shell">
<iframe id="sky-embed" title="Decorative sky background" scrolling="no" allow="geolocation"></iframe>
</div>
<script>
(function () {
"use strict";
const HOSTED_STELLARIUM_URL = "https://stellarium-web.org/";
const FORWARDED_PARAMS = ["lat", "lng", "elev", "date", "az", "alt", "fov"];
const wrapperParams = new URLSearchParams(window.location.search);
const hostedUrl = new URL(HOSTED_STELLARIUM_URL);
FORWARDED_PARAMS.forEach((key) => {
const value = wrapperParams.get(key);
if (value !== null && value !== "") {
hostedUrl.searchParams.set(key, value);
}
});
document.getElementById("sky-embed").src = hostedUrl.toString();
})();
</script>
</body>
</html>

View File

@@ -557,14 +557,170 @@
gap: 10px;
}
.tarot-house-layout {
display: grid;
.tarot-house-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.tarot-house-card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tarot-house-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid #3f3f46;
background: #18181b;
color: #f4f4f5;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.tarot-house-toggle:hover {
background: #27272a;
border-color: #52525b;
}
.tarot-house-toggle input {
margin: 0;
accent-color: #6366f1;
}
.tarot-house-toggle input:disabled {
cursor: progress;
}
.tarot-house-toggle:has(input:disabled) {
opacity: 0.65;
cursor: progress;
}
.tarot-house-filter {
display: grid;
gap: 4px;
color: #d4d4d8;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tarot-house-filter-select {
min-width: 132px;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid #3f3f46;
background: #18181b;
color: #f4f4f5;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.tarot-house-filter-select:disabled {
opacity: 0.65;
cursor: progress;
}
.tarot-house-filter-group {
margin: 0;
padding: 6px 8px;
min-height: 36px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
border: 1px solid #3f3f46;
border-radius: 8px;
background: #18181b;
}
.tarot-house-filter-group legend {
padding: 0 4px;
color: #d4d4d8;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tarot-house-mini-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding: 2px 6px;
border-radius: 999px;
background: #111118;
color: #f4f4f5;
font-size: 12px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.tarot-house-mini-toggle input {
margin: 0;
accent-color: #6366f1;
}
.tarot-house-mini-toggle:has(input:disabled) {
opacity: 0.65;
cursor: progress;
}
.tarot-house-action-btn {
padding: 7px 12px;
border-radius: 8px;
border: 1px solid #3f3f46;
background: #18181b;
color: #f4f4f5;
cursor: pointer;
font-size: 13px;
line-height: 1.2;
}
.tarot-house-action-btn:hover:not(:disabled) {
background: #27272a;
border-color: #52525b;
}
.tarot-house-action-btn[aria-pressed="true"] {
background: #312e81;
border-color: #6366f1;
}
.tarot-house-action-btn:disabled {
opacity: 0.65;
cursor: progress;
}
.tarot-house-layout {
--tarot-house-card-width: 76.8px;
--tarot-house-card-height: 115.2px;
--tarot-house-card-gap: 6px;
--tarot-house-row-gap: 8px;
--tarot-house-section-gap: 12px;
--tarot-house-major-row-width: calc((var(--tarot-house-card-width) * 11) + (var(--tarot-house-card-gap) * 10));
display: grid;
gap: var(--tarot-house-section-gap);
justify-content: center;
}
.tarot-house-trumps {
display: grid;
gap: 8px;
gap: var(--tarot-house-row-gap);
overflow-x: auto;
padding-bottom: 2px;
}
@@ -572,29 +728,34 @@
.tarot-house-trump-row {
display: flex;
flex-wrap: nowrap;
gap: 6px;
width: max-content;
gap: var(--tarot-house-card-gap);
width: min(100%, var(--tarot-house-major-row-width));
max-width: 100%;
justify-content: center;
margin: 0 auto;
}
.tarot-house-bottom-grid {
display: grid;
grid-template-columns: repeat(3, max-content);
column-gap: 6px;
width: min(100%, var(--tarot-house-major-row-width));
max-width: 100%;
column-gap: 0;
row-gap: 0;
justify-content: center;
justify-content: space-between;
margin: 0 auto;
}
.tarot-house-column {
display: grid;
gap: 8px;
gap: var(--tarot-house-row-gap);
align-content: start;
}
.tarot-house-row {
display: flex;
flex-wrap: nowrap;
gap: 6px;
gap: var(--tarot-house-card-gap);
}
.tarot-house-card-btn {
@@ -610,11 +771,24 @@
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.tarot-house-card-btn.is-text-only {
background:
radial-gradient(circle at top, rgba(99, 102, 241, 0.16) 0%, rgba(99, 102, 241, 0) 48%),
linear-gradient(180deg, #1b1b27 0%, #111118 100%);
line-height: 1.2;
}
.tarot-house-card-btn:hover {
border-color: #7060b0;
background: #27272a;
}
.tarot-house-card-btn.is-text-only:hover {
background:
radial-gradient(circle at top, rgba(129, 140, 248, 0.2) 0%, rgba(129, 140, 248, 0) 50%),
linear-gradient(180deg, #212132 0%, #171723 100%);
}
.tarot-house-card-btn.is-selected {
border-color: #7060b0;
background: #27272a;
@@ -625,15 +799,98 @@
.tarot-house-card-image {
display: block;
width: 76.8px;
height: 115.2px;
width: var(--tarot-house-card-width);
height: var(--tarot-house-card-height);
object-fit: cover;
background: #09090b;
}
.tarot-house-card-text-face {
width: var(--tarot-house-card-width);
height: var(--tarot-house-card-height);
display: grid;
align-content: center;
justify-items: center;
gap: 6px;
padding: 10px 8px;
box-sizing: border-box;
color: #fafafa;
text-align: center;
}
.tarot-house-card-text-face.is-dense {
gap: 4px;
padding: 8px 7px;
}
.tarot-house-card-text-face.is-top-hebrew .tarot-house-card-text-primary {
font-size: 26px;
line-height: 1;
font-family: "Noto Sans Hebrew", "Segoe UI Symbol", sans-serif;
}
.tarot-house-card-text-primary {
display: block;
font-size: 13px;
line-height: 1.2;
font-weight: 700;
letter-spacing: 0.02em;
overflow-wrap: anywhere;
}
.tarot-house-card-text-secondary {
display: block;
color: rgba(250, 250, 250, 0.76);
font-size: 9px;
line-height: 1.25;
letter-spacing: 0.02em;
overflow-wrap: anywhere;
}
.tarot-house-card-label {
position: absolute;
left: 4px;
right: 4px;
bottom: 4px;
padding: 4px 5px;
border-radius: 5px;
background: linear-gradient(180deg, rgba(9, 9, 11, 0.2) 0%, rgba(9, 9, 11, 0.9) 100%);
color: #fafafa;
font-size: 9px;
line-height: 1.2;
text-align: center;
letter-spacing: 0.02em;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
box-sizing: border-box;
}
.tarot-house-card-label.is-top-hebrew {
padding-top: 5px;
padding-bottom: 5px;
}
.tarot-house-card-label.is-dense {
font-size: 8px;
line-height: 1.15;
}
.tarot-house-card-label-primary {
display: block;
font-weight: 700;
}
.tarot-house-card-label-secondary {
display: block;
margin-top: 2px;
color: rgba(250, 250, 250, 0.84);
font-size: 8px;
font-weight: 500;
}
.tarot-house-card-fallback {
width: 76.8px;
height: 115.2px;
width: var(--tarot-house-card-width);
height: var(--tarot-house-card-height);
display: flex;
align-items: center;
justify-content: center;
@@ -647,6 +904,35 @@
box-sizing: border-box;
}
#tarot-browse-view.is-house-focus {
grid-template-rows: minmax(0, 1fr);
}
#tarot-browse-view.is-house-focus .tarot-section-house-top {
max-height: none;
height: 100%;
padding: 14px;
overflow: auto;
}
#tarot-browse-view.is-house-focus .tarot-layout {
display: none;
}
#tarot-browse-view.is-house-focus .tarot-house-layout {
--tarot-house-card-gap: clamp(4px, 0.6vw, 8px);
--tarot-house-row-gap: clamp(6px, 0.9vw, 10px);
--tarot-house-section-gap: clamp(12px, 1.4vw, 16px);
--tarot-house-card-width: clamp(48px, calc((100vw - 240px) / 11), 112px);
--tarot-house-card-height: calc(var(--tarot-house-card-width) * 1.5);
min-height: 100%;
align-content: start;
}
#tarot-browse-view.is-house-focus .tarot-house-trumps {
overflow-x: hidden;
}
.planet-layout {
height: 100%;
display: grid;
@@ -2024,6 +2310,7 @@
.alpha-enochian-glyph-img--detail {
width: 72px;
height: 72px;
filter:
drop-shadow(0 0 0.7px rgba(255, 255, 255, 0.82))
drop-shadow(0 0 1.6px rgba(255, 255, 255, 0.56));
flex: 1;
@@ -3269,13 +3556,15 @@
#now-panel {
position: relative;
overflow: hidden;
padding: 16px 24px;
padding: 38px 24px 76px;
min-height: clamp(740px, 90vh, 1140px);
background: #1e1e24;
border-bottom: 1px solid #27272a;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
align-items: start;
align-content: start;
}
#now-panel[hidden] {
@@ -3295,6 +3584,13 @@
filter: none;
background-color: #000;
}
#now-panel::before {
content: "";
grid-column: 1 / -1;
grid-row: 3;
height: clamp(250px, 31vh, 430px);
pointer-events: none;
}
#now-panel::after {
content: "";
position: absolute;
@@ -3437,6 +3733,7 @@
position: relative;
z-index: 2;
grid-column: 1 / -1;
grid-row: 2;
margin-top: 10px;
border-radius: 14px;
padding: 12px 14px;

View File

@@ -64,6 +64,7 @@
suit: null,
rank: null,
hebrewLetterId,
kabbalahPathNumber: Number.isFinite(Number(card?.number)) ? Number(card.number) + 11 : null,
hebrewLetter: hebrewLetterRelation?.data || null,
summary: card.summary,
meanings: {
@@ -139,6 +140,7 @@
const cards = [];
const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
const signById = Object.fromEntries(signs.map((sign) => [sign.id, sign]));
const planets = referenceData?.planets || {};
const decanById = new Map();
const decansBySign = referenceData?.decansBySign || {};
@@ -200,6 +202,24 @@
)
);
const ruler = planets?.[meta?.decan?.rulerPlanetId] || null;
if (ruler) {
dynamicRelations.push(
createRelation(
"decanRuler",
`${meta.signId}-${meta.index}-${ruler.id || meta.decan?.rulerPlanetId || rankKey}-${rankKey}-${suitKey}`,
`Decan ruler: ${ruler.symbol || ""} ${ruler.name || meta.decan?.rulerPlanetId || ""}`.trim(),
{
signId: meta.signId,
decanIndex: meta.index,
planetId: ruler.id || meta.decan?.rulerPlanetId || null,
symbol: ruler.symbol || "",
name: ruler.name || meta.decan?.rulerPlanetId || ""
}
)
);
}
const dateRange = meta.dateRange;
if (dateRange?.start && dateRange?.end) {
const monthNumbers = listMonthNumbersBetween(dateRange.start, dateRange.end);

View File

@@ -4,6 +4,8 @@
let config = {};
let lastNowSkyGeoKey = "";
let lastNowSkySourceUrl = "";
const NOW_SKY_WRAPPER_PATH = "app/stellarium-now-wrapper.html";
const NOW_SKY_FOV_DEGREES = "220";
function getNowSkyLayerEl() {
return config.nowSkyLayerEl || null;
@@ -37,16 +39,16 @@
return "";
}
const stellariumUrl = new URL("https://stellarium-web.org/");
stellariumUrl.searchParams.set("lat", String(normalizedGeo.latitude));
stellariumUrl.searchParams.set("lng", String(normalizedGeo.longitude));
stellariumUrl.searchParams.set("elev", "0");
stellariumUrl.searchParams.set("date", new Date().toISOString());
stellariumUrl.searchParams.set("az", "0");
stellariumUrl.searchParams.set("alt", "90");
stellariumUrl.searchParams.set("fov", "180");
const wrapperUrl = new URL(NOW_SKY_WRAPPER_PATH, window.location.href);
wrapperUrl.searchParams.set("lat", String(normalizedGeo.latitude));
wrapperUrl.searchParams.set("lng", String(normalizedGeo.longitude));
wrapperUrl.searchParams.set("elev", "0");
wrapperUrl.searchParams.set("date", new Date().toISOString());
wrapperUrl.searchParams.set("az", "0");
wrapperUrl.searchParams.set("alt", "90");
wrapperUrl.searchParams.set("fov", NOW_SKY_FOV_DEGREES);
return stellariumUrl.toString();
return wrapperUrl.toString();
}
function syncNowSkyBackground(geo, force = false) {

View File

@@ -1,611 +1,8 @@
/* ui-quiz-bank-builtins-domains.js — Built-in quiz domain template generation */
/* ui-quiz-bank-builtins-domains.js — Legacy built-in quiz compatibility layer */
(function () {
"use strict";
function appendBuiltInQuestionBankDomains(context) {
const {
bank,
helpers,
englishLetters,
hebrewLetters,
hebrewById,
signs,
planets,
planetsById,
treePaths,
sephirotById,
flattenDecans,
cubeWalls,
cubeEdges,
cubeCenter,
playingCards,
pools
} = context || {};
const {
createQuestionTemplate,
normalizeId,
normalizeOption,
toTitleCase,
formatHebrewLetterLabel,
getPlanetLabelById,
getSephiraName,
formatPathLetter,
formatDecanLabel,
labelFromId
} = helpers || {};
if (!Array.isArray(bank) || typeof createQuestionTemplate !== "function") {
return;
}
const {
englishGematriaPool,
hebrewNumerologyPool,
hebrewNameAndCharPool,
hebrewCharPool,
planetNamePool,
planetWeekdayPool,
zodiacElementPool,
zodiacTarotPool,
pathNumberPool,
pathLetterPool,
pathTarotPool,
sephirotPlanetPool,
decanLabelPool,
decanRulerPool,
cubeLocationPool,
cubeHebrewLetterPool,
playingTarotPool
} = pools || {};
(englishLetters || []).forEach((entry) => {
if (!entry?.letter || !Number.isFinite(Number(entry?.pythagorean))) {
return;
}
const template = createQuestionTemplate(
{
key: `english-gematria:${entry.letter}`,
categoryId: "english-gematria",
category: "English Gematria",
promptByDifficulty: `${entry.letter} has a simple gematria value of`,
answerByDifficulty: String(entry.pythagorean)
},
englishGematriaPool
);
if (template) {
bank.push(template);
}
});
(hebrewLetters || []).forEach((entry) => {
if (!entry?.name || !entry?.char || !Number.isFinite(Number(entry?.numerology))) {
return;
}
const template = createQuestionTemplate(
{
key: `hebrew-number:${entry.hebrewLetterId || entry.name}`,
categoryId: "hebrew-numerology",
category: "Hebrew Gematria",
promptByDifficulty: {
easy: `${entry.name} (${entry.char}) has a gematria value of`,
normal: `${entry.name} (${entry.char}) has a gematria value of`,
hard: `${entry.char} has a gematria value of`
},
answerByDifficulty: String(entry.numerology)
},
hebrewNumerologyPool
);
if (template) {
bank.push(template);
}
});
(englishLetters || []).forEach((entry) => {
if (!entry?.letter || !entry?.hebrewLetterId) {
return;
}
const mappedHebrew = hebrewById.get(normalizeId(entry.hebrewLetterId));
if (!mappedHebrew?.name || !mappedHebrew?.char) {
return;
}
const template = createQuestionTemplate(
{
key: `english-hebrew:${entry.letter}`,
categoryId: "english-hebrew-mapping",
category: "Alphabet Mapping",
promptByDifficulty: {
easy: `${entry.letter} maps to which Hebrew letter`,
normal: `${entry.letter} maps to which Hebrew letter`,
hard: `${entry.letter} maps to which Hebrew glyph`
},
answerByDifficulty: {
easy: `${mappedHebrew.name} (${mappedHebrew.char})`,
normal: `${mappedHebrew.name} (${mappedHebrew.char})`,
hard: mappedHebrew.char
}
},
{
easy: hebrewNameAndCharPool,
normal: hebrewNameAndCharPool,
hard: hebrewCharPool
}
);
if (template) {
bank.push(template);
}
});
(signs || []).forEach((entry) => {
if (!entry?.name || !entry?.rulingPlanetId) {
return;
}
const rulerName = planetsById[normalizeId(entry.rulingPlanetId)]?.name;
if (!rulerName) {
return;
}
const template = createQuestionTemplate(
{
key: `zodiac-ruler:${entry.id || entry.name}`,
categoryId: "zodiac-rulers",
category: "Zodiac Rulers",
promptByDifficulty: `${entry.name} is ruled by`,
answerByDifficulty: rulerName
},
planetNamePool
);
if (template) {
bank.push(template);
}
});
(signs || []).forEach((entry) => {
if (!entry?.name || !entry?.element) {
return;
}
const template = createQuestionTemplate(
{
key: `zodiac-element:${entry.id || entry.name}`,
categoryId: "zodiac-elements",
category: "Zodiac Elements",
promptByDifficulty: `${entry.name} is`,
answerByDifficulty: toTitleCase(entry.element)
},
zodiacElementPool
);
if (template) {
bank.push(template);
}
});
(planets || []).forEach((entry) => {
if (!entry?.name || !entry?.weekday) {
return;
}
const template = createQuestionTemplate(
{
key: `planet-weekday:${entry.id || entry.name}`,
categoryId: "planetary-weekdays",
category: "Planetary Weekdays",
promptByDifficulty: `${entry.name} corresponds to`,
answerByDifficulty: entry.weekday
},
planetWeekdayPool
);
if (template) {
bank.push(template);
}
});
(signs || []).forEach((entry) => {
if (!entry?.name || !entry?.tarot?.majorArcana) {
return;
}
const template = createQuestionTemplate(
{
key: `zodiac-tarot:${entry.id || entry.name}`,
categoryId: "zodiac-tarot",
category: "Zodiac ↔ Tarot",
promptByDifficulty: `${entry.name} corresponds to`,
answerByDifficulty: entry.tarot.majorArcana
},
zodiacTarotPool
);
if (template) {
bank.push(template);
}
});
(treePaths || []).forEach((path) => {
const pathNo = Number(path?.pathNumber);
if (!Number.isFinite(pathNo)) {
return;
}
const pathNumberLabel = String(Math.trunc(pathNo));
const fromNo = Number(path?.connects?.from);
const toNo = Number(path?.connects?.to);
const fromName = getSephiraName(fromNo, path?.connectIds?.from);
const toName = getSephiraName(toNo, path?.connectIds?.to);
const pathLetter = formatPathLetter(path);
const tarotCard = normalizeOption(path?.tarot?.card);
if (fromName && toName) {
const template = createQuestionTemplate(
{
key: `kabbalah-path-between:${pathNumberLabel}`,
categoryId: "kabbalah-path-between-sephirot",
category: "Kabbalah Paths",
promptByDifficulty: {
easy: `Which path is between ${fromName} and ${toName}`,
normal: `What path connects ${fromName} and ${toName}`,
hard: `${fromName}${toName} is which path`
},
answerByDifficulty: pathNumberLabel
},
pathNumberPool
);
if (template) {
bank.push(template);
}
}
if (pathLetter) {
const numberToLetterTemplate = createQuestionTemplate(
{
key: `kabbalah-path-letter:${pathNumberLabel}`,
categoryId: "kabbalah-path-letter",
category: "Kabbalah Paths",
promptByDifficulty: {
easy: `Which letter is on Path ${pathNumberLabel}`,
normal: `Path ${pathNumberLabel} carries which Hebrew letter`,
hard: `Letter on Path ${pathNumberLabel}`
},
answerByDifficulty: pathLetter
},
pathLetterPool
);
if (numberToLetterTemplate) {
bank.push(numberToLetterTemplate);
}
const letterToNumberTemplate = createQuestionTemplate(
{
key: `kabbalah-letter-path-number:${pathNumberLabel}`,
categoryId: "kabbalah-path-letter",
category: "Kabbalah Paths",
promptByDifficulty: {
easy: `${pathLetter} belongs to which path`,
normal: `${pathLetter} corresponds to Path`,
hard: `${pathLetter} is on Path`
},
answerByDifficulty: pathNumberLabel
},
pathNumberPool
);
if (letterToNumberTemplate) {
bank.push(letterToNumberTemplate);
}
}
if (tarotCard) {
const pathToTarotTemplate = createQuestionTemplate(
{
key: `kabbalah-path-tarot:${pathNumberLabel}`,
categoryId: "kabbalah-path-tarot",
category: "Kabbalah ↔ Tarot",
promptByDifficulty: {
easy: `Path ${pathNumberLabel} corresponds to which Tarot trump`,
normal: `Which Tarot trump is on Path ${pathNumberLabel}`,
hard: `Tarot trump on Path ${pathNumberLabel}`
},
answerByDifficulty: tarotCard
},
pathTarotPool
);
if (pathToTarotTemplate) {
bank.push(pathToTarotTemplate);
}
const tarotToPathTemplate = createQuestionTemplate(
{
key: `tarot-trump-path:${pathNumberLabel}`,
categoryId: "kabbalah-path-tarot",
category: "Tarot ↔ Kabbalah",
promptByDifficulty: {
easy: `${tarotCard} is on which path`,
normal: `Which path corresponds to ${tarotCard}`,
hard: `${tarotCard} corresponds to Path`
},
answerByDifficulty: pathNumberLabel
},
pathNumberPool
);
if (tarotToPathTemplate) {
bank.push(tarotToPathTemplate);
}
}
});
Object.values(sephirotById || {}).forEach((sephira) => {
const sephiraName = String(sephira?.name?.roman || sephira?.name?.en || "").trim();
const planetLabel = getPlanetLabelById(sephira?.planetId);
if (!sephiraName || !planetLabel) {
return;
}
const template = createQuestionTemplate(
{
key: `sephirot-planet:${normalizeId(sephira?.id || sephiraName)}`,
categoryId: "sephirot-planets",
category: "Sephirot ↔ Planet",
promptByDifficulty: {
easy: `${sephiraName} corresponds to which planet`,
normal: `Planetary correspondence of ${sephiraName}`,
hard: `${sephiraName} corresponds to`
},
answerByDifficulty: planetLabel
},
sephirotPlanetPool
);
if (template) {
bank.push(template);
}
});
(flattenDecans || []).forEach((decan) => {
const decanId = String(decan?.id || "").trim();
const card = normalizeOption(decan?.tarotMinorArcana);
const decanLabel = formatDecanLabel(decan);
const rulerLabel = getPlanetLabelById(decan?.rulerPlanetId);
if (!decanId || !card) {
return;
}
if (decanLabel) {
const template = createQuestionTemplate(
{
key: `tarot-decan-sign:${decanId}`,
categoryId: "tarot-decan-sign",
category: "Tarot Decans",
promptByDifficulty: {
easy: `${card} belongs to which decan`,
normal: `Which decan contains ${card}`,
hard: `${card} is in`
},
answerByDifficulty: decanLabel
},
decanLabelPool
);
if (template) {
bank.push(template);
}
}
if (rulerLabel) {
const template = createQuestionTemplate(
{
key: `tarot-decan-ruler:${decanId}`,
categoryId: "tarot-decan-ruler",
category: "Tarot Decans",
promptByDifficulty: {
easy: `The decan of ${card} is ruled by`,
normal: `Who rules the decan for ${card}`,
hard: `${card} decan ruler`
},
answerByDifficulty: rulerLabel
},
decanRulerPool
);
if (template) {
bank.push(template);
}
}
});
(cubeWalls || []).forEach((wall) => {
const wallName = String(wall?.name || labelFromId(wall?.id)).trim();
const wallLabel = wallName ? `${wallName} Wall` : "";
const tarotCard = normalizeOption(wall?.associations?.tarotCard);
const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId));
const hebrewLabel = formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId);
if (tarotCard && wallLabel) {
const template = createQuestionTemplate(
{
key: `tarot-cube-wall:${normalizeId(wall?.id || wallName)}`,
categoryId: "tarot-cube-location",
category: "Tarot ↔ Cube",
promptByDifficulty: {
easy: `${tarotCard} is on which Cube wall`,
normal: `Where is ${tarotCard} on the Cube`,
hard: `${tarotCard} location on Cube`
},
answerByDifficulty: wallLabel
},
cubeLocationPool
);
if (template) {
bank.push(template);
}
}
if (wallLabel && hebrewLabel) {
const template = createQuestionTemplate(
{
key: `cube-wall-letter:${normalizeId(wall?.id || wallName)}`,
categoryId: "cube-hebrew-letter",
category: "Cube ↔ Hebrew",
promptByDifficulty: {
easy: `${wallLabel} corresponds to which Hebrew letter`,
normal: `Which Hebrew letter is on ${wallLabel}`,
hard: `${wallLabel} letter`
},
answerByDifficulty: hebrewLabel
},
cubeHebrewLetterPool
);
if (template) {
bank.push(template);
}
}
});
(cubeEdges || []).forEach((edge) => {
const edgeName = String(edge?.name || labelFromId(edge?.id)).trim();
const edgeLabel = edgeName ? `${edgeName} Edge` : "";
const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId));
const hebrewLabel = formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId);
const tarotCard = normalizeOption(hebrew?.tarot?.card);
if (tarotCard && edgeLabel) {
const template = createQuestionTemplate(
{
key: `tarot-cube-edge:${normalizeId(edge?.id || edgeName)}`,
categoryId: "tarot-cube-location",
category: "Tarot ↔ Cube",
promptByDifficulty: {
easy: `${tarotCard} is on which Cube edge`,
normal: `Where is ${tarotCard} on the Cube edges`,
hard: `${tarotCard} edge location`
},
answerByDifficulty: edgeLabel
},
cubeLocationPool
);
if (template) {
bank.push(template);
}
}
if (edgeLabel && hebrewLabel) {
const template = createQuestionTemplate(
{
key: `cube-edge-letter:${normalizeId(edge?.id || edgeName)}`,
categoryId: "cube-hebrew-letter",
category: "Cube ↔ Hebrew",
promptByDifficulty: {
easy: `${edgeLabel} corresponds to which Hebrew letter`,
normal: `Which Hebrew letter is on ${edgeLabel}`,
hard: `${edgeLabel} letter`
},
answerByDifficulty: hebrewLabel
},
cubeHebrewLetterPool
);
if (template) {
bank.push(template);
}
}
});
if (cubeCenter) {
const centerTarot = normalizeOption(cubeCenter?.associations?.tarotCard || cubeCenter?.tarotCard);
const centerHebrew = hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId));
const centerHebrewLabel = formatHebrewLetterLabel(centerHebrew, cubeCenter?.hebrewLetterId);
if (centerTarot) {
const template = createQuestionTemplate(
{
key: "tarot-cube-center",
categoryId: "tarot-cube-location",
category: "Tarot ↔ Cube",
promptByDifficulty: {
easy: `${centerTarot} is located at which Cube position`,
normal: `Where is ${centerTarot} on the Cube`,
hard: `${centerTarot} Cube location`
},
answerByDifficulty: "Center"
},
cubeLocationPool
);
if (template) {
bank.push(template);
}
}
if (centerHebrewLabel) {
const template = createQuestionTemplate(
{
key: "cube-center-letter",
categoryId: "cube-hebrew-letter",
category: "Cube ↔ Hebrew",
promptByDifficulty: {
easy: "The Cube center corresponds to which Hebrew letter",
normal: "Which Hebrew letter is at the Cube center",
hard: "Cube center letter"
},
answerByDifficulty: centerHebrewLabel
},
cubeHebrewLetterPool
);
if (template) {
bank.push(template);
}
}
}
(playingCards || []).forEach((entry) => {
const cardId = String(entry?.id || "").trim();
const rankLabel = normalizeOption(entry?.rankLabel || entry?.rank);
const suitLabel = normalizeOption(entry?.suitLabel || labelFromId(entry?.suit));
const tarotCard = normalizeOption(entry?.tarotCard);
if (!cardId || !rankLabel || !suitLabel || !tarotCard) {
return;
}
const template = createQuestionTemplate(
{
key: `playing-card-tarot:${cardId}`,
categoryId: "playing-card-tarot",
category: "Playing Card ↔ Tarot",
promptByDifficulty: {
easy: `${rankLabel} of ${suitLabel} maps to which Tarot card`,
normal: `${rankLabel} of ${suitLabel} corresponds to`,
hard: `${rankLabel} of ${suitLabel} maps to`
},
answerByDifficulty: tarotCard
},
playingTarotPool
);
if (template) {
bank.push(template);
}
});
}
function appendBuiltInQuestionBankDomains() {}
window.QuizQuestionBankBuiltInDomains = {
appendBuiltInQuestionBankDomains

View File

@@ -1,355 +1,9 @@
/* ui-quiz-bank-builtins.js — Built-in quiz template generation */
/* ui-quiz-bank-builtins.js — Legacy built-in quiz compatibility layer */
(function () {
"use strict";
const quizQuestionBankBuiltInDomains = window.QuizQuestionBankBuiltInDomains || {};
if (typeof quizQuestionBankBuiltInDomains.appendBuiltInQuestionBankDomains !== "function") {
throw new Error("QuizQuestionBankBuiltInDomains module must load before ui-quiz-bank-builtins.js");
}
function buildBuiltInQuestionBank(context) {
const {
referenceData,
magickDataset,
helpers
} = context || {};
const {
toTitleCase,
normalizeOption,
toUniqueOptionList,
createQuestionTemplate
} = helpers || {};
if (
typeof toTitleCase !== "function"
|| typeof normalizeOption !== "function"
|| typeof toUniqueOptionList !== "function"
|| typeof createQuestionTemplate !== "function"
) {
return [];
}
const grouped = magickDataset?.grouped || {};
const alphabets = grouped.alphabets || {};
const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : [];
const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : [];
const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {};
const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : [];
const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : [];
const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object"
? grouped.kabbalah.sephirot
: {};
const cube = grouped?.kabbalah?.cube && typeof grouped.kabbalah.cube === "object"
? grouped.kabbalah.cube
: {};
const cubeWalls = Array.isArray(cube?.walls) ? cube.walls : [];
const cubeEdges = Array.isArray(cube?.edges) ? cube.edges : [];
const cubeCenter = cube?.center && typeof cube.center === "object" ? cube.center : null;
const playingCardsData = grouped?.["playing-cards-52"];
const playingCards = Array.isArray(playingCardsData)
? playingCardsData
: (Array.isArray(playingCardsData?.entries) ? playingCardsData.entries : []);
const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
const planetsById = referenceData?.planets && typeof referenceData.planets === "object"
? referenceData.planets
: {};
const planets = Object.values(planetsById);
const decansBySign = referenceData?.decansBySign && typeof referenceData.decansBySign === "object"
? referenceData.decansBySign
: {};
const normalizeId = (value) => String(value || "").trim().toLowerCase();
const toRomanNumeral = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return String(value || "");
}
const intValue = Math.trunc(numeric);
const lookup = [
[1000, "M"],
[900, "CM"],
[500, "D"],
[400, "CD"],
[100, "C"],
[90, "XC"],
[50, "L"],
[40, "XL"],
[10, "X"],
[9, "IX"],
[5, "V"],
[4, "IV"],
[1, "I"]
];
let current = intValue;
let result = "";
lookup.forEach(([size, symbol]) => {
while (current >= size) {
result += symbol;
current -= size;
}
});
return result || String(intValue);
};
const labelFromId = (value) => {
const id = String(value || "").trim();
if (!id) {
return "";
}
return id
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.map((part) => part ? part.charAt(0).toUpperCase() + part.slice(1) : "")
.join(" ");
};
const getPlanetLabelById = (planetId) => {
const normalized = normalizeId(planetId);
if (!normalized) {
return "";
}
const directPlanet = planetsById[normalized];
if (directPlanet?.name) {
return directPlanet.name;
}
if (normalized === "primum-mobile") {
return "Primum Mobile";
}
if (normalized === "olam-yesodot") {
return "Earth / Elements";
}
return labelFromId(normalized);
};
const hebrewById = new Map(
hebrewLetters
.filter((entry) => entry?.hebrewLetterId)
.map((entry) => [normalizeId(entry.hebrewLetterId), entry])
);
const formatHebrewLetterLabel = (entry, fallbackId = "") => {
if (entry?.name && entry?.char) {
return `${entry.name} (${entry.char})`;
}
if (entry?.name) {
return entry.name;
}
if (entry?.char) {
return entry.char;
}
return labelFromId(fallbackId);
};
const sephiraNameByNumber = new Map(
treeSephiroth
.filter((entry) => Number.isFinite(Number(entry?.number)) && entry?.name)
.map((entry) => [Math.trunc(Number(entry.number)), String(entry.name)])
);
const sephiraNameById = new Map(
treeSephiroth
.filter((entry) => entry?.sephiraId && entry?.name)
.map((entry) => [normalizeId(entry.sephiraId), String(entry.name)])
);
const getSephiraName = (numberValue, idValue) => {
const numberKey = Number(numberValue);
if (Number.isFinite(numberKey)) {
const byNumber = sephiraNameByNumber.get(Math.trunc(numberKey));
if (byNumber) {
return byNumber;
}
}
const byId = sephiraNameById.get(normalizeId(idValue));
if (byId) {
return byId;
}
if (Number.isFinite(numberKey)) {
return `Sephira ${Math.trunc(numberKey)}`;
}
return labelFromId(idValue);
};
const formatPathLetter = (path) => {
const transliteration = String(path?.hebrewLetter?.transliteration || "").trim();
const glyph = String(path?.hebrewLetter?.char || "").trim();
if (transliteration && glyph) {
return `${transliteration} (${glyph})`;
}
if (transliteration) {
return transliteration;
}
if (glyph) {
return glyph;
}
return "";
};
const flattenDecans = Object.values(decansBySign)
.flatMap((entries) => (Array.isArray(entries) ? entries : []));
const signNameById = new Map(
signs
.filter((entry) => entry?.id && entry?.name)
.map((entry) => [normalizeId(entry.id), String(entry.name)])
);
const formatDecanLabel = (decan) => {
const signName = signNameById.get(normalizeId(decan?.signId)) || labelFromId(decan?.signId);
const index = Number(decan?.index);
if (!signName || !Number.isFinite(index)) {
return "";
}
return `${signName} Decan ${toRomanNumeral(index)}`;
};
const bank = [];
const englishGematriaPool = englishLetters
.map((item) => (Number.isFinite(Number(item?.pythagorean)) ? String(item.pythagorean) : ""))
.filter(Boolean);
const hebrewNumerologyPool = hebrewLetters
.map((item) => (Number.isFinite(Number(item?.numerology)) ? String(item.numerology) : ""))
.filter(Boolean);
const hebrewNameAndCharPool = hebrewLetters
.filter((item) => item?.name && item?.char)
.map((item) => `${item.name} (${item.char})`);
const hebrewCharPool = hebrewLetters
.map((item) => item?.char)
.filter(Boolean);
const planetNamePool = planets
.map((planet) => planet?.name)
.filter(Boolean);
const planetWeekdayPool = planets
.map((planet) => planet?.weekday)
.filter(Boolean);
const zodiacElementPool = signs
.map((sign) => toTitleCase(sign?.element))
.filter(Boolean);
const zodiacTarotPool = signs
.map((sign) => sign?.tarot?.majorArcana)
.filter(Boolean);
const pathNumberPool = toUniqueOptionList(
treePaths
.map((path) => {
const pathNo = Number(path?.pathNumber);
return Number.isFinite(pathNo) ? String(Math.trunc(pathNo)) : "";
})
);
const pathLetterPool = toUniqueOptionList(treePaths.map((path) => formatPathLetter(path)));
const pathTarotPool = toUniqueOptionList(treePaths.map((path) => normalizeOption(path?.tarot?.card)));
const sephirotPlanetPool = toUniqueOptionList(
Object.values(sephirotById).map((entry) => getPlanetLabelById(entry?.planetId))
);
const decanLabelPool = toUniqueOptionList(flattenDecans.map((decan) => formatDecanLabel(decan)));
const decanRulerPool = toUniqueOptionList(
flattenDecans.map((decan) => getPlanetLabelById(decan?.rulerPlanetId))
);
const cubeWallLabelPool = toUniqueOptionList(
cubeWalls.map((wall) => `${String(wall?.name || labelFromId(wall?.id)).trim()} Wall`)
);
const cubeEdgeLabelPool = toUniqueOptionList(
cubeEdges.map((edge) => `${String(edge?.name || labelFromId(edge?.id)).trim()} Edge`)
);
const cubeLocationPool = toUniqueOptionList([
...cubeWallLabelPool,
...cubeEdgeLabelPool,
"Center"
]);
const cubeHebrewLetterPool = toUniqueOptionList([
...cubeWalls.map((wall) => {
const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId));
return formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId);
}),
...cubeEdges.map((edge) => {
const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId));
return formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId);
}),
formatHebrewLetterLabel(hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)), cubeCenter?.hebrewLetterId)
]);
const playingTarotPool = toUniqueOptionList(
playingCards.map((entry) => normalizeOption(entry?.tarotCard))
);
quizQuestionBankBuiltInDomains.appendBuiltInQuestionBankDomains({
bank,
englishLetters,
hebrewLetters,
hebrewById,
signs,
planets,
planetsById,
treePaths,
sephirotById,
flattenDecans,
cubeWalls,
cubeEdges,
cubeCenter,
playingCards,
pools: {
englishGematriaPool,
hebrewNumerologyPool,
hebrewNameAndCharPool,
hebrewCharPool,
planetNamePool,
planetWeekdayPool,
zodiacElementPool,
zodiacTarotPool,
pathNumberPool,
pathLetterPool,
pathTarotPool,
sephirotPlanetPool,
decanLabelPool,
decanRulerPool,
cubeLocationPool,
cubeHebrewLetterPool,
playingTarotPool
},
helpers: {
createQuestionTemplate,
normalizeId,
normalizeOption,
toTitleCase,
formatHebrewLetterLabel,
getPlanetLabelById,
getSephiraName,
formatPathLetter,
formatDecanLabel,
labelFromId
}
});
return bank;
function buildBuiltInQuestionBank() {
return [];
}
window.QuizQuestionBankBuiltins = {

View File

@@ -109,6 +109,19 @@
};
}
function dedupeTemplatesByKey(templates) {
const deduped = new Map();
(templates || []).forEach((template) => {
if (!template || !template.key) {
return;
}
deduped.set(template.key, template);
});
return [...deduped.values()];
}
function buildQuestionBank(referenceData, magickDataset, dynamicCategoryRegistry) {
const bank = quizQuestionBankBuiltins.buildBuiltInQuestionBank({
referenceData,
@@ -136,12 +149,13 @@
}
});
return bank;
return dedupeTemplatesByKey(bank);
}
window.QuizQuestionBank = {
buildQuestionBank,
createQuestionTemplate,
dedupeTemplatesByKey,
normalizeKey,
normalizeOption,
toTitleCase,

View File

@@ -310,9 +310,26 @@
function getCategoryOptions() {
const availableCategoryIds = new Set(state.questionBank.map((template) => template.categoryId));
const dynamic = CATEGORY_META
.filter((item) => availableCategoryIds.has(item.id))
.map((item) => ({ value: item.id, label: item.label }));
const labelByCategoryId = new Map(CATEGORY_META.map((item) => [item.id, item.label]));
state.questionBank.forEach((template) => {
const categoryId = String(template?.categoryId || "").trim();
const category = String(template?.category || "").trim();
if (categoryId && category && !labelByCategoryId.has(categoryId)) {
labelByCategoryId.set(categoryId, category);
}
});
const dynamic = [...availableCategoryIds]
.sort((left, right) => {
const leftLabel = String(labelByCategoryId.get(left) || left);
const rightLabel = String(labelByCategoryId.get(right) || right);
return leftLabel.localeCompare(rightLabel);
})
.map((categoryId) => ({
value: categoryId,
label: String(labelByCategoryId.get(categoryId) || categoryId)
}));
return [...FIXED_CATEGORY_OPTIONS, ...dynamic];
}
@@ -358,7 +375,9 @@
if (mode === "all") {
return "All";
}
return CATEGORY_META.find((item) => item.id === mode)?.label || "Category";
return CATEGORY_META.find((item) => item.id === mode)?.label
|| state.questionBank.find((template) => template.categoryId === mode)?.category
|| "Category";
}
function startRun(resetScore = false) {

View File

@@ -34,71 +34,7 @@
});
}
function renderDetail(card, elements) {
if (!card || !elements) {
return;
}
const cardDisplayName = getDisplayCardName(card);
const imageUrl = typeof resolveTarotCardImage === "function"
? resolveTarotCardImage(card.name)
: null;
if (elements.tarotDetailImageEl) {
if (imageUrl) {
elements.tarotDetailImageEl.src = imageUrl;
elements.tarotDetailImageEl.alt = cardDisplayName || card.name;
elements.tarotDetailImageEl.style.display = "block";
elements.tarotDetailImageEl.style.cursor = "zoom-in";
elements.tarotDetailImageEl.title = "Click to enlarge";
} else {
elements.tarotDetailImageEl.removeAttribute("src");
elements.tarotDetailImageEl.alt = "";
elements.tarotDetailImageEl.style.display = "none";
elements.tarotDetailImageEl.style.cursor = "default";
elements.tarotDetailImageEl.removeAttribute("title");
}
}
if (elements.tarotDetailNameEl) {
elements.tarotDetailNameEl.textContent = cardDisplayName || card.name;
}
if (elements.tarotDetailTypeEl) {
elements.tarotDetailTypeEl.textContent = buildTypeLabel(card);
}
if (elements.tarotDetailSummaryEl) {
elements.tarotDetailSummaryEl.textContent = card.summary || "--";
}
if (elements.tarotDetailUprightEl) {
elements.tarotDetailUprightEl.textContent = card.meanings?.upright || "--";
}
if (elements.tarotDetailReversedEl) {
elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
}
const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
if (meaningText) {
elements.tarotMetaMeaningCardEl.hidden = false;
elements.tarotDetailMeaningEl.textContent = meaningText;
} else {
elements.tarotMetaMeaningCardEl.hidden = true;
elements.tarotDetailMeaningEl.textContent = "--";
}
}
clearChildren(elements.tarotDetailKeywordsEl);
(card.keywords || []).forEach((keyword) => {
const chip = document.createElement("span");
chip.className = "tarot-keyword-chip";
chip.textContent = keyword;
elements.tarotDetailKeywordsEl?.appendChild(chip);
});
function collectDetailRelations(card) {
const allRelations = (card.relations || [])
.map((relation, index) => normalizeRelationObject(relation, index))
.filter(Boolean);
@@ -224,15 +160,119 @@
return String(left.label || "").localeCompare(String(right.label || ""));
});
renderStaticRelationGroup(elements.tarotDetailPlanetEl, elements.tarotMetaPlanetCardEl, planetRelations);
renderStaticRelationGroup(elements.tarotDetailElementEl, elements.tarotMetaElementCardEl, elementRelations);
renderStaticRelationGroup(elements.tarotDetailTetragrammatonEl, elements.tarotMetaTetragrammatonCardEl, tetragrammatonRelations);
renderStaticRelationGroup(elements.tarotDetailZodiacEl, elements.tarotMetaZodiacCardEl, zodiacRelationsWithRulership);
renderStaticRelationGroup(elements.tarotDetailCourtDateEl, elements.tarotMetaCourtDateCardEl, mergedCourtDateRelations);
renderStaticRelationGroup(elements.tarotDetailHebrewEl, elements.tarotMetaHebrewCardEl, hebrewRelations);
renderStaticRelationGroup(elements.tarotDetailCubeEl, elements.tarotMetaCubeCardEl, cubeRelations);
renderStaticRelationGroup(elements.tarotDetailIChingEl, elements.tarotMetaIChingCardEl, iChingRelations);
renderStaticRelationGroup(elements.tarotDetailCalendarEl, elements.tarotMetaCalendarCardEl, mergedMonthRelations);
return {
planetRelations,
elementRelations,
tetragrammatonRelations,
zodiacRelationsWithRulership,
mergedCourtDateRelations,
hebrewRelations,
cubeRelations,
iChingRelations,
mergedMonthRelations
};
}
function buildCompareDetails(card) {
if (!card) {
return [];
}
const detailRelations = collectDetailRelations(card);
const groups = [
{ title: "Letter", relations: detailRelations.hebrewRelations },
{ title: "Planet / Ruler", relations: detailRelations.planetRelations },
{ title: "Sign / Decan", relations: detailRelations.zodiacRelationsWithRulership },
{ title: "Element", relations: detailRelations.elementRelations },
{ title: "Tetragrammaton", relations: detailRelations.tetragrammatonRelations },
{ title: "Dates", relations: detailRelations.mergedCourtDateRelations },
{ title: "Calendar", relations: detailRelations.mergedMonthRelations }
];
return groups
.map((group) => ({
title: group.title,
items: [...new Set((group.relations || []).map((relation) => String(relation?.label || "").trim()).filter(Boolean))]
}))
.filter((group) => group.items.length);
}
function renderDetail(card, elements) {
if (!card || !elements) {
return;
}
const cardDisplayName = getDisplayCardName(card);
const imageUrl = typeof resolveTarotCardImage === "function"
? resolveTarotCardImage(card.name)
: null;
if (elements.tarotDetailImageEl) {
if (imageUrl) {
elements.tarotDetailImageEl.src = imageUrl;
elements.tarotDetailImageEl.alt = cardDisplayName || card.name;
elements.tarotDetailImageEl.style.display = "block";
elements.tarotDetailImageEl.style.cursor = "zoom-in";
elements.tarotDetailImageEl.title = "Click to enlarge";
} else {
elements.tarotDetailImageEl.removeAttribute("src");
elements.tarotDetailImageEl.alt = "";
elements.tarotDetailImageEl.style.display = "none";
elements.tarotDetailImageEl.style.cursor = "default";
elements.tarotDetailImageEl.removeAttribute("title");
}
}
if (elements.tarotDetailNameEl) {
elements.tarotDetailNameEl.textContent = cardDisplayName || card.name;
}
if (elements.tarotDetailTypeEl) {
elements.tarotDetailTypeEl.textContent = buildTypeLabel(card);
}
if (elements.tarotDetailSummaryEl) {
elements.tarotDetailSummaryEl.textContent = card.summary || "--";
}
if (elements.tarotDetailUprightEl) {
elements.tarotDetailUprightEl.textContent = card.meanings?.upright || "--";
}
if (elements.tarotDetailReversedEl) {
elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
}
const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
if (meaningText) {
elements.tarotMetaMeaningCardEl.hidden = false;
elements.tarotDetailMeaningEl.textContent = meaningText;
} else {
elements.tarotMetaMeaningCardEl.hidden = true;
elements.tarotDetailMeaningEl.textContent = "--";
}
}
clearChildren(elements.tarotDetailKeywordsEl);
(card.keywords || []).forEach((keyword) => {
const chip = document.createElement("span");
chip.className = "tarot-keyword-chip";
chip.textContent = keyword;
elements.tarotDetailKeywordsEl?.appendChild(chip);
});
const detailRelations = collectDetailRelations(card);
renderStaticRelationGroup(elements.tarotDetailPlanetEl, elements.tarotMetaPlanetCardEl, detailRelations.planetRelations);
renderStaticRelationGroup(elements.tarotDetailElementEl, elements.tarotMetaElementCardEl, detailRelations.elementRelations);
renderStaticRelationGroup(elements.tarotDetailTetragrammatonEl, elements.tarotMetaTetragrammatonCardEl, detailRelations.tetragrammatonRelations);
renderStaticRelationGroup(elements.tarotDetailZodiacEl, elements.tarotMetaZodiacCardEl, detailRelations.zodiacRelationsWithRulership);
renderStaticRelationGroup(elements.tarotDetailCourtDateEl, elements.tarotMetaCourtDateCardEl, detailRelations.mergedCourtDateRelations);
renderStaticRelationGroup(elements.tarotDetailHebrewEl, elements.tarotMetaHebrewCardEl, detailRelations.hebrewRelations);
renderStaticRelationGroup(elements.tarotDetailCubeEl, elements.tarotMetaCubeCardEl, detailRelations.cubeRelations);
renderStaticRelationGroup(elements.tarotDetailIChingEl, elements.tarotMetaIChingCardEl, detailRelations.iChingRelations);
renderStaticRelationGroup(elements.tarotDetailCalendarEl, elements.tarotMetaCalendarCardEl, detailRelations.mergedMonthRelations);
const kabPathEl = elements.tarotKabPathEl;
if (kabPathEl) {
@@ -304,6 +344,7 @@
}
return {
buildCompareDetails,
renderStaticRelationGroup,
renderDetail
};

View File

@@ -20,6 +20,28 @@
[18, 17, 15, 14, 13, 9, 8, 7, 6, 5, 4],
[11]
];
const EXPORT_CARD_WIDTH = 128;
const EXPORT_CARD_HEIGHT = 192;
const EXPORT_CARD_GAP = 10;
const EXPORT_ROW_GAP = 12;
const EXPORT_SECTION_GAP = 18;
const EXPORT_PADDING = 28;
const EXPORT_BACKGROUND = "#151520";
const EXPORT_PANEL = "#18181b";
const EXPORT_BORDER = "#3f3f46";
const EXPORT_FALLBACK_TEXT = "#f4f4f5";
const EXPORT_FORMATS = {
png: {
mimeType: "image/png",
extension: "png",
quality: null
},
webp: {
mimeType: "image/webp",
extension: "webp",
quality: 0.98
}
};
const config = {
resolveTarotCardImage: null,
@@ -27,8 +49,14 @@
clearChildren: () => {},
normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(),
selectCardById: () => {},
openCardLightbox: () => {},
isHouseFocusMode: () => false,
getCards: () => [],
getSelectedCardId: () => ""
getSelectedCardId: () => "",
getHouseTopCardsVisible: () => true,
getHouseTopInfoModes: () => ({}),
getHouseBottomCardsVisible: () => true,
getHouseBottomInfoModes: () => ({})
};
function init(nextConfig = {}) {
@@ -81,6 +109,494 @@
return (Array.isArray(cards) ? cards : []).find((card) => card?.arcana === "Major" && Number(card?.number) === target) || null;
}
function normalizeLabelText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function getCardRelationsByType(card, type) {
if (!card || !Array.isArray(card.relations)) {
return [];
}
return card.relations.filter((relation) => relation?.type === type);
}
function getFirstCardRelationByType(card, type) {
return getCardRelationsByType(card, type)[0] || null;
}
function toRomanNumeral(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
return "";
}
const numerals = [
[10, "X"],
[9, "IX"],
[5, "V"],
[4, "IV"],
[1, "I"]
];
let remaining = number;
let result = "";
numerals.forEach(([amount, glyph]) => {
while (remaining >= amount) {
result += glyph;
remaining -= amount;
}
});
return result;
}
function buildHebrewLabel(card) {
const hebrew = card?.hebrewLetter && typeof card.hebrewLetter === "object"
? card.hebrewLetter
: getFirstCardRelationByType(card, "hebrewLetter")?.data;
const glyph = normalizeLabelText(hebrew?.glyph || hebrew?.char);
const transliteration = normalizeLabelText(hebrew?.latin || hebrew?.name || card?.hebrewLetterId);
const primary = glyph || transliteration;
const secondary = glyph && transliteration ? transliteration : "";
if (!primary) {
return null;
}
return {
primary,
secondary,
className: "is-top-hebrew"
};
}
function buildPlanetLabel(card) {
const relation = getFirstCardRelationByType(card, "planetCorrespondence")
|| getFirstCardRelationByType(card, "planet")
|| getFirstCardRelationByType(card, "decanRuler");
const name = normalizeLabelText(relation?.data?.name || relation?.data?.planetId || relation?.id);
const symbol = normalizeLabelText(relation?.data?.symbol);
const primary = normalizeLabelText(symbol ? `${symbol} ${name}` : name);
if (!primary) {
return null;
}
return {
primary: relation?.type === "decanRuler" ? `Ruler: ${primary}` : `Planet: ${primary}`,
secondary: "",
className: ""
};
}
function buildMajorZodiacLabel(card) {
const relation = getFirstCardRelationByType(card, "zodiacCorrespondence")
|| getFirstCardRelationByType(card, "zodiac");
const name = normalizeLabelText(relation?.data?.name || relation?.data?.signName || relation?.id);
const symbol = normalizeLabelText(relation?.data?.symbol);
const primary = normalizeLabelText(symbol ? `${symbol} ${name}` : name);
if (!primary) {
return null;
}
return {
primary: `Zodiac: ${primary}`,
secondary: "",
className: ""
};
}
function buildTrumpNumberLabel(card) {
const number = Number(card?.number);
if (!Number.isFinite(number)) {
return null;
}
const formattedTrumpNumber = number === 0
? "0"
: toRomanNumeral(Math.trunc(number));
return {
primary: `Trump: ${formattedTrumpNumber}`,
secondary: "",
className: ""
};
}
function buildPathNumberLabel(card) {
const pathNumber = Number(card?.kabbalahPathNumber);
if (!Number.isFinite(pathNumber)) {
return null;
}
return {
primary: `Path: ${Math.trunc(pathNumber)}`,
secondary: "",
className: ""
};
}
function buildZodiacLabel(card) {
const zodiacRelation = getFirstCardRelationByType(card, "zodiac");
const decanRelations = getCardRelationsByType(card, "decan");
const primary = normalizeLabelText(
zodiacRelation?.data?.symbol
? `${zodiacRelation.data.symbol} ${zodiacRelation.data.signName || zodiacRelation.data.name || ""}`
: zodiacRelation?.data?.signName || zodiacRelation?.data?.name
);
if (primary) {
const dateRange = normalizeLabelText(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
return {
primary,
secondary: dateRange || "",
className: ""
};
}
if (decanRelations.length > 0) {
const first = decanRelations[0]?.data || {};
const last = decanRelations[decanRelations.length - 1]?.data || {};
const firstName = normalizeLabelText(first.signName);
const lastName = normalizeLabelText(last.signName);
const rangeLabel = firstName && lastName
? (firstName === lastName ? firstName : `${firstName} -> ${lastName}`)
: firstName || lastName;
const dateRange = normalizeLabelText(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
if (rangeLabel) {
return {
primary: rangeLabel,
secondary: dateRange || "",
className: ""
};
}
}
return null;
}
function buildDecanLabel(card) {
const decanRelations = getCardRelationsByType(card, "decan");
if (decanRelations.length === 0) {
return null;
}
if (decanRelations.length === 1) {
const data = decanRelations[0].data || {};
const hasDegrees = Number.isFinite(Number(data.startDegree)) && Number.isFinite(Number(data.endDegree));
const degreeLabel = hasDegrees ? `${data.startDegree}°-${data.endDegree}°` : "";
const signLabel = normalizeLabelText(data.signName);
const primary = degreeLabel || signLabel;
const secondary = degreeLabel && signLabel ? signLabel : normalizeLabelText(data.dateRange);
if (primary) {
return {
primary,
secondary,
className: ""
};
}
}
const first = decanRelations[0]?.data || {};
const last = decanRelations[decanRelations.length - 1]?.data || {};
const firstLabel = normalizeLabelText(first.signName) && Number.isFinite(Number(first.index))
? `${first.signName} ${toRomanNumeral(first.index)}`
: normalizeLabelText(first.signName);
const lastLabel = normalizeLabelText(last.signName) && Number.isFinite(Number(last.index))
? `${last.signName} ${toRomanNumeral(last.index)}`
: normalizeLabelText(last.signName);
const primary = firstLabel && lastLabel
? (firstLabel === lastLabel ? firstLabel : `${firstLabel} -> ${lastLabel}`)
: firstLabel || lastLabel;
const secondary = normalizeLabelText(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
if (!primary) {
return null;
}
return {
primary,
secondary,
className: ""
};
}
function buildDateLabel(card) {
const courtWindow = getFirstCardRelationByType(card, "courtDateWindow")?.data || null;
const decan = getFirstCardRelationByType(card, "decan")?.data || null;
const calendar = getFirstCardRelationByType(card, "calendarMonth")?.data || null;
const primary = normalizeLabelText(courtWindow?.dateRange || decan?.dateRange || calendar?.dateRange || calendar?.name);
const secondary = normalizeLabelText(
calendar?.name && primary !== calendar.name
? calendar.name
: decan?.signName
);
if (!primary) {
return null;
}
return {
primary,
secondary,
className: ""
};
}
function buildMonthLabel(card) {
const monthRelations = getCardRelationsByType(card, "calendarMonth");
const names = [];
const seen = new Set();
monthRelations.forEach((relation) => {
const name = normalizeLabelText(relation?.data?.name);
const key = name.toLowerCase();
if (!name || seen.has(key)) {
return;
}
seen.add(key);
names.push(name);
});
if (!names.length) {
return null;
}
return {
primary: `Month: ${names.join("/")}`,
secondary: "",
className: ""
};
}
function buildRulerLabel(card) {
const rulerRelations = getCardRelationsByType(card, "decanRuler");
const names = [];
const seen = new Set();
rulerRelations.forEach((relation) => {
const name = normalizeLabelText(
relation?.data?.symbol
? `${relation.data.symbol} ${relation.data.name || relation.data.planetId || ""}`
: relation?.data?.name || relation?.data?.planetId
);
const key = name.toLowerCase();
if (!name || seen.has(key)) {
return;
}
seen.add(key);
names.push(name);
});
if (!names.length) {
return null;
}
return {
primary: `Ruler: ${names.join("/")}`,
secondary: "",
className: ""
};
}
function getTopInfoModeEnabled(mode) {
const modes = config.getHouseTopInfoModes?.();
return Boolean(modes && modes[mode]);
}
function buildTopInfoLabel(card) {
const lineSet = new Set();
const lines = [];
function pushLine(value) {
const text = normalizeLabelText(value);
const key = text.toLowerCase();
if (!text || lineSet.has(key)) {
return;
}
lineSet.add(key);
lines.push(text);
}
if (getTopInfoModeEnabled("hebrew")) {
const hebrew = buildHebrewLabel(card);
pushLine(hebrew?.primary);
pushLine(hebrew?.secondary);
}
if (getTopInfoModeEnabled("planet")) {
pushLine(buildPlanetLabel(card)?.primary);
}
if (getTopInfoModeEnabled("zodiac")) {
pushLine(buildMajorZodiacLabel(card)?.primary);
}
if (getTopInfoModeEnabled("trump")) {
pushLine(buildTrumpNumberLabel(card)?.primary);
}
if (getTopInfoModeEnabled("path")) {
pushLine(buildPathNumberLabel(card)?.primary);
}
if (!lines.length) {
return null;
}
const hasHebrew = getTopInfoModeEnabled("hebrew") && Boolean(buildHebrewLabel(card)?.primary);
return {
primary: lines[0],
secondary: lines.slice(1).join(" · "),
className: `${lines.length >= 3 ? "is-dense" : ""}${hasHebrew ? " is-top-hebrew" : ""}`.trim()
};
}
function getBottomInfoModeEnabled(mode) {
const modes = config.getHouseBottomInfoModes?.();
return Boolean(modes && modes[mode]);
}
function buildBottomInfoLabel(card) {
const lineSet = new Set();
const lines = [];
function pushLine(value) {
const text = normalizeLabelText(value);
const key = text.toLowerCase();
if (!text || lineSet.has(key)) {
return;
}
lineSet.add(key);
lines.push(text);
}
if (getBottomInfoModeEnabled("zodiac")) {
pushLine(buildZodiacLabel(card)?.primary);
}
if (getBottomInfoModeEnabled("decan")) {
const decanLabel = buildDecanLabel(card);
pushLine(decanLabel?.primary);
if (!getBottomInfoModeEnabled("date")) {
pushLine(decanLabel?.secondary);
}
}
if (getBottomInfoModeEnabled("month")) {
pushLine(buildMonthLabel(card)?.primary);
}
if (getBottomInfoModeEnabled("ruler")) {
pushLine(buildRulerLabel(card)?.primary);
}
if (getBottomInfoModeEnabled("date")) {
pushLine(buildDateLabel(card)?.primary);
}
if (lines.length === 0) {
return null;
}
return {
primary: lines[0],
secondary: lines.slice(1).join(" · "),
className: lines.length >= 3 ? "is-dense" : ""
};
}
function buildHouseCardLabel(card) {
if (!card) {
return null;
}
if (card.arcana === "Major") {
return buildTopInfoLabel(card);
}
return buildBottomInfoLabel(card);
}
function isHouseCardImageVisible(card) {
if (!card) {
return false;
}
if (card.arcana === "Major") {
return config.getHouseTopCardsVisible?.() !== false;
}
return config.getHouseBottomCardsVisible?.() !== false;
}
function buildHouseCardTextFaceModel(card, label) {
const displayName = normalizeLabelText(config.getDisplayCardName(card) || card?.name || "Tarot");
if (card?.arcana !== "Major" && label?.primary) {
return {
primary: displayName || "Tarot",
secondary: [label.primary, label.secondary].filter(Boolean).join(" · "),
className: label.className || ""
};
}
if (label?.primary) {
const fallbackSecondary = displayName && label.primary !== displayName ? displayName : "";
return {
primary: label.primary,
secondary: label.secondary || fallbackSecondary,
className: label.className || ""
};
}
return {
primary: displayName || "Tarot",
secondary: "",
className: ""
};
}
function createHouseCardLabelElement(label) {
if (!label?.primary) {
return null;
}
const labelEl = document.createElement("span");
labelEl.className = `tarot-house-card-label${label.className ? ` ${label.className}` : ""}`;
const primaryEl = document.createElement("span");
primaryEl.className = "tarot-house-card-label-primary";
primaryEl.textContent = label.primary;
labelEl.appendChild(primaryEl);
if (label.secondary) {
const secondaryEl = document.createElement("span");
secondaryEl.className = "tarot-house-card-label-secondary";
secondaryEl.textContent = label.secondary;
labelEl.appendChild(secondaryEl);
}
return labelEl;
}
function createHouseCardTextFaceElement(faceModel) {
const faceEl = document.createElement("span");
faceEl.className = `tarot-house-card-text-face${faceModel?.className ? ` ${faceModel.className}` : ""}`;
const primaryEl = document.createElement("span");
primaryEl.className = "tarot-house-card-text-primary";
primaryEl.textContent = faceModel?.primary || "Tarot";
faceEl.appendChild(primaryEl);
if (faceModel?.secondary) {
const secondaryEl = document.createElement("span");
secondaryEl.className = "tarot-house-card-text-secondary";
secondaryEl.textContent = faceModel.secondary;
faceEl.appendChild(secondaryEl);
}
return faceEl;
}
function createHouseCardButton(card, elements) {
const button = document.createElement("button");
button.type = "button";
@@ -96,28 +612,49 @@
}
const cardDisplayName = config.getDisplayCardName(card);
button.title = cardDisplayName || card.name;
button.setAttribute("aria-label", cardDisplayName || card.name);
const label = buildHouseCardLabel(card);
const showImage = isHouseCardImageVisible(card);
const labelText = label?.secondary
? `${label.primary} - ${label.secondary}`
: label?.primary || "";
button.title = labelText ? `${cardDisplayName || card.name} - ${labelText}` : (cardDisplayName || card.name);
button.setAttribute("aria-label", labelText ? `${cardDisplayName || card.name}, ${labelText}` : (cardDisplayName || card.name));
button.dataset.houseCardId = card.id;
const imageUrl = typeof config.resolveTarotCardImage === "function"
? config.resolveTarotCardImage(card.name)
: null;
if (imageUrl) {
if (showImage && imageUrl) {
const image = document.createElement("img");
image.className = "tarot-house-card-image";
image.src = imageUrl;
image.alt = cardDisplayName || card.name;
button.appendChild(image);
} else {
} else if (showImage) {
const fallback = document.createElement("span");
fallback.className = "tarot-house-card-fallback";
fallback.textContent = cardDisplayName || card.name;
button.appendChild(fallback);
} else {
button.classList.add("is-text-only");
button.appendChild(createHouseCardTextFaceElement(buildHouseCardTextFaceModel(card, label)));
}
const labelEl = showImage ? createHouseCardLabelElement(label) : null;
if (labelEl) {
button.appendChild(labelEl);
}
button.addEventListener("click", () => {
config.selectCardById(card.id, elements);
if (config.isHouseFocusMode?.() === true && imageUrl) {
config.openCardLightbox?.(
imageUrl,
cardDisplayName || card.name || "Tarot card enlarged image",
{ cardId: card.id }
);
return;
}
elements?.tarotCardListEl
?.querySelector(`[data-card-id="${card.id}"]`)
?.scrollIntoView({ block: "nearest" });
@@ -140,6 +677,380 @@
});
}
function loadCardImage(url) {
return new Promise((resolve) => {
if (!url) {
resolve(null);
return;
}
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = url;
});
}
function buildHouseRows(cards, cardLookupMap) {
const trumpRows = HOUSE_TRUMP_ROWS.map((trumpNumbers) =>
(trumpNumbers || []).map((trumpNumber) => findMajorCardByTrumpNumber(cards, trumpNumber))
);
const leftRows = HOUSE_MINOR_NUMBER_BANDS.map((numbers, rowIndex) =>
numbers.map((rankNumber) => findCardByLookupName(cardLookupMap, buildMinorCardName(rankNumber, HOUSE_LEFT_SUITS[rowIndex])))
);
const middleRows = HOUSE_MIDDLE_RANKS.map((rank) =>
HOUSE_MIDDLE_SUITS.map((suit) => findCardByLookupName(cardLookupMap, buildCourtCardName(rank, suit)))
);
const rightRows = HOUSE_MINOR_NUMBER_BANDS.map((numbers, rowIndex) =>
numbers.map((rankNumber) => findCardByLookupName(cardLookupMap, buildMinorCardName(rankNumber, HOUSE_RIGHT_SUITS[rowIndex])))
);
return {
trumpRows,
leftRows,
middleRows,
rightRows
};
}
function drawRoundedRectPath(context, x, y, width, height, radius) {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.beginPath();
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
context.closePath();
}
function fitCanvasLabelText(context, text, maxWidth) {
const normalized = normalizeLabelText(text);
if (!normalized || context.measureText(normalized).width <= maxWidth) {
return normalized;
}
let result = normalized;
while (result.length > 1 && context.measureText(`${result}...`).width > maxWidth) {
result = result.slice(0, -1).trimEnd();
}
return `${result}...`;
}
function wrapCanvasText(context, text, maxWidth, maxLines = 4) {
const normalized = normalizeLabelText(text);
if (!normalized) {
return [];
}
const words = normalized.split(/\s+/).filter(Boolean);
if (words.length <= 1) {
return [fitCanvasLabelText(context, normalized, maxWidth)];
}
const lines = [];
let current = "";
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (current && context.measureText(next).width > maxWidth) {
lines.push(current);
current = word;
} else {
current = next;
}
});
if (current) {
lines.push(current);
}
if (lines.length <= maxLines) {
return lines;
}
const clipped = lines.slice(0, Math.max(1, maxLines));
clipped[clipped.length - 1] = fitCanvasLabelText(context, `${clipped[clipped.length - 1]}...`, maxWidth);
return clipped;
}
function drawTextFaceToCanvas(context, x, y, width, height, faceModel) {
const primaryText = normalizeLabelText(faceModel?.primary || "Tarot");
const secondaryText = normalizeLabelText(faceModel?.secondary);
const maxWidth = width - 20;
context.save();
context.fillStyle = "#f4f4f5";
const primaryFontSize = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 24 : 13;
const primaryFontFamily = faceModel?.className === "is-top-hebrew"
? "'Segoe UI Symbol', 'Noto Sans Hebrew', 'Segoe UI', sans-serif"
: "'Segoe UI', sans-serif";
context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`;
const primaryLines = wrapCanvasText(context, primaryText, maxWidth, secondaryText ? 3 : 4);
context.fillStyle = "rgba(250, 250, 250, 0.84)";
context.font = "500 9px 'Segoe UI', sans-serif";
const secondaryLines = secondaryText ? wrapCanvasText(context, secondaryText, maxWidth, 2) : [];
const primaryLineHeight = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 24 : 16;
const secondaryLineHeight = 12;
const totalHeight = (primaryLines.length * primaryLineHeight)
+ (secondaryLines.length ? 8 + (secondaryLines.length * secondaryLineHeight) : 0);
let currentY = y + ((height - totalHeight) / 2) + primaryLineHeight;
context.textAlign = "center";
context.textBaseline = "alphabetic";
context.fillStyle = "#f4f4f5";
context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`;
primaryLines.forEach((line) => {
context.fillText(line, x + (width / 2), currentY, maxWidth);
currentY += primaryLineHeight;
});
if (secondaryLines.length) {
currentY += 4;
context.fillStyle = "rgba(250, 250, 250, 0.84)";
context.font = "500 9px 'Segoe UI', sans-serif";
secondaryLines.forEach((line) => {
context.fillText(line, x + (width / 2), currentY, maxWidth);
currentY += secondaryLineHeight;
});
}
context.restore();
}
function drawCardLabelToCanvas(context, x, y, width, height, label) {
if (!label?.primary) {
return;
}
const hasSecondary = Boolean(label.secondary);
const overlayHeight = hasSecondary ? 34 : 24;
const overlayX = x + 4;
const overlayY = y + height - overlayHeight - 4;
const overlayWidth = width - 8;
const gradient = context.createLinearGradient(overlayX, overlayY, overlayX, overlayY + overlayHeight);
gradient.addColorStop(0, "rgba(9, 9, 11, 0.18)");
gradient.addColorStop(1, "rgba(9, 9, 11, 0.9)");
context.save();
drawRoundedRectPath(context, overlayX, overlayY, overlayWidth, overlayHeight, 6);
context.fillStyle = gradient;
context.fill();
context.textAlign = "center";
const primaryFontSize = label.className === "is-top-hebrew" && label.primary.length <= 3 ? 14 : 11;
context.textBaseline = hasSecondary ? "alphabetic" : "middle";
context.fillStyle = "#fafafa";
context.font = `700 ${primaryFontSize}px 'Segoe UI Symbol', 'Segoe UI', sans-serif`;
const primaryText = fitCanvasLabelText(context, label.primary, overlayWidth - 10);
if (hasSecondary) {
context.fillText(primaryText, x + width / 2, overlayY + 14, overlayWidth - 10);
context.fillStyle = "rgba(250, 250, 250, 0.84)";
context.font = "500 9px 'Segoe UI', sans-serif";
const secondaryText = fitCanvasLabelText(context, label.secondary, overlayWidth - 10);
context.fillText(secondaryText, x + width / 2, overlayY + overlayHeight - 8, overlayWidth - 10);
} else {
context.fillText(primaryText, x + width / 2, overlayY + (overlayHeight / 2), overlayWidth - 10);
}
context.restore();
}
function drawCardToCanvas(context, x, y, width, height, card, image) {
const label = buildHouseCardLabel(card);
const showImage = isHouseCardImageVisible(card);
drawRoundedRectPath(context, x, y, width, height, 8);
context.fillStyle = EXPORT_PANEL;
context.fill();
if (showImage && image) {
context.save();
drawRoundedRectPath(context, x, y, width, height, 8);
context.clip();
context.drawImage(image, x, y, width, height);
context.restore();
} else if (showImage) {
context.fillStyle = "#09090b";
context.fillRect(x, y, width, height);
context.fillStyle = EXPORT_FALLBACK_TEXT;
context.font = "600 12px 'Segoe UI', sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const label = card ? (config.getDisplayCardName(card) || card.name || "Tarot") : "Missing";
const words = String(label).split(/\s+/).filter(Boolean);
const lines = [];
let current = "";
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (next.length > 14 && current) {
lines.push(current);
current = word;
} else {
current = next;
}
});
if (current) {
lines.push(current);
}
const lineHeight = 16;
const startY = y + (height / 2) - ((lines.length - 1) * lineHeight / 2);
lines.slice(0, 4).forEach((line, index) => {
context.fillText(line, x + width / 2, startY + (index * lineHeight), width - 16);
});
} else {
drawTextFaceToCanvas(context, x, y, width, height, buildHouseCardTextFaceModel(card, label));
}
if (showImage) {
drawCardLabelToCanvas(context, x, y, width, height, label);
}
drawRoundedRectPath(context, x, y, width, height, 8);
context.lineWidth = 2;
context.strokeStyle = EXPORT_BORDER;
context.stroke();
}
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Canvas export failed."));
}, "image/png");
});
}
function isExportFormatSupported(format) {
const exportFormat = EXPORT_FORMATS[format];
if (!exportFormat) {
return false;
}
if (format === "png") {
return true;
}
const probeCanvas = document.createElement("canvas");
const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType);
return dataUrl.startsWith(`data:${exportFormat.mimeType}`);
}
function canvasToBlobByFormat(canvas, format) {
const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.png;
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Canvas export failed."));
}, exportFormat.mimeType, exportFormat.quality);
});
}
async function exportImage(format = "png") {
const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.png;
const cards = config.getCards();
const cardLookupMap = getCardLookupMap(cards);
const houseRows = buildHouseRows(cards, cardLookupMap);
const majorRowWidth = (11 * EXPORT_CARD_WIDTH) + (10 * EXPORT_CARD_GAP);
const leftColumnWidth = (3 * EXPORT_CARD_WIDTH) + (2 * EXPORT_CARD_GAP);
const middleColumnWidth = (4 * EXPORT_CARD_WIDTH) + (3 * EXPORT_CARD_GAP);
const rightColumnWidth = leftColumnWidth;
const usedBottomWidth = leftColumnWidth + middleColumnWidth + rightColumnWidth;
const betweenColumnGap = Math.max(0, (majorRowWidth - usedBottomWidth) / 2);
const contentWidth = majorRowWidth;
const trumpHeight = (houseRows.trumpRows.length * EXPORT_CARD_HEIGHT) + ((houseRows.trumpRows.length - 1) * EXPORT_ROW_GAP);
const bottomHeight = (houseRows.leftRows.length * EXPORT_CARD_HEIGHT) + ((houseRows.leftRows.length - 1) * EXPORT_ROW_GAP);
const contentHeight = trumpHeight + EXPORT_SECTION_GAP + bottomHeight;
const scale = Math.max(2, Math.min(3, Number(window.devicePixelRatio) || 1));
const canvas = document.createElement("canvas");
canvas.width = Math.ceil((contentWidth + (EXPORT_PADDING * 2)) * scale);
canvas.height = Math.ceil((contentHeight + (EXPORT_PADDING * 2)) * scale);
canvas.style.width = `${contentWidth + (EXPORT_PADDING * 2)}px`;
canvas.style.height = `${contentHeight + (EXPORT_PADDING * 2)}px`;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas context is unavailable.");
}
context.scale(scale, scale);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.fillStyle = EXPORT_BACKGROUND;
context.fillRect(0, 0, contentWidth + (EXPORT_PADDING * 2), contentHeight + (EXPORT_PADDING * 2));
const imageCache = new Map();
const imageUrlByCardId = new Map();
cards.forEach((card) => {
const url = typeof config.resolveTarotCardImage === "function"
? config.resolveTarotCardImage(card.name)
: null;
imageUrlByCardId.set(card.id, url || "");
if (url && !imageCache.has(url)) {
imageCache.set(url, loadCardImage(url));
}
});
const resolvedImageByCardId = new Map();
await Promise.all(cards.map(async (card) => {
const url = imageUrlByCardId.get(card.id);
const image = url ? await imageCache.get(url) : null;
resolvedImageByCardId.set(card.id, image || null);
}));
let currentY = EXPORT_PADDING;
houseRows.trumpRows.forEach((row) => {
const rowWidth = (row.length * EXPORT_CARD_WIDTH) + ((Math.max(0, row.length - 1)) * EXPORT_CARD_GAP);
let currentX = EXPORT_PADDING + ((contentWidth - rowWidth) / 2);
row.forEach((card) => {
drawCardToCanvas(context, currentX, currentY, EXPORT_CARD_WIDTH, EXPORT_CARD_HEIGHT, card, card ? resolvedImageByCardId.get(card.id) : null);
currentX += EXPORT_CARD_WIDTH + EXPORT_CARD_GAP;
});
currentY += EXPORT_CARD_HEIGHT + EXPORT_ROW_GAP;
});
currentY = EXPORT_PADDING + trumpHeight + EXPORT_SECTION_GAP;
const columnXs = [
EXPORT_PADDING,
EXPORT_PADDING + leftColumnWidth + betweenColumnGap,
EXPORT_PADDING + leftColumnWidth + betweenColumnGap + middleColumnWidth + betweenColumnGap
];
[houseRows.leftRows, houseRows.middleRows, houseRows.rightRows].forEach((columnRows, columnIndex) => {
let columnY = currentY;
columnRows.forEach((row) => {
let currentX = columnXs[columnIndex];
row.forEach((card) => {
drawCardToCanvas(context, currentX, columnY, EXPORT_CARD_WIDTH, EXPORT_CARD_HEIGHT, card, card ? resolvedImageByCardId.get(card.id) : null);
currentX += EXPORT_CARD_WIDTH + EXPORT_CARD_GAP;
});
columnY += EXPORT_CARD_HEIGHT + EXPORT_ROW_GAP;
});
});
const blob = await canvasToBlobByFormat(canvas, format);
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
const stamp = new Date().toISOString().slice(0, 10);
downloadLink.href = blobUrl;
downloadLink.download = `tarot-house-of-cards-${stamp}.${exportFormat.extension}`;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}
function appendHouseMinorRow(columnEl, cardLookupMap, numbers, suit, elements) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-row";
@@ -222,6 +1133,8 @@
window.TarotHouseUi = {
init,
render,
updateSelection
updateSelection,
exportImage,
isExportFormatSupported
};
})();

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,25 @@
filteredCards: [],
searchQuery: "",
selectedCardId: "",
houseFocusMode: false,
houseTopCardsVisible: true,
houseTopInfoModes: {
hebrew: true,
planet: true,
zodiac: true,
trump: true,
path: true
},
houseBottomCardsVisible: true,
houseBottomInfoModes: {
zodiac: true,
decan: true,
month: true,
ruler: true,
date: false
},
houseExportInProgress: false,
houseExportFormat: "png",
magickDataset: null,
referenceData: null,
monthRefsByCardId: new Map(),
@@ -248,10 +267,34 @@
tarotDetailIChingEl: document.getElementById("tarot-detail-iching"),
tarotDetailCalendarEl: document.getElementById("tarot-detail-calendar"),
tarotKabPathEl: document.getElementById("tarot-kab-path"),
tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards")
tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"),
tarotBrowseViewEl: document.getElementById("tarot-browse-view"),
tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"),
tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"),
tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"),
tarotHouseTopInfoZodiacEl: document.getElementById("tarot-house-top-info-zodiac"),
tarotHouseTopInfoTrumpEl: document.getElementById("tarot-house-top-info-trump"),
tarotHouseTopInfoPathEl: document.getElementById("tarot-house-top-info-path"),
tarotHouseBottomCardsVisibleEl: document.getElementById("tarot-house-bottom-cards-visible"),
tarotHouseBottomInfoZodiacEl: document.getElementById("tarot-house-bottom-info-zodiac"),
tarotHouseBottomInfoDecanEl: document.getElementById("tarot-house-bottom-info-decan"),
tarotHouseBottomInfoMonthEl: document.getElementById("tarot-house-bottom-info-month"),
tarotHouseBottomInfoRulerEl: document.getElementById("tarot-house-bottom-info-ruler"),
tarotHouseBottomInfoDateEl: document.getElementById("tarot-house-bottom-info-date"),
tarotHouseFocusToggleEl: document.getElementById("tarot-house-focus-toggle"),
tarotHouseExportEl: document.getElementById("tarot-house-export"),
tarotHouseExportWebpEl: document.getElementById("tarot-house-export-webp")
};
}
function setHouseBottomInfoCheckboxState(checkbox, enabled) {
if (!checkbox) {
return;
}
checkbox.checked = Boolean(enabled);
checkbox.disabled = Boolean(state.houseExportInProgress);
}
function normalizeRelationId(value) {
return String(value || "")
.trim()
@@ -485,6 +528,76 @@
tarotHouseUi.render?.(elements);
}
function syncHouseControls(elements) {
if (elements?.tarotBrowseViewEl) {
elements.tarotBrowseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode));
}
if (elements?.tarotHouseTopCardsVisibleEl) {
elements.tarotHouseTopCardsVisibleEl.checked = Boolean(state.houseTopCardsVisible);
elements.tarotHouseTopCardsVisibleEl.disabled = Boolean(state.houseExportInProgress);
}
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoHebrewEl, state.houseTopInfoModes.hebrew);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoPlanetEl, state.houseTopInfoModes.planet);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoZodiacEl, state.houseTopInfoModes.zodiac);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoTrumpEl, state.houseTopInfoModes.trump);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoPathEl, state.houseTopInfoModes.path);
if (elements?.tarotHouseBottomCardsVisibleEl) {
elements.tarotHouseBottomCardsVisibleEl.checked = Boolean(state.houseBottomCardsVisible);
elements.tarotHouseBottomCardsVisibleEl.disabled = Boolean(state.houseExportInProgress);
}
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoZodiacEl, state.houseBottomInfoModes.zodiac);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoDecanEl, state.houseBottomInfoModes.decan);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoMonthEl, state.houseBottomInfoModes.month);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoRulerEl, state.houseBottomInfoModes.ruler);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoDateEl, state.houseBottomInfoModes.date);
if (elements?.tarotHouseFocusToggleEl) {
elements.tarotHouseFocusToggleEl.setAttribute("aria-pressed", state.houseFocusMode ? "true" : "false");
elements.tarotHouseFocusToggleEl.textContent = state.houseFocusMode ? "Show Full Tarot" : "Focus House";
}
if (elements?.tarotHouseExportEl) {
elements.tarotHouseExportEl.disabled = Boolean(state.houseExportInProgress);
elements.tarotHouseExportEl.textContent = state.houseExportInProgress ? "Exporting..." : "Export PNG";
}
if (elements?.tarotHouseExportWebpEl) {
const supportsWebp = tarotHouseUi.isExportFormatSupported?.("webp") === true;
elements.tarotHouseExportWebpEl.disabled = Boolean(state.houseExportInProgress) || !supportsWebp;
elements.tarotHouseExportWebpEl.hidden = !supportsWebp;
elements.tarotHouseExportWebpEl.textContent = state.houseExportInProgress && state.houseExportFormat === "webp"
? "Exporting..."
: "Export WebP";
if (supportsWebp) {
elements.tarotHouseExportWebpEl.title = "Smaller file size, but not guaranteed lossless like PNG.";
}
}
}
async function exportHouseOfCards(elements, format = "png") {
if (state.houseExportInProgress) {
return;
}
state.houseExportInProgress = true;
state.houseExportFormat = format;
syncHouseControls(elements);
try {
await tarotHouseUi.exportImage?.(format);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Unable to export the House of Cards image.");
} finally {
state.houseExportInProgress = false;
state.houseExportFormat = "png";
syncHouseControls(elements);
}
}
function buildTypeLabel(card) {
return tarotCardDerivationsUi.buildTypeLabel(card);
}
@@ -566,6 +679,35 @@
renderDetail(card, elements);
}
function scrollCardIntoView(cardIdToReveal, elements) {
elements?.tarotCardListEl
?.querySelector(`[data-card-id="${cardIdToReveal}"]`)
?.scrollIntoView({ block: "nearest" });
}
function buildLightboxCardRequestById(cardIdToResolve) {
const card = state.cards.find((entry) => entry.id === cardIdToResolve);
if (!card) {
return null;
}
const src = typeof resolveTarotCardImage === "function"
? resolveTarotCardImage(card.name)
: "";
if (!src) {
return null;
}
const label = getDisplayCardName(card) || card.name || "Tarot card enlarged image";
return {
src,
altText: label,
label,
cardId: card.id,
compareDetails: tarotDetailRenderer.buildCompareDetails?.(card) || []
};
}
function renderList(elements) {
if (!elements?.tarotCardListEl) {
return;
@@ -613,8 +755,32 @@
clearChildren,
normalizeTarotCardLookupName,
selectCardById,
openCardLightbox: (src, altText, options = {}) => {
const cardId = String(options?.cardId || "").trim();
const primaryCardRequest = cardId ? buildLightboxCardRequestById(cardId) : null;
window.TarotUiLightbox?.open?.({
src: primaryCardRequest?.src || src,
altText: primaryCardRequest?.altText || altText || "Tarot card enlarged image",
label: primaryCardRequest?.label || altText || "Tarot card enlarged image",
cardId: primaryCardRequest?.cardId || cardId,
compareDetails: primaryCardRequest?.compareDetails || [],
allowOverlayCompare: true,
sequenceIds: state.cards.map((card) => card.id),
resolveCardById: buildLightboxCardRequestById,
onSelectCardId: (nextCardId) => {
const latestElements = getElements();
selectCardById(nextCardId, latestElements);
scrollCardIntoView(nextCardId, latestElements);
}
});
},
isHouseFocusMode: () => state.houseFocusMode,
getCards: () => state.cards,
getSelectedCardId: () => state.selectedCardId
getSelectedCardId: () => state.selectedCardId,
getHouseTopCardsVisible: () => state.houseTopCardsVisible,
getHouseTopInfoModes: () => ({ ...state.houseTopInfoModes }),
getHouseBottomCardsVisible: () => state.houseBottomCardsVisible,
getHouseBottomInfoModes: () => ({ ...state.houseBottomInfoModes })
});
const elements = getElements();
@@ -623,6 +789,7 @@
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, state.cards);
state.courtCardByDecanId = buildCourtCardByDecanId(state.cards);
renderHouseOfCards(elements);
syncHouseControls(elements);
if (state.selectedCardId) {
const selected = state.cards.find((card) => card.id === state.selectedCardId);
if (selected) {
@@ -652,6 +819,7 @@
state.filteredCards = [...cards];
renderList(elements);
renderHouseOfCards(elements);
syncHouseControls(elements);
if (cards.length > 0) {
selectCardById(cards[0].id, elements);
@@ -695,6 +863,75 @@
});
}
if (elements.tarotHouseFocusToggleEl) {
elements.tarotHouseFocusToggleEl.addEventListener("click", () => {
state.houseFocusMode = !state.houseFocusMode;
syncHouseControls(elements);
});
}
if (elements.tarotHouseTopCardsVisibleEl) {
elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => {
state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
[
[elements.tarotHouseTopInfoHebrewEl, "hebrew"],
[elements.tarotHouseTopInfoPlanetEl, "planet"],
[elements.tarotHouseTopInfoZodiacEl, "zodiac"],
[elements.tarotHouseTopInfoTrumpEl, "trump"],
[elements.tarotHouseTopInfoPathEl, "path"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseTopInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseBottomCardsVisibleEl) {
elements.tarotHouseBottomCardsVisibleEl.addEventListener("change", () => {
state.houseBottomCardsVisible = Boolean(elements.tarotHouseBottomCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
[
[elements.tarotHouseBottomInfoZodiacEl, "zodiac"],
[elements.tarotHouseBottomInfoDecanEl, "decan"],
[elements.tarotHouseBottomInfoMonthEl, "month"],
[elements.tarotHouseBottomInfoRulerEl, "ruler"],
[elements.tarotHouseBottomInfoDateEl, "date"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseBottomInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseExportEl) {
elements.tarotHouseExportEl.addEventListener("click", () => {
exportHouseOfCards(elements, "png");
});
}
if (elements.tarotHouseExportWebpEl) {
elements.tarotHouseExportWebpEl.addEventListener("click", () => {
exportHouseOfCards(elements, "webp");
});
}
if (elements.tarotDetailImageEl) {
elements.tarotDetailImageEl.addEventListener("click", () => {
const src = elements.tarotDetailImageEl.getAttribute("src") || "";
@@ -714,8 +951,7 @@
const card = state.cards.find(c => c.arcana === "Major" && c.number === trumpNumber);
if (!card) return;
selectCardById(card.id, el);
const listItem = el.tarotCardListEl?.querySelector(`[data-card-id="${card.id}"]`);
listItem?.scrollIntoView({ block: "nearest" });
scrollCardIntoView(card.id, el);
}
function selectCardByName(name) {
@@ -725,9 +961,7 @@
const card = state.cards.find((entry) => normalizeTarotCardLookupName(entry.name) === needle);
if (!card) return;
selectCardById(card.id, el);
el.tarotCardListEl
?.querySelector(`[data-card-id="${card.id}"]`)
?.scrollIntoView({ block: "nearest" });
scrollCardIntoView(card.id, el);
}
window.TarotSectionUi = {

View File

@@ -172,7 +172,68 @@
<div id="tarot-browse-view">
<section class="tarot-misc-section tarot-section-house-top" aria-label="Tarot misc">
<div class="tarot-meta-card tarot-house-card">
<strong>House of Cards</strong>
<div class="tarot-house-card-head">
<strong>House of Cards</strong>
<div class="tarot-house-card-actions">
<label class="tarot-house-toggle" for="tarot-house-top-cards-visible">
<input id="tarot-house-top-cards-visible" type="checkbox" checked>
<span>Show Top Cards</span>
</label>
<fieldset class="tarot-house-filter-group">
<legend>Top Info</legend>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-hebrew">
<input id="tarot-house-top-info-hebrew" type="checkbox" checked>
<span>Hebrew</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-planet">
<input id="tarot-house-top-info-planet" type="checkbox" checked>
<span>Planet</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-zodiac">
<input id="tarot-house-top-info-zodiac" type="checkbox" checked>
<span>Zodiac</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-trump">
<input id="tarot-house-top-info-trump" type="checkbox" checked>
<span>Trump</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-path">
<input id="tarot-house-top-info-path" type="checkbox" checked>
<span>Path</span>
</label>
</fieldset>
<label class="tarot-house-toggle" for="tarot-house-bottom-cards-visible">
<input id="tarot-house-bottom-cards-visible" type="checkbox" checked>
<span>Show Bottom Cards</span>
</label>
<fieldset class="tarot-house-filter-group">
<legend>Bottom Info</legend>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-zodiac">
<input id="tarot-house-bottom-info-zodiac" type="checkbox" checked>
<span>Sign</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-decan">
<input id="tarot-house-bottom-info-decan" type="checkbox" checked>
<span>Decan</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-month">
<input id="tarot-house-bottom-info-month" type="checkbox" checked>
<span>Month</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-ruler">
<input id="tarot-house-bottom-info-ruler" type="checkbox" checked>
<span>Ruler</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-date">
<input id="tarot-house-bottom-info-date" type="checkbox">
<span>Date</span>
</label>
</fieldset>
<button id="tarot-house-focus-toggle" class="tarot-house-action-btn" type="button" aria-pressed="false">Focus House</button>
<button id="tarot-house-export" class="tarot-house-action-btn" type="button">Export PNG</button>
<button id="tarot-house-export-webp" class="tarot-house-action-btn" type="button">Export WebP</button>
</div>
</div>
<div id="tarot-house-of-cards" class="tarot-house-layout" aria-live="polite"></div>
</div>
</section>
@@ -822,7 +883,9 @@
<script src="app/ui-quiz-bank-builtins.js"></script>
<script src="app/ui-quiz-bank.js"></script>
<script src="app/ui-quiz.js"></script>
<script src="app/quiz-plugin-helpers.js"></script>
<script src="app/quiz-calendars.js"></script>
<script src="app/quiz-connections.js"></script>
<script src="app/ui-gods-references.js"></script>
<script src="app/ui-gods.js"></script>
<script src="app/ui-enochian.js"></script>