(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"]; 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" }; const MASTER_GRID_SIZE = 18; const EXPORT_SLOT_SIZE = 120; 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 } }; const BOARD_LAYOUTS = [ { id: "extra-cards", title: "Extra Row", description: "Top row for aces, princesses, and the non-zodiac majors.", positions: Array.from({ length: MASTER_GRID_SIZE }, (_, index) => ({ row: 1, column: index + 1 })), getOrderedCards(cards) { return cards .filter((card) => isExtraTopRowCard(card)) .sort(compareExtraTopRowCards); } }, { id: "small-cards", title: "Small Cards", description: "Outer perimeter in chronological decan order.", positions: buildPerimeterPath(10, 5, 5), 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.", positions: buildPerimeterPath(4, 8, 6), 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.", positions: buildPerimeterPath(4, 8, 10), 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"); }); } } ]; const state = { initialized: false, layoutReady: false, cardSignature: "", slotAssignments: new Map(), statusMessage: "Loading tarot cards...", drag: null, suppressClick: false, showInfo: true, settingsOpen: false, exportInProgress: false, exportFormat: "webp" }; let config = { ensureTarotSection: null, getCards: () => [] }; 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"), tarotFrameResetEl: document.getElementById("tarot-frame-reset"), tarotFrameSettingsToggleEl: document.getElementById("tarot-frame-settings-toggle"), tarotFrameSettingsPanelEl: document.getElementById("tarot-frame-settings-panel"), tarotFrameShowInfoEl: document.getElementById("tarot-frame-show-info"), tarotFrameExportWebpEl: document.getElementById("tarot-frame-export-webp") }; } 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) { return `${Array.isArray(cards) ? cards.length : 0} cards ready. Drag any card to any grid square and it will snap into that spot.`; } function normalizeKey(value) { return String(value || "").trim().toLowerCase(); } 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; } 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); } 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"; } function getCardOverlayDate(card) { const decan = getRelation(card, "decan")?.data || null; if (decan?.dateStart && decan?.dateEnd) { return formatDateRange(decan.dateStart, decan.dateEnd); } const court = getRelation(card, "courtDateWindow")?.data || null; if (court?.dateStart && court?.dateEnd) { return formatDateRange(court.dateStart, court.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; } } function resetLayout(cards = getCards(), nextStatusMessage = "") { state.slotAssignments.clear(); BOARD_LAYOUTS.forEach((layout) => { const orderedCards = layout.getOrderedCards(cards); layout.positions.forEach((position, index) => { state.slotAssignments.set(getSlotId(position.row, position.column), getCardId(orderedCards[index] || null)); }); }); state.layoutReady = true; setStatus(nextStatusMessage || buildReadyStatus(cards)); } function getAssignedCard(slotId, cardMap) { const cardId = String(state.slotAssignments.get(slotId) || "").trim(); return cardMap.get(cardId) || null; } function getCardOverlayLabel(card) { return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card); } 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); const imageSrc = resolveCardThumbnail(card); if (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); } else { const fallback = document.createElement("span"); fallback.className = "tarot-frame-card-fallback"; fallback.textContent = getDisplayCardName(card); button.appendChild(fallback); } if (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; } function createLegend() { const legendEl = document.createElement("div"); legendEl.className = "tarot-frame-legend"; BOARD_LAYOUTS.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); tarotFrameBoardEl.replaceChildren(); const panelEl = document.createElement("section"); panelEl.className = "tarot-frame-panel tarot-frame-panel--master"; 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"; titleEl.textContent = "Master 18x18 Frame Grid"; const subtitleEl = document.createElement("p"); subtitleEl.className = "tarot-frame-panel-subtitle"; subtitleEl.textContent = "Top row 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."; titleWrapEl.append(titleEl, subtitleEl); const countEl = document.createElement("span"); countEl.className = "tarot-frame-panel-count"; countEl.textContent = `${cards.length} cards / ${MASTER_GRID_SIZE * MASTER_GRID_SIZE} cells`; headEl.append(titleWrapEl, countEl); panelEl.append(headEl, createLegend()); 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))); } } panelEl.appendChild(gridEl); tarotFrameBoardEl.appendChild(panelEl); } function syncControls() { const { tarotFrameSettingsToggleEl, tarotFrameSettingsPanelEl, tarotFrameShowInfoEl, tarotFrameExportWebpEl, tarotFrameResetEl } = getElements(); 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; } if (tarotFrameShowInfoEl) { tarotFrameShowInfoEl.checked = Boolean(state.showInfo); tarotFrameShowInfoEl.disabled = Boolean(state.exportInProgress); } 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."; } } if (tarotFrameResetEl) { tarotFrameResetEl.disabled = Boolean(state.exportInProgress); } } 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) { if (!state.settingsOpen) { return; } const target = event.target; if (!(target instanceof Node)) { return; } const { tarotFrameSettingsPanelEl, tarotFrameSettingsToggleEl } = getElements(); if (tarotFrameSettingsPanelEl?.contains(target) || tarotFrameSettingsToggleEl?.contains(target)) { return; } state.settingsOpen = false; syncControls(); } function handleDocumentKeydown(event) { if (!state.settingsOpen || event.key !== "Escape") { return; } state.settingsOpen = false; 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); } function drawSlotToCanvas(context, x, y, size, card, image) { if (!card) { context.save(); context.setLineDash([6, 6]); context.lineWidth = 1.5; context.strokeStyle = "rgba(148, 163, 184, 0.42)"; drawRoundedRectPath(context, x + 1, y + 1, size - 2, size - 2, 10); context.stroke(); context.restore(); return; } const cardX = x + EXPORT_CARD_INSET; const cardY = y + EXPORT_CARD_INSET; const cardSize = size - (EXPORT_CARD_INSET * 2); context.save(); drawRoundedRectPath(context, cardX, cardY, cardSize, cardSize, 0); context.clip(); if (image) { drawImageContain(context, image, cardX, cardY, cardSize, cardSize); } else { context.fillStyle = EXPORT_PANEL; context.fillRect(cardX, cardY, cardSize, cardSize); context.fillStyle = "#f8fafc"; context.textAlign = "center"; context.textBaseline = "middle"; context.font = "700 14px 'Segoe UI', sans-serif"; const lines = wrapCanvasText(context, getDisplayCardName(card), cardSize - 18, 4); const lineHeight = 18; let currentY = cardY + (cardSize / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2); lines.forEach((line) => { context.fillText(line, cardX + (cardSize / 2), currentY, cardSize - 18); currentY += lineHeight; }); } context.restore(); if (state.showInfo) { const overlayText = getCardOverlayLabel(card); if (overlayText) { const overlayHeight = 30; const overlayX = cardX + 4; const overlayY = cardY + cardSize - overlayHeight - 4; const overlayWidth = cardSize - 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; const contentSize = (MASTER_GRID_SIZE * EXPORT_SLOT_SIZE) + ((MASTER_GRID_SIZE - 1) * EXPORT_GRID_GAP); const canvasSize = contentSize + (EXPORT_PADDING * 2); const scale = Math.max(1.5, Math.min(2, Number(window.devicePixelRatio) || 1)); const canvas = document.createElement("canvas"); canvas.width = Math.ceil(canvasSize * scale); canvas.height = Math.ceil(canvasSize * scale); canvas.style.width = `${canvasSize}px`; canvas.style.height = `${canvasSize}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, canvasSize, canvasSize); 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); const x = EXPORT_PADDING + ((column - 1) * (EXPORT_SLOT_SIZE + EXPORT_GRID_GAP)); const y = EXPORT_PADDING + ((row - 1) * (EXPORT_SLOT_SIZE + EXPORT_GRID_GAP)); drawSlotToCanvas(context, x, y, EXPORT_SLOT_SIZE, 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, tarotFrameResetEl, tarotFrameSettingsToggleEl, tarotFrameSettingsPanelEl, tarotFrameShowInfoEl, tarotFrameExportWebpEl } = getElements(); if (tarotFrameBoardEl) { tarotFrameBoardEl.addEventListener("pointerdown", handlePointerDown); tarotFrameBoardEl.addEventListener("click", handleBoardClick); tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart); } if (tarotFrameResetEl) { tarotFrameResetEl.addEventListener("click", () => { const cards = getCards(); if (!cards.length) { return; } resetLayout(cards, "Master grid reset to the default chronological frame arrangement."); render(); }); } if (tarotFrameSettingsToggleEl) { tarotFrameSettingsToggleEl.addEventListener("click", (event) => { event.stopPropagation(); if (state.exportInProgress) { return; } state.settingsOpen = !state.settingsOpen; syncControls(); }); } if (tarotFrameSettingsPanelEl) { tarotFrameSettingsPanelEl.addEventListener("click", (event) => { event.stopPropagation(); }); } if (tarotFrameShowInfoEl) { tarotFrameShowInfoEl.addEventListener("change", () => { state.showInfo = Boolean(tarotFrameShowInfoEl.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); } 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; resetLayout(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, exportImage, isExportFormatSupported }; })();