diff --git a/app/data-service.js b/app/data-service.js index b89b1b9..1de2ebc 100644 --- a/app/data-service.js +++ b/app/data-service.js @@ -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, diff --git a/app/quiz-calendars.js b/app/quiz-calendars.js index 7acb5ab..e3745cf 100644 --- a/app/quiz-calendars.js +++ b/app/quiz-calendars.js @@ -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) { diff --git a/app/quiz-connections.js b/app/quiz-connections.js new file mode 100644 index 0000000..89d65d3 --- /dev/null +++ b/app/quiz-connections.js @@ -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 + }; +})(); \ No newline at end of file diff --git a/app/quiz-plugin-helpers.js b/app/quiz-plugin-helpers.js new file mode 100644 index 0000000..0be8b6e --- /dev/null +++ b/app/quiz-plugin-helpers.js @@ -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 + }; +})(); \ No newline at end of file diff --git a/app/stellarium-now-wrapper.html b/app/stellarium-now-wrapper.html new file mode 100644 index 0000000..3c65916 --- /dev/null +++ b/app/stellarium-now-wrapper.html @@ -0,0 +1,100 @@ + + + + + + Stellarium NOW Wrapper + + + +
+ +
+ + + \ No newline at end of file diff --git a/app/styles.css b/app/styles.css index 0f5ef0f..9c92ec5 100644 --- a/app/styles.css +++ b/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; diff --git a/app/tarot-database-assembly.js b/app/tarot-database-assembly.js index 80b0877..9052493 100644 --- a/app/tarot-database-assembly.js +++ b/app/tarot-database-assembly.js @@ -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); diff --git a/app/ui-home-calendar.js b/app/ui-home-calendar.js index b88a05b..877d65d 100644 --- a/app/ui-home-calendar.js +++ b/app/ui-home-calendar.js @@ -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) { diff --git a/app/ui-quiz-bank-builtins-domains.js b/app/ui-quiz-bank-builtins-domains.js index f39aaea..b1b08d1 100644 --- a/app/ui-quiz-bank-builtins-domains.js +++ b/app/ui-quiz-bank-builtins-domains.js @@ -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 diff --git a/app/ui-quiz-bank-builtins.js b/app/ui-quiz-bank-builtins.js index 35017c5..7c25b74 100644 --- a/app/ui-quiz-bank-builtins.js +++ b/app/ui-quiz-bank-builtins.js @@ -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 = { diff --git a/app/ui-quiz-bank.js b/app/ui-quiz-bank.js index 6891302..91fd958 100644 --- a/app/ui-quiz-bank.js +++ b/app/ui-quiz-bank.js @@ -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, diff --git a/app/ui-quiz.js b/app/ui-quiz.js index eb8c639..4fa436d 100644 --- a/app/ui-quiz.js +++ b/app/ui-quiz.js @@ -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) { diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js index e3c7690..874a218 100644 --- a/app/ui-tarot-detail.js +++ b/app/ui-tarot-detail.js @@ -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 }; diff --git a/app/ui-tarot-house.js b/app/ui-tarot-house.js index 681983e..3de41b8 100644 --- a/app/ui-tarot-house.js +++ b/app/ui-tarot-house.js @@ -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 }; })(); \ No newline at end of file diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js index 54855b9..98a19c2 100644 --- a/app/ui-tarot-lightbox.js +++ b/app/ui-tarot-lightbox.js @@ -2,20 +2,605 @@ "use strict"; let overlayEl = null; + let backdropEl = null; + let toolbarEl = null; + let helpButtonEl = null; + let helpPanelEl = null; + let compareButtonEl = null; + let zoomControlEl = null; + let zoomSliderEl = null; + let zoomValueEl = null; + let opacityControlEl = null; + let opacitySliderEl = null; + let opacityValueEl = null; + let stageEl = null; + let frameEl = null; + let baseLayerEl = null; + let overlayLayerEl = null; let imageEl = null; + let overlayImageEl = null; + let primaryInfoEl = null; + let primaryTitleEl = null; + let primaryGroupsEl = null; + let primaryHintEl = null; + let secondaryInfoEl = null; + let secondaryTitleEl = null; + let secondaryGroupsEl = null; + let secondaryHintEl = null; let zoomed = false; + let previousFocusedEl = null; const LIGHTBOX_ZOOM_SCALE = 6.66; + const LIGHTBOX_ZOOM_STEP = 0.1; + const LIGHTBOX_PAN_STEP = 4; + const LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY = 0.5; + const LIGHTBOX_COMPARE_SEQUENCE_STEP_KEYS = new Set(["ArrowLeft", "ArrowRight"]); - function resetZoom() { + const lightboxState = { + isOpen: false, + compareMode: false, + allowOverlayCompare: false, + primaryCard: null, + secondaryCard: null, + sequenceIds: [], + resolveCardById: null, + onSelectCardId: null, + overlayOpacity: LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY, + zoomScale: LIGHTBOX_ZOOM_SCALE, + helpOpen: false, + primaryRotated: false, + overlayRotated: false, + zoomOriginX: 50, + zoomOriginY: 50 + }; + + function hasSecondaryCard() { + return Boolean(lightboxState.secondaryCard?.src); + } + + function clampOverlayOpacity(value) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY; + } + + return Math.min(1, Math.max(0.05, numericValue)); + } + + function clampZoomScale(value) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return 1; + } + + return Math.min(LIGHTBOX_ZOOM_SCALE, Math.max(1, numericValue)); + } + + function normalizeCompareDetails(compareDetails) { + if (!Array.isArray(compareDetails)) { + return []; + } + + return compareDetails + .map((group) => ({ + title: String(group?.title || "").trim(), + items: Array.isArray(group?.items) + ? [...new Set(group.items.map((item) => String(item || "").trim()).filter(Boolean))] + : [] + })) + .filter((group) => group.title && group.items.length); + } + + function normalizeOpenRequest(srcOrOptions, altText, extraOptions) { + if (srcOrOptions && typeof srcOrOptions === "object" && !Array.isArray(srcOrOptions)) { + return { + ...srcOrOptions + }; + } + + return { + ...(extraOptions || {}), + src: srcOrOptions, + altText + }; + } + + function normalizeCardRequest(request) { + const normalized = normalizeOpenRequest(request); + const label = String(normalized.label || normalized.altText || "Tarot card enlarged image").trim() || "Tarot card enlarged image"; + return { + src: String(normalized.src || "").trim(), + altText: String(normalized.altText || label).trim() || label, + label, + cardId: String(normalized.cardId || "").trim(), + compareDetails: normalizeCompareDetails(normalized.compareDetails) + }; + } + + function resolveCardRequestById(cardId) { + if (!cardId || typeof lightboxState.resolveCardById !== "function") { + return null; + } + + const resolved = lightboxState.resolveCardById(cardId); + if (!resolved) { + return null; + } + + return normalizeCardRequest({ + ...resolved, + cardId + }); + } + + function clearSecondaryCard() { + lightboxState.secondaryCard = null; + if (overlayImageEl) { + overlayImageEl.removeAttribute("src"); + overlayImageEl.alt = ""; + overlayImageEl.style.display = "none"; + } + + syncComparePanels(); + syncOpacityControl(); + } + + function setOverlayOpacity(value) { + const opacity = clampOverlayOpacity(value); + lightboxState.overlayOpacity = opacity; + + if (overlayImageEl) { + overlayImageEl.style.opacity = String(opacity); + } + + if (opacitySliderEl) { + opacitySliderEl.value = String(Math.round(opacity * 100)); + opacitySliderEl.disabled = !lightboxState.compareMode || !hasSecondaryCard(); + } + + if (opacityValueEl) { + opacityValueEl.textContent = `${Math.round(opacity * 100)}%`; + } + } + + function updateImageCursor() { if (!imageEl) { return; } + imageEl.style.cursor = zoomed ? "zoom-out" : "zoom-in"; + } + + function buildRotationTransform(rotated) { + return rotated ? "rotate(180deg)" : "rotate(0deg)"; + } + + function isPrimaryRotationActive() { + return !lightboxState.compareMode && lightboxState.primaryRotated; + } + + function isOverlayRotationActive() { + return lightboxState.compareMode && hasSecondaryCard() && lightboxState.overlayRotated; + } + + function applyTransformOrigins(originX = lightboxState.zoomOriginX, originY = lightboxState.zoomOriginY) { + const nextOrigin = `${originX}% ${originY}%`; + + if (baseLayerEl) { + baseLayerEl.style.transformOrigin = nextOrigin; + } + + if (overlayLayerEl) { + overlayLayerEl.style.transformOrigin = nextOrigin; + } + } + + function applyZoomTransform() { + const activeZoomScale = zoomed ? lightboxState.zoomScale : 1; + const showPrimaryRotation = isPrimaryRotationActive(); + const showOverlayRotation = isOverlayRotationActive(); + + if (baseLayerEl) { + baseLayerEl.style.transform = `scale(${activeZoomScale})`; + } + + if (overlayLayerEl) { + overlayLayerEl.style.transform = `scale(${activeZoomScale})`; + } + + if (imageEl) { + imageEl.style.transform = buildRotationTransform(showPrimaryRotation); + } + + if (overlayImageEl) { + overlayImageEl.style.transform = buildRotationTransform(showOverlayRotation); + } + + applyTransformOrigins(); + + updateImageCursor(); + } + + function setZoomScale(value) { + const zoomScale = clampZoomScale(value); + lightboxState.zoomScale = zoomScale; + + if (zoomSliderEl) { + zoomSliderEl.value = String(Math.round(zoomScale * 100)); + } + + if (zoomValueEl) { + zoomValueEl.textContent = `${Math.round(zoomScale * 100)}%`; + } + + if (lightboxState.isOpen) { + applyComparePresentation(); + return; + } + + applyZoomTransform(); + } + + function isZoomInKey(event) { + return event.key === "+" + || event.key === "=" + || event.code === "NumpadAdd"; + } + + function isZoomOutKey(event) { + return event.key === "-" + || event.key === "_" + || event.code === "NumpadSubtract"; + } + + function isRotateKey(event) { + return event.code === "KeyR" || String(event.key || "").toLowerCase() === "r"; + } + + function stepZoom(direction) { + if (!lightboxState.isOpen) { + return; + } + + const activeScale = zoomed ? lightboxState.zoomScale : 1; + const nextScale = clampZoomScale(activeScale + (direction * LIGHTBOX_ZOOM_STEP)); + zoomed = nextScale > 1; + setZoomScale(nextScale); + + if (!zoomed && imageEl) { + lightboxState.zoomOriginX = 50; + lightboxState.zoomOriginY = 50; + } + + if (!zoomed && overlayImageEl) { + lightboxState.zoomOriginX = 50; + lightboxState.zoomOriginY = 50; + } + } + + function isPanUpKey(event) { + return event.code === "KeyW" || String(event.key || "").toLowerCase() === "w"; + } + + function isPanLeftKey(event) { + return event.code === "KeyA" || String(event.key || "").toLowerCase() === "a"; + } + + function isPanDownKey(event) { + return event.code === "KeyS" || String(event.key || "").toLowerCase() === "s"; + } + + function isPanRightKey(event) { + return event.code === "KeyD" || String(event.key || "").toLowerCase() === "d"; + } + + function stepPan(deltaX, deltaY) { + if (!lightboxState.isOpen || !zoomed || !imageEl) { + return; + } + + lightboxState.zoomOriginX = Math.min(100, Math.max(0, lightboxState.zoomOriginX + deltaX)); + lightboxState.zoomOriginY = Math.min(100, Math.max(0, lightboxState.zoomOriginY + deltaY)); + applyTransformOrigins(); + } + + function toggleRotation() { + if (!lightboxState.isOpen) { + return; + } + + if (lightboxState.compareMode && hasSecondaryCard()) { + lightboxState.overlayRotated = !lightboxState.overlayRotated; + } else { + lightboxState.primaryRotated = !lightboxState.primaryRotated; + } + + applyZoomTransform(); + } + + function createCompareGroupElement(group) { + const sectionEl = document.createElement("section"); + sectionEl.style.display = "flex"; + sectionEl.style.flexDirection = "column"; + sectionEl.style.gap = "5px"; + sectionEl.style.paddingTop = "8px"; + sectionEl.style.borderTop = "1px solid rgba(148, 163, 184, 0.14)"; + + const titleEl = document.createElement("div"); + titleEl.textContent = group.title; + titleEl.style.font = "600 10px/1.2 sans-serif"; + titleEl.style.letterSpacing = "0.1em"; + titleEl.style.textTransform = "uppercase"; + titleEl.style.color = "rgba(148, 163, 184, 0.92)"; + + const valuesEl = document.createElement("div"); + valuesEl.style.display = "flex"; + valuesEl.style.flexDirection = "column"; + valuesEl.style.gap = "3px"; + + group.items.forEach((item) => { + const itemEl = document.createElement("div"); + itemEl.textContent = item; + itemEl.style.font = "500 12px/1.35 sans-serif"; + itemEl.style.color = "#f8fafc"; + valuesEl.appendChild(itemEl); + }); + + sectionEl.append(titleEl, valuesEl); + return sectionEl; + } + + function renderComparePanel(panelEl, titleEl, groupsEl, hintEl, cardRequest, roleLabel, hintText, isVisible) { + if (!panelEl || !titleEl || !groupsEl || !hintEl) { + return; + } + + if (!isVisible || !cardRequest?.label) { + panelEl.style.display = "none"; + titleEl.textContent = ""; + hintEl.textContent = ""; + groupsEl.replaceChildren(); + return; + } + + panelEl.style.display = "flex"; + titleEl.textContent = `${roleLabel}: ${cardRequest.label}`; + groupsEl.replaceChildren(); + + if (Array.isArray(cardRequest.compareDetails) && cardRequest.compareDetails.length) { + cardRequest.compareDetails.forEach((group) => { + groupsEl.appendChild(createCompareGroupElement(group)); + }); + } else { + const emptyEl = document.createElement("div"); + emptyEl.textContent = "No compare metadata available."; + emptyEl.style.font = "500 12px/1.35 sans-serif"; + emptyEl.style.color = "rgba(226, 232, 240, 0.8)"; + groupsEl.appendChild(emptyEl); + } + + hintEl.textContent = hintText; + hintEl.style.display = hintText ? "block" : "none"; + } + + function syncComparePanels() { + const isComparing = lightboxState.compareMode; + const overlaySelected = hasSecondaryCard(); + const showPrimaryPanel = Boolean(lightboxState.isOpen && lightboxState.allowOverlayCompare && lightboxState.primaryCard?.label && !zoomed); + const showSecondaryPanel = Boolean(isComparing && overlaySelected && lightboxState.secondaryCard?.label && !zoomed); + + renderComparePanel( + primaryInfoEl, + primaryTitleEl, + primaryGroupsEl, + primaryHintEl, + lightboxState.primaryCard, + "Base", + isComparing + ? (overlaySelected + ? "Use Left and Right arrows to move through the overlaid card." + : "Click another House card behind the dock, or use Left and Right arrows to pick the first overlay card.") + : "Use Left and Right arrows to move through cards. Click Overlay to compare.", + showPrimaryPanel + ); + + renderComparePanel( + secondaryInfoEl, + secondaryTitleEl, + secondaryGroupsEl, + secondaryHintEl, + lightboxState.secondaryCard, + "Overlay", + overlaySelected ? "Use Left and Right arrows to swap the overlay card." : "", + showSecondaryPanel + ); + } + + function syncOpacityControl() { + if (!opacityControlEl) { + return; + } + + opacityControlEl.style.display = lightboxState.compareMode && hasSecondaryCard() && !zoomed ? "flex" : "none"; + setOverlayOpacity(lightboxState.overlayOpacity); + } + + function syncHelpUi() { + if (!helpButtonEl || !helpPanelEl) { + return; + } + + const canShow = lightboxState.isOpen && !zoomed; + helpButtonEl.style.display = canShow ? "inline-flex" : "none"; + helpPanelEl.style.display = canShow && lightboxState.helpOpen ? "flex" : "none"; + helpButtonEl.textContent = lightboxState.helpOpen ? "Hide Help" : "Help"; + } + + function syncZoomControl() { + if (!zoomControlEl) { + return; + } + + zoomControlEl.style.display = lightboxState.isOpen && !zoomed ? "flex" : "none"; + if (zoomSliderEl) { + zoomSliderEl.value = String(Math.round(lightboxState.zoomScale * 100)); + } + + if (zoomValueEl) { + zoomValueEl.textContent = `${Math.round(lightboxState.zoomScale * 100)}%`; + } + } + + function applyComparePresentation() { + if (!overlayEl || !backdropEl || !toolbarEl || !stageEl || !frameEl || !imageEl || !overlayImageEl || !compareButtonEl) { + return; + } + + compareButtonEl.hidden = zoomed || !lightboxState.allowOverlayCompare || (lightboxState.compareMode && !hasSecondaryCard()); + compareButtonEl.textContent = lightboxState.compareMode ? "Done Overlay" : "Overlay"; + syncHelpUi(); + syncZoomControl(); + syncOpacityControl(); + + if (!lightboxState.compareMode) { + overlayEl.style.pointerEvents = "none"; + backdropEl.style.display = "block"; + backdropEl.style.pointerEvents = "auto"; + backdropEl.style.background = "rgba(0, 0, 0, 0.82)"; + toolbarEl.style.top = "24px"; + toolbarEl.style.right = "24px"; + toolbarEl.style.left = "auto"; + stageEl.style.top = "0"; + stageEl.style.right = "0"; + stageEl.style.bottom = "0"; + stageEl.style.left = "0"; + stageEl.style.width = "auto"; + stageEl.style.height = "auto"; + stageEl.style.transform = "none"; + stageEl.style.pointerEvents = "auto"; + frameEl.style.position = "relative"; + frameEl.style.width = "100%"; + frameEl.style.height = "100%"; + frameEl.style.maxWidth = "none"; + frameEl.style.maxHeight = "none"; + frameEl.style.borderRadius = "0"; + frameEl.style.background = "transparent"; + frameEl.style.boxShadow = "none"; + frameEl.style.overflow = "hidden"; + primaryInfoEl.style.left = "auto"; + primaryInfoEl.style.right = "18px"; + primaryInfoEl.style.top = "50%"; + primaryInfoEl.style.bottom = "auto"; + primaryInfoEl.style.width = "clamp(220px, 20vw, 320px)"; + primaryInfoEl.style.transform = "translateY(-50%)"; + imageEl.style.width = "100%"; + imageEl.style.height = "100%"; + imageEl.style.maxWidth = "none"; + imageEl.style.maxHeight = "none"; + imageEl.style.objectFit = "contain"; + overlayImageEl.style.display = "none"; + secondaryInfoEl.style.display = "none"; + syncComparePanels(); + applyZoomTransform(); + return; + } + + overlayEl.style.pointerEvents = "none"; + backdropEl.style.display = "none"; + backdropEl.style.pointerEvents = "none"; + toolbarEl.style.top = "18px"; + toolbarEl.style.right = "18px"; + toolbarEl.style.left = "auto"; + stageEl.style.pointerEvents = "auto"; + frameEl.style.position = "relative"; + frameEl.style.width = "100%"; + frameEl.style.height = "100%"; + frameEl.style.maxWidth = "none"; + frameEl.style.maxHeight = "none"; + frameEl.style.overflow = "hidden"; + imageEl.style.width = "100%"; + imageEl.style.height = "100%"; + imageEl.style.maxWidth = "none"; + imageEl.style.maxHeight = "none"; + imageEl.style.objectFit = "contain"; + updateImageCursor(); + + if (zoomed && hasSecondaryCard()) { + stageEl.style.top = "0"; + stageEl.style.right = "0"; + stageEl.style.bottom = "0"; + stageEl.style.left = "0"; + stageEl.style.width = "auto"; + stageEl.style.height = "auto"; + stageEl.style.transform = "none"; + frameEl.style.borderRadius = "0"; + frameEl.style.background = "transparent"; + frameEl.style.boxShadow = "none"; + } else if (!hasSecondaryCard()) { + stageEl.style.top = "auto"; + stageEl.style.right = "18px"; + stageEl.style.bottom = "18px"; + stageEl.style.left = "auto"; + stageEl.style.width = "clamp(180px, 18vw, 280px)"; + stageEl.style.height = "min(44vh, 520px)"; + stageEl.style.transform = "none"; + frameEl.style.borderRadius = "22px"; + frameEl.style.background = "rgba(13, 13, 20, 0.9)"; + frameEl.style.boxShadow = "0 24px 64px rgba(0, 0, 0, 0.5)"; + primaryInfoEl.style.left = "auto"; + primaryInfoEl.style.right = "calc(100% + 16px)"; + primaryInfoEl.style.top = "50%"; + primaryInfoEl.style.bottom = "auto"; + primaryInfoEl.style.width = "clamp(220px, 22vw, 320px)"; + primaryInfoEl.style.transform = "translateY(-50%)"; + } else { + stageEl.style.top = "50%"; + stageEl.style.right = "auto"; + stageEl.style.bottom = "auto"; + stageEl.style.left = "50%"; + stageEl.style.width = "min(44vw, 560px)"; + stageEl.style.height = "min(92vh, 1400px)"; + stageEl.style.transform = "translate(-50%, -50%)"; + frameEl.style.borderRadius = "28px"; + frameEl.style.background = "rgba(11, 15, 26, 0.88)"; + frameEl.style.boxShadow = "0 30px 90px rgba(0, 0, 0, 0.56)"; + primaryInfoEl.style.left = "auto"; + primaryInfoEl.style.right = "calc(100% + 10px)"; + primaryInfoEl.style.top = "50%"; + primaryInfoEl.style.bottom = "auto"; + primaryInfoEl.style.width = "clamp(180px, 15vw, 220px)"; + primaryInfoEl.style.transform = "translateY(-50%)"; + secondaryInfoEl.style.left = "calc(100% + 10px)"; + secondaryInfoEl.style.right = "auto"; + secondaryInfoEl.style.top = "50%"; + secondaryInfoEl.style.bottom = "auto"; + secondaryInfoEl.style.width = "clamp(180px, 15vw, 220px)"; + secondaryInfoEl.style.transform = "translateY(-50%)"; + } + + if (hasSecondaryCard()) { + overlayImageEl.style.display = "block"; + } else { + overlayImageEl.style.display = "none"; + secondaryInfoEl.style.display = "none"; + } + + syncComparePanels(); + applyZoomTransform(); + setOverlayOpacity(lightboxState.overlayOpacity); + } + + function resetZoom() { + if (!imageEl && !overlayImageEl) { + return; + } + + lightboxState.zoomOriginX = 50; + lightboxState.zoomOriginY = 50; + applyTransformOrigins(); + zoomed = false; - imageEl.style.transform = "scale(1)"; - imageEl.style.transformOrigin = "center center"; - imageEl.style.cursor = "zoom-in"; + applyZoomTransform(); } function updateZoomOrigin(clientX, clientY) { @@ -30,7 +615,9 @@ const x = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100)); const y = Math.min(100, Math.max(0, ((clientY - rect.top) / rect.height) * 100)); - imageEl.style.transformOrigin = `${x}% ${y}%`; + lightboxState.zoomOriginX = x; + lightboxState.zoomOriginY = y; + applyTransformOrigins(); } function isPointOnCard(clientX, clientY) { @@ -66,71 +653,509 @@ } function ensure() { - if (overlayEl && imageEl) { + if (overlayEl && imageEl && overlayImageEl) { return; } overlayEl = document.createElement("div"); overlayEl.setAttribute("aria-hidden", "true"); + overlayEl.setAttribute("role", "dialog"); + overlayEl.setAttribute("aria-modal", "true"); + overlayEl.tabIndex = -1; overlayEl.style.position = "fixed"; overlayEl.style.inset = "0"; - overlayEl.style.background = "rgba(0, 0, 0, 0.82)"; overlayEl.style.display = "none"; - overlayEl.style.alignItems = "center"; - overlayEl.style.justifyContent = "center"; overlayEl.style.zIndex = "9999"; - overlayEl.style.padding = "0"; + overlayEl.style.pointerEvents = "none"; + + helpButtonEl = document.createElement("button"); + helpButtonEl.type = "button"; + helpButtonEl.textContent = "Help"; + helpButtonEl.style.position = "fixed"; + helpButtonEl.style.top = "24px"; + helpButtonEl.style.left = "24px"; + helpButtonEl.style.display = "none"; + helpButtonEl.style.alignItems = "center"; + helpButtonEl.style.justifyContent = "center"; + helpButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + helpButtonEl.style.background = "rgba(15, 23, 42, 0.84)"; + helpButtonEl.style.color = "#f8fafc"; + helpButtonEl.style.borderRadius = "999px"; + helpButtonEl.style.padding = "10px 14px"; + helpButtonEl.style.font = "600 13px/1.1 sans-serif"; + helpButtonEl.style.cursor = "pointer"; + helpButtonEl.style.backdropFilter = "blur(12px)"; + helpButtonEl.style.pointerEvents = "auto"; + helpButtonEl.style.zIndex = "2"; + + helpPanelEl = document.createElement("div"); + helpPanelEl.style.position = "fixed"; + helpPanelEl.style.top = "72px"; + helpPanelEl.style.left = "24px"; + helpPanelEl.style.display = "none"; + helpPanelEl.style.flexDirection = "column"; + helpPanelEl.style.gap = "8px"; + helpPanelEl.style.width = "min(320px, calc(100vw - 48px))"; + helpPanelEl.style.padding = "14px 16px"; + helpPanelEl.style.borderRadius = "18px"; + helpPanelEl.style.background = "rgba(2, 6, 23, 0.88)"; + helpPanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)"; + helpPanelEl.style.color = "#f8fafc"; + helpPanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)"; + helpPanelEl.style.backdropFilter = "blur(12px)"; + helpPanelEl.style.pointerEvents = "auto"; + helpPanelEl.style.zIndex = "2"; + + const helpTitleEl = document.createElement("div"); + helpTitleEl.textContent = "Lightbox Shortcuts"; + helpTitleEl.style.font = "700 13px/1.3 sans-serif"; + + const helpListEl = document.createElement("div"); + helpListEl.style.display = "flex"; + helpListEl.style.flexDirection = "column"; + helpListEl.style.gap = "6px"; + helpListEl.style.font = "500 12px/1.4 sans-serif"; + helpListEl.style.color = "rgba(226, 232, 240, 0.92)"; + + [ + "Click card: toggle zoom at the clicked point", + "Left / Right: move cards, or move overlay card in compare mode", + "Overlay: pick a second card to compare", + "Space: swap base and overlay cards", + "R: rotate base card, or rotate overlay card in compare mode", + "+ / -: zoom in or out in steps", + "W A S D: pan while zoomed", + "Escape or backdrop click: close" + ].forEach((line) => { + const lineEl = document.createElement("div"); + lineEl.textContent = line; + helpListEl.appendChild(lineEl); + }); + + helpPanelEl.append(helpTitleEl, helpListEl); + + backdropEl = document.createElement("button"); + backdropEl.type = "button"; + backdropEl.setAttribute("aria-label", "Close enlarged tarot card"); + backdropEl.style.position = "absolute"; + backdropEl.style.inset = "0"; + backdropEl.style.border = "none"; + backdropEl.style.padding = "0"; + backdropEl.style.margin = "0"; + backdropEl.style.background = "rgba(0, 0, 0, 0.82)"; + backdropEl.style.cursor = "pointer"; + + toolbarEl = document.createElement("div"); + toolbarEl.style.position = "fixed"; + toolbarEl.style.top = "24px"; + toolbarEl.style.right = "24px"; + toolbarEl.style.display = "flex"; + toolbarEl.style.flexDirection = "column"; + toolbarEl.style.alignItems = "flex-end"; + toolbarEl.style.gap = "8px"; + toolbarEl.style.pointerEvents = "auto"; + toolbarEl.style.zIndex = "2"; + + compareButtonEl = document.createElement("button"); + compareButtonEl.type = "button"; + compareButtonEl.textContent = "Overlay"; + compareButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + compareButtonEl.style.background = "rgba(15, 23, 42, 0.84)"; + compareButtonEl.style.color = "#f8fafc"; + compareButtonEl.style.borderRadius = "999px"; + compareButtonEl.style.padding = "10px 14px"; + compareButtonEl.style.font = "600 13px/1.1 sans-serif"; + compareButtonEl.style.cursor = "pointer"; + compareButtonEl.style.backdropFilter = "blur(12px)"; + + zoomControlEl = document.createElement("label"); + zoomControlEl.style.display = "flex"; + zoomControlEl.style.alignItems = "center"; + zoomControlEl.style.gap = "8px"; + zoomControlEl.style.padding = "10px 14px"; + zoomControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + zoomControlEl.style.borderRadius = "999px"; + zoomControlEl.style.background = "rgba(15, 23, 42, 0.84)"; + zoomControlEl.style.color = "#f8fafc"; + zoomControlEl.style.font = "600 12px/1.1 sans-serif"; + zoomControlEl.style.backdropFilter = "blur(12px)"; + + const zoomTextEl = document.createElement("span"); + zoomTextEl.textContent = "Zoom"; + + zoomSliderEl = document.createElement("input"); + zoomSliderEl.type = "range"; + zoomSliderEl.min = "100"; + zoomSliderEl.max = String(Math.round(LIGHTBOX_ZOOM_SCALE * 100)); + zoomSliderEl.step = "10"; + zoomSliderEl.value = String(Math.round(LIGHTBOX_ZOOM_SCALE * 100)); + zoomSliderEl.style.width = "110px"; + zoomSliderEl.style.cursor = "pointer"; + + zoomValueEl = document.createElement("span"); + zoomValueEl.textContent = `${Math.round(LIGHTBOX_ZOOM_SCALE * 100)}%`; + zoomValueEl.style.minWidth = "42px"; + zoomValueEl.style.textAlign = "right"; + + zoomControlEl.append(zoomTextEl, zoomSliderEl, zoomValueEl); + + opacityControlEl = document.createElement("label"); + opacityControlEl.style.display = "none"; + opacityControlEl.style.alignItems = "center"; + opacityControlEl.style.gap = "8px"; + opacityControlEl.style.padding = "10px 14px"; + opacityControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + opacityControlEl.style.borderRadius = "999px"; + opacityControlEl.style.background = "rgba(15, 23, 42, 0.84)"; + opacityControlEl.style.color = "#f8fafc"; + opacityControlEl.style.font = "600 12px/1.1 sans-serif"; + opacityControlEl.style.backdropFilter = "blur(12px)"; + + const opacityTextEl = document.createElement("span"); + opacityTextEl.textContent = "Overlay"; + + opacitySliderEl = document.createElement("input"); + opacitySliderEl.type = "range"; + opacitySliderEl.min = "5"; + opacitySliderEl.max = "100"; + opacitySliderEl.step = "5"; + opacitySliderEl.value = String(Math.round(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY * 100)); + opacitySliderEl.style.width = "110px"; + opacitySliderEl.style.cursor = "pointer"; + + opacityValueEl = document.createElement("span"); + opacityValueEl.textContent = `${Math.round(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY * 100)}%`; + opacityValueEl.style.minWidth = "34px"; + opacityValueEl.style.textAlign = "right"; + + opacityControlEl.append(opacityTextEl, opacitySliderEl, opacityValueEl); + + toolbarEl.append(compareButtonEl, zoomControlEl, opacityControlEl); + + stageEl = document.createElement("div"); + stageEl.style.position = "fixed"; + stageEl.style.top = "0"; + stageEl.style.right = "0"; + stageEl.style.bottom = "0"; + stageEl.style.left = "0"; + stageEl.style.pointerEvents = "auto"; + stageEl.style.overflow = "visible"; + stageEl.style.transition = "top 220ms ease, right 220ms ease, bottom 220ms ease, left 220ms ease, width 220ms ease, height 220ms ease, transform 220ms ease"; + stageEl.style.transform = "none"; + stageEl.style.zIndex = "1"; + + frameEl = document.createElement("div"); + frameEl.style.position = "relative"; + frameEl.style.width = "100%"; + frameEl.style.height = "100%"; + frameEl.style.overflow = "hidden"; + frameEl.style.transition = "border-radius 220ms ease, background 220ms ease, box-shadow 220ms ease"; + + baseLayerEl = document.createElement("div"); + baseLayerEl.style.position = "absolute"; + baseLayerEl.style.inset = "0"; + baseLayerEl.style.transform = "scale(1)"; + baseLayerEl.style.transformOrigin = "50% 50%"; + baseLayerEl.style.transition = "transform 120ms ease-out"; + + overlayLayerEl = document.createElement("div"); + overlayLayerEl.style.position = "absolute"; + overlayLayerEl.style.inset = "0"; + overlayLayerEl.style.transform = "scale(1)"; + overlayLayerEl.style.transformOrigin = "50% 50%"; + overlayLayerEl.style.transition = "transform 120ms ease-out"; + overlayLayerEl.style.pointerEvents = "none"; imageEl = document.createElement("img"); imageEl.alt = "Tarot card enlarged image"; - imageEl.style.maxWidth = "100vw"; - imageEl.style.maxHeight = "100vh"; - imageEl.style.width = "100vw"; - imageEl.style.height = "100vh"; + imageEl.style.width = "100%"; + imageEl.style.height = "100%"; imageEl.style.objectFit = "contain"; - imageEl.style.borderRadius = "0"; - imageEl.style.boxShadow = "none"; - imageEl.style.border = "none"; imageEl.style.cursor = "zoom-in"; - imageEl.style.transform = "scale(1)"; + imageEl.style.transform = "rotate(0deg)"; imageEl.style.transformOrigin = "center center"; - imageEl.style.transition = "transform 120ms ease-out"; + imageEl.style.transition = "transform 120ms ease-out, opacity 180ms ease"; imageEl.style.userSelect = "none"; - overlayEl.appendChild(imageEl); + overlayImageEl = document.createElement("img"); + overlayImageEl.alt = "Tarot card overlay image"; + overlayImageEl.style.width = "100%"; + overlayImageEl.style.height = "100%"; + overlayImageEl.style.objectFit = "contain"; + overlayImageEl.style.opacity = String(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY); + overlayImageEl.style.pointerEvents = "none"; + overlayImageEl.style.display = "none"; + overlayImageEl.style.transform = "rotate(0deg)"; + overlayImageEl.style.transformOrigin = "center center"; + overlayImageEl.style.transition = "opacity 180ms ease"; + + function createInfoPanel() { + const panelEl = document.createElement("div"); + panelEl.style.position = "absolute"; + panelEl.style.display = "none"; + panelEl.style.flexDirection = "column"; + panelEl.style.gap = "10px"; + panelEl.style.padding = "14px 16px"; + panelEl.style.borderRadius = "18px"; + panelEl.style.background = "rgba(2, 6, 23, 0.8)"; + panelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)"; + panelEl.style.color = "#f8fafc"; + panelEl.style.backdropFilter = "blur(12px)"; + panelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)"; + panelEl.style.transition = "opacity 180ms ease, transform 180ms ease"; + panelEl.style.transform = "translateY(-50%)"; + panelEl.style.pointerEvents = "none"; + panelEl.style.maxHeight = "min(78vh, 760px)"; + panelEl.style.overflowY = "auto"; + + const titleEl = document.createElement("div"); + titleEl.style.font = "700 13px/1.3 sans-serif"; + titleEl.style.color = "#f8fafc"; + + const groupsEl = document.createElement("div"); + groupsEl.style.display = "flex"; + groupsEl.style.flexDirection = "column"; + groupsEl.style.gap = "0"; + + const hintEl = document.createElement("div"); + hintEl.style.font = "500 11px/1.35 sans-serif"; + hintEl.style.color = "rgba(226, 232, 240, 0.82)"; + + panelEl.append(titleEl, groupsEl, hintEl); + return { panelEl, titleEl, groupsEl, hintEl }; + } + + const primaryPanel = createInfoPanel(); + primaryInfoEl = primaryPanel.panelEl; + primaryTitleEl = primaryPanel.titleEl; + primaryGroupsEl = primaryPanel.groupsEl; + primaryHintEl = primaryPanel.hintEl; + + const secondaryPanel = createInfoPanel(); + secondaryInfoEl = secondaryPanel.panelEl; + secondaryTitleEl = secondaryPanel.titleEl; + secondaryGroupsEl = secondaryPanel.groupsEl; + secondaryHintEl = secondaryPanel.hintEl; + baseLayerEl.appendChild(imageEl); + overlayLayerEl.appendChild(overlayImageEl); + frameEl.append(baseLayerEl, overlayLayerEl); + stageEl.append(frameEl, primaryInfoEl, secondaryInfoEl); + overlayEl.append(backdropEl, stageEl, toolbarEl, helpButtonEl, helpPanelEl); const close = () => { - if (!overlayEl || !imageEl) { + if (!overlayEl || !imageEl || !overlayImageEl) { return; } + + lightboxState.isOpen = false; + lightboxState.compareMode = false; + lightboxState.allowOverlayCompare = false; + lightboxState.primaryCard = null; + lightboxState.secondaryCard = null; + lightboxState.sequenceIds = []; + lightboxState.resolveCardById = null; + lightboxState.onSelectCardId = null; + lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY; + lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE; + lightboxState.helpOpen = false; overlayEl.style.display = "none"; overlayEl.setAttribute("aria-hidden", "true"); imageEl.removeAttribute("src"); + imageEl.alt = "Tarot card enlarged image"; + overlayImageEl.removeAttribute("src"); + overlayImageEl.alt = ""; + overlayImageEl.style.display = "none"; resetZoom(); + syncHelpUi(); + syncComparePanels(); + syncOpacityControl(); + + if (previousFocusedEl instanceof HTMLElement) { + previousFocusedEl.focus({ preventScroll: true }); + } + previousFocusedEl = null; }; - overlayEl.addEventListener("click", (event) => { - if (event.target === overlayEl) { - close(); + function toggleCompareMode() { + if (!lightboxState.allowOverlayCompare || !lightboxState.primaryCard) { + return; } + + lightboxState.compareMode = !lightboxState.compareMode; + if (!lightboxState.compareMode) { + clearSecondaryCard(); + } + applyComparePresentation(); + } + + function setSecondaryCard(cardRequest, syncSelection = false) { + const normalizedCard = normalizeCardRequest(cardRequest); + if (!normalizedCard.src || !normalizedCard.cardId || normalizedCard.cardId === lightboxState.primaryCard?.cardId) { + return false; + } + + lightboxState.secondaryCard = normalizedCard; + overlayImageEl.src = normalizedCard.src; + overlayImageEl.alt = normalizedCard.altText; + overlayImageEl.style.display = "block"; + overlayImageEl.style.opacity = String(lightboxState.overlayOpacity); + if (syncSelection && typeof lightboxState.onSelectCardId === "function") { + lightboxState.onSelectCardId(normalizedCard.cardId); + } + applyComparePresentation(); + return true; + } + + function stepSecondaryCard(direction) { + const sequence = Array.isArray(lightboxState.sequenceIds) ? lightboxState.sequenceIds : []; + if (!lightboxState.compareMode || sequence.length < 2 || typeof lightboxState.resolveCardById !== "function") { + return; + } + + const anchorId = lightboxState.secondaryCard?.cardId || lightboxState.primaryCard?.cardId; + const startIndex = sequence.indexOf(anchorId); + if (startIndex < 0) { + return; + } + + for (let offset = 1; offset <= sequence.length; offset += 1) { + const nextIndex = (startIndex + direction * offset + sequence.length) % sequence.length; + const nextCardId = sequence[nextIndex]; + if (!nextCardId || nextCardId === lightboxState.primaryCard?.cardId) { + continue; + } + + const nextCard = resolveCardRequestById(nextCardId); + if (nextCard && setSecondaryCard(nextCard, true)) { + break; + } + } + } + + function stepPrimaryCard(direction) { + const sequence = Array.isArray(lightboxState.sequenceIds) ? lightboxState.sequenceIds : []; + if (lightboxState.compareMode || sequence.length < 2 || typeof lightboxState.resolveCardById !== "function") { + return; + } + + const startIndex = sequence.indexOf(lightboxState.primaryCard?.cardId); + if (startIndex < 0) { + return; + } + + const nextIndex = (startIndex + direction + sequence.length) % sequence.length; + const nextCardId = sequence[nextIndex]; + const nextCard = resolveCardRequestById(nextCardId); + if (!nextCard?.src) { + return; + } + + lightboxState.primaryCard = nextCard; + imageEl.src = nextCard.src; + imageEl.alt = nextCard.altText; + resetZoom(); + clearSecondaryCard(); + if (typeof lightboxState.onSelectCardId === "function") { + lightboxState.onSelectCardId(nextCard.cardId); + } + applyComparePresentation(); + } + + function swapCompareCards() { + if (!lightboxState.compareMode || !lightboxState.primaryCard?.src || !lightboxState.secondaryCard?.src) { + return; + } + + const nextPrimaryCard = lightboxState.secondaryCard; + const nextSecondaryCard = lightboxState.primaryCard; + + lightboxState.primaryCard = nextPrimaryCard; + lightboxState.secondaryCard = nextSecondaryCard; + + imageEl.src = nextPrimaryCard.src; + imageEl.alt = nextPrimaryCard.altText; + overlayImageEl.src = nextSecondaryCard.src; + overlayImageEl.alt = nextSecondaryCard.altText; + overlayImageEl.style.display = "block"; + overlayImageEl.style.opacity = String(lightboxState.overlayOpacity); + + if (typeof lightboxState.onSelectCardId === "function") { + lightboxState.onSelectCardId(nextPrimaryCard.cardId); + } + + applyComparePresentation(); + } + + function shouldIgnoreGlobalKeydown(event) { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return false; + } + + if (!overlayEl?.contains(target)) { + return false; + } + + return target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || target instanceof HTMLSelectElement + || target instanceof HTMLButtonElement; + } + + function restoreLightboxFocus() { + if (!overlayEl || !lightboxState.isOpen) { + return; + } + + requestAnimationFrame(() => { + if (overlayEl && lightboxState.isOpen) { + overlayEl.focus({ preventScroll: true }); + } + }); + } + + backdropEl.addEventListener("click", close); + helpButtonEl.addEventListener("click", () => { + lightboxState.helpOpen = !lightboxState.helpOpen; + syncHelpUi(); + restoreLightboxFocus(); }); + compareButtonEl.addEventListener("click", () => { + toggleCompareMode(); + restoreLightboxFocus(); + }); + zoomSliderEl.addEventListener("input", () => { + setZoomScale(Number(zoomSliderEl.value) / 100); + }); + zoomSliderEl.addEventListener("change", restoreLightboxFocus); + zoomSliderEl.addEventListener("pointerup", restoreLightboxFocus); + opacitySliderEl.addEventListener("input", () => { + setOverlayOpacity(Number(opacitySliderEl.value) / 100); + }); + opacitySliderEl.addEventListener("change", restoreLightboxFocus); + opacitySliderEl.addEventListener("pointerup", restoreLightboxFocus); imageEl.addEventListener("click", (event) => { event.stopPropagation(); if (!isPointOnCard(event.clientX, event.clientY)) { + if (lightboxState.compareMode) { + return; + } + close(); return; } if (!zoomed) { zoomed = true; - imageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`; - imageEl.style.cursor = "zoom-out"; + applyZoomTransform(); updateZoomOrigin(event.clientX, event.clientY); + applyComparePresentation(); return; } resetZoom(); + applyComparePresentation(); }); imageEl.addEventListener("mousemove", (event) => { @@ -139,34 +1164,141 @@ imageEl.addEventListener("mouseleave", () => { if (zoomed) { - imageEl.style.transformOrigin = "center center"; + lightboxState.zoomOriginX = 50; + lightboxState.zoomOriginY = 50; + applyTransformOrigins(); } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { close(); + return; } + + if (shouldIgnoreGlobalKeydown(event)) { + return; + } + + if (lightboxState.isOpen && event.code === "Space" && lightboxState.compareMode && hasSecondaryCard()) { + event.preventDefault(); + swapCompareCards(); + return; + } + + if (lightboxState.isOpen && isRotateKey(event)) { + event.preventDefault(); + toggleRotation(); + return; + } + + if (lightboxState.isOpen && isZoomInKey(event)) { + event.preventDefault(); + stepZoom(1); + return; + } + + if (lightboxState.isOpen && isZoomOutKey(event)) { + event.preventDefault(); + stepZoom(-1); + return; + } + + if (lightboxState.isOpen && zoomed && isPanUpKey(event)) { + event.preventDefault(); + stepPan(0, -LIGHTBOX_PAN_STEP); + return; + } + + if (lightboxState.isOpen && zoomed && isPanLeftKey(event)) { + event.preventDefault(); + stepPan(-LIGHTBOX_PAN_STEP, 0); + return; + } + + if (lightboxState.isOpen && zoomed && isPanDownKey(event)) { + event.preventDefault(); + stepPan(0, LIGHTBOX_PAN_STEP); + return; + } + + if (lightboxState.isOpen && zoomed && isPanRightKey(event)) { + event.preventDefault(); + stepPan(LIGHTBOX_PAN_STEP, 0); + return; + } + + if (!lightboxState.isOpen || !LIGHTBOX_COMPARE_SEQUENCE_STEP_KEYS.has(event.key)) { + return; + } + + event.preventDefault(); + if (lightboxState.compareMode) { + stepSecondaryCard(event.key === "ArrowRight" ? 1 : -1); + return; + } + + stepPrimaryCard(event.key === "ArrowRight" ? 1 : -1); }); document.body.appendChild(overlayEl); + + overlayEl.closeLightbox = close; + overlayEl.setSecondaryCard = setSecondaryCard; + overlayEl.applyComparePresentation = applyComparePresentation; } - function open(src, altText) { - if (!src) { + function open(srcOrOptions, altText, extraOptions) { + const request = normalizeOpenRequest(srcOrOptions, altText, extraOptions); + const normalizedPrimary = normalizeCardRequest(request); + + if (!normalizedPrimary.src) { return; } ensure(); - if (!overlayEl || !imageEl) { + if (!overlayEl || !imageEl || !overlayImageEl) { return; } - imageEl.src = src; - imageEl.alt = altText || "Tarot card enlarged image"; + const canCompare = Boolean( + request.allowOverlayCompare + && normalizedPrimary.cardId + && Array.isArray(request.sequenceIds) + && request.sequenceIds.length > 1 + && typeof request.resolveCardById === "function" + ); + + if (lightboxState.isOpen && lightboxState.compareMode && lightboxState.allowOverlayCompare && canCompare && normalizedPrimary.cardId) { + if (normalizedPrimary.cardId === lightboxState.primaryCard?.cardId) { + return; + } + overlayEl.setSecondaryCard?.(normalizedPrimary, false); + return; + } + + lightboxState.isOpen = true; + lightboxState.compareMode = false; + lightboxState.allowOverlayCompare = canCompare; + lightboxState.primaryCard = normalizedPrimary; + lightboxState.sequenceIds = canCompare ? [...request.sequenceIds] : []; + lightboxState.resolveCardById = canCompare ? request.resolveCardById : null; + lightboxState.onSelectCardId = canCompare && typeof request.onSelectCardId === "function" + ? request.onSelectCardId + : null; + lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY; + lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE; + lightboxState.helpOpen = false; + + imageEl.src = normalizedPrimary.src; + imageEl.alt = normalizedPrimary.altText; + clearSecondaryCard(); resetZoom(); - overlayEl.style.display = "flex"; + previousFocusedEl = document.activeElement instanceof HTMLElement ? document.activeElement : null; + overlayEl.style.display = "block"; overlayEl.setAttribute("aria-hidden", "false"); + overlayEl.applyComparePresentation?.(); + overlayEl.focus({ preventScroll: true }); } window.TarotUiLightbox = { diff --git a/app/ui-tarot.js b/app/ui-tarot.js index 0e35411..c924f8c 100644 --- a/app/ui-tarot.js +++ b/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 = { diff --git a/index.html b/index.html index 4b0c624..bc18103 100644 --- a/index.html +++ b/index.html @@ -172,7 +172,68 @@
- House of Cards +
+ House of Cards +
+ +
+ Top Info + + + + + +
+ +
+ Bottom Info + + + + + +
+ + + +
+
@@ -822,7 +883,9 @@ + +