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

1223 lines
38 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"];
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
};
})();