added overlay function for tarot cards
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
873
app/quiz-connections.js
Normal 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
168
app/quiz-plugin-helpers.js
Normal 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
|
||||
};
|
||||
})();
|
||||
100
app/stellarium-now-wrapper.html
Normal file
100
app/stellarium-now-wrapper.html
Normal 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>
|
||||
325
app/styles.css
325
app/styles.css
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
248
app/ui-tarot.js
248
app/ui-tarot.js
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user