4143 lines
137 KiB
JavaScript
4143 lines
137 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, 2.4, 3, 3.6, 4.2];
|
|
const FRAME_GRID_MIN_SCALE = 0.8;
|
|
const FRAME_GRID_MAX_SCALE = 5.2;
|
|
const FRAME_CUSTOM_LAYOUTS_STORAGE_KEY = "tarot-frame-custom-layouts-v1";
|
|
const FRAME_ACTIVE_LAYOUT_STORAGE_KEY = "tarot-frame-active-layout-v1";
|
|
const FRAME_CARD_PICKER_QUERY_STORAGE_KEY = "tarot-frame-card-picker-query-v1";
|
|
const FRAME_LAYOUT_NOTES_STORAGE_KEY = "tarot-frame-layout-notes-v1";
|
|
const HOUSE_TOP_INFO_MODE_IDS = ["hebrew", "planet", "zodiac", "trump", "path", "date"];
|
|
const HOUSE_BOTTOM_INFO_MODE_IDS = ["zodiac", "decan", "month", "ruler", "date"];
|
|
const FRAME_LONG_PRESS_DELAY_MS = 460;
|
|
const FRAME_LONG_PRESS_MOVE_TOLERANCE = 10;
|
|
const FRAME_TOUCH_DRAG_ACTIVATION_DELAY_MS = 140;
|
|
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,
|
|
panMode: false,
|
|
panGesture: null,
|
|
pinchGesture: null,
|
|
longPress: null,
|
|
cardPicker: {
|
|
open: false,
|
|
slotId: "",
|
|
query: ""
|
|
},
|
|
suppressClick: false,
|
|
showInfo: true,
|
|
settingsOpen: false,
|
|
layoutMenuOpen: false,
|
|
gridFocusMode: false,
|
|
currentLayoutId: "frames",
|
|
customLayouts: [],
|
|
layoutNotesById: {},
|
|
exportInProgress: false,
|
|
exportFormat: "webp",
|
|
gridZoomStepIndex: 0,
|
|
gridZoomScale: FRAME_GRID_ZOOM_STEPS[0]
|
|
};
|
|
|
|
let config = {
|
|
ensureTarotSection: null,
|
|
getCards: () => [],
|
|
openCardLightbox: () => {},
|
|
getHouseTopCardsVisible: () => true,
|
|
getHouseTopInfoModes: () => ({}),
|
|
getHouseBottomCardsVisible: () => true,
|
|
getHouseBottomInfoModes: () => ({}),
|
|
setHouseTopCardsVisible: () => {},
|
|
setHouseTopInfoMode: () => {},
|
|
setHouseBottomCardsVisible: () => {},
|
|
setHouseBottomInfoMode: () => {}
|
|
};
|
|
|
|
let cardPickerEl = null;
|
|
let cardPickerTitleEl = null;
|
|
let cardPickerSearchEl = null;
|
|
let cardPickerSectionsEl = null;
|
|
let pendingGridViewportRestoreFrameId = 0;
|
|
let activeTouchGestureCapture = false;
|
|
|
|
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 {
|
|
tarotFrameSectionEl: document.getElementById("tarot-frame-section"),
|
|
tarotFrameViewEl: document.getElementById("tarot-frame-view"),
|
|
tarotFrameBoardEl: document.getElementById("tarot-frame-board"),
|
|
tarotFrameStatusEl: document.getElementById("tarot-frame-status"),
|
|
tarotFrameOverviewEl: document.getElementById("tarot-frame-overview"),
|
|
tarotFramePanToggleEl: document.getElementById("tarot-frame-pan-toggle"),
|
|
tarotFrameFocusToggleEl: document.getElementById("tarot-frame-focus-toggle"),
|
|
tarotFrameFocusExitEl: document.getElementById("tarot-frame-focus-exit"),
|
|
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"),
|
|
tarotFrameClearGridEl: document.getElementById("tarot-frame-clear-grid"),
|
|
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 cards freely and use Settings to change the grid zoom for any layout.`;
|
|
}
|
|
|
|
function clampFrameGridZoomScale(value) {
|
|
const numericValue = Number(value);
|
|
if (!Number.isFinite(numericValue)) {
|
|
return FRAME_GRID_ZOOM_STEPS[0];
|
|
}
|
|
|
|
return Math.min(FRAME_GRID_MAX_SCALE, Math.max(FRAME_GRID_MIN_SCALE, numericValue));
|
|
}
|
|
|
|
function getNearestFrameZoomStepIndex(scale) {
|
|
const safeScale = clampFrameGridZoomScale(scale);
|
|
let bestIndex = 0;
|
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
|
|
FRAME_GRID_ZOOM_STEPS.forEach((step, index) => {
|
|
const distance = Math.abs(step - safeScale);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
bestIndex = index;
|
|
}
|
|
});
|
|
|
|
return bestIndex;
|
|
}
|
|
|
|
function getGridZoomScale() {
|
|
return clampFrameGridZoomScale(state.gridZoomScale);
|
|
}
|
|
|
|
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 readStorageValue(key) {
|
|
try {
|
|
return String(window.localStorage?.getItem?.(key) || "");
|
|
} catch (_error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function writeStorageValue(key, value) {
|
|
try {
|
|
window.localStorage?.setItem?.(key, value);
|
|
return true;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function removeStorageValue(key) {
|
|
try {
|
|
window.localStorage?.removeItem?.(key);
|
|
return true;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizeLayoutLabel(value) {
|
|
return String(value || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 64);
|
|
}
|
|
|
|
function normalizeLayoutNote(value) {
|
|
return String(value || "")
|
|
.replace(/\r\n?/g, "\n")
|
|
.replace(/\u0000/g, "")
|
|
.trim()
|
|
.slice(0, 1600);
|
|
}
|
|
|
|
function createSavedLayoutId() {
|
|
return `saved-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function isValidSlotId(value) {
|
|
const match = String(value || "").trim().match(/^(\d+):(\d+)$/);
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
|
|
const row = Number(match[1]);
|
|
const column = Number(match[2]);
|
|
return Number.isInteger(row)
|
|
&& Number.isInteger(column)
|
|
&& row >= 1
|
|
&& row <= MASTER_GRID_SIZE
|
|
&& column >= 1
|
|
&& column <= MASTER_GRID_SIZE;
|
|
}
|
|
|
|
function buildFrameSettingsSnapshot() {
|
|
const topModes = config.getHouseTopInfoModes?.() || {};
|
|
const bottomModes = config.getHouseBottomInfoModes?.() || {};
|
|
|
|
return {
|
|
showInfo: Boolean(state.showInfo),
|
|
gridZoomScale: getGridZoomScale(),
|
|
gridZoomStepIndex: state.gridZoomStepIndex,
|
|
houseTopCardsVisible: config.getHouseTopCardsVisible?.() !== false,
|
|
houseBottomCardsVisible: config.getHouseBottomCardsVisible?.() !== false,
|
|
houseTopInfoModes: HOUSE_TOP_INFO_MODE_IDS.reduce((result, mode) => {
|
|
result[mode] = Boolean(topModes[mode]);
|
|
return result;
|
|
}, {}),
|
|
houseBottomInfoModes: HOUSE_BOTTOM_INFO_MODE_IDS.reduce((result, mode) => {
|
|
result[mode] = Boolean(bottomModes[mode]);
|
|
return result;
|
|
}, {})
|
|
};
|
|
}
|
|
|
|
function normalizeFrameSettingsSnapshot(rawSettings) {
|
|
const fallback = buildFrameSettingsSnapshot();
|
|
const raw = rawSettings && typeof rawSettings === "object" ? rawSettings : {};
|
|
const rawZoomIndex = Number(raw.gridZoomStepIndex);
|
|
const rawZoomScale = Number(raw.gridZoomScale);
|
|
const normalizedZoomScale = Number.isFinite(rawZoomScale)
|
|
? clampFrameGridZoomScale(rawZoomScale)
|
|
: (Number.isFinite(rawZoomIndex)
|
|
? clampFrameGridZoomScale(FRAME_GRID_ZOOM_STEPS[Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, rawZoomIndex))])
|
|
: fallback.gridZoomScale);
|
|
return {
|
|
showInfo: raw.showInfo === undefined ? fallback.showInfo : Boolean(raw.showInfo),
|
|
gridZoomScale: normalizedZoomScale,
|
|
gridZoomStepIndex: Number.isFinite(rawZoomIndex)
|
|
? Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, rawZoomIndex))
|
|
: getNearestFrameZoomStepIndex(normalizedZoomScale),
|
|
houseTopCardsVisible: raw.houseTopCardsVisible === undefined ? fallback.houseTopCardsVisible : Boolean(raw.houseTopCardsVisible),
|
|
houseBottomCardsVisible: raw.houseBottomCardsVisible === undefined ? fallback.houseBottomCardsVisible : Boolean(raw.houseBottomCardsVisible),
|
|
houseTopInfoModes: HOUSE_TOP_INFO_MODE_IDS.reduce((result, mode) => {
|
|
result[mode] = raw.houseTopInfoModes?.[mode] === undefined
|
|
? fallback.houseTopInfoModes[mode]
|
|
: Boolean(raw.houseTopInfoModes[mode]);
|
|
return result;
|
|
}, {}),
|
|
houseBottomInfoModes: HOUSE_BOTTOM_INFO_MODE_IDS.reduce((result, mode) => {
|
|
result[mode] = raw.houseBottomInfoModes?.[mode] === undefined
|
|
? fallback.houseBottomInfoModes[mode]
|
|
: Boolean(raw.houseBottomInfoModes[mode]);
|
|
return result;
|
|
}, {})
|
|
};
|
|
}
|
|
|
|
function captureSlotAssignmentsSnapshot(cards = getCards()) {
|
|
const validCardIds = new Set(cards.map((card) => getCardId(card)).filter(Boolean));
|
|
return [...state.slotAssignments.entries()]
|
|
.map(([slotId, cardId]) => ({
|
|
slotId: String(slotId || "").trim(),
|
|
cardId: String(cardId || "").trim()
|
|
}))
|
|
.filter((entry) => isValidSlotId(entry.slotId) && validCardIds.has(entry.cardId))
|
|
.sort((left, right) => {
|
|
const [leftRow, leftColumn] = left.slotId.split(":").map(Number);
|
|
const [rightRow, rightColumn] = right.slotId.split(":").map(Number);
|
|
if (leftRow !== rightRow) {
|
|
return leftRow - rightRow;
|
|
}
|
|
return leftColumn - rightColumn;
|
|
});
|
|
}
|
|
|
|
function normalizeSavedLayoutRecord(rawLayout) {
|
|
const label = normalizeLayoutLabel(rawLayout?.label || rawLayout?.name);
|
|
if (!label) {
|
|
return null;
|
|
}
|
|
|
|
const id = String(rawLayout?.id || "").trim() || createSavedLayoutId();
|
|
const slotAssignments = Array.isArray(rawLayout?.slotAssignments)
|
|
? rawLayout.slotAssignments
|
|
.map((entry) => ({
|
|
slotId: String(entry?.slotId || "").trim(),
|
|
cardId: String(entry?.cardId || "").trim()
|
|
}))
|
|
.filter((entry) => isValidSlotId(entry.slotId) && entry.cardId)
|
|
: [];
|
|
const settings = normalizeFrameSettingsSnapshot(rawLayout?.settings);
|
|
const createdAt = String(rawLayout?.createdAt || rawLayout?.updatedAt || "").trim();
|
|
const note = normalizeLayoutNote(rawLayout?.note);
|
|
|
|
return {
|
|
id,
|
|
label,
|
|
title: label,
|
|
subtitle: "Saved browser layout with custom card positions, frame display settings, and optional notes.",
|
|
statusMessage: `${label} layout loaded from browser storage.`,
|
|
legendItems: [
|
|
{
|
|
title: "Saved Layout",
|
|
description: "Custom card positions, frame settings, and notes stored only in this browser."
|
|
}
|
|
],
|
|
slotAssignments,
|
|
settings,
|
|
note,
|
|
createdAt,
|
|
isCustom: true
|
|
};
|
|
}
|
|
|
|
function loadLayoutNotesFromStorage() {
|
|
const rawValue = readStorageValue(FRAME_LAYOUT_NOTES_STORAGE_KEY);
|
|
if (!rawValue) {
|
|
state.layoutNotesById = {};
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawValue);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
state.layoutNotesById = {};
|
|
return;
|
|
}
|
|
|
|
state.layoutNotesById = Object.entries(parsed).reduce((result, [layoutId, note]) => {
|
|
const normalizedId = String(layoutId || "").trim();
|
|
const normalizedNote = normalizeLayoutNote(note);
|
|
if (normalizedId && normalizedNote) {
|
|
result[normalizedId] = normalizedNote;
|
|
}
|
|
return result;
|
|
}, {});
|
|
} catch (_error) {
|
|
state.layoutNotesById = {};
|
|
}
|
|
}
|
|
|
|
function loadSavedLayoutsFromStorage() {
|
|
const rawValue = readStorageValue(FRAME_CUSTOM_LAYOUTS_STORAGE_KEY);
|
|
if (!rawValue) {
|
|
state.customLayouts = [];
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawValue);
|
|
state.customLayouts = Array.isArray(parsed)
|
|
? parsed.map((entry) => normalizeSavedLayoutRecord(entry)).filter(Boolean)
|
|
: [];
|
|
} catch (_error) {
|
|
state.customLayouts = [];
|
|
}
|
|
}
|
|
|
|
function persistSavedLayouts() {
|
|
return writeStorageValue(FRAME_CUSTOM_LAYOUTS_STORAGE_KEY, JSON.stringify(state.customLayouts.map((layout) => ({
|
|
id: layout.id,
|
|
label: layout.label,
|
|
slotAssignments: layout.slotAssignments,
|
|
settings: layout.settings,
|
|
note: normalizeLayoutNote(layout.note),
|
|
createdAt: layout.createdAt || new Date().toISOString()
|
|
}))));
|
|
}
|
|
|
|
function persistLayoutNotes() {
|
|
const entries = Object.entries(state.layoutNotesById || {}).reduce((result, [layoutId, note]) => {
|
|
const normalizedId = String(layoutId || "").trim();
|
|
const normalizedNote = normalizeLayoutNote(note);
|
|
if (normalizedId && normalizedNote) {
|
|
result[normalizedId] = normalizedNote;
|
|
}
|
|
return result;
|
|
}, {});
|
|
|
|
if (!Object.keys(entries).length) {
|
|
removeStorageValue(FRAME_LAYOUT_NOTES_STORAGE_KEY);
|
|
return true;
|
|
}
|
|
|
|
return writeStorageValue(FRAME_LAYOUT_NOTES_STORAGE_KEY, JSON.stringify(entries));
|
|
}
|
|
|
|
function persistActiveLayoutId(layoutId = state.currentLayoutId) {
|
|
const nextId = String(layoutId || "").trim();
|
|
if (!nextId) {
|
|
removeStorageValue(FRAME_ACTIVE_LAYOUT_STORAGE_KEY);
|
|
return;
|
|
}
|
|
writeStorageValue(FRAME_ACTIVE_LAYOUT_STORAGE_KEY, nextId);
|
|
}
|
|
|
|
function restoreActiveLayoutId() {
|
|
const storedId = String(readStorageValue(FRAME_ACTIVE_LAYOUT_STORAGE_KEY) || "").trim();
|
|
if (!storedId) {
|
|
return;
|
|
}
|
|
|
|
const nextLayout = getLayoutDefinition(storedId);
|
|
state.currentLayoutId = nextLayout.id;
|
|
}
|
|
|
|
function persistCardPickerQuery(query = state.cardPicker.query) {
|
|
writeStorageValue(FRAME_CARD_PICKER_QUERY_STORAGE_KEY, String(query || ""));
|
|
}
|
|
|
|
function restoreCardPickerQuery() {
|
|
state.cardPicker.query = String(readStorageValue(FRAME_CARD_PICKER_QUERY_STORAGE_KEY) || "");
|
|
}
|
|
|
|
function getLayoutNote(layoutId = state.currentLayoutId) {
|
|
const nextLayoutId = String(layoutId || "").trim();
|
|
if (!nextLayoutId) {
|
|
return "";
|
|
}
|
|
|
|
const storedNote = normalizeLayoutNote(state.layoutNotesById?.[nextLayoutId]);
|
|
if (storedNote) {
|
|
return storedNote;
|
|
}
|
|
|
|
return normalizeLayoutNote(getSavedLayout(nextLayoutId)?.note);
|
|
}
|
|
|
|
function setLayoutNote(layoutId, note, options = {}) {
|
|
const nextLayoutId = String(layoutId || "").trim();
|
|
if (!nextLayoutId) {
|
|
return;
|
|
}
|
|
|
|
const normalizedNote = normalizeLayoutNote(note);
|
|
if (normalizedNote) {
|
|
state.layoutNotesById[nextLayoutId] = normalizedNote;
|
|
} else {
|
|
delete state.layoutNotesById[nextLayoutId];
|
|
}
|
|
|
|
const savedLayout = getSavedLayout(nextLayoutId);
|
|
if (savedLayout) {
|
|
savedLayout.note = normalizedNote;
|
|
persistSavedLayouts();
|
|
}
|
|
|
|
persistLayoutNotes();
|
|
|
|
if (options.updateUi !== false) {
|
|
updateLayoutNotesUi();
|
|
}
|
|
}
|
|
|
|
function getLayoutNotePlaceholder(layoutDefinition = getLayoutDefinition()) {
|
|
if (layoutDefinition?.id === "house") {
|
|
return "Add placement notes, reading rules, or reminders for this House of Cards arrangement.";
|
|
}
|
|
return "Add your own notes for this layout: intentions, spread logic, card placement rules, or reminders.";
|
|
}
|
|
|
|
function updateLayoutNotesUi() {
|
|
const { tarotFrameOverviewEl } = getElements();
|
|
if (!(tarotFrameOverviewEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const currentNote = getLayoutNote();
|
|
|
|
const noteFieldEl = tarotFrameOverviewEl.querySelector("#tarot-frame-layout-note");
|
|
if (noteFieldEl instanceof HTMLTextAreaElement) {
|
|
noteFieldEl.value = currentNote;
|
|
noteFieldEl.placeholder = getLayoutNotePlaceholder(getLayoutDefinition());
|
|
noteFieldEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
const noteClearEl = tarotFrameOverviewEl.querySelector("[data-frame-note-clear='true']");
|
|
if (noteClearEl instanceof HTMLButtonElement) {
|
|
noteClearEl.disabled = !currentNote || Boolean(state.exportInProgress);
|
|
}
|
|
|
|
const noteBadgeEl = tarotFrameOverviewEl.querySelector(".tarot-frame-notes-badge");
|
|
if (noteBadgeEl instanceof HTMLElement) {
|
|
noteBadgeEl.textContent = currentNote ? "Saved" : "Optional";
|
|
}
|
|
}
|
|
|
|
function applyGridFocusModeUi() {
|
|
const {
|
|
tarotFrameSectionEl,
|
|
tarotFrameFocusToggleEl,
|
|
tarotFrameFocusExitEl
|
|
} = getElements();
|
|
|
|
if (tarotFrameSectionEl instanceof HTMLElement) {
|
|
tarotFrameSectionEl.classList.toggle("is-grid-focus", Boolean(state.gridFocusMode));
|
|
}
|
|
|
|
document.body.classList.toggle("is-tarot-frame-focus-lock", Boolean(state.gridFocusMode));
|
|
|
|
if (tarotFrameFocusToggleEl) {
|
|
tarotFrameFocusToggleEl.setAttribute("aria-pressed", state.gridFocusMode ? "true" : "false");
|
|
tarotFrameFocusToggleEl.classList.toggle("is-active", Boolean(state.gridFocusMode));
|
|
tarotFrameFocusToggleEl.textContent = state.gridFocusMode ? "Exit Full Screen" : "Full Screen";
|
|
tarotFrameFocusToggleEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
if (tarotFrameFocusExitEl) {
|
|
tarotFrameFocusExitEl.hidden = !state.gridFocusMode;
|
|
tarotFrameFocusExitEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
}
|
|
|
|
function setGridFocusMode(nextFocus) {
|
|
const shouldFocus = Boolean(nextFocus);
|
|
if (state.gridFocusMode === shouldFocus) {
|
|
applyGridFocusModeUi();
|
|
return;
|
|
}
|
|
|
|
state.gridFocusMode = shouldFocus;
|
|
state.settingsOpen = false;
|
|
state.layoutMenuOpen = false;
|
|
finishPanGesture();
|
|
clearLongPressGesture();
|
|
if (state.cardPicker.open) {
|
|
closeCardPicker();
|
|
}
|
|
cleanupDrag();
|
|
syncControls();
|
|
updateViewportInteractionState();
|
|
setStatus(shouldFocus
|
|
? "Full-screen frame mode enabled. Tap outside the board or press Escape to exit."
|
|
: "Full-screen frame mode closed.");
|
|
}
|
|
|
|
function applyFrameSettingsSnapshot(rawSettings) {
|
|
const settings = normalizeFrameSettingsSnapshot(rawSettings);
|
|
state.showInfo = settings.showInfo;
|
|
state.gridZoomScale = settings.gridZoomScale;
|
|
state.gridZoomStepIndex = settings.gridZoomStepIndex;
|
|
config.setHouseTopCardsVisible?.(settings.houseTopCardsVisible);
|
|
config.setHouseBottomCardsVisible?.(settings.houseBottomCardsVisible);
|
|
HOUSE_TOP_INFO_MODE_IDS.forEach((mode) => {
|
|
config.setHouseTopInfoMode?.(mode, settings.houseTopInfoModes[mode]);
|
|
});
|
|
HOUSE_BOTTOM_INFO_MODE_IDS.forEach((mode) => {
|
|
config.setHouseBottomInfoMode?.(mode, settings.houseBottomInfoModes[mode]);
|
|
});
|
|
}
|
|
|
|
function getSuitSortIndex(suit) {
|
|
const suitIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(suit));
|
|
return suitIndex === -1 ? EXTRA_SUIT_ORDER.length : suitIndex;
|
|
}
|
|
|
|
function getMinorRankSortIndex(rank) {
|
|
const rankName = String(rank || "").trim();
|
|
const lookup = {
|
|
Two: 2,
|
|
Three: 3,
|
|
Four: 4,
|
|
Five: 5,
|
|
Six: 6,
|
|
Seven: 7,
|
|
Eight: 8,
|
|
Nine: 9,
|
|
Ten: 10
|
|
};
|
|
return lookup[rankName] || 999;
|
|
}
|
|
|
|
function getCourtRankSortIndex(rank) {
|
|
const lookup = {
|
|
Knight: 0,
|
|
Queen: 1,
|
|
Prince: 2,
|
|
Princess: 3
|
|
};
|
|
return lookup[String(rank || "").trim()] ?? 999;
|
|
}
|
|
|
|
function getGridViewportElement() {
|
|
const { tarotFrameBoardEl } = getElements();
|
|
const viewportEl = tarotFrameBoardEl?.querySelector(".tarot-frame-grid-viewport");
|
|
return viewportEl instanceof HTMLElement ? viewportEl : null;
|
|
}
|
|
|
|
function isLayoutNoteTextarea(element) {
|
|
return element instanceof HTMLTextAreaElement && element.id === "tarot-frame-layout-note";
|
|
}
|
|
|
|
function blurLayoutNoteForBoardInteraction(target) {
|
|
const activeElement = document.activeElement;
|
|
if (!isLayoutNoteTextarea(activeElement)) {
|
|
return;
|
|
}
|
|
|
|
if (target instanceof Node && target === activeElement) {
|
|
return;
|
|
}
|
|
|
|
activeElement.blur();
|
|
}
|
|
|
|
function updateViewportInteractionState() {
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
viewportEl.classList.toggle("is-pan-enabled", Boolean(state.panMode));
|
|
viewportEl.classList.toggle("is-panning", Boolean(state.panGesture));
|
|
viewportEl.classList.toggle(
|
|
"is-touch-gesture-active",
|
|
Boolean(state.pinchGesture || (state.panGesture && state.panGesture.source === "touch"))
|
|
);
|
|
}
|
|
|
|
function syncActiveTouchGestureCapture() {
|
|
const shouldCapture = Boolean(state.pinchGesture || (state.panGesture && state.panGesture.source === "touch"));
|
|
if (shouldCapture === activeTouchGestureCapture) {
|
|
return;
|
|
}
|
|
|
|
activeTouchGestureCapture = shouldCapture;
|
|
const method = shouldCapture ? "addEventListener" : "removeEventListener";
|
|
document[method]("touchmove", handleBoardTouchMove, { passive: false });
|
|
document[method]("touchend", handleBoardTouchEnd, { passive: false });
|
|
document[method]("touchcancel", handleBoardTouchCancel, { passive: false });
|
|
}
|
|
|
|
function createCardPickerElements() {
|
|
if (cardPickerEl) {
|
|
return;
|
|
}
|
|
|
|
cardPickerEl = document.createElement("div");
|
|
cardPickerEl.className = "tarot-frame-card-picker";
|
|
cardPickerEl.hidden = true;
|
|
|
|
const headEl = document.createElement("div");
|
|
headEl.className = "tarot-frame-card-picker-head";
|
|
|
|
cardPickerTitleEl = document.createElement("div");
|
|
cardPickerTitleEl.className = "tarot-frame-card-picker-title";
|
|
cardPickerTitleEl.textContent = "Place Tarot Card";
|
|
|
|
const closeButtonEl = document.createElement("button");
|
|
closeButtonEl.type = "button";
|
|
closeButtonEl.className = "tarot-frame-card-picker-close";
|
|
closeButtonEl.textContent = "Close";
|
|
closeButtonEl.addEventListener("click", () => {
|
|
closeCardPicker();
|
|
});
|
|
|
|
headEl.append(cardPickerTitleEl, closeButtonEl);
|
|
|
|
const searchWrapEl = document.createElement("label");
|
|
searchWrapEl.className = "tarot-frame-card-picker-search";
|
|
const searchLabelEl = document.createElement("span");
|
|
searchLabelEl.textContent = "Search Cards & Associations";
|
|
cardPickerSearchEl = document.createElement("input");
|
|
cardPickerSearchEl.type = "search";
|
|
cardPickerSearchEl.placeholder = "Find by card, planet, sign, decan...";
|
|
cardPickerSearchEl.autocomplete = "off";
|
|
cardPickerSearchEl.spellcheck = false;
|
|
cardPickerSearchEl.addEventListener("input", () => {
|
|
state.cardPicker.query = String(cardPickerSearchEl.value || "");
|
|
persistCardPickerQuery();
|
|
renderCardPickerSections();
|
|
});
|
|
searchWrapEl.append(searchLabelEl, cardPickerSearchEl);
|
|
|
|
cardPickerSectionsEl = document.createElement("div");
|
|
cardPickerSectionsEl.className = "tarot-frame-card-picker-sections";
|
|
|
|
cardPickerEl.append(headEl, searchWrapEl, cardPickerSectionsEl);
|
|
cardPickerEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const option = target.closest(".tarot-frame-card-picker-option[data-card-id]");
|
|
if (!(option instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
placeCardInSlot(state.cardPicker.slotId, option.dataset.cardId);
|
|
closeCardPicker();
|
|
});
|
|
document.body.appendChild(cardPickerEl);
|
|
}
|
|
|
|
function closeCardPicker() {
|
|
state.cardPicker.open = false;
|
|
state.cardPicker.slotId = "";
|
|
if (cardPickerSearchEl) {
|
|
cardPickerSearchEl.value = state.cardPicker.query;
|
|
}
|
|
if (cardPickerEl) {
|
|
cardPickerEl.hidden = true;
|
|
}
|
|
}
|
|
|
|
function appendCardPickerSearchValue(terms, value) {
|
|
const normalizedValue = normalizeLabelText(value);
|
|
if (!normalizedValue) {
|
|
return;
|
|
}
|
|
|
|
terms.add(normalizeKey(normalizedValue));
|
|
}
|
|
|
|
function appendCardPickerSearchValuesFromObject(terms, value, depth = 0) {
|
|
if (depth > 4 || value === null || value === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((entry) => {
|
|
appendCardPickerSearchValuesFromObject(terms, entry, depth + 1);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (typeof value === "object") {
|
|
Object.values(value).forEach((entry) => {
|
|
appendCardPickerSearchValuesFromObject(terms, entry, depth + 1);
|
|
});
|
|
return;
|
|
}
|
|
|
|
appendCardPickerSearchValue(terms, value);
|
|
}
|
|
|
|
function buildCardPickerSearchText(card) {
|
|
const terms = new Set();
|
|
|
|
[
|
|
getDisplayCardName(card),
|
|
card?.name,
|
|
card?.arcana,
|
|
card?.rank,
|
|
card?.suit,
|
|
card?.number,
|
|
card?.summary,
|
|
card?.hebrewLetterId,
|
|
card?.kabbalahPathNumber,
|
|
buildHebrewLabel(card)?.primary,
|
|
buildHebrewLabel(card)?.secondary,
|
|
buildPlanetLabel(card)?.primary,
|
|
buildMajorZodiacLabel(card)?.primary,
|
|
buildTrumpNumberLabel(card)?.primary,
|
|
buildPathNumberLabel(card)?.primary,
|
|
buildZodiacLabel(card)?.primary,
|
|
buildZodiacLabel(card)?.secondary,
|
|
buildDecanLabel(card)?.primary,
|
|
buildDecanLabel(card)?.secondary,
|
|
buildDateLabel(card)?.primary,
|
|
buildDateLabel(card)?.secondary,
|
|
buildMonthLabel(card)?.primary,
|
|
buildRulerLabel(card)?.primary,
|
|
getCardOverlayDate(card)
|
|
].forEach((value) => {
|
|
appendCardPickerSearchValue(terms, value);
|
|
});
|
|
|
|
appendCardPickerSearchValuesFromObject(terms, card?.relations || []);
|
|
|
|
return Array.from(terms).join(" ");
|
|
}
|
|
|
|
function buildCardPickerSections() {
|
|
const cards = getCards();
|
|
const queryTerms = normalizeKey(state.cardPicker.query).split(/\s+/).filter(Boolean);
|
|
const matchesQuery = (card) => {
|
|
if (!queryTerms.length) {
|
|
return true;
|
|
}
|
|
|
|
const haystack = buildCardPickerSearchText(card);
|
|
return queryTerms.every((term) => haystack.includes(term));
|
|
};
|
|
|
|
const majorCards = cards
|
|
.filter((card) => card?.arcana === "Major" && matchesQuery(card))
|
|
.sort((left, right) => Number(left?.number) - Number(right?.number));
|
|
|
|
const minorSuitGroups = EXTRA_SUIT_ORDER.map((suitId) => {
|
|
const items = cards
|
|
.filter((card) => card?.arcana === "Minor"
|
|
&& MINOR_RANKS.has(String(card?.rank || ""))
|
|
&& normalizeKey(card?.suit) === suitId
|
|
&& matchesQuery(card))
|
|
.sort((left, right) => getMinorRankSortIndex(left?.rank) - getMinorRankSortIndex(right?.rank));
|
|
return {
|
|
title: normalizeLabelText(items[0]?.suit || suitId),
|
|
items
|
|
};
|
|
}).filter((group) => group.items.length);
|
|
|
|
const courtSuitGroups = EXTRA_SUIT_ORDER.map((suitId) => {
|
|
const items = cards
|
|
.filter((card) => card?.arcana === "Minor"
|
|
&& !MINOR_RANKS.has(String(card?.rank || ""))
|
|
&& String(card?.rank || "").trim() !== "Ace"
|
|
&& normalizeKey(card?.suit) === suitId
|
|
&& matchesQuery(card))
|
|
.sort((left, right) => getCourtRankSortIndex(left?.rank) - getCourtRankSortIndex(right?.rank));
|
|
return {
|
|
title: normalizeLabelText(items[0]?.suit || suitId),
|
|
items
|
|
};
|
|
}).filter((group) => group.items.length);
|
|
|
|
const aceCards = cards
|
|
.filter((card) => card?.arcana === "Minor" && String(card?.rank || "").trim() === "Ace" && matchesQuery(card))
|
|
.sort((left, right) => getSuitSortIndex(left?.suit) - getSuitSortIndex(right?.suit));
|
|
|
|
return [
|
|
{ title: "Major Arcana", groups: [{ title: "", items: majorCards }] },
|
|
{ title: "Minor Arcana", groups: minorSuitGroups },
|
|
{ title: "Court Cards", groups: courtSuitGroups },
|
|
{ title: "Aces", groups: [{ title: "", items: aceCards }] }
|
|
].filter((section) => section.groups.some((group) => group.items.length));
|
|
}
|
|
|
|
function renderCardPickerSections() {
|
|
if (!(cardPickerSectionsEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
cardPickerSectionsEl.replaceChildren();
|
|
const sections = buildCardPickerSections();
|
|
if (!sections.length) {
|
|
const emptyEl = document.createElement("div");
|
|
emptyEl.className = "tarot-frame-card-picker-empty";
|
|
emptyEl.textContent = "No tarot cards match that search.";
|
|
cardPickerSectionsEl.appendChild(emptyEl);
|
|
return;
|
|
}
|
|
|
|
sections.forEach((section) => {
|
|
const sectionEl = document.createElement("section");
|
|
sectionEl.className = "tarot-frame-card-picker-section";
|
|
|
|
const sectionTitleEl = document.createElement("h4");
|
|
sectionTitleEl.className = "tarot-frame-card-picker-section-title";
|
|
sectionTitleEl.textContent = section.title;
|
|
sectionEl.appendChild(sectionTitleEl);
|
|
|
|
section.groups.forEach((group) => {
|
|
if (!group.items.length) {
|
|
return;
|
|
}
|
|
|
|
if (group.title) {
|
|
const groupTitleEl = document.createElement("div");
|
|
groupTitleEl.className = "tarot-frame-card-picker-subtitle";
|
|
groupTitleEl.textContent = group.title;
|
|
sectionEl.appendChild(groupTitleEl);
|
|
}
|
|
|
|
const gridEl = document.createElement("div");
|
|
gridEl.className = "tarot-frame-card-picker-grid";
|
|
|
|
group.items.forEach((card) => {
|
|
const buttonEl = document.createElement("button");
|
|
buttonEl.type = "button";
|
|
buttonEl.className = "tarot-frame-card-picker-option";
|
|
buttonEl.dataset.cardId = getCardId(card);
|
|
|
|
const titleEl = document.createElement("strong");
|
|
titleEl.textContent = getDisplayCardName(card);
|
|
const metaEl = document.createElement("span");
|
|
metaEl.textContent = card?.arcana === "Major"
|
|
? `Trump ${Number.isFinite(Number(card?.number)) ? Number(card.number) : ""}`.trim()
|
|
: [normalizeLabelText(card?.rank), normalizeLabelText(card?.suit)].filter(Boolean).join(" · ");
|
|
buttonEl.append(titleEl, metaEl);
|
|
gridEl.appendChild(buttonEl);
|
|
});
|
|
|
|
sectionEl.appendChild(gridEl);
|
|
});
|
|
|
|
cardPickerSectionsEl.appendChild(sectionEl);
|
|
});
|
|
}
|
|
|
|
function positionCardPicker(anchorX, anchorY) {
|
|
if (!(cardPickerEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
cardPickerEl.hidden = false;
|
|
cardPickerEl.style.visibility = "hidden";
|
|
requestAnimationFrame(() => {
|
|
if (!(cardPickerEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const panelWidth = cardPickerEl.offsetWidth || 360;
|
|
const panelHeight = cardPickerEl.offsetHeight || 420;
|
|
const margin = 12;
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
let left = Math.min(Math.max(anchorX + 10, margin), viewportWidth - panelWidth - margin);
|
|
let top = Math.min(Math.max(anchorY + 10, margin), viewportHeight - panelHeight - margin);
|
|
if (top > viewportHeight - panelHeight - margin) {
|
|
top = Math.max(margin, anchorY - panelHeight - 10);
|
|
}
|
|
if (viewportWidth <= 760) {
|
|
left = Math.max(margin, Math.round((viewportWidth - panelWidth) / 2));
|
|
}
|
|
|
|
cardPickerEl.style.left = `${left}px`;
|
|
cardPickerEl.style.top = `${top}px`;
|
|
cardPickerEl.style.visibility = "visible";
|
|
});
|
|
}
|
|
|
|
function openCardPicker(slotId, anchorX, anchorY) {
|
|
createCardPickerElements();
|
|
state.cardPicker.open = true;
|
|
state.cardPicker.slotId = String(slotId || "").trim();
|
|
if (cardPickerTitleEl) {
|
|
cardPickerTitleEl.textContent = `Place Tarot Card at ${describeSlot(slotId)}`;
|
|
}
|
|
if (cardPickerSearchEl) {
|
|
cardPickerSearchEl.value = state.cardPicker.query;
|
|
}
|
|
renderCardPickerSections();
|
|
positionCardPicker(anchorX, anchorY);
|
|
requestAnimationFrame(() => {
|
|
cardPickerSearchEl?.focus({ preventScroll: true });
|
|
});
|
|
}
|
|
|
|
function findAssignedSlotIdByCardId(cardId) {
|
|
const targetCardId = String(cardId || "").trim();
|
|
if (!targetCardId) {
|
|
return "";
|
|
}
|
|
|
|
for (const [slotId, assignedCardId] of state.slotAssignments.entries()) {
|
|
if (String(assignedCardId || "").trim() === targetCardId) {
|
|
return slotId;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function placeCardInSlot(slotId, cardId) {
|
|
const targetSlotId = String(slotId || "").trim();
|
|
const targetCardId = String(cardId || "").trim();
|
|
if (!isValidSlotId(targetSlotId) || !targetCardId) {
|
|
return;
|
|
}
|
|
|
|
const card = getCardMap(getCards()).get(targetCardId) || null;
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const previousSlotId = findAssignedSlotIdByCardId(targetCardId);
|
|
if (previousSlotId && previousSlotId !== targetSlotId) {
|
|
state.slotAssignments.delete(previousSlotId);
|
|
}
|
|
|
|
state.slotAssignments.set(targetSlotId, targetCardId);
|
|
state.layoutReady = true;
|
|
render({ preserveViewport: true });
|
|
syncControls();
|
|
setStatus(`${getDisplayCardName(card)} placed at ${describeSlot(targetSlotId)}.`);
|
|
}
|
|
|
|
function clearGrid() {
|
|
if (!state.slotAssignments.size) {
|
|
setStatus("The Tarot Frame grid is already empty.");
|
|
return;
|
|
}
|
|
|
|
const shouldClear = window.confirm("Clear every card from the current Tarot Frame grid?");
|
|
if (!shouldClear) {
|
|
return;
|
|
}
|
|
|
|
state.slotAssignments.clear();
|
|
state.layoutReady = true;
|
|
render();
|
|
syncControls();
|
|
setStatus("Tarot Frame grid cleared. Use the card picker or a layout preset to repopulate it.");
|
|
}
|
|
|
|
function clearLongPressGesture() {
|
|
if (state.longPress?.timerId) {
|
|
window.clearTimeout(state.longPress.timerId);
|
|
}
|
|
document.removeEventListener("pointermove", handleLongPressPointerMove);
|
|
document.removeEventListener("pointerup", handleLongPressPointerUp);
|
|
document.removeEventListener("pointercancel", handleLongPressPointerCancel);
|
|
state.longPress = null;
|
|
}
|
|
|
|
function scheduleLongPress(slotId, event) {
|
|
clearLongPressGesture();
|
|
document.addEventListener("pointermove", handleLongPressPointerMove);
|
|
document.addEventListener("pointerup", handleLongPressPointerUp);
|
|
document.addEventListener("pointercancel", handleLongPressPointerCancel);
|
|
state.longPress = {
|
|
pointerId: event.pointerId,
|
|
slotId,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
timerId: window.setTimeout(() => {
|
|
const activeGesture = state.longPress;
|
|
clearLongPressGesture();
|
|
if (!activeGesture) {
|
|
return;
|
|
}
|
|
state.suppressClick = true;
|
|
openCardPicker(activeGesture.slotId, activeGesture.startX, activeGesture.startY);
|
|
}, FRAME_LONG_PRESS_DELAY_MS)
|
|
};
|
|
}
|
|
|
|
function updateLongPress(event) {
|
|
if (!state.longPress || event.pointerId !== state.longPress.pointerId) {
|
|
return;
|
|
}
|
|
|
|
if (Math.hypot(event.clientX - state.longPress.startX, event.clientY - state.longPress.startY) > FRAME_LONG_PRESS_MOVE_TOLERANCE) {
|
|
clearLongPressGesture();
|
|
}
|
|
}
|
|
|
|
function finishLongPress(event) {
|
|
if (!state.longPress || event.pointerId !== state.longPress.pointerId) {
|
|
return;
|
|
}
|
|
clearLongPressGesture();
|
|
}
|
|
|
|
function handleLongPressPointerMove(event) {
|
|
updateLongPress(event);
|
|
}
|
|
|
|
function handleLongPressPointerUp(event) {
|
|
finishLongPress(event);
|
|
}
|
|
|
|
function handleLongPressPointerCancel(event) {
|
|
finishLongPress(event);
|
|
}
|
|
|
|
function getTouchPanAnchor(touches) {
|
|
if (!touches || touches.length < 1) {
|
|
return null;
|
|
}
|
|
|
|
let totalX = 0;
|
|
let totalY = 0;
|
|
let count = 0;
|
|
|
|
Array.from(touches).forEach((touch) => {
|
|
if (!touch) {
|
|
return;
|
|
}
|
|
totalX += Number(touch.clientX) || 0;
|
|
totalY += Number(touch.clientY) || 0;
|
|
count += 1;
|
|
});
|
|
|
|
if (!count) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: totalX / count,
|
|
y: totalY / count
|
|
};
|
|
}
|
|
|
|
function getTouchDistance(touches) {
|
|
if (!touches || touches.length < 2) {
|
|
return 0;
|
|
}
|
|
|
|
const first = touches[0];
|
|
const second = touches[1];
|
|
if (!first || !second) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.hypot(Number(first.clientX) - Number(second.clientX), Number(first.clientY) - Number(second.clientY));
|
|
}
|
|
|
|
function startPanGesture(event, options = {}) {
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
clearLongPressGesture();
|
|
if (state.drag) {
|
|
cleanupDrag();
|
|
}
|
|
|
|
const source = String(options.source || "pointer").trim() || "pointer";
|
|
const startX = Number(options.startX ?? event?.clientX);
|
|
const startY = Number(options.startY ?? event?.clientY);
|
|
state.panGesture = {
|
|
source,
|
|
pointerId: source === "pointer" ? event?.pointerId : null,
|
|
startX,
|
|
startY,
|
|
startScrollLeft: viewportEl.scrollLeft,
|
|
startScrollTop: viewportEl.scrollTop
|
|
};
|
|
updateViewportInteractionState();
|
|
syncActiveTouchGestureCapture();
|
|
if (source === "pointer") {
|
|
document.addEventListener("pointermove", handlePanPointerMove);
|
|
document.addEventListener("pointerup", handlePanPointerUp);
|
|
document.addEventListener("pointercancel", handlePanPointerCancel);
|
|
event?.preventDefault?.();
|
|
}
|
|
}
|
|
|
|
function startTouchPanGesture(event) {
|
|
const anchor = getTouchPanAnchor(event?.touches);
|
|
if (!anchor) {
|
|
return;
|
|
}
|
|
|
|
removeOrphanedDragGhosts();
|
|
startPanGesture(null, {
|
|
source: "touch",
|
|
startX: anchor.x,
|
|
startY: anchor.y
|
|
});
|
|
state.suppressClick = true;
|
|
event.preventDefault();
|
|
}
|
|
|
|
function startTouchPinchGesture(event) {
|
|
const anchor = getTouchPanAnchor(event?.touches);
|
|
const distance = getTouchDistance(event?.touches);
|
|
const viewportEl = getGridViewportElement();
|
|
if (!anchor || !(distance > 0) || !(viewportEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
removeOrphanedDragGhosts();
|
|
clearLongPressGesture();
|
|
if (state.drag) {
|
|
cleanupDrag();
|
|
}
|
|
|
|
state.pinchGesture = {
|
|
startDistance: distance,
|
|
startScale: getGridZoomScale(),
|
|
startAnchorX: anchor.x,
|
|
startAnchorY: anchor.y,
|
|
startScrollLeft: viewportEl.scrollLeft,
|
|
startScrollTop: viewportEl.scrollTop
|
|
};
|
|
finishPanGesture();
|
|
syncActiveTouchGestureCapture();
|
|
updateViewportInteractionState();
|
|
state.suppressClick = true;
|
|
event.preventDefault();
|
|
}
|
|
|
|
function finishTouchPinchGesture() {
|
|
state.pinchGesture = null;
|
|
syncActiveTouchGestureCapture();
|
|
updateViewportInteractionState();
|
|
}
|
|
|
|
function finishPanGesture() {
|
|
if (!state.panGesture) {
|
|
return;
|
|
}
|
|
|
|
state.panGesture = null;
|
|
document.removeEventListener("pointermove", handlePanPointerMove);
|
|
document.removeEventListener("pointerup", handlePanPointerUp);
|
|
document.removeEventListener("pointercancel", handlePanPointerCancel);
|
|
syncActiveTouchGestureCapture();
|
|
updateViewportInteractionState();
|
|
}
|
|
|
|
function handlePanPointerMove(event) {
|
|
if (!state.panGesture || state.panGesture.source !== "pointer" || event.pointerId !== state.panGesture.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
finishPanGesture();
|
|
return;
|
|
}
|
|
|
|
viewportEl.scrollLeft = state.panGesture.startScrollLeft - (event.clientX - state.panGesture.startX);
|
|
viewportEl.scrollTop = state.panGesture.startScrollTop - (event.clientY - state.panGesture.startY);
|
|
state.suppressClick = true;
|
|
event.preventDefault();
|
|
}
|
|
|
|
function handlePanPointerUp(event) {
|
|
if (!state.panGesture || state.panGesture.source !== "pointer" || event.pointerId !== state.panGesture.pointerId) {
|
|
return;
|
|
}
|
|
finishPanGesture();
|
|
}
|
|
|
|
function handlePanPointerCancel(event) {
|
|
if (!state.panGesture || state.panGesture.source !== "pointer" || event.pointerId !== state.panGesture.pointerId) {
|
|
return;
|
|
}
|
|
finishPanGesture();
|
|
}
|
|
|
|
function handleBoardTouchStart(event) {
|
|
blurLayoutNoteForBoardInteraction(event.target);
|
|
|
|
if (event.touches.length >= 3) {
|
|
startTouchPanGesture(event);
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length !== 2) {
|
|
return;
|
|
}
|
|
|
|
startTouchPinchGesture(event);
|
|
}
|
|
|
|
function handleBoardTouchMove(event) {
|
|
if (state.pinchGesture) {
|
|
if (event.touches.length >= 3) {
|
|
finishTouchPinchGesture();
|
|
startTouchPanGesture(event);
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length !== 2) {
|
|
finishTouchPinchGesture();
|
|
return;
|
|
}
|
|
|
|
const anchor = getTouchPanAnchor(event.touches);
|
|
const distance = getTouchDistance(event.touches);
|
|
const viewportEl = getGridViewportElement();
|
|
if (!anchor || !(distance > 0) || !(viewportEl instanceof HTMLElement)) {
|
|
finishTouchPinchGesture();
|
|
return;
|
|
}
|
|
|
|
const pinchRatio = distance / state.pinchGesture.startDistance;
|
|
const nextScale = clampFrameGridZoomScale(state.pinchGesture.startScale * pinchRatio);
|
|
setGridZoomScale(nextScale, {
|
|
preserveViewport: false,
|
|
anchorClientX: anchor.x,
|
|
anchorClientY: anchor.y,
|
|
statusMessage: ""
|
|
});
|
|
state.suppressClick = true;
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (!state.panGesture || state.panGesture.source !== "touch") {
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length === 2) {
|
|
finishPanGesture();
|
|
startTouchPinchGesture(event);
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length < 3) {
|
|
finishPanGesture();
|
|
return;
|
|
}
|
|
|
|
const anchor = getTouchPanAnchor(event.touches);
|
|
if (!anchor) {
|
|
finishPanGesture();
|
|
return;
|
|
}
|
|
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
finishPanGesture();
|
|
return;
|
|
}
|
|
|
|
viewportEl.scrollLeft = state.panGesture.startScrollLeft - (anchor.x - state.panGesture.startX);
|
|
viewportEl.scrollTop = state.panGesture.startScrollTop - (anchor.y - state.panGesture.startY);
|
|
state.suppressClick = true;
|
|
event.preventDefault();
|
|
}
|
|
|
|
function handleBoardTouchEnd(event) {
|
|
if (state.pinchGesture) {
|
|
if (event.touches.length >= 3) {
|
|
finishTouchPinchGesture();
|
|
startTouchPanGesture(event);
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length === 2) {
|
|
startTouchPinchGesture(event);
|
|
return;
|
|
}
|
|
|
|
finishTouchPinchGesture();
|
|
return;
|
|
}
|
|
|
|
if (!state.panGesture || state.panGesture.source !== "touch") {
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length >= 3) {
|
|
startTouchPanGesture(event);
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length === 2) {
|
|
finishPanGesture();
|
|
startTouchPinchGesture(event);
|
|
return;
|
|
}
|
|
|
|
finishPanGesture();
|
|
}
|
|
|
|
function handleBoardTouchCancel() {
|
|
finishTouchPinchGesture();
|
|
if (!state.panGesture || state.panGesture.source !== "touch") {
|
|
return;
|
|
}
|
|
|
|
finishPanGesture();
|
|
}
|
|
|
|
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)) || null;
|
|
}
|
|
|
|
function getSavedLayout(layoutId = state.currentLayoutId) {
|
|
return state.customLayouts.find((layout) => layout.id === String(layoutId || "").trim()) || null;
|
|
}
|
|
|
|
function getLayoutDefinition(layoutId = state.currentLayoutId) {
|
|
return getSavedLayout(layoutId) || getLayoutPreset(layoutId) || LAYOUT_PRESETS[0];
|
|
}
|
|
|
|
function isCustomLayout(layoutId = state.currentLayoutId) {
|
|
return Boolean(getSavedLayout(layoutId));
|
|
}
|
|
|
|
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) || LAYOUT_PRESETS[0];
|
|
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;
|
|
persistActiveLayoutId(layoutPreset.id);
|
|
setStatus(nextStatusMessage || layoutPreset.statusMessage || buildReadyStatus(cards));
|
|
}
|
|
|
|
function applySavedLayout(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") {
|
|
const savedLayout = getSavedLayout(layoutId);
|
|
if (!savedLayout) {
|
|
applyLayoutPreset("frames", cards, nextStatusMessage);
|
|
return;
|
|
}
|
|
|
|
const cardMap = getCardMap(cards);
|
|
state.currentLayoutId = savedLayout.id;
|
|
state.slotAssignments.clear();
|
|
savedLayout.slotAssignments.forEach((entry) => {
|
|
if (cardMap.has(entry.cardId)) {
|
|
state.slotAssignments.set(entry.slotId, entry.cardId);
|
|
}
|
|
});
|
|
applyFrameSettingsSnapshot(savedLayout.settings);
|
|
state.layoutReady = true;
|
|
persistActiveLayoutId(savedLayout.id);
|
|
setStatus(nextStatusMessage || savedLayout.statusMessage || `${savedLayout.label} layout applied to the master grid.`);
|
|
}
|
|
|
|
function applyLayoutSelection(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") {
|
|
if (isCustomLayout(layoutId)) {
|
|
applySavedLayout(layoutId, cards, nextStatusMessage);
|
|
return;
|
|
}
|
|
|
|
applyLayoutPreset(layoutId, cards, nextStatusMessage);
|
|
}
|
|
|
|
function resetLayout(cards = getCards(), nextStatusMessage = "") {
|
|
applyLayoutSelection(state.currentLayoutId, cards, nextStatusMessage);
|
|
}
|
|
|
|
function buildSavedLayoutMenuDescription(layout) {
|
|
const zoomLabel = Number.isFinite(Number(layout?.settings?.gridZoomScale))
|
|
? `${Math.round(clampFrameGridZoomScale(layout.settings.gridZoomScale) * 100)}% zoom`
|
|
: (Number.isFinite(Number(layout?.settings?.gridZoomStepIndex))
|
|
? `${Math.round((FRAME_GRID_ZOOM_STEPS[layout.settings.gridZoomStepIndex] || FRAME_GRID_ZOOM_STEPS[0]) * 100)}% zoom`
|
|
: "saved settings");
|
|
const infoLabel = layout?.settings?.showInfo === false ? "info hidden" : "info visible";
|
|
const noteLabel = normalizeLayoutNote(layout?.note || state.layoutNotesById?.[layout?.id]) ? "note added" : "no note";
|
|
return `${layout?.slotAssignments?.length || 0} saved slots · ${zoomLabel} · ${infoLabel} · ${noteLabel}`;
|
|
}
|
|
|
|
function createLayoutOptionButton(layout, isActive) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "tarot-frame-layout-option";
|
|
button.dataset.layoutId = layout.id;
|
|
button.setAttribute("role", "menuitemradio");
|
|
button.setAttribute("aria-checked", isActive ? "true" : "false");
|
|
button.classList.toggle("is-active", isActive);
|
|
button.disabled = Boolean(state.exportInProgress);
|
|
|
|
const titleEl = document.createElement("strong");
|
|
titleEl.textContent = layout.label;
|
|
const descriptionEl = document.createElement("span");
|
|
descriptionEl.textContent = layout.isCustom
|
|
? buildSavedLayoutMenuDescription(layout)
|
|
: (layout.id === "house"
|
|
? "The legacy house composition rebuilt inside the 14x14 snap grid."
|
|
: "The current master frame with top-row extras and nested chronological rings.");
|
|
button.append(titleEl, descriptionEl);
|
|
return button;
|
|
}
|
|
|
|
function renderLayoutPanel() {
|
|
const { tarotFrameLayoutPanelEl } = getElements();
|
|
if (!(tarotFrameLayoutPanelEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
tarotFrameLayoutPanelEl.replaceChildren();
|
|
|
|
const saveButtonEl = document.createElement("button");
|
|
saveButtonEl.type = "button";
|
|
saveButtonEl.className = "tarot-frame-layout-save-btn";
|
|
saveButtonEl.dataset.layoutSaveAction = "true";
|
|
saveButtonEl.textContent = "Save Current Layout";
|
|
saveButtonEl.disabled = Boolean(state.exportInProgress);
|
|
tarotFrameLayoutPanelEl.appendChild(saveButtonEl);
|
|
|
|
const builtInHeadingEl = document.createElement("div");
|
|
builtInHeadingEl.className = "tarot-frame-layout-section-title";
|
|
builtInHeadingEl.textContent = "Built-in Layouts";
|
|
tarotFrameLayoutPanelEl.appendChild(builtInHeadingEl);
|
|
|
|
LAYOUT_PRESETS.forEach((layout) => {
|
|
tarotFrameLayoutPanelEl.appendChild(createLayoutOptionButton(layout, state.currentLayoutId === layout.id));
|
|
});
|
|
|
|
const savedHeadingEl = document.createElement("div");
|
|
savedHeadingEl.className = "tarot-frame-layout-section-title";
|
|
savedHeadingEl.textContent = "Saved Layouts";
|
|
tarotFrameLayoutPanelEl.appendChild(savedHeadingEl);
|
|
|
|
if (!state.customLayouts.length) {
|
|
const emptyEl = document.createElement("div");
|
|
emptyEl.className = "tarot-frame-layout-empty-note";
|
|
emptyEl.textContent = "Save a layout to keep custom card positions and frame settings in this browser.";
|
|
tarotFrameLayoutPanelEl.appendChild(emptyEl);
|
|
return;
|
|
}
|
|
|
|
state.customLayouts.forEach((layout) => {
|
|
const rowEl = document.createElement("div");
|
|
rowEl.className = "tarot-frame-layout-entry";
|
|
rowEl.appendChild(createLayoutOptionButton(layout, state.currentLayoutId === layout.id));
|
|
|
|
const deleteButtonEl = document.createElement("button");
|
|
deleteButtonEl.type = "button";
|
|
deleteButtonEl.className = "tarot-frame-layout-delete-btn";
|
|
deleteButtonEl.dataset.layoutDeleteId = layout.id;
|
|
deleteButtonEl.textContent = "Delete";
|
|
deleteButtonEl.disabled = Boolean(state.exportInProgress);
|
|
deleteButtonEl.setAttribute("aria-label", `Delete saved layout ${layout.label}`);
|
|
rowEl.appendChild(deleteButtonEl);
|
|
tarotFrameLayoutPanelEl.appendChild(rowEl);
|
|
});
|
|
}
|
|
|
|
function saveCurrentLayout() {
|
|
const cards = getCards();
|
|
if (!cards.length) {
|
|
setStatus("Tarot cards are still loading...");
|
|
return;
|
|
}
|
|
|
|
const activeSavedLayout = getSavedLayout(state.currentLayoutId);
|
|
const suggestedName = activeSavedLayout?.label || "";
|
|
const inputName = window.prompt("Save current Tarot Frame layout as:", suggestedName);
|
|
if (inputName === null) {
|
|
return;
|
|
}
|
|
|
|
const label = normalizeLayoutLabel(inputName);
|
|
if (!label) {
|
|
setStatus("Layout save cancelled. Enter a name to save this arrangement.");
|
|
return;
|
|
}
|
|
|
|
const existingLayout = state.customLayouts.find((layout) => normalizeKey(layout.label) === normalizeKey(label)) || null;
|
|
if (existingLayout && existingLayout.id !== activeSavedLayout?.id) {
|
|
const shouldOverwrite = window.confirm(`Replace the saved layout \"${existingLayout.label}\"?`);
|
|
if (!shouldOverwrite) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const savedLayout = normalizeSavedLayoutRecord({
|
|
id: existingLayout?.id || activeSavedLayout?.id || createSavedLayoutId(),
|
|
label,
|
|
slotAssignments: captureSlotAssignmentsSnapshot(cards),
|
|
settings: buildFrameSettingsSnapshot(),
|
|
note: getLayoutNote(state.currentLayoutId),
|
|
createdAt: existingLayout?.createdAt || activeSavedLayout?.createdAt || new Date().toISOString()
|
|
});
|
|
if (!savedLayout) {
|
|
setStatus("Unable to save this layout.");
|
|
return;
|
|
}
|
|
|
|
state.customLayouts = [...state.customLayouts.filter((layout) => layout.id !== savedLayout.id), savedLayout]
|
|
.sort((left, right) => String(left.label || "").localeCompare(String(right.label || "")));
|
|
state.currentLayoutId = savedLayout.id;
|
|
setLayoutNote(savedLayout.id, savedLayout.note, { updateUi: false });
|
|
persistSavedLayouts();
|
|
persistActiveLayoutId(savedLayout.id);
|
|
render();
|
|
syncControls();
|
|
setStatus(`Saved layout \"${savedLayout.label}\" to this browser.`);
|
|
}
|
|
|
|
function deleteSavedLayout(layoutId) {
|
|
const savedLayout = getSavedLayout(layoutId);
|
|
if (!savedLayout) {
|
|
return;
|
|
}
|
|
|
|
const shouldDelete = window.confirm(`Delete the saved layout \"${savedLayout.label}\" from this browser?`);
|
|
if (!shouldDelete) {
|
|
return;
|
|
}
|
|
|
|
state.customLayouts = state.customLayouts.filter((layout) => layout.id !== savedLayout.id);
|
|
delete state.layoutNotesById[savedLayout.id];
|
|
persistSavedLayouts();
|
|
persistLayoutNotes();
|
|
|
|
const cards = getCards();
|
|
if (state.currentLayoutId === savedLayout.id) {
|
|
applyLayoutPreset("frames", cards, `Deleted saved layout \"${savedLayout.label}\". Frames layout applied to the master grid.`);
|
|
render();
|
|
syncControls();
|
|
return;
|
|
}
|
|
|
|
syncControls();
|
|
setStatus(`Deleted saved layout \"${savedLayout.label}\" from this browser.`);
|
|
}
|
|
|
|
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 getOccupiedGridBounds(gridTrackEl) {
|
|
if (!(gridTrackEl instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const filledSlots = Array.from(gridTrackEl.querySelectorAll(".tarot-frame-slot:not(.is-empty-slot)"));
|
|
if (!filledSlots.length) {
|
|
return null;
|
|
}
|
|
|
|
const trackRect = gridTrackEl.getBoundingClientRect();
|
|
return filledSlots.reduce((bounds, slotEl) => {
|
|
if (!(slotEl instanceof HTMLElement)) {
|
|
return bounds;
|
|
}
|
|
|
|
const slotRect = slotEl.getBoundingClientRect();
|
|
const left = slotRect.left - trackRect.left;
|
|
const right = slotRect.right - trackRect.left;
|
|
if (!bounds) {
|
|
return { left, right };
|
|
}
|
|
|
|
return {
|
|
left: Math.min(bounds.left, left),
|
|
right: Math.max(bounds.right, right)
|
|
};
|
|
}, null);
|
|
}
|
|
|
|
function resetFrameSectionScroll() {
|
|
const sectionEl = document.getElementById("tarot-frame-section");
|
|
if (!(sectionEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
sectionEl.scrollTop = 0;
|
|
}
|
|
|
|
function centerGridViewport(attempt = 0) {
|
|
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 contentWidth = gridTrackEl.scrollWidth || gridTrackEl.offsetWidth;
|
|
const viewportWidth = gridViewportEl.clientWidth;
|
|
if (!contentWidth || !viewportWidth) {
|
|
if (attempt < 6) {
|
|
centerGridViewport(attempt + 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const occupiedBounds = getOccupiedGridBounds(gridTrackEl);
|
|
const targetCenter = occupiedBounds
|
|
? (occupiedBounds.left + occupiedBounds.right) / 2
|
|
: contentWidth / 2;
|
|
const maxScrollLeft = Math.max(0, contentWidth - viewportWidth);
|
|
const targetScrollLeft = Math.min(Math.max(targetCenter - (viewportWidth / 2), 0), maxScrollLeft);
|
|
gridViewportEl.scrollLeft = targetScrollLeft;
|
|
|
|
requestAnimationFrame(() => {
|
|
if (!(gridViewportEl instanceof HTMLElement) || !(gridTrackEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const nextContentWidth = gridTrackEl.scrollWidth || gridTrackEl.offsetWidth;
|
|
const nextViewportWidth = gridViewportEl.clientWidth;
|
|
if (!nextContentWidth || !nextViewportWidth) {
|
|
if (attempt < 6) {
|
|
centerGridViewport(attempt + 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const nextOccupiedBounds = getOccupiedGridBounds(gridTrackEl);
|
|
const nextTargetCenter = nextOccupiedBounds
|
|
? (nextOccupiedBounds.left + nextOccupiedBounds.right) / 2
|
|
: nextContentWidth / 2;
|
|
const nextMaxScrollLeft = Math.max(0, nextContentWidth - nextViewportWidth);
|
|
const nextTargetScrollLeft = Math.min(Math.max(nextTargetCenter - (nextViewportWidth / 2), 0), nextMaxScrollLeft);
|
|
gridViewportEl.scrollLeft = nextTargetScrollLeft;
|
|
});
|
|
});
|
|
}
|
|
|
|
function captureGridViewportSnapshot() {
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
scrollLeft: viewportEl.scrollLeft,
|
|
scrollTop: viewportEl.scrollTop
|
|
};
|
|
}
|
|
|
|
function captureGridViewportAnchor(clientX, clientY, scale = getGridZoomScale()) {
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const rect = viewportEl.getBoundingClientRect();
|
|
if (!(rect.width > 0 && rect.height > 0 && scale > 0)) {
|
|
return null;
|
|
}
|
|
|
|
const offsetX = Math.min(Math.max((Number(clientX) || 0) - rect.left, 0), rect.width);
|
|
const offsetY = Math.min(Math.max((Number(clientY) || 0) - rect.top, 0), rect.height);
|
|
|
|
return {
|
|
offsetX,
|
|
offsetY,
|
|
contentX: (viewportEl.scrollLeft + offsetX) / scale,
|
|
contentY: (viewportEl.scrollTop + offsetY) / scale
|
|
};
|
|
}
|
|
|
|
function cancelPendingGridViewportRestore() {
|
|
if (!pendingGridViewportRestoreFrameId) {
|
|
return;
|
|
}
|
|
|
|
window.cancelAnimationFrame(pendingGridViewportRestoreFrameId);
|
|
pendingGridViewportRestoreFrameId = 0;
|
|
}
|
|
|
|
function applyClampedGridViewportScroll(viewportEl, scrollLeft, scrollTop) {
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const maxScrollLeft = Math.max(0, viewportEl.scrollWidth - viewportEl.clientWidth);
|
|
const maxScrollTop = Math.max(0, viewportEl.scrollHeight - viewportEl.clientHeight);
|
|
viewportEl.scrollLeft = Math.min(Math.max(Number(scrollLeft) || 0, 0), maxScrollLeft);
|
|
viewportEl.scrollTop = Math.min(Math.max(Number(scrollTop) || 0, 0), maxScrollTop);
|
|
}
|
|
|
|
function restoreGridViewport(snapshot) {
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
cancelPendingGridViewportRestore();
|
|
applyClampedGridViewportScroll(viewportEl, snapshot.scrollLeft, snapshot.scrollTop);
|
|
|
|
pendingGridViewportRestoreFrameId = window.requestAnimationFrame(() => {
|
|
pendingGridViewportRestoreFrameId = 0;
|
|
const activeViewportEl = getGridViewportElement();
|
|
if (!(activeViewportEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
applyClampedGridViewportScroll(activeViewportEl, snapshot.scrollLeft, snapshot.scrollTop);
|
|
});
|
|
}
|
|
|
|
function restoreGridViewportAnchor(anchorSnapshot, scale = getGridZoomScale()) {
|
|
if (!anchorSnapshot) {
|
|
return;
|
|
}
|
|
|
|
const applyAnchor = () => {
|
|
const viewportEl = getGridViewportElement();
|
|
if (!(viewportEl instanceof HTMLElement) || !(scale > 0)) {
|
|
return;
|
|
}
|
|
|
|
const targetScrollLeft = (Number(anchorSnapshot.contentX) * scale) - Number(anchorSnapshot.offsetX);
|
|
const targetScrollTop = (Number(anchorSnapshot.contentY) * scale) - Number(anchorSnapshot.offsetY);
|
|
applyClampedGridViewportScroll(viewportEl, targetScrollLeft, targetScrollTop);
|
|
};
|
|
|
|
cancelPendingGridViewportRestore();
|
|
applyAnchor();
|
|
|
|
pendingGridViewportRestoreFrameId = window.requestAnimationFrame(() => {
|
|
pendingGridViewportRestoreFrameId = 0;
|
|
applyAnchor();
|
|
});
|
|
}
|
|
|
|
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.setAttribute("aria-label", `Empty slot at row ${row}, column ${column}`);
|
|
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 createOverview(layoutPreset, cards = getCards()) {
|
|
const overviewEl = document.createElement("section");
|
|
overviewEl.className = "tarot-frame-overview";
|
|
|
|
const summaryEl = document.createElement("div");
|
|
summaryEl.className = "tarot-frame-overview-summary";
|
|
|
|
const headEl = document.createElement("div");
|
|
headEl.className = "tarot-frame-overview-head";
|
|
|
|
const titleWrapEl = document.createElement("div");
|
|
const eyebrowEl = document.createElement("div");
|
|
eyebrowEl.className = "tarot-frame-overview-eyebrow";
|
|
eyebrowEl.textContent = layoutPreset?.isCustom ? "Saved Layout" : "Layout Guide";
|
|
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(eyebrowEl, titleEl, subtitleEl);
|
|
|
|
const countEl = document.createElement("span");
|
|
countEl.className = "tarot-frame-panel-count";
|
|
countEl.textContent = buildPanelCountText(cards);
|
|
headEl.append(titleWrapEl, countEl);
|
|
summaryEl.appendChild(headEl);
|
|
|
|
if (Array.isArray(layoutPreset.legendItems) && layoutPreset.legendItems.length) {
|
|
summaryEl.appendChild(createLegend(layoutPreset));
|
|
}
|
|
|
|
const notesEl = document.createElement("section");
|
|
notesEl.className = "tarot-frame-notes-card";
|
|
|
|
const notesHeadEl = document.createElement("div");
|
|
notesHeadEl.className = "tarot-frame-notes-head";
|
|
const notesTitleWrapEl = document.createElement("div");
|
|
const notesTitleEl = document.createElement("h4");
|
|
notesTitleEl.className = "tarot-frame-notes-title";
|
|
notesTitleEl.textContent = "Layout Notes";
|
|
const notesCopyEl = document.createElement("p");
|
|
notesCopyEl.className = "tarot-frame-notes-copy";
|
|
notesCopyEl.textContent = "Saved automatically in this browser for the current layout.";
|
|
notesTitleWrapEl.append(notesTitleEl, notesCopyEl);
|
|
const notesBadgeEl = document.createElement("span");
|
|
notesBadgeEl.className = "tarot-frame-notes-badge";
|
|
notesBadgeEl.textContent = getLayoutNote() ? "Saved" : "Optional";
|
|
notesHeadEl.append(notesTitleWrapEl, notesBadgeEl);
|
|
|
|
const noteFieldEl = document.createElement("label");
|
|
noteFieldEl.className = "tarot-frame-notes-field";
|
|
const noteLabelEl = document.createElement("span");
|
|
noteLabelEl.textContent = "Custom text / notes";
|
|
const noteInputEl = document.createElement("textarea");
|
|
noteInputEl.id = "tarot-frame-layout-note";
|
|
noteInputEl.rows = 7;
|
|
noteInputEl.maxLength = 1600;
|
|
noteInputEl.placeholder = getLayoutNotePlaceholder(layoutPreset);
|
|
noteInputEl.value = getLayoutNote();
|
|
noteInputEl.disabled = Boolean(state.exportInProgress);
|
|
noteFieldEl.append(noteLabelEl, noteInputEl);
|
|
|
|
const notesFooterEl = document.createElement("div");
|
|
notesFooterEl.className = "tarot-frame-notes-footer";
|
|
const notesHintEl = document.createElement("span");
|
|
notesHintEl.textContent = layoutPreset?.isCustom
|
|
? "This note stays with the saved layout and reopens with it."
|
|
: "Use this area for placement reminders, timing, or custom reading instructions.";
|
|
const clearButtonEl = document.createElement("button");
|
|
clearButtonEl.type = "button";
|
|
clearButtonEl.className = "tarot-frame-notes-clear";
|
|
clearButtonEl.dataset.frameNoteClear = "true";
|
|
clearButtonEl.textContent = "Clear Note";
|
|
clearButtonEl.disabled = !getLayoutNote() || Boolean(state.exportInProgress);
|
|
notesFooterEl.append(notesHintEl, clearButtonEl);
|
|
|
|
notesEl.append(notesHeadEl, noteFieldEl, notesFooterEl);
|
|
overviewEl.append(summaryEl, notesEl);
|
|
return overviewEl;
|
|
}
|
|
|
|
function render(options = {}) {
|
|
const { tarotFrameBoardEl, tarotFrameOverviewEl } = getElements();
|
|
if (!tarotFrameBoardEl || !tarotFrameOverviewEl) {
|
|
return;
|
|
}
|
|
|
|
const preserveViewport = options.preserveViewport === true;
|
|
const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot() : null;
|
|
|
|
const cards = getCards();
|
|
const cardMap = getCardMap(cards);
|
|
const layoutPreset = getLayoutDefinition();
|
|
tarotFrameOverviewEl.replaceChildren();
|
|
tarotFrameBoardEl.replaceChildren();
|
|
|
|
tarotFrameOverviewEl.appendChild(createOverview(layoutPreset, cards));
|
|
|
|
const panelEl = document.createElement("section");
|
|
panelEl.className = "tarot-frame-panel tarot-frame-panel--master";
|
|
panelEl.style.setProperty("--frame-grid-zoom-scale", String(getGridZoomScale()));
|
|
|
|
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);
|
|
updateViewportInteractionState();
|
|
if (preserveViewport) {
|
|
restoreGridViewport(viewportSnapshot);
|
|
return;
|
|
}
|
|
|
|
centerGridViewport();
|
|
}
|
|
|
|
function applyGridZoomState(options = {}) {
|
|
const { tarotFrameBoardEl, tarotFrameOverviewEl } = getElements();
|
|
const panelEl = tarotFrameBoardEl?.querySelector(".tarot-frame-panel--master");
|
|
if (!(panelEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const anchorSnapshot = options.anchorSnapshot || null;
|
|
const preserveViewport = options.preserveViewport !== false;
|
|
const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot() : null;
|
|
|
|
panelEl.style.setProperty("--frame-grid-zoom-scale", String(getGridZoomScale()));
|
|
|
|
const countEl = tarotFrameOverviewEl?.querySelector(".tarot-frame-panel-count");
|
|
if (countEl instanceof HTMLElement) {
|
|
countEl.textContent = buildPanelCountText();
|
|
}
|
|
|
|
if (anchorSnapshot) {
|
|
restoreGridViewportAnchor(anchorSnapshot, getGridZoomScale());
|
|
return;
|
|
}
|
|
|
|
if (preserveViewport) {
|
|
restoreGridViewport(viewportSnapshot);
|
|
return;
|
|
}
|
|
|
|
centerGridViewport();
|
|
}
|
|
|
|
function setGridZoomScale(nextScale, options = {}) {
|
|
const anchorSnapshot = options.anchorClientX === undefined || options.anchorClientY === undefined
|
|
? null
|
|
: captureGridViewportAnchor(options.anchorClientX, options.anchorClientY, getGridZoomScale());
|
|
const safeScale = clampFrameGridZoomScale(nextScale);
|
|
state.gridZoomScale = safeScale;
|
|
state.gridZoomStepIndex = getNearestFrameZoomStepIndex(safeScale);
|
|
applyGridZoomState({
|
|
preserveViewport: options.preserveViewport !== false,
|
|
anchorSnapshot
|
|
});
|
|
if (options.statusMessage !== "") {
|
|
setStatus(options.statusMessage || `Frame grid zoom ${Math.round(getGridZoomScale() * 100)}%. This setting applies to every Frame layout.`);
|
|
}
|
|
}
|
|
|
|
function setGridZoomStepIndex(nextIndex) {
|
|
const safeIndex = Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, Number(nextIndex) || 0));
|
|
state.gridZoomStepIndex = safeIndex;
|
|
state.gridZoomScale = FRAME_GRID_ZOOM_STEPS[safeIndex] || FRAME_GRID_ZOOM_STEPS[0];
|
|
applyGridZoomState({ preserveViewport: true });
|
|
setStatus(`Frame grid zoom ${Math.round(getGridZoomScale() * 100)}%. This setting applies to every Frame layout.`);
|
|
}
|
|
|
|
function syncControls() {
|
|
const {
|
|
tarotFramePanToggleEl,
|
|
tarotFrameFocusToggleEl,
|
|
tarotFrameFocusExitEl,
|
|
tarotFrameLayoutToggleEl,
|
|
tarotFrameLayoutPanelEl,
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameGridZoomEl,
|
|
tarotFrameShowInfoEl,
|
|
tarotFrameHouseSettingsEl,
|
|
tarotFrameHouseTopCardsVisibleEl,
|
|
tarotFrameHouseTopInfoHebrewEl,
|
|
tarotFrameHouseTopInfoPlanetEl,
|
|
tarotFrameHouseTopInfoZodiacEl,
|
|
tarotFrameHouseTopInfoTrumpEl,
|
|
tarotFrameHouseTopInfoPathEl,
|
|
tarotFrameHouseTopInfoDateEl,
|
|
tarotFrameHouseBottomCardsVisibleEl,
|
|
tarotFrameHouseBottomInfoZodiacEl,
|
|
tarotFrameHouseBottomInfoDecanEl,
|
|
tarotFrameHouseBottomInfoMonthEl,
|
|
tarotFrameHouseBottomInfoRulerEl,
|
|
tarotFrameHouseBottomInfoDateEl,
|
|
tarotFrameClearGridEl,
|
|
tarotFrameExportWebpEl,
|
|
} = getElements();
|
|
const activeLayout = getLayoutDefinition();
|
|
|
|
if (tarotFramePanToggleEl) {
|
|
tarotFramePanToggleEl.setAttribute("aria-pressed", state.panMode ? "true" : "false");
|
|
tarotFramePanToggleEl.classList.toggle("is-active", state.panMode);
|
|
tarotFramePanToggleEl.textContent = state.panMode ? "Panning" : "Pan Grid";
|
|
tarotFramePanToggleEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
if (tarotFrameFocusToggleEl || tarotFrameFocusExitEl) {
|
|
applyGridFocusModeUi();
|
|
}
|
|
|
|
if (tarotFrameLayoutToggleEl) {
|
|
tarotFrameLayoutToggleEl.setAttribute("aria-expanded", state.layoutMenuOpen ? "true" : "false");
|
|
tarotFrameLayoutToggleEl.textContent = `Layout: ${activeLayout.label}`;
|
|
tarotFrameLayoutToggleEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
if (tarotFrameLayoutPanelEl) {
|
|
tarotFrameLayoutPanelEl.hidden = !state.layoutMenuOpen;
|
|
renderLayoutPanel();
|
|
}
|
|
|
|
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 (tarotFrameClearGridEl) {
|
|
tarotFrameClearGridEl.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.";
|
|
}
|
|
}
|
|
|
|
updateLayoutNotesUi();
|
|
}
|
|
|
|
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 removeOrphanedDragGhosts() {
|
|
document.querySelectorAll(".tarot-frame-drag-ghost").forEach((ghostEl) => {
|
|
if (ghostEl instanceof HTMLElement) {
|
|
ghostEl.remove();
|
|
}
|
|
});
|
|
document.body.classList.remove("is-tarot-frame-dragging");
|
|
}
|
|
|
|
function detachPointerListeners() {
|
|
document.removeEventListener("pointermove", handlePointerMove);
|
|
document.removeEventListener("pointerup", handlePointerUp);
|
|
document.removeEventListener("pointercancel", handlePointerCancel);
|
|
}
|
|
|
|
function cleanupDrag() {
|
|
if (!state.drag) {
|
|
removeOrphanedDragGhosts();
|
|
return;
|
|
}
|
|
|
|
if (state.drag.sourceButton instanceof HTMLElement && typeof state.drag.sourceButton.releasePointerCapture === "function") {
|
|
try {
|
|
if (state.drag.sourceButton.hasPointerCapture?.(state.drag.pointerId)) {
|
|
state.drag.sourceButton.releasePointerCapture(state.drag.pointerId);
|
|
}
|
|
} catch (_error) {
|
|
// Ignore pointer-capture release failures during cleanup.
|
|
}
|
|
}
|
|
|
|
setHoverSlot("");
|
|
getSlotElement(state.drag.sourceSlotId)?.classList.remove("is-drag-source");
|
|
if (state.drag.ghostEl instanceof HTMLElement) {
|
|
state.drag.ghostEl.remove();
|
|
}
|
|
|
|
state.drag = null;
|
|
removeOrphanedDragGhosts();
|
|
detachPointerListeners();
|
|
}
|
|
|
|
function handleDocumentTouchStart(event) {
|
|
if (Number(event.touches?.length || 0) < 2) {
|
|
return;
|
|
}
|
|
|
|
clearLongPressGesture();
|
|
if (state.drag?.pointerType === "touch") {
|
|
cleanupDrag();
|
|
state.suppressClick = true;
|
|
return;
|
|
}
|
|
|
|
removeOrphanedDragGhosts();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (typeof config.openCardLightbox === "function") {
|
|
config.openCardLightbox(getCardId(card), {
|
|
onSelectCardId: () => {}
|
|
});
|
|
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)) {
|
|
return;
|
|
}
|
|
|
|
blurLayoutNoteForBoardInteraction(target);
|
|
|
|
if (event.button === 1) {
|
|
startPanGesture(event, { source: "pointer" });
|
|
return;
|
|
}
|
|
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
if (state.panMode) {
|
|
startPanGesture(event);
|
|
return;
|
|
}
|
|
|
|
const cardButton = target.closest(".tarot-frame-card[data-slot-id][data-card-id]");
|
|
if (!(cardButton instanceof HTMLButtonElement)) {
|
|
const emptyButton = target.closest(".tarot-frame-card.is-empty[data-slot-id]");
|
|
if (emptyButton instanceof HTMLButtonElement && (event.pointerType === "touch" || event.pointerType === "pen")) {
|
|
event.preventDefault();
|
|
scheduleLongPress(String(emptyButton.dataset.slotId || ""), event);
|
|
}
|
|
return;
|
|
}
|
|
|
|
state.drag = {
|
|
pointerId: event.pointerId,
|
|
pointerType: String(event.pointerType || "").toLowerCase(),
|
|
sourceSlotId: String(cardButton.dataset.slotId || ""),
|
|
cardId: String(cardButton.dataset.cardId || ""),
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
touchEligibleAt: String(event.pointerType || "").toLowerCase() === "touch"
|
|
? (Number(event.timeStamp) || window.performance.now()) + FRAME_TOUCH_DRAG_ACTIVATION_DELAY_MS
|
|
: 0,
|
|
started: false,
|
|
hoverSlotId: "",
|
|
ghostEl: null,
|
|
sourceButton: cardButton
|
|
};
|
|
|
|
if (typeof cardButton.setPointerCapture === "function") {
|
|
try {
|
|
cardButton.setPointerCapture(event.pointerId);
|
|
} catch (_error) {
|
|
// Ignore pointer-capture failures and continue with document listeners.
|
|
}
|
|
}
|
|
|
|
if (String(event.pointerType || "").toLowerCase() === "touch") {
|
|
event.preventDefault();
|
|
}
|
|
|
|
detachPointerListeners();
|
|
document.addEventListener("pointermove", handlePointerMove);
|
|
document.addEventListener("pointerup", handlePointerUp);
|
|
document.addEventListener("pointercancel", handlePointerCancel);
|
|
}
|
|
|
|
function handlePointerMove(event) {
|
|
updateLongPress(event);
|
|
if (!state.drag || event.pointerId !== state.drag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
if (state.drag.pointerType === "touch" && (state.pinchGesture || (state.panGesture && state.panGesture.source === "touch"))) {
|
|
cleanupDrag();
|
|
state.suppressClick = true;
|
|
return;
|
|
}
|
|
|
|
if (!state.drag.started
|
|
&& state.drag.pointerType === "touch"
|
|
&& (Number(event.timeStamp) || window.performance.now()) < Number(state.drag.touchEligibleAt || 0)) {
|
|
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({ preserveViewport: true });
|
|
setStatus(`${getDisplayCardName(draggedCard)} snapped to ${describeSlot(targetSlotId)}.`);
|
|
}
|
|
|
|
cleanupDrag();
|
|
if (!moved) {
|
|
state.suppressClick = false;
|
|
}
|
|
}
|
|
|
|
function handlePointerUp(event) {
|
|
finishLongPress(event);
|
|
if (!state.drag || event.pointerId !== state.drag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
if (!state.drag.started) {
|
|
cleanupDrag();
|
|
return;
|
|
}
|
|
|
|
finishDrop();
|
|
}
|
|
|
|
function handlePointerCancel(event) {
|
|
finishLongPress(event);
|
|
if (!state.drag || event.pointerId !== state.drag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
cleanupDrag();
|
|
state.suppressClick = false;
|
|
}
|
|
|
|
function handleBoardClick(event) {
|
|
if (state.panMode) {
|
|
state.suppressClick = false;
|
|
return;
|
|
}
|
|
|
|
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 handleBoardContextMenu(event) {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const emptyButton = target.closest(".tarot-frame-card.is-empty[data-slot-id]");
|
|
if (!(emptyButton instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openCardPicker(String(emptyButton.dataset.slotId || ""), event.clientX, event.clientY);
|
|
}
|
|
|
|
function handleDocumentClick(event) {
|
|
const target = event.target;
|
|
if (!(target instanceof Node)) {
|
|
return;
|
|
}
|
|
|
|
const {
|
|
tarotFrameSectionEl,
|
|
tarotFrameBoardEl,
|
|
tarotFrameFocusToggleEl,
|
|
tarotFrameFocusExitEl,
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameLayoutPanelEl,
|
|
tarotFrameLayoutToggleEl
|
|
} = getElements();
|
|
|
|
if (state.gridFocusMode && tarotFrameSectionEl?.contains(target)) {
|
|
const targetElement = target instanceof Element ? target : null;
|
|
const clickedInsideBoard = Boolean(targetElement?.closest("#tarot-frame-board"));
|
|
const clickedOnFocusControl = Boolean(
|
|
tarotFrameFocusToggleEl?.contains(target)
|
|
|| tarotFrameFocusExitEl?.contains(target)
|
|
);
|
|
if (!clickedInsideBoard && !clickedOnFocusControl) {
|
|
setGridFocusMode(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
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 (state.cardPicker.open && cardPickerEl && !cardPickerEl.contains(target)) {
|
|
closeCardPicker();
|
|
}
|
|
|
|
if (changed) {
|
|
syncControls();
|
|
}
|
|
}
|
|
|
|
function handleDocumentKeydown(event) {
|
|
if (event.key !== "Escape") {
|
|
return;
|
|
}
|
|
|
|
if (state.gridFocusMode) {
|
|
setGridFocusMode(false);
|
|
return;
|
|
}
|
|
|
|
let changed = false;
|
|
if (state.settingsOpen) {
|
|
state.settingsOpen = false;
|
|
changed = true;
|
|
}
|
|
if (state.layoutMenuOpen) {
|
|
state.layoutMenuOpen = false;
|
|
changed = true;
|
|
}
|
|
if (state.cardPicker.open) {
|
|
closeCardPicker();
|
|
}
|
|
|
|
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,
|
|
tarotFrameOverviewEl,
|
|
tarotFramePanToggleEl,
|
|
tarotFrameFocusToggleEl,
|
|
tarotFrameFocusExitEl,
|
|
tarotFrameLayoutToggleEl,
|
|
tarotFrameLayoutPanelEl,
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameGridZoomEl,
|
|
tarotFrameShowInfoEl,
|
|
tarotFrameHouseTopCardsVisibleEl,
|
|
tarotFrameHouseTopInfoHebrewEl,
|
|
tarotFrameHouseTopInfoPlanetEl,
|
|
tarotFrameHouseTopInfoZodiacEl,
|
|
tarotFrameHouseTopInfoTrumpEl,
|
|
tarotFrameHouseTopInfoPathEl,
|
|
tarotFrameHouseTopInfoDateEl,
|
|
tarotFrameHouseBottomCardsVisibleEl,
|
|
tarotFrameHouseBottomInfoZodiacEl,
|
|
tarotFrameHouseBottomInfoDecanEl,
|
|
tarotFrameHouseBottomInfoMonthEl,
|
|
tarotFrameHouseBottomInfoRulerEl,
|
|
tarotFrameHouseBottomInfoDateEl,
|
|
tarotFrameClearGridEl,
|
|
tarotFrameExportWebpEl
|
|
} = getElements();
|
|
if (tarotFrameBoardEl) {
|
|
tarotFrameBoardEl.addEventListener("pointerdown", handlePointerDown);
|
|
tarotFrameBoardEl.addEventListener("click", handleBoardClick);
|
|
tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart);
|
|
tarotFrameBoardEl.addEventListener("contextmenu", handleBoardContextMenu);
|
|
tarotFrameBoardEl.addEventListener("touchstart", handleBoardTouchStart, { passive: false });
|
|
}
|
|
|
|
document.addEventListener("touchstart", handleDocumentTouchStart, {
|
|
capture: true,
|
|
passive: true
|
|
});
|
|
|
|
if (tarotFrameOverviewEl) {
|
|
tarotFrameOverviewEl.addEventListener("input", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLTextAreaElement) || target.id !== "tarot-frame-layout-note") {
|
|
return;
|
|
}
|
|
|
|
setLayoutNote(state.currentLayoutId, target.value, { updateUi: false });
|
|
const badgeEl = tarotFrameOverviewEl.querySelector(".tarot-frame-notes-badge");
|
|
if (badgeEl instanceof HTMLElement) {
|
|
badgeEl.textContent = getLayoutNote() ? "Saved" : "Optional";
|
|
}
|
|
const clearButton = tarotFrameOverviewEl.querySelector("[data-frame-note-clear='true']");
|
|
if (clearButton instanceof HTMLButtonElement) {
|
|
clearButton.disabled = !getLayoutNote() || Boolean(state.exportInProgress);
|
|
}
|
|
});
|
|
|
|
tarotFrameOverviewEl.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const clearButton = target.closest("[data-frame-note-clear='true']");
|
|
if (!(clearButton instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
setLayoutNote(state.currentLayoutId, "");
|
|
});
|
|
}
|
|
|
|
if (tarotFramePanToggleEl) {
|
|
tarotFramePanToggleEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (state.exportInProgress) {
|
|
return;
|
|
}
|
|
state.panMode = !state.panMode;
|
|
finishPanGesture();
|
|
clearLongPressGesture();
|
|
syncControls();
|
|
updateViewportInteractionState();
|
|
setStatus(state.panMode
|
|
? "Pan mode enabled. Drag inside the frame grid to move around."
|
|
: "Pan mode disabled. Drag cards to rearrange the layout.");
|
|
});
|
|
}
|
|
|
|
if (tarotFrameFocusToggleEl) {
|
|
tarotFrameFocusToggleEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (state.exportInProgress) {
|
|
return;
|
|
}
|
|
setGridFocusMode(!state.gridFocusMode);
|
|
});
|
|
}
|
|
|
|
if (tarotFrameFocusExitEl) {
|
|
tarotFrameFocusExitEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
setGridFocusMode(false);
|
|
});
|
|
}
|
|
|
|
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;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const saveButton = target.closest("[data-layout-save-action='true']");
|
|
if (saveButton instanceof HTMLButtonElement) {
|
|
saveCurrentLayout();
|
|
return;
|
|
}
|
|
|
|
const deleteButton = target.closest(".tarot-frame-layout-delete-btn[data-layout-delete-id]");
|
|
if (deleteButton instanceof HTMLButtonElement) {
|
|
deleteSavedLayout(deleteButton.dataset.layoutDeleteId);
|
|
return;
|
|
}
|
|
|
|
const option = target.closest(".tarot-frame-layout-option[data-layout-id]");
|
|
if (!(option instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const cards = getCards();
|
|
if (!cards.length) {
|
|
return;
|
|
}
|
|
|
|
const selectedLayout = getLayoutDefinition(option.dataset.layoutId);
|
|
applyLayoutSelection(option.dataset.layoutId, cards, `${selectedLayout.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");
|
|
});
|
|
}
|
|
|
|
if (tarotFrameClearGridEl) {
|
|
tarotFrameClearGridEl.addEventListener("click", () => {
|
|
clearGrid();
|
|
});
|
|
}
|
|
|
|
document.addEventListener("click", handleDocumentClick);
|
|
document.addEventListener("keydown", handleDocumentKeydown);
|
|
}
|
|
|
|
async function ensureTarotFrameSection(referenceData, magickDataset) {
|
|
if (typeof config.ensureTarotSection === "function") {
|
|
await config.ensureTarotSection(referenceData, magickDataset);
|
|
}
|
|
|
|
resetFrameSectionScroll();
|
|
|
|
const cards = getCards();
|
|
if (!cards.length) {
|
|
setStatus("Tarot cards are still loading...");
|
|
return;
|
|
}
|
|
|
|
const signature = buildCardSignature(cards);
|
|
if (!state.layoutReady || state.cardSignature !== signature) {
|
|
state.cardSignature = signature;
|
|
applyLayoutSelection(state.currentLayoutId, cards);
|
|
} else {
|
|
setStatus(state.statusMessage || buildReadyStatus(cards));
|
|
}
|
|
|
|
render();
|
|
syncControls();
|
|
}
|
|
|
|
function init(nextConfig = {}) {
|
|
config = {
|
|
...config,
|
|
...nextConfig
|
|
};
|
|
|
|
if (state.initialized) {
|
|
return;
|
|
}
|
|
|
|
loadSavedLayoutsFromStorage();
|
|
loadLayoutNotesFromStorage();
|
|
restoreActiveLayoutId();
|
|
restoreCardPickerQuery();
|
|
bindEvents();
|
|
createCardPickerElements();
|
|
syncControls();
|
|
state.initialized = true;
|
|
}
|
|
|
|
window.TarotFrameUi = {
|
|
...(window.TarotFrameUi || {}),
|
|
init,
|
|
ensureTarotFrameSection,
|
|
render,
|
|
resetLayout,
|
|
setLayoutPreset(layoutId, options = {}) {
|
|
const cards = getCards();
|
|
state.currentLayoutId = getLayoutDefinition(layoutId).id;
|
|
if (cards.length && options.reapply !== false) {
|
|
applyLayoutSelection(state.currentLayoutId, cards, options.statusMessage || `${getLayoutDefinition(layoutId).label} layout applied to the master grid.`);
|
|
render();
|
|
}
|
|
syncControls();
|
|
},
|
|
exportImage,
|
|
isExportFormatSupported
|
|
};
|
|
})(); |