Files
TaroTime/app/ui-tarot-frame.js

2184 lines
73 KiB
JavaScript
Raw Normal View History

(function () {
"use strict";
const tarotCardImages = window.TarotCardImages || {};
const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MINOR_RANKS = new Set(["Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]);
const COURT_RANKS = new Set(["Knight", "Queen", "Prince"]);
const EXTRA_SUIT_ORDER = ["wands", "cups", "swords", "disks"];
2026-04-01 16:08:52 -07:00
const HOUSE_MINOR_NUMBER_BANDS = [
[2, 3, 4],
[5, 6, 7],
[8, 9, 10],
[2, 3, 4],
[5, 6, 7],
[8, 9, 10]
];
const HOUSE_LEFT_SUITS = ["Wands", "Disks", "Swords", "Cups", "Wands", "Disks"];
const HOUSE_RIGHT_SUITS = ["Swords", "Cups", "Wands", "Disks", "Swords", "Cups"];
const HOUSE_MIDDLE_SUITS = ["Wands", "Cups", "Swords", "Disks"];
const HOUSE_MIDDLE_RANKS = ["Ace", "Knight", "Queen", "Prince", "Princess"];
const HOUSE_TRUMP_ROWS = [
[0],
[20, 21, 12],
[19, 10, 2, 1, 3, 16],
[18, 17, 15, 14, 13, 9, 8, 7, 6, 5, 4],
[11]
];
2026-04-01 19:26:38 -07:00
const HOUSE_TRUMP_GRID_ROWS = [1, 2, 3, 4, 5];
const HOUSE_BOTTOM_START_ROW = 8;
2026-04-01 16:08:52 -07:00
const HOUSE_LEFT_START_COLUMN = 2;
2026-04-01 19:26:38 -07:00
const HOUSE_MIDDLE_START_COLUMN = 6;
const HOUSE_RIGHT_START_COLUMN = 11;
const TAROT_CARD_WIDTH_RATIO = 2;
const TAROT_CARD_HEIGHT_RATIO = 3;
const ZODIAC_START_TOKEN_BY_SIGN_ID = {
aries: "03-21",
taurus: "04-20",
gemini: "05-21",
cancer: "06-21",
leo: "07-23",
virgo: "08-23",
libra: "09-23",
scorpio: "10-23",
sagittarius: "11-22",
capricorn: "12-22",
aquarius: "01-20",
pisces: "02-19"
};
2026-04-01 19:26:38 -07:00
const MASTER_GRID_SIZE = 14;
const FRAME_GRID_ZOOM_STEPS = [1, 1.2, 1.4, 1.7, 2];
const EXPORT_SLOT_WIDTH = 120;
const EXPORT_SLOT_HEIGHT = Math.round((EXPORT_SLOT_WIDTH * TAROT_CARD_HEIGHT_RATIO) / TAROT_CARD_WIDTH_RATIO);
const EXPORT_CARD_INSET = 0;
const EXPORT_GRID_GAP = 10;
const EXPORT_PADDING = 28;
const EXPORT_BACKGROUND = "#0f0f17";
const EXPORT_PANEL = "#18181b";
const EXPORT_CARD_BORDER = "#475569";
const EXPORT_BADGE_BACKGROUND = "rgba(2, 6, 23, 0.9)";
const EXPORT_BADGE_TEXT = "#f8fafc";
const EXPORT_FORMATS = {
webp: {
mimeType: "image/webp",
extension: "webp",
quality: 0.98
}
};
2026-04-01 16:08:52 -07:00
const FRAME_LAYOUT_GROUPS = [
{
id: "extra-cards",
2026-04-01 19:26:38 -07:00
title: "Extra Band",
description: "Two-row top band for aces, princesses, and the non-zodiac majors.",
positions: [
...Array.from({ length: MASTER_GRID_SIZE }, (_, index) => ({ row: 1, column: index + 1 })),
{ row: 2, column: 6 },
{ row: 2, column: 7 },
{ row: 2, column: 8 },
{ row: 2, column: 9 }
],
getOrderedCards(cards) {
return cards
.filter((card) => isExtraTopRowCard(card))
.sort(compareExtraTopRowCards);
}
},
{
id: "small-cards",
title: "Small Cards",
description: "Outer perimeter in chronological decan order.",
2026-04-01 19:26:38 -07:00
positions: buildPerimeterPath(10, 5, 3),
getOrderedCards(cards) {
return cards
.filter((card) => isSmallCard(card))
.sort((left, right) => compareDateTokens(getRelation(left, "decan")?.data?.dateStart, getRelation(right, "decan")?.data?.dateStart, "03-21"));
}
},
{
id: "court-dates",
title: "Court Dates",
description: "Inner left frame in chronological court-date order.",
2026-04-01 19:26:38 -07:00
positions: buildPerimeterPath(4, 8, 4),
getOrderedCards(cards) {
return cards
.filter((card) => isCourtDateCard(card))
.sort((left, right) => compareDateTokens(getRelation(left, "courtDateWindow")?.data?.dateStart, getRelation(right, "courtDateWindow")?.data?.dateStart, "11-12"));
}
},
{
id: "zodiac-trumps",
title: "Zodiac Trumps",
description: "Inner right frame in chronological zodiac order.",
2026-04-01 19:26:38 -07:00
positions: buildPerimeterPath(4, 8, 8),
getOrderedCards(cards) {
return cards
.filter((card) => isZodiacTrump(card))
.sort((left, right) => {
const leftSignId = normalizeKey(getRelation(left, "zodiacCorrespondence")?.data?.signId);
const rightSignId = normalizeKey(getRelation(right, "zodiacCorrespondence")?.data?.signId);
return compareDateTokens(ZODIAC_START_TOKEN_BY_SIGN_ID[leftSignId], ZODIAC_START_TOKEN_BY_SIGN_ID[rightSignId], "03-21");
});
}
}
];
2026-04-01 16:08:52 -07:00
const LAYOUT_PRESETS = [
{
id: "frames",
label: "Frames",
2026-04-01 19:26:38 -07:00
title: "Master 14x14 Frame Grid",
subtitle: "A two-row top band holds the remaining 18 cards, while the centered frame keeps the small cards, court dates, and zodiac trumps grouped together. Every square on the grid is a snap target for custom layouts.",
2026-04-01 16:08:52 -07:00
statusMessage: "Frames layout applied to the master grid.",
legendItems: FRAME_LAYOUT_GROUPS.map((group) => ({
title: group.title,
description: group.description
})),
buildPlacements(cards) {
const placements = [];
FRAME_LAYOUT_GROUPS.forEach((group) => {
assignCardsToPositions(placements, group.positions, group.getOrderedCards(cards));
});
return placements;
}
},
{
id: "house",
label: "House of Cards",
title: "House of Cards Layout",
2026-04-01 19:26:38 -07:00
subtitle: "The legacy house composition now lives inside the same draggable 14x14 grid. Centered trump tiers sit above the three lower columns, while every square still remains available for custom rearranging.",
2026-04-01 16:08:52 -07:00
statusMessage: "House of Cards layout applied to the master grid.",
legendItems: [
{
title: "Trump Tiers",
description: "Five centered major-arcana rows preserve the original House silhouette."
},
{
title: "Left Wing",
description: "Minor bands descend through Wands, Disks, Swords, Cups, then repeat Wands and Disks."
},
{
title: "Middle Court",
description: "Aces and the court ranks run through the four suits down the center column."
},
{
title: "Right Wing",
description: "Minor bands mirror the opposite side with Swords, Cups, Wands, Disks, then Swords and Cups."
}
],
buildPlacements(cards) {
return buildHousePlacements(cards);
}
}
];
const state = {
initialized: false,
layoutReady: false,
cardSignature: "",
slotAssignments: new Map(),
statusMessage: "Loading tarot cards...",
drag: null,
suppressClick: false,
showInfo: true,
settingsOpen: false,
2026-04-01 16:08:52 -07:00
layoutMenuOpen: false,
currentLayoutId: "frames",
exportInProgress: false,
2026-04-01 19:26:38 -07:00
exportFormat: "webp",
gridZoomStepIndex: 0
};
let config = {
ensureTarotSection: null,
2026-04-01 16:08:52 -07:00
getCards: () => [],
getHouseTopCardsVisible: () => true,
getHouseTopInfoModes: () => ({}),
getHouseBottomCardsVisible: () => true,
getHouseBottomInfoModes: () => ({}),
setHouseTopCardsVisible: () => {},
setHouseTopInfoMode: () => {},
setHouseBottomCardsVisible: () => {},
setHouseBottomInfoMode: () => {}
};
function buildPerimeterPath(size, rowOffset = 1, columnOffset = 1) {
const path = [];
for (let column = 0; column < size; column += 1) {
path.push({ row: rowOffset, column: columnOffset + column });
}
for (let row = 1; row < size - 1; row += 1) {
path.push({ row: rowOffset + row, column: columnOffset + size - 1 });
}
for (let column = size - 1; column >= 0; column -= 1) {
path.push({ row: rowOffset + size - 1, column: columnOffset + column });
}
for (let row = size - 2; row >= 1; row -= 1) {
path.push({ row: rowOffset + row, column: columnOffset });
}
return path;
}
function getElements() {
return {
tarotFrameBoardEl: document.getElementById("tarot-frame-board"),
tarotFrameStatusEl: document.getElementById("tarot-frame-status"),
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl: document.getElementById("tarot-frame-layout-toggle"),
tarotFrameLayoutPanelEl: document.getElementById("tarot-frame-layout-panel"),
tarotFrameSettingsToggleEl: document.getElementById("tarot-frame-settings-toggle"),
tarotFrameSettingsPanelEl: document.getElementById("tarot-frame-settings-panel"),
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl: document.getElementById("tarot-frame-grid-zoom"),
tarotFrameShowInfoEl: document.getElementById("tarot-frame-show-info"),
2026-04-01 16:08:52 -07:00
tarotFrameHouseSettingsEl: document.getElementById("tarot-frame-house-settings"),
tarotFrameHouseTopCardsVisibleEl: document.getElementById("tarot-frame-house-top-cards-visible"),
tarotFrameHouseTopInfoHebrewEl: document.getElementById("tarot-frame-house-top-info-hebrew"),
tarotFrameHouseTopInfoPlanetEl: document.getElementById("tarot-frame-house-top-info-planet"),
tarotFrameHouseTopInfoZodiacEl: document.getElementById("tarot-frame-house-top-info-zodiac"),
tarotFrameHouseTopInfoTrumpEl: document.getElementById("tarot-frame-house-top-info-trump"),
tarotFrameHouseTopInfoPathEl: document.getElementById("tarot-frame-house-top-info-path"),
tarotFrameHouseTopInfoDateEl: document.getElementById("tarot-frame-house-top-info-date"),
tarotFrameHouseBottomCardsVisibleEl: document.getElementById("tarot-frame-house-bottom-cards-visible"),
tarotFrameHouseBottomInfoZodiacEl: document.getElementById("tarot-frame-house-bottom-info-zodiac"),
tarotFrameHouseBottomInfoDecanEl: document.getElementById("tarot-frame-house-bottom-info-decan"),
tarotFrameHouseBottomInfoMonthEl: document.getElementById("tarot-frame-house-bottom-info-month"),
tarotFrameHouseBottomInfoRulerEl: document.getElementById("tarot-frame-house-bottom-info-ruler"),
tarotFrameHouseBottomInfoDateEl: document.getElementById("tarot-frame-house-bottom-info-date"),
tarotFrameExportWebpEl: document.getElementById("tarot-frame-export-webp")
};
}
2026-04-01 16:08:52 -07:00
function getLayoutOptionElements() {
return Array.from(document.querySelectorAll(".tarot-frame-layout-option[data-layout-preset-id]"));
}
function normalizeLabelText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function isSmallCard(card) {
return card?.arcana === "Minor"
&& MINOR_RANKS.has(String(card?.rank || ""))
&& Boolean(getRelation(card, "decan"));
}
function isCourtDateCard(card) {
return COURT_RANKS.has(String(card?.rank || ""))
&& Boolean(getRelation(card, "courtDateWindow"));
}
function isZodiacTrump(card) {
return card?.arcana === "Major"
&& Boolean(getRelation(card, "zodiacCorrespondence"));
}
function getExtraTopRowCategory(card) {
const rank = String(card?.rank || "").trim();
if (rank === "Ace") {
return 0;
}
if (card?.arcana === "Major") {
return 1;
}
if (rank === "Princess") {
return 2;
}
return 3;
}
function compareSuitOrder(leftSuit, rightSuit) {
const leftIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(leftSuit));
const rightIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(rightSuit));
const safeLeft = leftIndex === -1 ? EXTRA_SUIT_ORDER.length : leftIndex;
const safeRight = rightIndex === -1 ? EXTRA_SUIT_ORDER.length : rightIndex;
return safeLeft - safeRight;
}
function compareExtraTopRowCards(left, right) {
const categoryDiff = getExtraTopRowCategory(left) - getExtraTopRowCategory(right);
if (categoryDiff !== 0) {
return categoryDiff;
}
const category = getExtraTopRowCategory(left);
if (category === 0 || category === 2) {
return compareSuitOrder(left?.suit, right?.suit);
}
if (category === 1) {
return Number(left?.number) - Number(right?.number);
}
return String(left?.name || "").localeCompare(String(right?.name || ""));
}
function isExtraTopRowCard(card) {
return Boolean(card) && !isSmallCard(card) && !isCourtDateCard(card) && !isZodiacTrump(card);
}
function buildReadyStatus(cards) {
2026-04-01 19:26:38 -07:00
return `${Array.isArray(cards) ? cards.length : 0} cards ready. Drag cards freely and use Settings to change the grid zoom for any layout.`;
}
function getGridZoomScale() {
return FRAME_GRID_ZOOM_STEPS[state.gridZoomStepIndex] || FRAME_GRID_ZOOM_STEPS[0];
}
function buildPanelCountText(cards = getCards()) {
return `${cards.length} cards / ${MASTER_GRID_SIZE * MASTER_GRID_SIZE} cells · Zoom ${Math.round(getGridZoomScale() * 100)}%`;
}
function normalizeKey(value) {
return String(value || "").trim().toLowerCase();
}
2026-04-01 16:08:52 -07:00
function normalizeLookupCardName(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/\b(pentacles?|coins?)\b/g, "disks");
}
function getCards() {
const cards = config.getCards?.();
return Array.isArray(cards) ? cards : [];
}
function getCardId(card) {
return String(card?.id || "").trim();
}
function getCardMap(cards) {
return new Map(cards.map((card) => [getCardId(card), card]));
}
function getRelation(card, type) {
return Array.isArray(card?.relations)
? card.relations.find((relation) => relation?.type === type) || null
: null;
}
2026-04-01 16:08:52 -07:00
function getRelations(card, type) {
return Array.isArray(card?.relations)
? card.relations.filter((relation) => relation?.type === type)
: [];
}
function parseMonthDayToken(token) {
const match = String(token || "").trim().match(/^(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const month = Number(match[1]);
const day = Number(match[2]);
if (!Number.isInteger(month) || !Number.isInteger(day) || month < 1 || month > 12) {
return null;
}
return { month, day };
}
function formatMonthDay(token) {
const parsed = parseMonthDayToken(token);
if (!parsed) {
return "";
}
return `${MONTH_ABBR[parsed.month - 1]} ${parsed.day}`;
}
function decrementToken(token) {
const parsed = parseMonthDayToken(token);
if (!parsed) {
return null;
}
if (parsed.day > 1) {
return `${String(parsed.month).padStart(2, "0")}-${String(parsed.day - 1).padStart(2, "0")}`;
}
const previousMonth = parsed.month === 1 ? 12 : parsed.month - 1;
const previousDay = MONTH_LENGTHS[previousMonth - 1];
return `${String(previousMonth).padStart(2, "0")}-${String(previousDay).padStart(2, "0")}`;
}
function formatDateRange(startToken, endToken) {
const start = parseMonthDayToken(startToken);
const end = parseMonthDayToken(endToken);
if (!start || !end) {
return "";
}
const startMonth = MONTH_ABBR[start.month - 1];
const endMonth = MONTH_ABBR[end.month - 1];
if (start.month === end.month) {
return `${startMonth} ${start.day}-${end.day}`;
}
return `${startMonth} ${start.day}-${endMonth} ${end.day}`;
}
function toOrdinalDay(token) {
const parsed = parseMonthDayToken(token);
if (!parsed) {
return Number.POSITIVE_INFINITY;
}
const daysBeforeMonth = MONTH_LENGTHS.slice(0, parsed.month - 1).reduce((total, length) => total + length, 0);
return daysBeforeMonth + parsed.day;
}
function getCyclicDayValue(token, cycleStartToken) {
const value = toOrdinalDay(token);
const cycleStart = toOrdinalDay(cycleStartToken);
if (!Number.isFinite(value) || !Number.isFinite(cycleStart)) {
return Number.POSITIVE_INFINITY;
}
return (value - cycleStart + 365) % 365;
}
function compareDateTokens(leftToken, rightToken, cycleStartToken) {
return getCyclicDayValue(leftToken, cycleStartToken) - getCyclicDayValue(rightToken, cycleStartToken);
}
2026-04-01 16:08:52 -07:00
function assignCardsToPositions(placements, positions, orderedCards) {
(Array.isArray(positions) ? positions : []).forEach((position, index) => {
const card = orderedCards[index] || null;
if (!card) {
return;
}
placements.push({
row: position.row,
column: position.column,
cardId: getCardId(card)
});
});
}
function buildMinorCardName(rankNumber, suit) {
const rankName = ({
1: "Ace",
2: "Two",
3: "Three",
4: "Four",
5: "Five",
6: "Six",
7: "Seven",
8: "Eight",
9: "Nine",
10: "Ten"
})[Number(rankNumber)];
const suitName = String(suit || "").trim();
return rankName && suitName ? `${rankName} of ${suitName}` : "";
}
function buildCourtCardName(rank, suit) {
const rankName = String(rank || "").trim();
const suitName = String(suit || "").trim();
return rankName && suitName ? `${rankName} of ${suitName}` : "";
}
function getCardLookupMap(cards) {
const lookup = new Map();
cards.forEach((card) => {
const key = normalizeLookupCardName(card?.name);
if (key) {
lookup.set(key, card);
}
});
return lookup;
}
function findCardByLookupName(cardLookupMap, cardName) {
return cardLookupMap.get(normalizeLookupCardName(cardName)) || null;
}
function findMajorCardByTrumpNumber(cards, trumpNumber) {
const target = Number(trumpNumber);
return cards.find((card) => card?.arcana === "Major" && Number(card?.number) === target) || null;
}
function buildHousePlacements(cards) {
const placements = [];
const lookupMap = getCardLookupMap(cards);
HOUSE_TRUMP_ROWS.forEach((trumpNumbers, rowIndex) => {
const rowCards = trumpNumbers.map((trumpNumber) => findMajorCardByTrumpNumber(cards, trumpNumber));
const startColumn = Math.floor((MASTER_GRID_SIZE - rowCards.length) / 2) + 1;
assignCardsToPositions(
placements,
rowCards.map((card, index) => ({ row: HOUSE_TRUMP_GRID_ROWS[rowIndex], column: startColumn + index })),
rowCards
);
});
HOUSE_MINOR_NUMBER_BANDS.forEach((numbers, rowIndex) => {
const row = HOUSE_BOTTOM_START_ROW + rowIndex;
const leftCards = numbers.map((rankNumber) => findCardByLookupName(lookupMap, buildMinorCardName(rankNumber, HOUSE_LEFT_SUITS[rowIndex])));
const rightCards = numbers.map((rankNumber) => findCardByLookupName(lookupMap, buildMinorCardName(rankNumber, HOUSE_RIGHT_SUITS[rowIndex])));
assignCardsToPositions(
placements,
leftCards.map((card, index) => ({ row, column: HOUSE_LEFT_START_COLUMN + index })),
leftCards
);
assignCardsToPositions(
placements,
rightCards.map((card, index) => ({ row, column: HOUSE_RIGHT_START_COLUMN + index })),
rightCards
);
});
HOUSE_MIDDLE_RANKS.forEach((rank, rowIndex) => {
const row = HOUSE_BOTTOM_START_ROW + rowIndex;
const middleCards = HOUSE_MIDDLE_SUITS.map((suit) => findCardByLookupName(lookupMap, buildCourtCardName(rank, suit)));
assignCardsToPositions(
placements,
middleCards.map((card, index) => ({ row, column: HOUSE_MIDDLE_START_COLUMN + index })),
middleCards
);
});
return placements;
}
function getLayoutPreset(layoutId = state.currentLayoutId) {
return LAYOUT_PRESETS.find((preset) => preset.id === normalizeKey(layoutId)) || LAYOUT_PRESETS[0];
}
function buildCardSignature(cards) {
return cards.map((card) => getCardId(card)).filter(Boolean).sort().join("|");
}
function resolveDeckOptions(card) {
const deckId = String(tarotCardImages.getActiveDeck?.() || "").trim();
const trumpNumber = card?.arcana === "Major" && Number.isFinite(Number(card?.number))
? Number(card.number)
: undefined;
if (!deckId && !Number.isFinite(trumpNumber)) {
return null;
}
return {
...(deckId ? { deckId } : {}),
...(Number.isFinite(trumpNumber) ? { trumpNumber } : {})
};
}
function resolveCardThumbnail(card) {
if (!card) {
return "";
}
const deckOptions = resolveDeckOptions(card) || undefined;
return String(
tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions)
|| tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions)
|| ""
).trim();
}
function getDisplayCardName(card) {
const label = tarotCardImages.getTarotCardDisplayName?.(card?.name, resolveDeckOptions(card) || undefined);
return String(label || card?.name || "Tarot").trim() || "Tarot";
}
2026-04-01 16:08:52 -07:00
function toRomanNumeral(value) {
let remaining = Number(value);
if (!Number.isFinite(remaining) || remaining <= 0) {
return "";
}
2026-04-01 16:08:52 -07:00
const numerals = [
[10, "X"],
[9, "IX"],
[5, "V"],
[4, "IV"],
[1, "I"]
];
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
: getRelation(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 : "";
return primary ? { primary, secondary, className: "is-top-hebrew" } : null;
}
function buildPlanetLabel(card) {
const relation = getRelation(card, "planetCorrespondence")
|| getRelation(card, "planet")
|| getRelation(card, "decanRuler");
const name = normalizeLabelText(relation?.data?.symbol
? `${relation.data.symbol} ${relation.data.name || relation.data.planetId || ""}`
: relation?.data?.name || relation?.data?.planetId || relation?.id);
return name ? { primary: relation?.type === "decanRuler" ? `Ruler: ${name}` : `Planet: ${name}`, secondary: "", className: "" } : null;
}
function buildMajorZodiacLabel(card) {
const relation = getRelation(card, "zodiacCorrespondence") || getRelation(card, "zodiac");
const name = normalizeLabelText(relation?.data?.symbol
? `${relation.data.symbol} ${relation.data.name || relation.data.signName || ""}`
: relation?.data?.name || relation?.data?.signName || relation?.id);
return name ? { primary: `Zodiac: ${name}`, secondary: "", className: "" } : null;
}
function buildTrumpNumberLabel(card) {
const number = Number(card?.number);
if (!Number.isFinite(number)) {
return null;
}
return {
primary: `Trump: ${number === 0 ? "0" : toRomanNumeral(Math.trunc(number))}`,
secondary: "",
className: ""
};
}
function buildPathNumberLabel(card) {
const pathNumber = Number(card?.kabbalahPathNumber);
return Number.isFinite(pathNumber)
? { primary: `Path: ${Math.trunc(pathNumber)}`, secondary: "", className: "" }
: null;
}
function buildZodiacLabel(card) {
const zodiacRelation = getRelation(card, "zodiac");
const decanRelations = getRelations(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(getRelation(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(getRelation(card, "courtDateWindow")?.data?.dateRange);
return rangeLabel
? { primary: rangeLabel, secondary: dateRange || "", className: "" }
: null;
}
return null;
}
function buildDecanLabel(card) {
const decanRelations = getRelations(card, "decan");
if (!decanRelations.length) {
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);
return primary ? { primary, secondary, className: "" } : null;
}
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(getRelation(card, "courtDateWindow")?.data?.dateRange);
return primary ? { primary, secondary, className: "" } : null;
}
function buildDateLabel(card) {
const dateRange = normalizeLabelText(
getRelation(card, "courtDateWindow")?.data?.dateRange
|| getRelation(card, "decan")?.data?.dateRange
|| getRelation(card, "calendarMonth")?.data?.dateRange
|| getCardOverlayDate(card)
|| getRelation(card, "calendarMonth")?.data?.name
);
const secondary = normalizeLabelText(
getRelation(card, "calendarMonth")?.data?.name
|| getRelation(card, "decan")?.data?.signName
|| getRelation(card, "zodiacCorrespondence")?.data?.name
|| getRelation(card, "zodiac")?.data?.name
);
return dateRange
? { primary: dateRange, secondary: secondary && secondary !== dateRange ? secondary : "", className: "" }
: null;
}
function buildMonthLabel(card) {
const names = [];
const seen = new Set();
getRelations(card, "calendarMonth").forEach((relation) => {
const name = normalizeLabelText(relation?.data?.name);
const key = name.toLowerCase();
if (name && !seen.has(key)) {
seen.add(key);
names.push(name);
}
});
return names.length ? { primary: `Month: ${names.join("/")}`, secondary: "", className: "" } : null;
}
function buildRulerLabel(card) {
const names = [];
const seen = new Set();
getRelations(card, "decanRuler").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)) {
seen.add(key);
names.push(name);
}
});
return names.length ? { primary: `Ruler: ${names.join("/")}`, secondary: "", className: "" } : null;
}
function getHouseTopInfoModeEnabled(mode) {
return Boolean(config.getHouseTopInfoModes?.()?.[mode]);
}
function getHouseBottomInfoModeEnabled(mode) {
return Boolean(config.getHouseBottomInfoModes?.()?.[mode]);
}
function buildHouseTopLabel(card) {
const lines = [];
const seen = new Set();
const pushLine = (value) => {
const text = normalizeLabelText(value);
const key = text.toLowerCase();
if (text && !seen.has(key)) {
seen.add(key);
lines.push(text);
}
};
if (getHouseTopInfoModeEnabled("hebrew")) {
const hebrew = buildHebrewLabel(card);
pushLine(hebrew?.primary);
pushLine(hebrew?.secondary);
}
if (getHouseTopInfoModeEnabled("planet")) {
pushLine(buildPlanetLabel(card)?.primary);
}
if (getHouseTopInfoModeEnabled("zodiac")) {
pushLine(buildMajorZodiacLabel(card)?.primary);
}
if (getHouseTopInfoModeEnabled("trump")) {
pushLine(buildTrumpNumberLabel(card)?.primary);
}
if (getHouseTopInfoModeEnabled("path")) {
pushLine(buildPathNumberLabel(card)?.primary);
}
if (getHouseTopInfoModeEnabled("date")) {
pushLine(buildDateLabel(card)?.primary);
}
if (!lines.length) {
return null;
}
const hasHebrew = getHouseTopInfoModeEnabled("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 buildHouseBottomLabel(card) {
const lines = [];
const seen = new Set();
const pushLine = (value) => {
const text = normalizeLabelText(value);
const key = text.toLowerCase();
if (text && !seen.has(key)) {
seen.add(key);
lines.push(text);
}
};
if (getHouseBottomInfoModeEnabled("zodiac")) {
pushLine(buildZodiacLabel(card)?.primary);
}
if (getHouseBottomInfoModeEnabled("decan")) {
const decanLabel = buildDecanLabel(card);
pushLine(decanLabel?.primary);
if (!getHouseBottomInfoModeEnabled("date")) {
pushLine(decanLabel?.secondary);
}
}
if (getHouseBottomInfoModeEnabled("month")) {
pushLine(buildMonthLabel(card)?.primary);
}
if (getHouseBottomInfoModeEnabled("ruler")) {
pushLine(buildRulerLabel(card)?.primary);
}
if (getHouseBottomInfoModeEnabled("date")) {
pushLine(buildDateLabel(card)?.primary);
}
if (!lines.length) {
return null;
}
return {
primary: lines[0],
secondary: lines.slice(1).join(" · "),
className: lines.length >= 3 ? "is-dense" : ""
};
}
function buildHouseLabel(card) {
if (!card) {
return null;
}
return card.arcana === "Major" ? buildHouseTopLabel(card) : buildHouseBottomLabel(card);
}
function shouldShowCardImage(card) {
2026-04-01 19:26:38 -07:00
if (!card) {
2026-04-01 16:08:52 -07:00
return true;
}
if (card.arcana === "Major") {
return config.getHouseTopCardsVisible?.() !== false;
}
return config.getHouseBottomCardsVisible?.() !== false;
}
function buildCardTextFaceModel(card) {
2026-04-01 19:26:38 -07:00
const label = state.showInfo ? buildHouseLabel(card) : null;
2026-04-01 16:08:52 -07:00
const displayName = normalizeLabelText(getDisplayCardName(card));
if (card?.arcana !== "Major" && label?.primary) {
return {
primary: displayName || "Tarot",
secondary: [label.primary, label.secondary].filter(Boolean).join(" · "),
className: label.className || ""
};
}
if (label?.primary) {
return {
primary: label.primary,
secondary: label.secondary || (displayName && label.primary !== displayName ? displayName : ""),
className: label.className || ""
};
}
return {
primary: displayName || "Tarot",
secondary: "",
className: ""
};
}
function getCardOverlayDate(card) {
const court = getRelation(card, "courtDateWindow")?.data || null;
if (court?.dateStart && court?.dateEnd) {
return formatDateRange(court.dateStart, court.dateEnd);
}
2026-04-01 16:08:52 -07:00
const decan = getRelation(card, "decan")?.data || null;
if (decan?.dateStart && decan?.dateEnd) {
return formatDateRange(decan.dateStart, decan.dateEnd);
}
const zodiac = getRelation(card, "zodiacCorrespondence")?.data || null;
const signId = normalizeKey(zodiac?.signId);
const signStart = ZODIAC_START_TOKEN_BY_SIGN_ID[signId];
if (signStart) {
const signIds = Object.keys(ZODIAC_START_TOKEN_BY_SIGN_ID);
const index = signIds.indexOf(signId);
const nextSignId = signIds[(index + 1) % signIds.length];
const nextStart = ZODIAC_START_TOKEN_BY_SIGN_ID[nextSignId];
const endToken = decrementToken(nextStart);
return formatDateRange(signStart, endToken);
}
return "";
}
function getSlotId(row, column) {
return `${row}:${column}`;
}
function setStatus(message) {
state.statusMessage = String(message || "").trim();
const { tarotFrameStatusEl } = getElements();
if (tarotFrameStatusEl) {
tarotFrameStatusEl.textContent = state.statusMessage;
}
}
2026-04-01 16:08:52 -07:00
function applyLayoutPreset(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") {
const layoutPreset = getLayoutPreset(layoutId);
state.currentLayoutId = layoutPreset.id;
state.slotAssignments.clear();
2026-04-01 16:08:52 -07:00
layoutPreset.buildPlacements(cards).forEach((placement) => {
state.slotAssignments.set(getSlotId(placement.row, placement.column), placement.cardId);
});
state.layoutReady = true;
2026-04-01 16:08:52 -07:00
setStatus(nextStatusMessage || layoutPreset.statusMessage || buildReadyStatus(cards));
}
function resetLayout(cards = getCards(), nextStatusMessage = "") {
applyLayoutPreset(state.currentLayoutId, cards, nextStatusMessage);
}
function getAssignedCard(slotId, cardMap) {
const cardId = String(state.slotAssignments.get(slotId) || "").trim();
return cardMap.get(cardId) || null;
}
function getCardOverlayLabel(card) {
2026-04-01 16:08:52 -07:00
if (!state.showInfo) {
return "";
}
2026-04-01 19:26:38 -07:00
const label = buildHouseLabel(card);
const structuredLabel = normalizeLabelText([label?.primary, label?.secondary].filter(Boolean).join(" · "));
if (structuredLabel) {
return structuredLabel;
2026-04-01 16:08:52 -07:00
}
return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card);
}
2026-04-02 01:10:50 -07:00
function getOccupiedGridBounds(gridTrackEl) {
if (!(gridTrackEl instanceof HTMLElement)) {
return null;
}
const filledSlots = Array.from(gridTrackEl.querySelectorAll(".tarot-frame-slot:not(.is-empty-slot)"));
if (!filledSlots.length) {
return null;
}
const trackRect = gridTrackEl.getBoundingClientRect();
return filledSlots.reduce((bounds, slotEl) => {
if (!(slotEl instanceof HTMLElement)) {
return bounds;
}
const slotRect = slotEl.getBoundingClientRect();
const left = slotRect.left - trackRect.left;
const right = slotRect.right - trackRect.left;
if (!bounds) {
return { left, right };
}
return {
left: Math.min(bounds.left, left),
right: Math.max(bounds.right, right)
};
}, null);
}
function resetFrameSectionScroll() {
const sectionEl = document.getElementById("tarot-frame-section");
if (!(sectionEl instanceof HTMLElement)) {
return;
}
sectionEl.scrollTop = 0;
}
function centerGridViewport(attempt = 0) {
2026-04-01 19:26:38 -07:00
const { tarotFrameBoardEl } = getElements();
const gridViewportEl = tarotFrameBoardEl?.querySelector(".tarot-frame-grid-viewport");
const gridTrackEl = tarotFrameBoardEl?.querySelector(".tarot-frame-grid-track");
if (!(gridViewportEl instanceof HTMLElement) || !(gridTrackEl instanceof HTMLElement)) {
return;
}
requestAnimationFrame(() => {
if (!(gridViewportEl instanceof HTMLElement) || !(gridTrackEl instanceof HTMLElement)) {
return;
}
2026-04-02 01:10:50 -07:00
const contentWidth = gridTrackEl.scrollWidth || gridTrackEl.offsetWidth;
const viewportWidth = gridViewportEl.clientWidth;
if (!contentWidth || !viewportWidth) {
if (attempt < 6) {
centerGridViewport(attempt + 1);
}
return;
}
const occupiedBounds = getOccupiedGridBounds(gridTrackEl);
const targetCenter = occupiedBounds
? (occupiedBounds.left + occupiedBounds.right) / 2
: contentWidth / 2;
const maxScrollLeft = Math.max(0, contentWidth - viewportWidth);
const targetScrollLeft = Math.min(Math.max(targetCenter - (viewportWidth / 2), 0), maxScrollLeft);
gridViewportEl.scrollLeft = targetScrollLeft;
2026-04-01 19:26:38 -07:00
requestAnimationFrame(() => {
if (!(gridViewportEl instanceof HTMLElement) || !(gridTrackEl instanceof HTMLElement)) {
return;
}
2026-04-02 01:10:50 -07:00
const nextContentWidth = gridTrackEl.scrollWidth || gridTrackEl.offsetWidth;
const nextViewportWidth = gridViewportEl.clientWidth;
if (!nextContentWidth || !nextViewportWidth) {
if (attempt < 6) {
centerGridViewport(attempt + 1);
}
return;
}
const nextOccupiedBounds = getOccupiedGridBounds(gridTrackEl);
const nextTargetCenter = nextOccupiedBounds
? (nextOccupiedBounds.left + nextOccupiedBounds.right) / 2
: nextContentWidth / 2;
const nextMaxScrollLeft = Math.max(0, nextContentWidth - nextViewportWidth);
const nextTargetScrollLeft = Math.min(Math.max(nextTargetCenter - (nextViewportWidth / 2), 0), nextMaxScrollLeft);
gridViewportEl.scrollLeft = nextTargetScrollLeft;
2026-04-01 19:26:38 -07:00
});
});
}
2026-04-01 16:08:52 -07:00
function createCardTextFaceElement(faceModel) {
const faceEl = document.createElement("span");
faceEl.className = `tarot-frame-card-text-face${faceModel?.className ? ` ${faceModel.className}` : ""}`;
const primaryEl = document.createElement("span");
primaryEl.className = "tarot-frame-card-text-primary";
primaryEl.textContent = faceModel?.primary || "Tarot";
faceEl.appendChild(primaryEl);
if (faceModel?.secondary) {
const secondaryEl = document.createElement("span");
secondaryEl.className = "tarot-frame-card-text-secondary";
secondaryEl.textContent = faceModel.secondary;
faceEl.appendChild(secondaryEl);
}
return faceEl;
}
function createSlot(row, column, card) {
const slotId = getSlotId(row, column);
const slotEl = document.createElement("div");
slotEl.className = "tarot-frame-slot";
slotEl.dataset.slotId = slotId;
slotEl.style.gridRow = String(row);
slotEl.style.gridColumn = String(column);
if (state.drag?.sourceSlotId === slotId) {
slotEl.classList.add("is-drag-source");
}
if (state.drag?.hoverSlotId === slotId && state.drag?.started) {
slotEl.classList.add("is-drop-target");
}
const button = document.createElement("button");
button.type = "button";
button.className = "tarot-frame-card";
button.dataset.slotId = slotId;
button.draggable = false;
if (!card) {
slotEl.classList.add("is-empty-slot");
button.classList.add("is-empty");
button.tabIndex = -1;
const emptyEl = document.createElement("span");
emptyEl.className = "tarot-frame-slot-empty";
button.appendChild(emptyEl);
slotEl.appendChild(button);
return slotEl;
}
button.dataset.cardId = getCardId(card);
button.setAttribute("aria-label", `${getDisplayCardName(card)} in row ${row}, column ${column}`);
button.title = getDisplayCardName(card);
2026-04-01 16:08:52 -07:00
const showImage = shouldShowCardImage(card);
const imageSrc = resolveCardThumbnail(card);
2026-04-01 16:08:52 -07:00
if (showImage && imageSrc) {
const image = document.createElement("img");
image.className = "tarot-frame-card-image";
image.src = imageSrc;
image.alt = getDisplayCardName(card);
image.loading = "lazy";
image.decoding = "async";
image.draggable = false;
button.appendChild(image);
2026-04-01 16:08:52 -07:00
} else if (showImage) {
const fallback = document.createElement("span");
fallback.className = "tarot-frame-card-fallback";
fallback.textContent = getDisplayCardName(card);
button.appendChild(fallback);
2026-04-01 16:08:52 -07:00
} else {
button.appendChild(createCardTextFaceElement(buildCardTextFaceModel(card)));
}
2026-04-01 16:08:52 -07:00
if (showImage && state.showInfo) {
const overlay = document.createElement("span");
overlay.className = "tarot-frame-card-badge";
overlay.textContent = getCardOverlayLabel(card);
button.appendChild(overlay);
}
slotEl.appendChild(button);
return slotEl;
}
2026-04-01 16:08:52 -07:00
function createLegend(layoutPreset) {
const legendEl = document.createElement("div");
legendEl.className = "tarot-frame-legend";
2026-04-01 16:08:52 -07:00
layoutPreset.legendItems.forEach((layout) => {
const itemEl = document.createElement("div");
itemEl.className = "tarot-frame-legend-item";
const titleEl = document.createElement("strong");
titleEl.textContent = layout.title;
const textEl = document.createElement("span");
textEl.textContent = layout.description;
itemEl.append(titleEl, textEl);
legendEl.appendChild(itemEl);
});
return legendEl;
}
function render() {
const { tarotFrameBoardEl } = getElements();
if (!tarotFrameBoardEl) {
return;
}
const cards = getCards();
const cardMap = getCardMap(cards);
2026-04-01 16:08:52 -07:00
const layoutPreset = getLayoutPreset();
tarotFrameBoardEl.replaceChildren();
const panelEl = document.createElement("section");
panelEl.className = "tarot-frame-panel tarot-frame-panel--master";
2026-04-01 19:26:38 -07:00
panelEl.style.setProperty("--frame-grid-zoom-scale", String(getGridZoomScale()));
const headEl = document.createElement("div");
headEl.className = "tarot-frame-panel-head";
const titleWrapEl = document.createElement("div");
const titleEl = document.createElement("h3");
titleEl.className = "tarot-frame-panel-title";
2026-04-01 16:08:52 -07:00
titleEl.textContent = layoutPreset.title;
const subtitleEl = document.createElement("p");
subtitleEl.className = "tarot-frame-panel-subtitle";
2026-04-01 16:08:52 -07:00
subtitleEl.textContent = layoutPreset.subtitle;
titleWrapEl.append(titleEl, subtitleEl);
const countEl = document.createElement("span");
countEl.className = "tarot-frame-panel-count";
2026-04-01 19:26:38 -07:00
countEl.textContent = buildPanelCountText(cards);
headEl.append(titleWrapEl, countEl);
2026-04-01 16:08:52 -07:00
panelEl.append(headEl, createLegend(layoutPreset));
2026-04-01 19:26:38 -07:00
const gridViewportEl = document.createElement("div");
gridViewportEl.className = "tarot-frame-grid-viewport";
const gridTrackEl = document.createElement("div");
gridTrackEl.className = "tarot-frame-grid-track";
const gridEl = document.createElement("div");
gridEl.className = "tarot-frame-grid tarot-frame-grid--master";
gridEl.classList.toggle("is-info-hidden", !state.showInfo);
gridEl.style.setProperty("--frame-grid-size", String(MASTER_GRID_SIZE));
for (let row = 1; row <= MASTER_GRID_SIZE; row += 1) {
for (let column = 1; column <= MASTER_GRID_SIZE; column += 1) {
gridEl.appendChild(createSlot(row, column, getAssignedCard(getSlotId(row, column), cardMap)));
}
}
2026-04-01 19:26:38 -07:00
gridTrackEl.appendChild(gridEl);
gridViewportEl.appendChild(gridTrackEl);
panelEl.appendChild(gridViewportEl);
tarotFrameBoardEl.appendChild(panelEl);
2026-04-01 19:26:38 -07:00
centerGridViewport();
}
function applyGridZoomState() {
const { tarotFrameBoardEl } = getElements();
const panelEl = tarotFrameBoardEl?.querySelector(".tarot-frame-panel--master");
if (!(panelEl instanceof HTMLElement)) {
return;
}
panelEl.style.setProperty("--frame-grid-zoom-scale", String(getGridZoomScale()));
const countEl = panelEl.querySelector(".tarot-frame-panel-count");
if (countEl instanceof HTMLElement) {
countEl.textContent = buildPanelCountText();
}
centerGridViewport();
}
function setGridZoomStepIndex(nextIndex) {
const safeIndex = Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, Number(nextIndex) || 0));
state.gridZoomStepIndex = safeIndex;
applyGridZoomState();
setStatus(`Frame grid zoom ${Math.round(getGridZoomScale() * 100)}%. This setting applies to every Frame layout.`);
}
function syncControls() {
const {
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl,
tarotFrameLayoutPanelEl,
tarotFrameSettingsToggleEl,
tarotFrameSettingsPanelEl,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl,
tarotFrameShowInfoEl,
2026-04-01 16:08:52 -07:00
tarotFrameHouseSettingsEl,
tarotFrameHouseTopCardsVisibleEl,
tarotFrameHouseTopInfoHebrewEl,
tarotFrameHouseTopInfoPlanetEl,
tarotFrameHouseTopInfoZodiacEl,
tarotFrameHouseTopInfoTrumpEl,
tarotFrameHouseTopInfoPathEl,
tarotFrameHouseTopInfoDateEl,
tarotFrameHouseBottomCardsVisibleEl,
tarotFrameHouseBottomInfoZodiacEl,
tarotFrameHouseBottomInfoDecanEl,
tarotFrameHouseBottomInfoMonthEl,
tarotFrameHouseBottomInfoRulerEl,
tarotFrameHouseBottomInfoDateEl,
tarotFrameExportWebpEl,
} = getElements();
2026-04-01 16:08:52 -07:00
const layoutPreset = getLayoutPreset();
if (tarotFrameLayoutToggleEl) {
tarotFrameLayoutToggleEl.setAttribute("aria-expanded", state.layoutMenuOpen ? "true" : "false");
tarotFrameLayoutToggleEl.textContent = `Layout: ${layoutPreset.label}`;
tarotFrameLayoutToggleEl.disabled = Boolean(state.exportInProgress);
}
if (tarotFrameLayoutPanelEl) {
tarotFrameLayoutPanelEl.hidden = !state.layoutMenuOpen;
}
getLayoutOptionElements().forEach((button) => {
const isActive = String(button.dataset.layoutPresetId || "") === layoutPreset.id;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-checked", isActive ? "true" : "false");
button.disabled = Boolean(state.exportInProgress);
});
if (tarotFrameSettingsToggleEl) {
tarotFrameSettingsToggleEl.setAttribute("aria-expanded", state.settingsOpen ? "true" : "false");
tarotFrameSettingsToggleEl.textContent = state.settingsOpen ? "Hide Settings" : "Settings";
tarotFrameSettingsToggleEl.disabled = Boolean(state.exportInProgress);
}
if (tarotFrameSettingsPanelEl) {
tarotFrameSettingsPanelEl.hidden = !state.settingsOpen;
}
2026-04-01 19:26:38 -07:00
if (tarotFrameGridZoomEl) {
tarotFrameGridZoomEl.value = String(state.gridZoomStepIndex);
tarotFrameGridZoomEl.disabled = Boolean(state.exportInProgress);
}
if (tarotFrameShowInfoEl) {
tarotFrameShowInfoEl.checked = Boolean(state.showInfo);
tarotFrameShowInfoEl.disabled = Boolean(state.exportInProgress);
}
2026-04-01 16:08:52 -07:00
if (tarotFrameHouseSettingsEl) {
2026-04-01 19:26:38 -07:00
tarotFrameHouseSettingsEl.hidden = false;
2026-04-01 16:08:52 -07:00
}
if (tarotFrameHouseTopCardsVisibleEl) {
tarotFrameHouseTopCardsVisibleEl.checked = config.getHouseTopCardsVisible?.() !== false;
2026-04-01 19:26:38 -07:00
tarotFrameHouseTopCardsVisibleEl.disabled = Boolean(state.exportInProgress);
2026-04-01 16:08:52 -07:00
}
[
[tarotFrameHouseTopInfoHebrewEl, "hebrew", config.getHouseTopInfoModes],
[tarotFrameHouseTopInfoPlanetEl, "planet", config.getHouseTopInfoModes],
[tarotFrameHouseTopInfoZodiacEl, "zodiac", config.getHouseTopInfoModes],
[tarotFrameHouseTopInfoTrumpEl, "trump", config.getHouseTopInfoModes],
[tarotFrameHouseTopInfoPathEl, "path", config.getHouseTopInfoModes],
[tarotFrameHouseTopInfoDateEl, "date", config.getHouseTopInfoModes],
[tarotFrameHouseBottomInfoZodiacEl, "zodiac", config.getHouseBottomInfoModes],
[tarotFrameHouseBottomInfoDecanEl, "decan", config.getHouseBottomInfoModes],
[tarotFrameHouseBottomInfoMonthEl, "month", config.getHouseBottomInfoModes],
[tarotFrameHouseBottomInfoRulerEl, "ruler", config.getHouseBottomInfoModes],
[tarotFrameHouseBottomInfoDateEl, "date", config.getHouseBottomInfoModes]
].forEach(([checkbox, mode, getter]) => {
if (!checkbox) {
return;
}
checkbox.checked = Boolean(getter?.()?.[mode]);
2026-04-01 19:26:38 -07:00
checkbox.disabled = Boolean(state.exportInProgress);
2026-04-01 16:08:52 -07:00
});
if (tarotFrameHouseBottomCardsVisibleEl) {
tarotFrameHouseBottomCardsVisibleEl.checked = config.getHouseBottomCardsVisible?.() !== false;
2026-04-01 19:26:38 -07:00
tarotFrameHouseBottomCardsVisibleEl.disabled = Boolean(state.exportInProgress);
2026-04-01 16:08:52 -07:00
}
if (tarotFrameExportWebpEl) {
const supportsWebp = isExportFormatSupported("webp");
tarotFrameExportWebpEl.hidden = !supportsWebp;
tarotFrameExportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp;
tarotFrameExportWebpEl.textContent = state.exportInProgress ? "Exporting..." : "Export WebP";
if (supportsWebp) {
tarotFrameExportWebpEl.title = "Download the current frame grid arrangement as a WebP image.";
}
}
}
function getSlotElement(slotId) {
return document.querySelector(`.tarot-frame-slot[data-slot-id="${slotId}"]`);
}
function setHoverSlot(slotId) {
const previous = state.drag?.hoverSlotId;
if (previous && previous !== slotId) {
getSlotElement(previous)?.classList.remove("is-drop-target");
}
if (state.drag) {
state.drag.hoverSlotId = slotId || "";
}
if (slotId) {
getSlotElement(slotId)?.classList.add("is-drop-target");
}
}
function createDragGhost(card) {
const ghost = document.createElement("div");
ghost.className = "tarot-frame-drag-ghost";
const imageSrc = resolveCardThumbnail(card);
if (imageSrc) {
const image = document.createElement("img");
image.src = imageSrc;
image.alt = "";
ghost.appendChild(image);
}
if (state.showInfo) {
const label = document.createElement("span");
label.className = "tarot-frame-drag-ghost-label";
label.textContent = getCardOverlayLabel(card);
ghost.appendChild(label);
}
document.body.appendChild(ghost);
return ghost;
}
function moveGhost(ghostEl, clientX, clientY) {
if (!(ghostEl instanceof HTMLElement)) {
return;
}
ghostEl.style.left = `${clientX}px`;
ghostEl.style.top = `${clientY}px`;
}
function updateHoverSlotFromPoint(clientX, clientY, sourceSlotId) {
const target = document.elementFromPoint(clientX, clientY);
const slot = target instanceof Element ? target.closest(".tarot-frame-slot[data-slot-id]") : null;
const nextSlotId = slot instanceof HTMLElement ? String(slot.dataset.slotId || "") : "";
setHoverSlot(nextSlotId && nextSlotId !== sourceSlotId ? nextSlotId : "");
}
function detachPointerListeners() {
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerCancel);
}
function cleanupDrag() {
if (!state.drag) {
return;
}
setHoverSlot("");
getSlotElement(state.drag.sourceSlotId)?.classList.remove("is-drag-source");
if (state.drag.ghostEl instanceof HTMLElement) {
state.drag.ghostEl.remove();
}
state.drag = null;
document.body.classList.remove("is-tarot-frame-dragging");
detachPointerListeners();
}
function swapOrMoveSlots(sourceSlotId, targetSlotId) {
const sourceCardId = String(state.slotAssignments.get(sourceSlotId) || "");
const targetCardId = String(state.slotAssignments.get(targetSlotId) || "");
state.slotAssignments.set(targetSlotId, sourceCardId);
if (targetCardId) {
state.slotAssignments.set(sourceSlotId, targetCardId);
} else {
state.slotAssignments.delete(sourceSlotId);
}
}
function describeSlot(slotId) {
const [rowText, columnText] = String(slotId || "").split(":");
return `row ${rowText || "?"}, column ${columnText || "?"}`;
}
function openCardLightbox(cardId) {
const card = getCardMap(getCards()).get(String(cardId || "").trim()) || null;
if (!card) {
return;
}
const deckOptions = resolveDeckOptions(card);
const src = String(
tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions)
|| tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions)
|| ""
).trim();
if (!src) {
return;
}
const label = getDisplayCardName(card);
window.TarotUiLightbox?.open?.({
src,
altText: label,
label,
cardId: getCardId(card),
deckId: String(tarotCardImages.getActiveDeck?.() || "").trim()
});
}
function handlePointerDown(event) {
const target = event.target;
if (!(target instanceof Element) || event.button !== 0) {
return;
}
const cardButton = target.closest(".tarot-frame-card[data-slot-id][data-card-id]");
if (!(cardButton instanceof HTMLButtonElement)) {
return;
}
state.drag = {
pointerId: event.pointerId,
sourceSlotId: String(cardButton.dataset.slotId || ""),
cardId: String(cardButton.dataset.cardId || ""),
startX: event.clientX,
startY: event.clientY,
started: false,
hoverSlotId: "",
ghostEl: null
};
detachPointerListeners();
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerCancel);
}
function handlePointerMove(event) {
if (!state.drag || event.pointerId !== state.drag.pointerId) {
return;
}
const movedEnough = Math.hypot(event.clientX - state.drag.startX, event.clientY - state.drag.startY) >= 6;
if (!state.drag.started && movedEnough) {
const card = getCardMap(getCards()).get(state.drag.cardId) || null;
if (!card) {
cleanupDrag();
return;
}
state.drag.started = true;
state.drag.ghostEl = createDragGhost(card);
getSlotElement(state.drag.sourceSlotId)?.classList.add("is-drag-source");
document.body.classList.add("is-tarot-frame-dragging");
state.suppressClick = true;
}
if (!state.drag.started) {
return;
}
moveGhost(state.drag.ghostEl, event.clientX, event.clientY);
updateHoverSlotFromPoint(event.clientX, event.clientY, state.drag.sourceSlotId);
event.preventDefault();
}
function finishDrop() {
if (!state.drag) {
return;
}
const sourceSlotId = state.drag.sourceSlotId;
const targetSlotId = state.drag.hoverSlotId;
const draggedCard = getCardMap(getCards()).get(state.drag.cardId) || null;
const moved = Boolean(targetSlotId && targetSlotId !== sourceSlotId);
if (moved) {
swapOrMoveSlots(sourceSlotId, targetSlotId);
render();
setStatus(`${getDisplayCardName(draggedCard)} snapped to ${describeSlot(targetSlotId)}.`);
}
cleanupDrag();
if (!moved) {
state.suppressClick = false;
}
}
function handlePointerUp(event) {
if (!state.drag || event.pointerId !== state.drag.pointerId) {
return;
}
if (!state.drag.started) {
cleanupDrag();
return;
}
finishDrop();
}
function handlePointerCancel(event) {
if (!state.drag || event.pointerId !== state.drag.pointerId) {
return;
}
cleanupDrag();
state.suppressClick = false;
}
function handleBoardClick(event) {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const cardButton = target.closest(".tarot-frame-card[data-card-id]");
if (!(cardButton instanceof HTMLButtonElement)) {
return;
}
if (state.suppressClick) {
state.suppressClick = false;
return;
}
openCardLightbox(cardButton.dataset.cardId);
}
function handleNativeDragStart(event) {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (target.closest(".tarot-frame-card")) {
event.preventDefault();
}
}
function handleDocumentClick(event) {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
2026-04-01 16:08:52 -07:00
const {
tarotFrameSettingsPanelEl,
tarotFrameSettingsToggleEl,
tarotFrameLayoutPanelEl,
tarotFrameLayoutToggleEl
} = getElements();
let changed = false;
if (state.settingsOpen && !tarotFrameSettingsPanelEl?.contains(target) && !tarotFrameSettingsToggleEl?.contains(target)) {
state.settingsOpen = false;
changed = true;
}
2026-04-01 16:08:52 -07:00
if (state.layoutMenuOpen && !tarotFrameLayoutPanelEl?.contains(target) && !tarotFrameLayoutToggleEl?.contains(target)) {
state.layoutMenuOpen = false;
changed = true;
}
if (changed) {
syncControls();
}
}
function handleDocumentKeydown(event) {
2026-04-01 16:08:52 -07:00
if (event.key !== "Escape") {
return;
}
2026-04-01 16:08:52 -07:00
let changed = false;
if (state.settingsOpen) {
state.settingsOpen = false;
changed = true;
}
if (state.layoutMenuOpen) {
state.layoutMenuOpen = false;
changed = true;
}
if (changed) {
syncControls();
}
}
function drawRoundedRectPath(context, x, y, width, height, radius) {
const nextRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
context.beginPath();
context.moveTo(x + nextRadius, y);
context.lineTo(x + width - nextRadius, y);
context.quadraticCurveTo(x + width, y, x + width, y + nextRadius);
context.lineTo(x + width, y + height - nextRadius);
context.quadraticCurveTo(x + width, y + height, x + width - nextRadius, y + height);
context.lineTo(x + nextRadius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - nextRadius);
context.lineTo(x, y + nextRadius);
context.quadraticCurveTo(x, y, x + nextRadius, y);
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 = 2) {
const normalized = normalizeLabelText(text);
if (!normalized) {
return [];
}
const words = normalized.split(/\s+/).filter(Boolean);
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 drawImageContain(context, image, x, y, width, height) {
if (!(image instanceof HTMLImageElement) && !(image instanceof ImageBitmap)) {
return;
}
const sourceWidth = Number(image.width || image.naturalWidth || 0);
const sourceHeight = Number(image.height || image.naturalHeight || 0);
if (!(sourceWidth > 0 && sourceHeight > 0)) {
return;
}
const scale = Math.min(width / sourceWidth, height / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
const drawX = x + ((width - drawWidth) / 2);
const drawY = y + ((height - drawHeight) / 2);
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
2026-04-01 19:26:38 -07:00
function drawTextFaceToCanvas(context, x, y, width, height, faceModel) {
2026-04-01 16:08:52 -07:00
const primaryText = normalizeLabelText(faceModel?.primary || "Tarot");
const secondaryText = normalizeLabelText(faceModel?.secondary);
2026-04-01 19:26:38 -07:00
const maxWidth = width - 12;
2026-04-01 16:08:52 -07:00
context.save();
const primaryFontSize = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 14 : 10;
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);
const secondaryLines = secondaryText ? wrapCanvasText(context, secondaryText, maxWidth, 3) : [];
const primaryLineHeight = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 14 : 11;
const secondaryLineHeight = 9;
const totalHeight = (primaryLines.length * primaryLineHeight) + (secondaryLines.length ? 4 + (secondaryLines.length * secondaryLineHeight) : 0);
2026-04-01 19:26:38 -07:00
let currentY = y + ((height - totalHeight) / 2) + primaryLineHeight;
2026-04-01 16:08:52 -07:00
context.textAlign = "center";
context.textBaseline = "alphabetic";
primaryLines.forEach((line) => {
context.fillStyle = "#f8fafc";
context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`;
2026-04-01 19:26:38 -07:00
context.fillText(line, x + (width / 2), currentY, maxWidth);
2026-04-01 16:08:52 -07:00
currentY += primaryLineHeight;
});
if (secondaryLines.length) {
currentY += 2;
context.fillStyle = "rgba(248, 250, 252, 0.78)";
context.font = "500 7px 'Segoe UI', sans-serif";
secondaryLines.forEach((line) => {
2026-04-01 19:26:38 -07:00
context.fillText(line, x + (width / 2), currentY, maxWidth);
2026-04-01 16:08:52 -07:00
currentY += secondaryLineHeight;
});
}
context.restore();
}
2026-04-01 19:26:38 -07:00
function drawSlotToCanvas(context, x, y, width, height, card, image) {
if (!card) {
context.save();
context.setLineDash([6, 6]);
context.lineWidth = 1.5;
context.strokeStyle = "rgba(148, 163, 184, 0.42)";
2026-04-01 19:26:38 -07:00
drawRoundedRectPath(context, x + 1, y + 1, width - 2, height - 2, 10);
context.stroke();
context.restore();
return;
}
const cardX = x + EXPORT_CARD_INSET;
const cardY = y + EXPORT_CARD_INSET;
2026-04-01 19:26:38 -07:00
const cardWidth = width - (EXPORT_CARD_INSET * 2);
const cardHeight = height - (EXPORT_CARD_INSET * 2);
2026-04-01 16:08:52 -07:00
const showImage = shouldShowCardImage(card);
context.save();
2026-04-01 19:26:38 -07:00
drawRoundedRectPath(context, cardX, cardY, cardWidth, cardHeight, 0);
context.clip();
2026-04-01 16:08:52 -07:00
if (showImage && image) {
2026-04-01 19:26:38 -07:00
drawImageContain(context, image, cardX, cardY, cardWidth, cardHeight);
2026-04-01 16:08:52 -07:00
} else if (showImage) {
context.fillStyle = EXPORT_PANEL;
2026-04-01 19:26:38 -07:00
context.fillRect(cardX, cardY, cardWidth, cardHeight);
context.fillStyle = "#f8fafc";
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "700 14px 'Segoe UI', sans-serif";
2026-04-01 19:26:38 -07:00
const lines = wrapCanvasText(context, getDisplayCardName(card), cardWidth - 18, 4);
const lineHeight = 18;
2026-04-01 19:26:38 -07:00
let currentY = cardY + (cardHeight / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2);
lines.forEach((line) => {
2026-04-01 19:26:38 -07:00
context.fillText(line, cardX + (cardWidth / 2), currentY, cardWidth - 18);
currentY += lineHeight;
});
2026-04-01 16:08:52 -07:00
} else {
context.fillStyle = EXPORT_PANEL;
2026-04-01 19:26:38 -07:00
context.fillRect(cardX, cardY, cardWidth, cardHeight);
drawTextFaceToCanvas(context, cardX, cardY, cardWidth, cardHeight, buildCardTextFaceModel(card));
}
context.restore();
2026-04-01 16:08:52 -07:00
if (showImage && state.showInfo) {
const overlayText = getCardOverlayLabel(card);
if (overlayText) {
const overlayHeight = 30;
const overlayX = cardX + 4;
2026-04-01 19:26:38 -07:00
const overlayY = cardY + cardHeight - overlayHeight - 4;
const overlayWidth = cardWidth - 8;
drawRoundedRectPath(context, overlayX, overlayY, overlayWidth, overlayHeight, 8);
context.fillStyle = EXPORT_BADGE_BACKGROUND;
context.fill();
context.fillStyle = EXPORT_BADGE_TEXT;
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "700 11px 'Segoe UI', sans-serif";
const lines = wrapCanvasText(context, overlayText, overlayWidth - 10, 2);
const lineHeight = 12;
let currentY = overlayY + (overlayHeight / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2);
lines.forEach((line) => {
context.fillText(line, overlayX + (overlayWidth / 2), currentY, overlayWidth - 10);
currentY += lineHeight;
});
}
}
}
function loadCardImage(src) {
return new Promise((resolve) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = src;
});
}
function isExportFormatSupported(format) {
const exportFormat = EXPORT_FORMATS[format];
if (!exportFormat) {
return false;
}
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.webp;
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 = "webp") {
const cards = getCards();
const cardMap = getCardMap(cards);
const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.webp;
2026-04-01 19:26:38 -07:00
const contentWidth = (MASTER_GRID_SIZE * EXPORT_SLOT_WIDTH) + ((MASTER_GRID_SIZE - 1) * EXPORT_GRID_GAP);
const contentHeight = (MASTER_GRID_SIZE * EXPORT_SLOT_HEIGHT) + ((MASTER_GRID_SIZE - 1) * EXPORT_GRID_GAP);
const canvasWidth = contentWidth + (EXPORT_PADDING * 2);
const canvasHeight = contentHeight + (EXPORT_PADDING * 2);
const scale = Math.max(1.5, Math.min(2, Number(window.devicePixelRatio) || 1));
const canvas = document.createElement("canvas");
2026-04-01 19:26:38 -07:00
canvas.width = Math.ceil(canvasWidth * scale);
canvas.height = Math.ceil(canvasHeight * scale);
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}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;
2026-04-01 19:26:38 -07:00
context.fillRect(0, 0, canvasWidth, canvasHeight);
const imageCache = new Map();
cards.forEach((card) => {
const src = resolveCardThumbnail(card);
if (src && !imageCache.has(src)) {
imageCache.set(src, loadCardImage(src));
}
});
const resolvedImages = new Map();
await Promise.all(cards.map(async (card) => {
const src = resolveCardThumbnail(card);
const image = src ? await imageCache.get(src) : null;
resolvedImages.set(getCardId(card), image || null);
}));
for (let row = 1; row <= MASTER_GRID_SIZE; row += 1) {
for (let column = 1; column <= MASTER_GRID_SIZE; column += 1) {
const slotId = getSlotId(row, column);
const card = getAssignedCard(slotId, cardMap);
2026-04-01 19:26:38 -07:00
const x = EXPORT_PADDING + ((column - 1) * (EXPORT_SLOT_WIDTH + EXPORT_GRID_GAP));
const y = EXPORT_PADDING + ((row - 1) * (EXPORT_SLOT_HEIGHT + EXPORT_GRID_GAP));
drawSlotToCanvas(context, x, y, EXPORT_SLOT_WIDTH, EXPORT_SLOT_HEIGHT, card, card ? resolvedImages.get(getCardId(card)) : null);
}
}
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-frame-grid-${stamp}.${exportFormat.extension}`;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}
async function exportFrame(format = "webp") {
if (state.exportInProgress) {
return;
}
state.exportInProgress = true;
state.exportFormat = format;
syncControls();
try {
await exportImage(format);
setStatus(`Downloaded a ${String(format || "webp").toUpperCase()} export of the current frame grid.`);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Unable to export the Tarot Frame image.");
} finally {
state.exportInProgress = false;
state.exportFormat = "webp";
syncControls();
}
}
function bindEvents() {
const {
tarotFrameBoardEl,
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl,
tarotFrameLayoutPanelEl,
tarotFrameSettingsToggleEl,
tarotFrameSettingsPanelEl,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl,
tarotFrameShowInfoEl,
2026-04-01 16:08:52 -07:00
tarotFrameHouseTopCardsVisibleEl,
tarotFrameHouseTopInfoHebrewEl,
tarotFrameHouseTopInfoPlanetEl,
tarotFrameHouseTopInfoZodiacEl,
tarotFrameHouseTopInfoTrumpEl,
tarotFrameHouseTopInfoPathEl,
tarotFrameHouseTopInfoDateEl,
tarotFrameHouseBottomCardsVisibleEl,
tarotFrameHouseBottomInfoZodiacEl,
tarotFrameHouseBottomInfoDecanEl,
tarotFrameHouseBottomInfoMonthEl,
tarotFrameHouseBottomInfoRulerEl,
tarotFrameHouseBottomInfoDateEl,
tarotFrameExportWebpEl
} = getElements();
if (tarotFrameBoardEl) {
tarotFrameBoardEl.addEventListener("pointerdown", handlePointerDown);
tarotFrameBoardEl.addEventListener("click", handleBoardClick);
tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart);
}
2026-04-01 16:08:52 -07:00
if (tarotFrameLayoutToggleEl) {
tarotFrameLayoutToggleEl.addEventListener("click", (event) => {
event.stopPropagation();
if (state.exportInProgress) {
return;
}
state.layoutMenuOpen = !state.layoutMenuOpen;
if (state.layoutMenuOpen) {
state.settingsOpen = false;
}
syncControls();
});
}
if (tarotFrameLayoutPanelEl) {
tarotFrameLayoutPanelEl.addEventListener("click", (event) => {
event.stopPropagation();
const target = event.target;
const option = target instanceof Element ? target.closest(".tarot-frame-layout-option[data-layout-preset-id]") : null;
if (!(option instanceof HTMLButtonElement)) {
return;
}
const cards = getCards();
if (!cards.length) {
return;
}
2026-04-01 16:08:52 -07:00
applyLayoutPreset(option.dataset.layoutPresetId, cards, `${getLayoutPreset(option.dataset.layoutPresetId).label} layout applied to the master grid.`);
state.layoutMenuOpen = false;
render();
2026-04-01 16:08:52 -07:00
syncControls();
});
}
if (tarotFrameSettingsToggleEl) {
tarotFrameSettingsToggleEl.addEventListener("click", (event) => {
event.stopPropagation();
if (state.exportInProgress) {
return;
}
state.settingsOpen = !state.settingsOpen;
2026-04-01 16:08:52 -07:00
if (state.settingsOpen) {
state.layoutMenuOpen = false;
}
syncControls();
});
}
if (tarotFrameSettingsPanelEl) {
tarotFrameSettingsPanelEl.addEventListener("click", (event) => {
event.stopPropagation();
});
}
if (tarotFrameShowInfoEl) {
tarotFrameShowInfoEl.addEventListener("change", () => {
state.showInfo = Boolean(tarotFrameShowInfoEl.checked);
render();
syncControls();
});
}
2026-04-01 19:26:38 -07:00
if (tarotFrameGridZoomEl) {
tarotFrameGridZoomEl.addEventListener("change", () => {
setGridZoomStepIndex(tarotFrameGridZoomEl.value);
});
}
2026-04-01 16:08:52 -07:00
[
[tarotFrameHouseTopCardsVisibleEl, (checked) => config.setHouseTopCardsVisible?.(checked)],
[tarotFrameHouseTopInfoHebrewEl, (checked) => config.setHouseTopInfoMode?.("hebrew", checked)],
[tarotFrameHouseTopInfoPlanetEl, (checked) => config.setHouseTopInfoMode?.("planet", checked)],
[tarotFrameHouseTopInfoZodiacEl, (checked) => config.setHouseTopInfoMode?.("zodiac", checked)],
[tarotFrameHouseTopInfoTrumpEl, (checked) => config.setHouseTopInfoMode?.("trump", checked)],
[tarotFrameHouseTopInfoPathEl, (checked) => config.setHouseTopInfoMode?.("path", checked)],
[tarotFrameHouseTopInfoDateEl, (checked) => config.setHouseTopInfoMode?.("date", checked)],
[tarotFrameHouseBottomCardsVisibleEl, (checked) => config.setHouseBottomCardsVisible?.(checked)],
[tarotFrameHouseBottomInfoZodiacEl, (checked) => config.setHouseBottomInfoMode?.("zodiac", checked)],
[tarotFrameHouseBottomInfoDecanEl, (checked) => config.setHouseBottomInfoMode?.("decan", checked)],
[tarotFrameHouseBottomInfoMonthEl, (checked) => config.setHouseBottomInfoMode?.("month", checked)],
[tarotFrameHouseBottomInfoRulerEl, (checked) => config.setHouseBottomInfoMode?.("ruler", checked)],
[tarotFrameHouseBottomInfoDateEl, (checked) => config.setHouseBottomInfoMode?.("date", checked)]
].forEach(([element, callback]) => {
if (!element) {
return;
}
element.addEventListener("change", () => {
callback(Boolean(element.checked));
render();
syncControls();
});
});
if (tarotFrameExportWebpEl) {
tarotFrameExportWebpEl.addEventListener("click", () => {
exportFrame("webp");
});
}
document.addEventListener("click", handleDocumentClick);
document.addEventListener("keydown", handleDocumentKeydown);
}
async function ensureTarotFrameSection(referenceData, magickDataset) {
if (typeof config.ensureTarotSection === "function") {
await config.ensureTarotSection(referenceData, magickDataset);
}
2026-04-02 01:10:50 -07:00
resetFrameSectionScroll();
const cards = getCards();
if (!cards.length) {
setStatus("Tarot cards are still loading...");
return;
}
const signature = buildCardSignature(cards);
if (!state.layoutReady || state.cardSignature !== signature) {
state.cardSignature = signature;
2026-04-01 16:08:52 -07:00
applyLayoutPreset(state.currentLayoutId, cards);
} else {
setStatus(state.statusMessage || buildReadyStatus(cards));
}
render();
syncControls();
}
function init(nextConfig = {}) {
config = {
...config,
...nextConfig
};
if (state.initialized) {
return;
}
bindEvents();
syncControls();
state.initialized = true;
}
window.TarotFrameUi = {
...(window.TarotFrameUi || {}),
init,
ensureTarotFrameSection,
render,
resetLayout,
2026-04-01 16:08:52 -07:00
setLayoutPreset(layoutId, options = {}) {
const cards = getCards();
state.currentLayoutId = getLayoutPreset(layoutId).id;
if (cards.length && options.reapply !== false) {
applyLayoutPreset(state.currentLayoutId, cards, options.statusMessage || `${getLayoutPreset(layoutId).label} layout applied to the master grid.`);
render();
}
syncControls();
},
exportImage,
isExportFormatSupported
};
})();