From efe50177406470ca6dde3b082ef5fdbe94f5758a Mon Sep 17 00:00:00 2001 From: Nose Date: Wed, 1 Apr 2026 16:08:52 -0700 Subject: [PATCH] update tarot frame settings UI --- app.js | 10 +- app/styles.css | 184 +++++++ app/tarot-database-assembly.js | 56 ++- app/tarot-database-builders.js | 36 +- app/ui-tarot-frame.js | 889 +++++++++++++++++++++++++++++++-- app/ui-tarot.js | 50 +- index.html | 58 ++- 7 files changed, 1216 insertions(+), 67 deletions(-) diff --git a/app.js b/app.js index 5bbfe29..8dfd045 100644 --- a/app.js +++ b/app.js @@ -397,7 +397,15 @@ window.TarotSpreadUi?.init?.({ window.TarotFrameUi?.init?.({ ensureTarotSection, - getCards: () => window.TarotSectionUi?.getCards?.() || [] + getCards: () => window.TarotSectionUi?.getCards?.() || [], + getHouseTopCardsVisible: () => window.TarotSectionUi?.getHouseTopCardsVisible?.() !== false, + getHouseTopInfoModes: () => window.TarotSectionUi?.getHouseTopInfoModes?.() || {}, + getHouseBottomCardsVisible: () => window.TarotSectionUi?.getHouseBottomCardsVisible?.() !== false, + getHouseBottomInfoModes: () => window.TarotSectionUi?.getHouseBottomInfoModes?.() || {}, + setHouseTopCardsVisible: (value) => window.TarotSectionUi?.setHouseTopCardsVisible?.(value), + setHouseTopInfoMode: (mode, value) => window.TarotSectionUi?.setHouseTopInfoMode?.(mode, value), + setHouseBottomCardsVisible: (value) => window.TarotSectionUi?.setHouseBottomCardsVisible?.(value), + setHouseBottomInfoMode: (mode, value) => window.TarotSectionUi?.setHouseBottomInfoMode?.(mode, value) }); sectionStateUi.init?.({ diff --git a/app/styles.css b/app/styles.css index b948488..8c8163b 100644 --- a/app/styles.css +++ b/app/styles.css @@ -903,6 +903,57 @@ flex-wrap: wrap; } + .tarot-frame-layout-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 26; + min-width: 270px; + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid #312e81; + border-radius: 16px; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.16), transparent 42%), + linear-gradient(180deg, rgba(22, 22, 34, 0.98), rgba(10, 10, 18, 0.98)); + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.3); + } + + .tarot-frame-layout-panel[hidden] { + display: none !important; + } + + .tarot-frame-layout-option { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid rgba(99, 102, 241, 0.28); + border-radius: 12px; + background: rgba(15, 23, 42, 0.5); + color: #cbd5e1; + text-align: left; + cursor: pointer; + } + + .tarot-frame-layout-option:hover, + .tarot-frame-layout-option.is-active { + border-color: rgba(129, 140, 248, 0.85); + background: rgba(49, 46, 129, 0.44); + color: #f8fafc; + } + + .tarot-frame-layout-option strong { + font-size: 13px; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + .tarot-frame-layout-option span { + font-size: 12px; + line-height: 1.35; + } + .tarot-frame-action-btn { padding: 10px 14px; border: 1px solid #4c1d95; @@ -937,6 +988,67 @@ box-shadow: 0 18px 38px rgba(0, 0, 0, 0.3); } + .tarot-frame-settings-group { + display: grid; + gap: 10px; + padding-top: 2px; + border-top: 1px solid rgba(99, 102, 241, 0.18); + } + + .tarot-frame-settings-group[hidden] { + display: none !important; + } + + .tarot-frame-settings-heading { + color: #f8fafc; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .tarot-frame-settings-subheading { + color: #cbd5e1; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .tarot-frame-settings-note { + color: #94a3b8; + font-size: 11px; + line-height: 1.45; + } + + .tarot-frame-checkbox-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .tarot-frame-check { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 9px; + border: 1px solid rgba(99, 102, 241, 0.24); + border-radius: 10px; + background: rgba(15, 23, 42, 0.4); + color: #dbe4f0; + font-size: 12px; + line-height: 1.2; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + } + + .tarot-frame-check input { + margin: 0; + accent-color: #818cf8; + } + .tarot-frame-settings-panel[hidden] { display: none !important; } @@ -963,6 +1075,8 @@ } .tarot-frame-toggle input:disabled, + .tarot-frame-check input:disabled, + .tarot-frame-layout-option:disabled, .tarot-frame-export-btn:disabled, .tarot-frame-settings-toggle:disabled { cursor: wait; @@ -1171,6 +1285,54 @@ linear-gradient(180deg, #1e1b4b 0%, #0f172a 100%); } + .tarot-frame-card-text-face { + width: 100%; + height: 100%; + display: grid; + align-content: center; + justify-items: center; + gap: 4px; + padding: 6px 5px; + box-sizing: border-box; + color: #fafafa; + text-align: center; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.16), transparent 55%), + linear-gradient(180deg, rgba(30, 41, 59, 0.94), rgba(15, 23, 42, 0.94)); + pointer-events: none; + -webkit-user-select: none; + user-select: none; + } + + .tarot-frame-card-text-face.is-dense { + gap: 3px; + padding: 5px 4px; + } + + .tarot-frame-card-text-face.is-top-hebrew .tarot-frame-card-text-primary { + font-size: clamp(11px, 1vw, 16px); + line-height: 1; + font-family: "Noto Sans Hebrew", "Segoe UI Symbol", sans-serif; + } + + .tarot-frame-card-text-primary { + display: block; + font-size: clamp(7px, 0.78vw, 10px); + line-height: 1.15; + font-weight: 700; + letter-spacing: 0.02em; + overflow-wrap: anywhere; + } + + .tarot-frame-card-text-secondary { + display: block; + color: rgba(250, 250, 250, 0.76); + font-size: clamp(6px, 0.7vw, 8px); + line-height: 1.2; + letter-spacing: 0.02em; + overflow-wrap: anywhere; + } + .tarot-frame-card-badge { position: absolute; left: 4px; @@ -1262,6 +1424,11 @@ right: auto; } + .tarot-frame-layout-panel { + left: 0; + right: auto; + } + .tarot-frame-panel { padding: 14px; } @@ -1274,6 +1441,23 @@ font-size: 7px; padding: 3px 4px; } + + .tarot-frame-card-text-face { + gap: 2px; + padding: 4px 3px; + } + + .tarot-frame-card-text-face.is-top-hebrew .tarot-frame-card-text-primary { + font-size: 10px; + } + + .tarot-frame-card-text-primary { + font-size: 6px; + } + + .tarot-frame-card-text-secondary { + font-size: 5px; + } } .tarot-house-card-head { diff --git a/app/tarot-database-assembly.js b/app/tarot-database-assembly.js index 9052493..fd6d106 100644 --- a/app/tarot-database-assembly.js +++ b/app/tarot-database-assembly.js @@ -43,6 +43,40 @@ throw new Error("Tarot database assembly dependencies are incomplete"); } + function buildTokenDateRange(startToken, endToken) { + const parseToken = (value) => { + const match = String(value || "").trim().match(/^(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + + const month = Number(match[1]); + const day = Number(match[2]); + if (!Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + + return new Date(2025, month - 1, day); + }; + + const start = parseToken(startToken); + const endBase = parseToken(endToken); + if (!start || !endBase) { + return null; + } + + const wraps = endBase.getTime() < start.getTime(); + const end = wraps ? new Date(2026, endBase.getMonth(), endBase.getDate()) : endBase; + + return { + start, + end, + startToken: startToken || null, + endToken: endToken || null, + label: `${formatMonthDayLabel(start)}–${formatMonthDayLabel(end)}` + }; + } + function buildMajorCards(referenceData, magickDataset) { const tarotDb = getTarotDbConfig(referenceData); const dynamicRelations = buildMajorDynamicRelations(referenceData); @@ -178,6 +212,10 @@ const windowDecans = windowDecanIds .map((decanId) => decanById.get(decanId) || null) .filter(Boolean); + const explicitWindowRange = buildTokenDateRange( + tarotDb.courtDateRanges?.[cardName]?.start, + tarotDb.courtDateRanges?.[cardName]?.end + ); const dynamicRelations = []; const monthKeys = new Set(); @@ -255,9 +293,17 @@ if (windowDecans.length) { const firstRange = windowDecans[0].dateRange; const lastRange = windowDecans[windowDecans.length - 1].dateRange; - const windowLabel = firstRange && lastRange - ? `${formatMonthDayLabel(firstRange.start)}–${formatMonthDayLabel(lastRange.end)}` - : "--"; + const fallbackWindowRange = firstRange && lastRange + ? { + start: firstRange.start, + end: lastRange.end, + startToken: firstRange.startToken, + endToken: lastRange.endToken, + label: `${formatMonthDayLabel(firstRange.start)}–${formatMonthDayLabel(lastRange.end)}` + } + : null; + const windowRange = explicitWindowRange || fallbackWindowRange; + const windowLabel = windowRange?.label || "--"; dynamicRelations.unshift( createRelation( @@ -265,8 +311,8 @@ `${rankKey}-${suitKey}`, `Court date window: ${windowLabel}`, { - dateStart: firstRange?.startToken || null, - dateEnd: lastRange?.endToken || null, + dateStart: windowRange?.startToken || null, + dateEnd: windowRange?.endToken || null, dateRange: windowLabel, decanIds: windowDecanIds } diff --git a/app/tarot-database-builders.js b/app/tarot-database-builders.js index 1b7e3bc..bd948a1 100644 --- a/app/tarot-database-builders.js +++ b/app/tarot-database-builders.js @@ -29,7 +29,8 @@ suitInfo: hasDb && db.suitInfo && typeof db.suitInfo === "object" ? db.suitInfo : suitInfo, rankInfo: hasDb && db.rankInfo && typeof db.rankInfo === "object" ? db.rankInfo : rankInfo, courtInfo: hasDb && db.courtInfo && typeof db.courtInfo === "object" ? db.courtInfo : courtInfo, - courtDecanWindows: hasDb && db.courtDecanWindows && typeof db.courtDecanWindows === "object" ? db.courtDecanWindows : courtDecanWindows + courtDecanWindows: hasDb && db.courtDecanWindows && typeof db.courtDecanWindows === "object" ? db.courtDecanWindows : courtDecanWindows, + courtDateRanges: hasDb && db.courtDateRanges && typeof db.courtDateRanges === "object" ? db.courtDateRanges : {} }; } @@ -178,7 +179,36 @@ return { start, end }; } - function buildDecanDateRange(sign, decanIndex) { + function buildTokenDateRange(startToken, endToken) { + const start = monthDayToDate(startToken, 2025); + const endBase = monthDayToDate(endToken, 2025); + if (!start || !endBase) { + return null; + } + + const wraps = endBase.getTime() < start.getTime(); + const end = wraps ? monthDayToDate(endToken, 2026) : endBase; + if (!end) { + return null; + } + + return { + start, + end, + startMonth: start.getMonth() + 1, + endMonth: end.getMonth() + 1, + startToken: formatMonthDayToken(start), + endToken: formatMonthDayToken(end), + label: `${formatMonthDayLabel(start)}–${formatMonthDayLabel(end)}` + }; + } + + function buildDecanDateRange(sign, decanIndex, decan = null) { + const explicitRange = buildTokenDateRange(decan?.dateStart, decan?.dateEnd); + if (explicitRange) { + return explicitRange; + } + const bounds = getSignDateBounds(sign); if (!bounds || !Number.isFinite(Number(decanIndex))) { return null; @@ -238,7 +268,7 @@ const startDegree = (index - 1) * 10; const endDegree = startDegree + 10; - const dateRange = buildDecanDateRange(sign, index); + const dateRange = buildDecanDateRange(sign, index, decan); return { decan, diff --git a/app/ui-tarot-frame.js b/app/ui-tarot-frame.js index 4404865..a89c12f 100644 --- a/app/ui-tarot-frame.js +++ b/app/ui-tarot-frame.js @@ -7,6 +7,30 @@ 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, 3, 5, 7, 9]; + const HOUSE_BOTTOM_START_ROW = 12; + const HOUSE_LEFT_START_COLUMN = 2; + const HOUSE_MIDDLE_START_COLUMN = 8; + const HOUSE_RIGHT_START_COLUMN = 15; const ZODIAC_START_TOKEN_BY_SIGN_ID = { aries: "03-21", taurus: "04-20", @@ -38,7 +62,7 @@ quality: 0.98 } }; - const BOARD_LAYOUTS = [ + const FRAME_LAYOUT_GROUPS = [ { id: "extra-cards", title: "Extra Row", @@ -89,6 +113,55 @@ } ]; + const LAYOUT_PRESETS = [ + { + id: "frames", + label: "Frames", + title: "Master 18x18 Frame Grid", + subtitle: "Top row holds the remaining 18 cards, while the centered frame keeps the small cards, court dates, and zodiac trumps grouped together. Every square on the grid is a snap target for custom layouts.", + 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 18x18 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, @@ -99,13 +172,23 @@ suppressClick: false, showInfo: true, settingsOpen: false, + layoutMenuOpen: false, + currentLayoutId: "frames", exportInProgress: false, exportFormat: "webp" }; let config = { ensureTarotSection: null, - getCards: () => [] + getCards: () => [], + getHouseTopCardsVisible: () => true, + getHouseTopInfoModes: () => ({}), + getHouseBottomCardsVisible: () => true, + getHouseBottomInfoModes: () => ({}), + setHouseTopCardsVisible: () => {}, + setHouseTopInfoMode: () => {}, + setHouseBottomCardsVisible: () => {}, + setHouseBottomInfoMode: () => {} }; function buildPerimeterPath(size, rowOffset = 1, columnOffset = 1) { @@ -129,14 +212,33 @@ return { tarotFrameBoardEl: document.getElementById("tarot-frame-board"), tarotFrameStatusEl: document.getElementById("tarot-frame-status"), - tarotFrameResetEl: document.getElementById("tarot-frame-reset"), + 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"), tarotFrameShowInfoEl: document.getElementById("tarot-frame-show-info"), + tarotFrameHouseSettingsEl: document.getElementById("tarot-frame-house-settings"), + tarotFrameHouseTopCardsVisibleEl: document.getElementById("tarot-frame-house-top-cards-visible"), + tarotFrameHouseTopInfoHebrewEl: document.getElementById("tarot-frame-house-top-info-hebrew"), + tarotFrameHouseTopInfoPlanetEl: document.getElementById("tarot-frame-house-top-info-planet"), + tarotFrameHouseTopInfoZodiacEl: document.getElementById("tarot-frame-house-top-info-zodiac"), + tarotFrameHouseTopInfoTrumpEl: document.getElementById("tarot-frame-house-top-info-trump"), + tarotFrameHouseTopInfoPathEl: document.getElementById("tarot-frame-house-top-info-path"), + tarotFrameHouseTopInfoDateEl: document.getElementById("tarot-frame-house-top-info-date"), + tarotFrameHouseBottomCardsVisibleEl: document.getElementById("tarot-frame-house-bottom-cards-visible"), + tarotFrameHouseBottomInfoZodiacEl: document.getElementById("tarot-frame-house-bottom-info-zodiac"), + tarotFrameHouseBottomInfoDecanEl: document.getElementById("tarot-frame-house-bottom-info-decan"), + tarotFrameHouseBottomInfoMonthEl: document.getElementById("tarot-frame-house-bottom-info-month"), + tarotFrameHouseBottomInfoRulerEl: document.getElementById("tarot-frame-house-bottom-info-ruler"), + tarotFrameHouseBottomInfoDateEl: document.getElementById("tarot-frame-house-bottom-info-date"), tarotFrameExportWebpEl: document.getElementById("tarot-frame-export-webp") }; } + function getLayoutOptionElements() { + return Array.from(document.querySelectorAll(".tarot-frame-layout-option[data-layout-preset-id]")); + } + function normalizeLabelText(value) { return String(value || "").replace(/\s+/g, " ").trim(); } @@ -209,6 +311,14 @@ return String(value || "").trim().toLowerCase(); } + function normalizeLookupCardName(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/\b(pentacles?|coins?)\b/g, "disks"); + } + function getCards() { const cards = config.getCards?.(); return Array.isArray(cards) ? cards : []; @@ -228,6 +338,12 @@ : 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) { @@ -305,6 +421,112 @@ return getCyclicDayValue(leftToken, cycleStartToken) - getCyclicDayValue(rightToken, cycleStartToken); } + function assignCardsToPositions(placements, positions, orderedCards) { + (Array.isArray(positions) ? positions : []).forEach((position, index) => { + const card = orderedCards[index] || null; + if (!card) { + return; + } + + placements.push({ + row: position.row, + column: position.column, + cardId: getCardId(card) + }); + }); + } + + function buildMinorCardName(rankNumber, suit) { + const rankName = ({ + 1: "Ace", + 2: "Two", + 3: "Three", + 4: "Four", + 5: "Five", + 6: "Six", + 7: "Seven", + 8: "Eight", + 9: "Nine", + 10: "Ten" + })[Number(rankNumber)]; + const suitName = String(suit || "").trim(); + return rankName && suitName ? `${rankName} of ${suitName}` : ""; + } + + function buildCourtCardName(rank, suit) { + const rankName = String(rank || "").trim(); + const suitName = String(suit || "").trim(); + return rankName && suitName ? `${rankName} of ${suitName}` : ""; + } + + function getCardLookupMap(cards) { + const lookup = new Map(); + cards.forEach((card) => { + const key = normalizeLookupCardName(card?.name); + if (key) { + lookup.set(key, card); + } + }); + return lookup; + } + + function findCardByLookupName(cardLookupMap, cardName) { + return cardLookupMap.get(normalizeLookupCardName(cardName)) || null; + } + + function findMajorCardByTrumpNumber(cards, trumpNumber) { + const target = Number(trumpNumber); + return cards.find((card) => card?.arcana === "Major" && Number(card?.number) === target) || null; + } + + function buildHousePlacements(cards) { + const placements = []; + const lookupMap = getCardLookupMap(cards); + + HOUSE_TRUMP_ROWS.forEach((trumpNumbers, rowIndex) => { + const rowCards = trumpNumbers.map((trumpNumber) => findMajorCardByTrumpNumber(cards, trumpNumber)); + const startColumn = Math.floor((MASTER_GRID_SIZE - rowCards.length) / 2) + 1; + assignCardsToPositions( + placements, + rowCards.map((card, index) => ({ row: HOUSE_TRUMP_GRID_ROWS[rowIndex], column: startColumn + index })), + rowCards + ); + }); + + HOUSE_MINOR_NUMBER_BANDS.forEach((numbers, rowIndex) => { + const row = HOUSE_BOTTOM_START_ROW + rowIndex; + const leftCards = numbers.map((rankNumber) => findCardByLookupName(lookupMap, buildMinorCardName(rankNumber, HOUSE_LEFT_SUITS[rowIndex]))); + const rightCards = numbers.map((rankNumber) => findCardByLookupName(lookupMap, buildMinorCardName(rankNumber, HOUSE_RIGHT_SUITS[rowIndex]))); + + assignCardsToPositions( + placements, + leftCards.map((card, index) => ({ row, column: HOUSE_LEFT_START_COLUMN + index })), + leftCards + ); + assignCardsToPositions( + placements, + rightCards.map((card, index) => ({ row, column: HOUSE_RIGHT_START_COLUMN + index })), + rightCards + ); + }); + + HOUSE_MIDDLE_RANKS.forEach((rank, rowIndex) => { + const row = HOUSE_BOTTOM_START_ROW + rowIndex; + const middleCards = HOUSE_MIDDLE_SUITS.map((suit) => findCardByLookupName(lookupMap, buildCourtCardName(rank, suit))); + assignCardsToPositions( + placements, + middleCards.map((card, index) => ({ row, column: HOUSE_MIDDLE_START_COLUMN + index })), + middleCards + ); + }); + + return placements; + } + + function getLayoutPreset(layoutId = state.currentLayoutId) { + return LAYOUT_PRESETS.find((preset) => preset.id === normalizeKey(layoutId)) || LAYOUT_PRESETS[0]; + } + function buildCardSignature(cards) { return cards.map((card) => getCardId(card)).filter(Boolean).sort().join("|"); } @@ -343,17 +565,349 @@ return String(label || card?.name || "Tarot").trim() || "Tarot"; } - function getCardOverlayDate(card) { - const decan = getRelation(card, "decan")?.data || null; - if (decan?.dateStart && decan?.dateEnd) { - return formatDateRange(decan.dateStart, decan.dateEnd); + 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 (getLayoutPreset().id !== "house" || !card) { + return true; + } + + if (card.arcana === "Major") { + return config.getHouseTopCardsVisible?.() !== false; + } + + return config.getHouseBottomCardsVisible?.() !== false; + } + + function buildCardTextFaceModel(card) { + const label = state.showInfo && getLayoutPreset().id === "house" ? 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]; @@ -381,18 +935,21 @@ } } - function resetLayout(cards = getCards(), nextStatusMessage = "") { + function applyLayoutPreset(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") { + const layoutPreset = getLayoutPreset(layoutId); + state.currentLayoutId = layoutPreset.id; state.slotAssignments.clear(); - BOARD_LAYOUTS.forEach((layout) => { - const orderedCards = layout.getOrderedCards(cards); - layout.positions.forEach((position, index) => { - state.slotAssignments.set(getSlotId(position.row, position.column), getCardId(orderedCards[index] || null)); - }); + layoutPreset.buildPlacements(cards).forEach((placement) => { + state.slotAssignments.set(getSlotId(placement.row, placement.column), placement.cardId); }); state.layoutReady = true; - setStatus(nextStatusMessage || buildReadyStatus(cards)); + setStatus(nextStatusMessage || layoutPreset.statusMessage || buildReadyStatus(cards)); + } + + function resetLayout(cards = getCards(), nextStatusMessage = "") { + applyLayoutPreset(state.currentLayoutId, cards, nextStatusMessage); } function getAssignedCard(slotId, cardMap) { @@ -401,9 +958,37 @@ } function getCardOverlayLabel(card) { + if (!state.showInfo) { + return ""; + } + + if (getLayoutPreset().id === "house") { + const label = buildHouseLabel(card); + return normalizeLabelText([label?.primary, label?.secondary].filter(Boolean).join(" · ")); + } + return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card); } + 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"); @@ -441,8 +1026,10 @@ button.setAttribute("aria-label", `${getDisplayCardName(card)} in row ${row}, column ${column}`); button.title = getDisplayCardName(card); + const showImage = shouldShowCardImage(card); + const imageSrc = resolveCardThumbnail(card); - if (imageSrc) { + if (showImage && imageSrc) { const image = document.createElement("img"); image.className = "tarot-frame-card-image"; image.src = imageSrc; @@ -451,14 +1038,16 @@ image.decoding = "async"; image.draggable = false; button.appendChild(image); - } else { + } 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 (state.showInfo) { + if (showImage && state.showInfo) { const overlay = document.createElement("span"); overlay.className = "tarot-frame-card-badge"; overlay.textContent = getCardOverlayLabel(card); @@ -469,10 +1058,10 @@ return slotEl; } - function createLegend() { + function createLegend(layoutPreset) { const legendEl = document.createElement("div"); legendEl.className = "tarot-frame-legend"; - BOARD_LAYOUTS.forEach((layout) => { + layoutPreset.legendItems.forEach((layout) => { const itemEl = document.createElement("div"); itemEl.className = "tarot-frame-legend-item"; @@ -494,6 +1083,7 @@ const cards = getCards(); const cardMap = getCardMap(cards); + const layoutPreset = getLayoutPreset(); tarotFrameBoardEl.replaceChildren(); const panelEl = document.createElement("section"); @@ -505,10 +1095,10 @@ const titleWrapEl = document.createElement("div"); const titleEl = document.createElement("h3"); titleEl.className = "tarot-frame-panel-title"; - titleEl.textContent = "Master 18x18 Frame Grid"; + titleEl.textContent = layoutPreset.title; const subtitleEl = document.createElement("p"); subtitleEl.className = "tarot-frame-panel-subtitle"; - subtitleEl.textContent = "Top row holds the remaining 18 cards, while the centered frame keeps the small cards, court dates, and zodiac trumps grouped together. Every square on the grid is a snap target for custom layouts."; + subtitleEl.textContent = layoutPreset.subtitle; titleWrapEl.append(titleEl, subtitleEl); const countEl = document.createElement("span"); @@ -516,7 +1106,7 @@ countEl.textContent = `${cards.length} cards / ${MASTER_GRID_SIZE * MASTER_GRID_SIZE} cells`; headEl.append(titleWrapEl, countEl); - panelEl.append(headEl, createLegend()); + panelEl.append(headEl, createLegend(layoutPreset)); const gridEl = document.createElement("div"); gridEl.className = "tarot-frame-grid tarot-frame-grid--master"; @@ -535,12 +1125,46 @@ function syncControls() { const { + tarotFrameLayoutToggleEl, + tarotFrameLayoutPanelEl, tarotFrameSettingsToggleEl, tarotFrameSettingsPanelEl, tarotFrameShowInfoEl, + tarotFrameHouseSettingsEl, + tarotFrameHouseTopCardsVisibleEl, + tarotFrameHouseTopInfoHebrewEl, + tarotFrameHouseTopInfoPlanetEl, + tarotFrameHouseTopInfoZodiacEl, + tarotFrameHouseTopInfoTrumpEl, + tarotFrameHouseTopInfoPathEl, + tarotFrameHouseTopInfoDateEl, + tarotFrameHouseBottomCardsVisibleEl, + tarotFrameHouseBottomInfoZodiacEl, + tarotFrameHouseBottomInfoDecanEl, + tarotFrameHouseBottomInfoMonthEl, + tarotFrameHouseBottomInfoRulerEl, + tarotFrameHouseBottomInfoDateEl, tarotFrameExportWebpEl, - tarotFrameResetEl } = getElements(); + const layoutPreset = getLayoutPreset(); + const isHouseLayout = layoutPreset.id === "house"; + + if (tarotFrameLayoutToggleEl) { + tarotFrameLayoutToggleEl.setAttribute("aria-expanded", state.layoutMenuOpen ? "true" : "false"); + tarotFrameLayoutToggleEl.textContent = `Layout: ${layoutPreset.label}`; + tarotFrameLayoutToggleEl.disabled = Boolean(state.exportInProgress); + } + + if (tarotFrameLayoutPanelEl) { + tarotFrameLayoutPanelEl.hidden = !state.layoutMenuOpen; + } + + getLayoutOptionElements().forEach((button) => { + const isActive = String(button.dataset.layoutPresetId || "") === layoutPreset.id; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-checked", isActive ? "true" : "false"); + button.disabled = Boolean(state.exportInProgress); + }); if (tarotFrameSettingsToggleEl) { tarotFrameSettingsToggleEl.setAttribute("aria-expanded", state.settingsOpen ? "true" : "false"); @@ -557,6 +1181,40 @@ tarotFrameShowInfoEl.disabled = Boolean(state.exportInProgress); } + if (tarotFrameHouseSettingsEl) { + tarotFrameHouseSettingsEl.hidden = !isHouseLayout; + } + + if (tarotFrameHouseTopCardsVisibleEl) { + tarotFrameHouseTopCardsVisibleEl.checked = config.getHouseTopCardsVisible?.() !== false; + tarotFrameHouseTopCardsVisibleEl.disabled = !isHouseLayout || 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 = !isHouseLayout || Boolean(state.exportInProgress); + }); + + if (tarotFrameHouseBottomCardsVisibleEl) { + tarotFrameHouseBottomCardsVisibleEl.checked = config.getHouseBottomCardsVisible?.() !== false; + tarotFrameHouseBottomCardsVisibleEl.disabled = !isHouseLayout || Boolean(state.exportInProgress); + } + if (tarotFrameExportWebpEl) { const supportsWebp = isExportFormatSupported("webp"); tarotFrameExportWebpEl.hidden = !supportsWebp; @@ -566,10 +1224,6 @@ tarotFrameExportWebpEl.title = "Download the current frame grid arrangement as a WebP image."; } } - - if (tarotFrameResetEl) { - tarotFrameResetEl.disabled = Boolean(state.exportInProgress); - } } function getSlotElement(slotId) { @@ -827,31 +1481,52 @@ } function handleDocumentClick(event) { - if (!state.settingsOpen) { - return; - } - const target = event.target; if (!(target instanceof Node)) { return; } - const { tarotFrameSettingsPanelEl, tarotFrameSettingsToggleEl } = getElements(); - if (tarotFrameSettingsPanelEl?.contains(target) || tarotFrameSettingsToggleEl?.contains(target)) { - return; + const { + tarotFrameSettingsPanelEl, + tarotFrameSettingsToggleEl, + tarotFrameLayoutPanelEl, + tarotFrameLayoutToggleEl + } = getElements(); + + let changed = false; + if (state.settingsOpen && !tarotFrameSettingsPanelEl?.contains(target) && !tarotFrameSettingsToggleEl?.contains(target)) { + state.settingsOpen = false; + changed = true; } - state.settingsOpen = false; - syncControls(); + if (state.layoutMenuOpen && !tarotFrameLayoutPanelEl?.contains(target) && !tarotFrameLayoutToggleEl?.contains(target)) { + state.layoutMenuOpen = false; + changed = true; + } + + if (changed) { + syncControls(); + } } function handleDocumentKeydown(event) { - if (!state.settingsOpen || event.key !== "Escape") { + if (event.key !== "Escape") { return; } - state.settingsOpen = false; - syncControls(); + let changed = false; + if (state.settingsOpen) { + state.settingsOpen = false; + changed = true; + } + if (state.layoutMenuOpen) { + state.layoutMenuOpen = false; + changed = true; + } + + if (changed) { + syncControls(); + } } function drawRoundedRectPath(context, x, y, width, height, radius) { @@ -932,6 +1607,46 @@ context.drawImage(image, drawX, drawY, drawWidth, drawHeight); } + function drawTextFaceToCanvas(context, x, y, size, faceModel) { + const primaryText = normalizeLabelText(faceModel?.primary || "Tarot"); + const secondaryText = normalizeLabelText(faceModel?.secondary); + const maxWidth = size - 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 + ((size - 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 + (size / 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 + (size / 2), currentY, maxWidth); + currentY += secondaryLineHeight; + }); + } + + context.restore(); + } + function drawSlotToCanvas(context, x, y, size, card, image) { if (!card) { context.save(); @@ -947,13 +1662,14 @@ const cardX = x + EXPORT_CARD_INSET; const cardY = y + EXPORT_CARD_INSET; const cardSize = size - (EXPORT_CARD_INSET * 2); + const showImage = shouldShowCardImage(card); context.save(); drawRoundedRectPath(context, cardX, cardY, cardSize, cardSize, 0); context.clip(); - if (image) { + if (showImage && image) { drawImageContain(context, image, cardX, cardY, cardSize, cardSize); - } else { + } else if (showImage) { context.fillStyle = EXPORT_PANEL; context.fillRect(cardX, cardY, cardSize, cardSize); context.fillStyle = "#f8fafc"; @@ -967,10 +1683,14 @@ context.fillText(line, cardX + (cardSize / 2), currentY, cardSize - 18); currentY += lineHeight; }); + } else { + context.fillStyle = EXPORT_PANEL; + context.fillRect(cardX, cardY, cardSize, cardSize); + drawTextFaceToCanvas(context, cardX, cardY, cardSize, buildCardTextFaceModel(card)); } context.restore(); - if (state.showInfo) { + if (showImage && state.showInfo) { const overlayText = getCardOverlayLabel(card); if (overlayText) { const overlayHeight = 30; @@ -1115,10 +1835,24 @@ function bindEvents() { const { tarotFrameBoardEl, - tarotFrameResetEl, + tarotFrameLayoutToggleEl, + tarotFrameLayoutPanelEl, tarotFrameSettingsToggleEl, tarotFrameSettingsPanelEl, tarotFrameShowInfoEl, + tarotFrameHouseTopCardsVisibleEl, + tarotFrameHouseTopInfoHebrewEl, + tarotFrameHouseTopInfoPlanetEl, + tarotFrameHouseTopInfoZodiacEl, + tarotFrameHouseTopInfoTrumpEl, + tarotFrameHouseTopInfoPathEl, + tarotFrameHouseTopInfoDateEl, + tarotFrameHouseBottomCardsVisibleEl, + tarotFrameHouseBottomInfoZodiacEl, + tarotFrameHouseBottomInfoDecanEl, + tarotFrameHouseBottomInfoMonthEl, + tarotFrameHouseBottomInfoRulerEl, + tarotFrameHouseBottomInfoDateEl, tarotFrameExportWebpEl } = getElements(); if (tarotFrameBoardEl) { @@ -1127,14 +1861,38 @@ tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart); } - if (tarotFrameResetEl) { - tarotFrameResetEl.addEventListener("click", () => { + if (tarotFrameLayoutToggleEl) { + tarotFrameLayoutToggleEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (state.exportInProgress) { + return; + } + state.layoutMenuOpen = !state.layoutMenuOpen; + if (state.layoutMenuOpen) { + state.settingsOpen = false; + } + syncControls(); + }); + } + + if (tarotFrameLayoutPanelEl) { + tarotFrameLayoutPanelEl.addEventListener("click", (event) => { + event.stopPropagation(); + const target = event.target; + const option = target instanceof Element ? target.closest(".tarot-frame-layout-option[data-layout-preset-id]") : null; + if (!(option instanceof HTMLButtonElement)) { + return; + } + const cards = getCards(); if (!cards.length) { return; } - resetLayout(cards, "Master grid reset to the default chronological frame arrangement."); + + applyLayoutPreset(option.dataset.layoutPresetId, cards, `${getLayoutPreset(option.dataset.layoutPresetId).label} layout applied to the master grid.`); + state.layoutMenuOpen = false; render(); + syncControls(); }); } @@ -1145,6 +1903,9 @@ return; } state.settingsOpen = !state.settingsOpen; + if (state.settingsOpen) { + state.layoutMenuOpen = false; + } syncControls(); }); } @@ -1163,6 +1924,31 @@ }); } + [ + [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"); @@ -1187,7 +1973,7 @@ const signature = buildCardSignature(cards); if (!state.layoutReady || state.cardSignature !== signature) { state.cardSignature = signature; - resetLayout(cards); + applyLayoutPreset(state.currentLayoutId, cards); } else { setStatus(state.statusMessage || buildReadyStatus(cards)); } @@ -1217,6 +2003,15 @@ ensureTarotFrameSection, render, resetLayout, + setLayoutPreset(layoutId, options = {}) { + const cards = getCards(); + state.currentLayoutId = getLayoutPreset(layoutId).id; + if (cards.length && options.reapply !== false) { + applyLayoutPreset(state.currentLayoutId, cards, options.statusMessage || `${getLayoutPreset(layoutId).label} layout applied to the master grid.`); + render(); + } + syncControls(); + }, exportImage, isExportFormatSupported }; diff --git a/app/ui-tarot.js b/app/ui-tarot.js index 0797429..f5d588c 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -594,6 +594,46 @@ } } + function refreshHouseUi() { + if (!state.initialized) { + return; + } + + const elements = getElements(); + renderHouseOfCards(elements); + syncHouseControls(elements); + } + + function setHouseTopCardsVisible(value) { + state.houseTopCardsVisible = Boolean(value); + refreshHouseUi(); + } + + function setHouseTopInfoMode(mode, value) { + const key = String(mode || "").trim(); + if (!key || !Object.prototype.hasOwnProperty.call(state.houseTopInfoModes, key)) { + return; + } + + state.houseTopInfoModes[key] = Boolean(value); + refreshHouseUi(); + } + + function setHouseBottomCardsVisible(value) { + state.houseBottomCardsVisible = Boolean(value); + refreshHouseUi(); + } + + function setHouseBottomInfoMode(mode, value) { + const key = String(mode || "").trim(); + if (!key || !Object.prototype.hasOwnProperty.call(state.houseBottomInfoModes, key)) { + return; + } + + state.houseBottomInfoModes[key] = Boolean(value); + refreshHouseUi(); + } + async function exportHouseOfCards(elements, format = "png") { if (state.houseExportInProgress) { return; @@ -1076,6 +1116,14 @@ ensureTarotSection, selectCardByTrump, selectCardByName, - getCards: () => state.cards + getCards: () => state.cards, + getHouseTopCardsVisible: () => state.houseTopCardsVisible, + getHouseTopInfoModes: () => ({ ...state.houseTopInfoModes }), + getHouseBottomCardsVisible: () => state.houseBottomCardsVisible, + getHouseBottomInfoModes: () => ({ ...state.houseBottomInfoModes }), + setHouseTopCardsVisible, + setHouseTopInfoMode, + setHouseBottomCardsVisible, + setHouseBottomInfoMode }; })(); diff --git a/index.html b/index.html index 1ad1b43..3835a11 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +
@@ -75,7 +75,6 @@ -
@@ -314,16 +313,55 @@

Tarot Frame

-

Arrange all 78 tarot cards inside one master 18x18 grid. The extra cards sit across the top row, and every square stays available for custom layouts.

+

Arrange all 78 tarot cards inside one master 18x18 grid, then switch between the Frames and House of Cards presets without leaving the page.

- + +
@@ -1076,9 +1114,9 @@ - - - + + + @@ -1090,7 +1128,7 @@ - + @@ -1130,7 +1168,7 @@ - + @@ -1139,7 +1177,7 @@ - +