2115 lines
71 KiB
JavaScript
2115 lines
71 KiB
JavaScript
(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 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]
|
|
];
|
|
const HOUSE_TRUMP_GRID_ROWS = [1, 2, 3, 4, 5];
|
|
const HOUSE_BOTTOM_START_ROW = 8;
|
|
const HOUSE_LEFT_START_COLUMN = 2;
|
|
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"
|
|
};
|
|
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
|
|
}
|
|
};
|
|
const FRAME_LAYOUT_GROUPS = [
|
|
{
|
|
id: "extra-cards",
|
|
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.",
|
|
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.",
|
|
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.",
|
|
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");
|
|
});
|
|
}
|
|
}
|
|
];
|
|
|
|
const LAYOUT_PRESETS = [
|
|
{
|
|
id: "frames",
|
|
label: "Frames",
|
|
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.",
|
|
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",
|
|
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.",
|
|
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,
|
|
layoutMenuOpen: false,
|
|
currentLayoutId: "frames",
|
|
exportInProgress: false,
|
|
exportFormat: "webp",
|
|
gridZoomStepIndex: 0
|
|
};
|
|
|
|
let config = {
|
|
ensureTarotSection: null,
|
|
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"),
|
|
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"),
|
|
tarotFrameGridZoomEl: document.getElementById("tarot-frame-grid-zoom"),
|
|
tarotFrameShowInfoEl: document.getElementById("tarot-frame-show-info"),
|
|
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")
|
|
};
|
|
}
|
|
|
|
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) {
|
|
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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function toRomanNumeral(value) {
|
|
let remaining = Number(value);
|
|
if (!Number.isFinite(remaining) || remaining <= 0) {
|
|
return "";
|
|
}
|
|
|
|
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) {
|
|
if (!card) {
|
|
return true;
|
|
}
|
|
|
|
if (card.arcana === "Major") {
|
|
return config.getHouseTopCardsVisible?.() !== false;
|
|
}
|
|
|
|
return config.getHouseBottomCardsVisible?.() !== false;
|
|
}
|
|
|
|
function buildCardTextFaceModel(card) {
|
|
const label = state.showInfo ? buildHouseLabel(card) : null;
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function applyLayoutPreset(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") {
|
|
const layoutPreset = getLayoutPreset(layoutId);
|
|
state.currentLayoutId = layoutPreset.id;
|
|
state.slotAssignments.clear();
|
|
|
|
layoutPreset.buildPlacements(cards).forEach((placement) => {
|
|
state.slotAssignments.set(getSlotId(placement.row, placement.column), placement.cardId);
|
|
});
|
|
|
|
state.layoutReady = true;
|
|
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) {
|
|
if (!state.showInfo) {
|
|
return "";
|
|
}
|
|
|
|
const label = buildHouseLabel(card);
|
|
const structuredLabel = normalizeLabelText([label?.primary, label?.secondary].filter(Boolean).join(" · "));
|
|
if (structuredLabel) {
|
|
return structuredLabel;
|
|
}
|
|
|
|
return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card);
|
|
}
|
|
|
|
function centerGridViewport() {
|
|
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;
|
|
}
|
|
|
|
const overflowX = Math.max(0, gridTrackEl.offsetWidth - gridViewportEl.clientWidth);
|
|
gridViewportEl.scrollLeft = overflowX > 0 ? overflowX / 2 : 0;
|
|
|
|
requestAnimationFrame(() => {
|
|
if (!(gridViewportEl instanceof HTMLElement) || !(gridTrackEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const nextOverflowX = Math.max(0, gridTrackEl.offsetWidth - gridViewportEl.clientWidth);
|
|
gridViewportEl.scrollLeft = nextOverflowX > 0 ? nextOverflowX / 2 : 0;
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
const showImage = shouldShowCardImage(card);
|
|
|
|
const imageSrc = resolveCardThumbnail(card);
|
|
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);
|
|
} else if (showImage) {
|
|
const fallback = document.createElement("span");
|
|
fallback.className = "tarot-frame-card-fallback";
|
|
fallback.textContent = getDisplayCardName(card);
|
|
button.appendChild(fallback);
|
|
} else {
|
|
button.appendChild(createCardTextFaceElement(buildCardTextFaceModel(card)));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function createLegend(layoutPreset) {
|
|
const legendEl = document.createElement("div");
|
|
legendEl.className = "tarot-frame-legend";
|
|
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);
|
|
const layoutPreset = getLayoutPreset();
|
|
tarotFrameBoardEl.replaceChildren();
|
|
|
|
const panelEl = document.createElement("section");
|
|
panelEl.className = "tarot-frame-panel tarot-frame-panel--master";
|
|
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";
|
|
titleEl.textContent = layoutPreset.title;
|
|
const subtitleEl = document.createElement("p");
|
|
subtitleEl.className = "tarot-frame-panel-subtitle";
|
|
subtitleEl.textContent = layoutPreset.subtitle;
|
|
titleWrapEl.append(titleEl, subtitleEl);
|
|
|
|
const countEl = document.createElement("span");
|
|
countEl.className = "tarot-frame-panel-count";
|
|
countEl.textContent = buildPanelCountText(cards);
|
|
headEl.append(titleWrapEl, countEl);
|
|
|
|
panelEl.append(headEl, createLegend(layoutPreset));
|
|
|
|
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)));
|
|
}
|
|
}
|
|
|
|
gridTrackEl.appendChild(gridEl);
|
|
gridViewportEl.appendChild(gridTrackEl);
|
|
panelEl.appendChild(gridViewportEl);
|
|
tarotFrameBoardEl.appendChild(panelEl);
|
|
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 {
|
|
tarotFrameLayoutToggleEl,
|
|
tarotFrameLayoutPanelEl,
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameGridZoomEl,
|
|
tarotFrameShowInfoEl,
|
|
tarotFrameHouseSettingsEl,
|
|
tarotFrameHouseTopCardsVisibleEl,
|
|
tarotFrameHouseTopInfoHebrewEl,
|
|
tarotFrameHouseTopInfoPlanetEl,
|
|
tarotFrameHouseTopInfoZodiacEl,
|
|
tarotFrameHouseTopInfoTrumpEl,
|
|
tarotFrameHouseTopInfoPathEl,
|
|
tarotFrameHouseTopInfoDateEl,
|
|
tarotFrameHouseBottomCardsVisibleEl,
|
|
tarotFrameHouseBottomInfoZodiacEl,
|
|
tarotFrameHouseBottomInfoDecanEl,
|
|
tarotFrameHouseBottomInfoMonthEl,
|
|
tarotFrameHouseBottomInfoRulerEl,
|
|
tarotFrameHouseBottomInfoDateEl,
|
|
tarotFrameExportWebpEl,
|
|
} = getElements();
|
|
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;
|
|
}
|
|
|
|
if (tarotFrameGridZoomEl) {
|
|
tarotFrameGridZoomEl.value = String(state.gridZoomStepIndex);
|
|
tarotFrameGridZoomEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
if (tarotFrameShowInfoEl) {
|
|
tarotFrameShowInfoEl.checked = Boolean(state.showInfo);
|
|
tarotFrameShowInfoEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
if (tarotFrameHouseSettingsEl) {
|
|
tarotFrameHouseSettingsEl.hidden = false;
|
|
}
|
|
|
|
if (tarotFrameHouseTopCardsVisibleEl) {
|
|
tarotFrameHouseTopCardsVisibleEl.checked = config.getHouseTopCardsVisible?.() !== false;
|
|
tarotFrameHouseTopCardsVisibleEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
[
|
|
[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]);
|
|
checkbox.disabled = Boolean(state.exportInProgress);
|
|
});
|
|
|
|
if (tarotFrameHouseBottomCardsVisibleEl) {
|
|
tarotFrameHouseBottomCardsVisibleEl.checked = config.getHouseBottomCardsVisible?.() !== false;
|
|
tarotFrameHouseBottomCardsVisibleEl.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.";
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const {
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameLayoutPanelEl,
|
|
tarotFrameLayoutToggleEl
|
|
} = getElements();
|
|
|
|
let changed = false;
|
|
if (state.settingsOpen && !tarotFrameSettingsPanelEl?.contains(target) && !tarotFrameSettingsToggleEl?.contains(target)) {
|
|
state.settingsOpen = false;
|
|
changed = true;
|
|
}
|
|
|
|
if (state.layoutMenuOpen && !tarotFrameLayoutPanelEl?.contains(target) && !tarotFrameLayoutToggleEl?.contains(target)) {
|
|
state.layoutMenuOpen = false;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
syncControls();
|
|
}
|
|
}
|
|
|
|
function handleDocumentKeydown(event) {
|
|
if (event.key !== "Escape") {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function drawTextFaceToCanvas(context, x, y, width, height, faceModel) {
|
|
const primaryText = normalizeLabelText(faceModel?.primary || "Tarot");
|
|
const secondaryText = normalizeLabelText(faceModel?.secondary);
|
|
const maxWidth = width - 12;
|
|
|
|
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);
|
|
let currentY = y + ((height - totalHeight) / 2) + primaryLineHeight;
|
|
|
|
context.textAlign = "center";
|
|
context.textBaseline = "alphabetic";
|
|
primaryLines.forEach((line) => {
|
|
context.fillStyle = "#f8fafc";
|
|
context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`;
|
|
context.fillText(line, x + (width / 2), currentY, maxWidth);
|
|
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) => {
|
|
context.fillText(line, x + (width / 2), currentY, maxWidth);
|
|
currentY += secondaryLineHeight;
|
|
});
|
|
}
|
|
|
|
context.restore();
|
|
}
|
|
|
|
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)";
|
|
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;
|
|
const cardWidth = width - (EXPORT_CARD_INSET * 2);
|
|
const cardHeight = height - (EXPORT_CARD_INSET * 2);
|
|
const showImage = shouldShowCardImage(card);
|
|
|
|
context.save();
|
|
drawRoundedRectPath(context, cardX, cardY, cardWidth, cardHeight, 0);
|
|
context.clip();
|
|
if (showImage && image) {
|
|
drawImageContain(context, image, cardX, cardY, cardWidth, cardHeight);
|
|
} else if (showImage) {
|
|
context.fillStyle = EXPORT_PANEL;
|
|
context.fillRect(cardX, cardY, cardWidth, cardHeight);
|
|
context.fillStyle = "#f8fafc";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.font = "700 14px 'Segoe UI', sans-serif";
|
|
const lines = wrapCanvasText(context, getDisplayCardName(card), cardWidth - 18, 4);
|
|
const lineHeight = 18;
|
|
let currentY = cardY + (cardHeight / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2);
|
|
lines.forEach((line) => {
|
|
context.fillText(line, cardX + (cardWidth / 2), currentY, cardWidth - 18);
|
|
currentY += lineHeight;
|
|
});
|
|
} else {
|
|
context.fillStyle = EXPORT_PANEL;
|
|
context.fillRect(cardX, cardY, cardWidth, cardHeight);
|
|
drawTextFaceToCanvas(context, cardX, cardY, cardWidth, cardHeight, buildCardTextFaceModel(card));
|
|
}
|
|
context.restore();
|
|
|
|
if (showImage && state.showInfo) {
|
|
const overlayText = getCardOverlayLabel(card);
|
|
if (overlayText) {
|
|
const overlayHeight = 30;
|
|
const overlayX = cardX + 4;
|
|
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;
|
|
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");
|
|
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;
|
|
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);
|
|
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,
|
|
tarotFrameLayoutToggleEl,
|
|
tarotFrameLayoutPanelEl,
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameGridZoomEl,
|
|
tarotFrameShowInfoEl,
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
applyLayoutPreset(option.dataset.layoutPresetId, cards, `${getLayoutPreset(option.dataset.layoutPresetId).label} layout applied to the master grid.`);
|
|
state.layoutMenuOpen = false;
|
|
render();
|
|
syncControls();
|
|
});
|
|
}
|
|
|
|
if (tarotFrameSettingsToggleEl) {
|
|
tarotFrameSettingsToggleEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (state.exportInProgress) {
|
|
return;
|
|
}
|
|
state.settingsOpen = !state.settingsOpen;
|
|
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();
|
|
});
|
|
}
|
|
|
|
if (tarotFrameGridZoomEl) {
|
|
tarotFrameGridZoomEl.addEventListener("change", () => {
|
|
setGridZoomStepIndex(tarotFrameGridZoomEl.value);
|
|
});
|
|
}
|
|
|
|
[
|
|
[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);
|
|
}
|
|
|
|
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;
|
|
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,
|
|
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
|
|
};
|
|
})(); |