2026-04-01 12:31:56 -07:00
( 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" ] ;
2026-04-01 16:08:52 -07:00
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 ]
] ;
2026-04-01 19:26:38 -07:00
const HOUSE _TRUMP _GRID _ROWS = [ 1 , 2 , 3 , 4 , 5 ] ;
const HOUSE _BOTTOM _START _ROW = 8 ;
2026-04-01 16:08:52 -07:00
const HOUSE _LEFT _START _COLUMN = 2 ;
2026-04-01 19:26:38 -07:00
const HOUSE _MIDDLE _START _COLUMN = 6 ;
const HOUSE _RIGHT _START _COLUMN = 11 ;
const TAROT _CARD _WIDTH _RATIO = 2 ;
const TAROT _CARD _HEIGHT _RATIO = 3 ;
2026-04-01 12:31:56 -07:00
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"
} ;
2026-04-01 19:26:38 -07:00
const MASTER _GRID _SIZE = 14 ;
const FRAME _GRID _ZOOM _STEPS = [ 1 , 1.2 , 1.4 , 1.7 , 2 ] ;
const EXPORT _SLOT _WIDTH = 120 ;
const EXPORT _SLOT _HEIGHT = Math . round ( ( EXPORT _SLOT _WIDTH * TAROT _CARD _HEIGHT _RATIO ) / TAROT _CARD _WIDTH _RATIO ) ;
2026-04-01 12:31:56 -07:00
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
}
} ;
2026-04-01 16:08:52 -07:00
const FRAME _LAYOUT _GROUPS = [
2026-04-01 12:31:56 -07:00
{
id : "extra-cards" ,
2026-04-01 19:26:38 -07:00
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 }
] ,
2026-04-01 12:31:56 -07:00
getOrderedCards ( cards ) {
return cards
. filter ( ( card ) => isExtraTopRowCard ( card ) )
. sort ( compareExtraTopRowCards ) ;
}
} ,
{
id : "small-cards" ,
title : "Small Cards" ,
description : "Outer perimeter in chronological decan order." ,
2026-04-01 19:26:38 -07:00
positions : buildPerimeterPath ( 10 , 5 , 3 ) ,
2026-04-01 12:31:56 -07:00
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." ,
2026-04-01 19:26:38 -07:00
positions : buildPerimeterPath ( 4 , 8 , 4 ) ,
2026-04-01 12:31:56 -07:00
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." ,
2026-04-01 19:26:38 -07:00
positions : buildPerimeterPath ( 4 , 8 , 8 ) ,
2026-04-01 12:31:56 -07:00
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" ) ;
} ) ;
}
}
] ;
2026-04-01 16:08:52 -07:00
const LAYOUT _PRESETS = [
{
id : "frames" ,
label : "Frames" ,
2026-04-01 19:26:38 -07:00
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." ,
2026-04-01 16:08:52 -07:00
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" ,
2026-04-01 19:26:38 -07:00
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." ,
2026-04-01 16:08:52 -07:00
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 ) ;
}
}
] ;
2026-04-01 12:31:56 -07:00
const state = {
initialized : false ,
layoutReady : false ,
cardSignature : "" ,
slotAssignments : new Map ( ) ,
statusMessage : "Loading tarot cards..." ,
drag : null ,
suppressClick : false ,
showInfo : true ,
settingsOpen : false ,
2026-04-01 16:08:52 -07:00
layoutMenuOpen : false ,
currentLayoutId : "frames" ,
2026-04-01 12:31:56 -07:00
exportInProgress : false ,
2026-04-01 19:26:38 -07:00
exportFormat : "webp" ,
gridZoomStepIndex : 0
2026-04-01 12:31:56 -07:00
} ;
let config = {
ensureTarotSection : null ,
2026-04-01 16:08:52 -07:00
getCards : ( ) => [ ] ,
getHouseTopCardsVisible : ( ) => true ,
getHouseTopInfoModes : ( ) => ( { } ) ,
getHouseBottomCardsVisible : ( ) => true ,
getHouseBottomInfoModes : ( ) => ( { } ) ,
setHouseTopCardsVisible : ( ) => { } ,
setHouseTopInfoMode : ( ) => { } ,
setHouseBottomCardsVisible : ( ) => { } ,
setHouseBottomInfoMode : ( ) => { }
2026-04-01 12:31:56 -07:00
} ;
function buildPerimeterPath ( size , rowOffset = 1 , columnOffset = 1 ) {
const path = [ ] ;
for ( let column = 0 ; column < size ; column += 1 ) {
path . push ( { row : rowOffset , column : columnOffset + column } ) ;
}
for ( let row = 1 ; row < size - 1 ; row += 1 ) {
path . push ( { row : rowOffset + row , column : columnOffset + size - 1 } ) ;
}
for ( let column = size - 1 ; column >= 0 ; column -= 1 ) {
path . push ( { row : rowOffset + size - 1 , column : columnOffset + column } ) ;
}
for ( let row = size - 2 ; row >= 1 ; row -= 1 ) {
path . push ( { row : rowOffset + row , column : columnOffset } ) ;
}
return path ;
}
function getElements ( ) {
return {
tarotFrameBoardEl : document . getElementById ( "tarot-frame-board" ) ,
tarotFrameStatusEl : document . getElementById ( "tarot-frame-status" ) ,
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl : document . getElementById ( "tarot-frame-layout-toggle" ) ,
tarotFrameLayoutPanelEl : document . getElementById ( "tarot-frame-layout-panel" ) ,
2026-04-01 12:31:56 -07:00
tarotFrameSettingsToggleEl : document . getElementById ( "tarot-frame-settings-toggle" ) ,
tarotFrameSettingsPanelEl : document . getElementById ( "tarot-frame-settings-panel" ) ,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl : document . getElementById ( "tarot-frame-grid-zoom" ) ,
2026-04-01 12:31:56 -07:00
tarotFrameShowInfoEl : document . getElementById ( "tarot-frame-show-info" ) ,
2026-04-01 16:08:52 -07:00
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" ) ,
2026-04-01 12:31:56 -07:00
tarotFrameExportWebpEl : document . getElementById ( "tarot-frame-export-webp" )
} ;
}
2026-04-01 16:08:52 -07:00
function getLayoutOptionElements ( ) {
return Array . from ( document . querySelectorAll ( ".tarot-frame-layout-option[data-layout-preset-id]" ) ) ;
}
2026-04-01 12:31:56 -07:00
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 ) {
2026-04-01 19:26:38 -07:00
return ` ${ Array . isArray ( cards ) ? cards . length : 0 } cards ready. Drag cards freely and use Settings to change the grid zoom for any layout. ` ;
}
function getGridZoomScale ( ) {
return FRAME _GRID _ZOOM _STEPS [ state . gridZoomStepIndex ] || FRAME _GRID _ZOOM _STEPS [ 0 ] ;
}
function buildPanelCountText ( cards = getCards ( ) ) {
return ` ${ cards . length } cards / ${ MASTER _GRID _SIZE * MASTER _GRID _SIZE } cells · Zoom ${ Math . round ( getGridZoomScale ( ) * 100 ) } % ` ;
2026-04-01 12:31:56 -07:00
}
function normalizeKey ( value ) {
return String ( value || "" ) . trim ( ) . toLowerCase ( ) ;
}
2026-04-01 16:08:52 -07:00
function normalizeLookupCardName ( value ) {
return String ( value || "" )
. trim ( )
. toLowerCase ( )
. replace ( /\s+/g , " " )
. replace ( /\b(pentacles?|coins?)\b/g , "disks" ) ;
}
2026-04-01 12:31:56 -07:00
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 ;
}
2026-04-01 16:08:52 -07:00
function getRelations ( card , type ) {
return Array . isArray ( card ? . relations )
? card . relations . filter ( ( relation ) => relation ? . type === type )
: [ ] ;
}
2026-04-01 12:31:56 -07:00
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 ) ;
}
2026-04-01 16:08:52 -07:00
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 ] ;
}
2026-04-01 12:31:56 -07:00
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" ;
}
2026-04-01 16:08:52 -07:00
function toRomanNumeral ( value ) {
let remaining = Number ( value ) ;
if ( ! Number . isFinite ( remaining ) || remaining <= 0 ) {
return "" ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
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 ) {
2026-04-01 19:26:38 -07:00
if ( ! card ) {
2026-04-01 16:08:52 -07:00
return true ;
}
if ( card . arcana === "Major" ) {
return config . getHouseTopCardsVisible ? . ( ) !== false ;
}
return config . getHouseBottomCardsVisible ? . ( ) !== false ;
}
function buildCardTextFaceModel ( card ) {
2026-04-01 19:26:38 -07:00
const label = state . showInfo ? buildHouseLabel ( card ) : null ;
2026-04-01 16:08:52 -07:00
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 ) {
2026-04-01 12:31:56 -07:00
const court = getRelation ( card , "courtDateWindow" ) ? . data || null ;
if ( court ? . dateStart && court ? . dateEnd ) {
return formatDateRange ( court . dateStart , court . dateEnd ) ;
}
2026-04-01 16:08:52 -07:00
const decan = getRelation ( card , "decan" ) ? . data || null ;
if ( decan ? . dateStart && decan ? . dateEnd ) {
return formatDateRange ( decan . dateStart , decan . dateEnd ) ;
}
2026-04-01 12:31:56 -07:00
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 ;
}
}
2026-04-01 16:08:52 -07:00
function applyLayoutPreset ( layoutId = state . currentLayoutId , cards = getCards ( ) , nextStatusMessage = "" ) {
const layoutPreset = getLayoutPreset ( layoutId ) ;
state . currentLayoutId = layoutPreset . id ;
2026-04-01 12:31:56 -07:00
state . slotAssignments . clear ( ) ;
2026-04-01 16:08:52 -07:00
layoutPreset . buildPlacements ( cards ) . forEach ( ( placement ) => {
state . slotAssignments . set ( getSlotId ( placement . row , placement . column ) , placement . cardId ) ;
2026-04-01 12:31:56 -07:00
} ) ;
state . layoutReady = true ;
2026-04-01 16:08:52 -07:00
setStatus ( nextStatusMessage || layoutPreset . statusMessage || buildReadyStatus ( cards ) ) ;
}
function resetLayout ( cards = getCards ( ) , nextStatusMessage = "" ) {
applyLayoutPreset ( state . currentLayoutId , cards , nextStatusMessage ) ;
2026-04-01 12:31:56 -07:00
}
function getAssignedCard ( slotId , cardMap ) {
const cardId = String ( state . slotAssignments . get ( slotId ) || "" ) . trim ( ) ;
return cardMap . get ( cardId ) || null ;
}
function getCardOverlayLabel ( card ) {
2026-04-01 16:08:52 -07:00
if ( ! state . showInfo ) {
return "" ;
}
2026-04-01 19:26:38 -07:00
const label = buildHouseLabel ( card ) ;
const structuredLabel = normalizeLabelText ( [ label ? . primary , label ? . secondary ] . filter ( Boolean ) . join ( " · " ) ) ;
if ( structuredLabel ) {
return structuredLabel ;
2026-04-01 16:08:52 -07:00
}
2026-04-01 12:31:56 -07:00
return getCardOverlayDate ( card ) || formatMonthDay ( getRelation ( card , "decan" ) ? . data ? . dateStart ) || getDisplayCardName ( card ) ;
}
2026-04-01 19:26:38 -07:00
function centerGridViewport ( ) {
const { tarotFrameBoardEl } = getElements ( ) ;
const gridViewportEl = tarotFrameBoardEl ? . querySelector ( ".tarot-frame-grid-viewport" ) ;
const gridTrackEl = tarotFrameBoardEl ? . querySelector ( ".tarot-frame-grid-track" ) ;
if ( ! ( gridViewportEl instanceof HTMLElement ) || ! ( gridTrackEl instanceof HTMLElement ) ) {
return ;
}
requestAnimationFrame ( ( ) => {
if ( ! ( gridViewportEl instanceof HTMLElement ) || ! ( gridTrackEl instanceof HTMLElement ) ) {
return ;
}
const overflowX = Math . max ( 0 , gridTrackEl . offsetWidth - gridViewportEl . clientWidth ) ;
gridViewportEl . scrollLeft = overflowX > 0 ? overflowX / 2 : 0 ;
requestAnimationFrame ( ( ) => {
if ( ! ( gridViewportEl instanceof HTMLElement ) || ! ( gridTrackEl instanceof HTMLElement ) ) {
return ;
}
const nextOverflowX = Math . max ( 0 , gridTrackEl . offsetWidth - gridViewportEl . clientWidth ) ;
gridViewportEl . scrollLeft = nextOverflowX > 0 ? nextOverflowX / 2 : 0 ;
} ) ;
} ) ;
}
2026-04-01 16:08:52 -07:00
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 ;
}
2026-04-01 12:31:56 -07:00
function createSlot ( row , column , card ) {
const slotId = getSlotId ( row , column ) ;
const slotEl = document . createElement ( "div" ) ;
slotEl . className = "tarot-frame-slot" ;
slotEl . dataset . slotId = slotId ;
slotEl . style . gridRow = String ( row ) ;
slotEl . style . gridColumn = String ( column ) ;
if ( state . drag ? . sourceSlotId === slotId ) {
slotEl . classList . add ( "is-drag-source" ) ;
}
if ( state . drag ? . hoverSlotId === slotId && state . drag ? . started ) {
slotEl . classList . add ( "is-drop-target" ) ;
}
const button = document . createElement ( "button" ) ;
button . type = "button" ;
button . className = "tarot-frame-card" ;
button . dataset . slotId = slotId ;
button . draggable = false ;
if ( ! card ) {
slotEl . classList . add ( "is-empty-slot" ) ;
button . classList . add ( "is-empty" ) ;
button . tabIndex = - 1 ;
const emptyEl = document . createElement ( "span" ) ;
emptyEl . className = "tarot-frame-slot-empty" ;
button . appendChild ( emptyEl ) ;
slotEl . appendChild ( button ) ;
return slotEl ;
}
button . dataset . cardId = getCardId ( card ) ;
button . setAttribute ( "aria-label" , ` ${ getDisplayCardName ( card ) } in row ${ row } , column ${ column } ` ) ;
button . title = getDisplayCardName ( card ) ;
2026-04-01 16:08:52 -07:00
const showImage = shouldShowCardImage ( card ) ;
2026-04-01 12:31:56 -07:00
const imageSrc = resolveCardThumbnail ( card ) ;
2026-04-01 16:08:52 -07:00
if ( showImage && imageSrc ) {
2026-04-01 12:31:56 -07:00
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 ) ;
2026-04-01 16:08:52 -07:00
} else if ( showImage ) {
2026-04-01 12:31:56 -07:00
const fallback = document . createElement ( "span" ) ;
fallback . className = "tarot-frame-card-fallback" ;
fallback . textContent = getDisplayCardName ( card ) ;
button . appendChild ( fallback ) ;
2026-04-01 16:08:52 -07:00
} else {
button . appendChild ( createCardTextFaceElement ( buildCardTextFaceModel ( card ) ) ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
if ( showImage && state . showInfo ) {
2026-04-01 12:31:56 -07:00
const overlay = document . createElement ( "span" ) ;
overlay . className = "tarot-frame-card-badge" ;
overlay . textContent = getCardOverlayLabel ( card ) ;
button . appendChild ( overlay ) ;
}
slotEl . appendChild ( button ) ;
return slotEl ;
}
2026-04-01 16:08:52 -07:00
function createLegend ( layoutPreset ) {
2026-04-01 12:31:56 -07:00
const legendEl = document . createElement ( "div" ) ;
legendEl . className = "tarot-frame-legend" ;
2026-04-01 16:08:52 -07:00
layoutPreset . legendItems . forEach ( ( layout ) => {
2026-04-01 12:31:56 -07:00
const itemEl = document . createElement ( "div" ) ;
itemEl . className = "tarot-frame-legend-item" ;
const titleEl = document . createElement ( "strong" ) ;
titleEl . textContent = layout . title ;
const textEl = document . createElement ( "span" ) ;
textEl . textContent = layout . description ;
itemEl . append ( titleEl , textEl ) ;
legendEl . appendChild ( itemEl ) ;
} ) ;
return legendEl ;
}
function render ( ) {
const { tarotFrameBoardEl } = getElements ( ) ;
if ( ! tarotFrameBoardEl ) {
return ;
}
const cards = getCards ( ) ;
const cardMap = getCardMap ( cards ) ;
2026-04-01 16:08:52 -07:00
const layoutPreset = getLayoutPreset ( ) ;
2026-04-01 12:31:56 -07:00
tarotFrameBoardEl . replaceChildren ( ) ;
const panelEl = document . createElement ( "section" ) ;
panelEl . className = "tarot-frame-panel tarot-frame-panel--master" ;
2026-04-01 19:26:38 -07:00
panelEl . style . setProperty ( "--frame-grid-zoom-scale" , String ( getGridZoomScale ( ) ) ) ;
2026-04-01 12:31:56 -07:00
const headEl = document . createElement ( "div" ) ;
headEl . className = "tarot-frame-panel-head" ;
const titleWrapEl = document . createElement ( "div" ) ;
const titleEl = document . createElement ( "h3" ) ;
titleEl . className = "tarot-frame-panel-title" ;
2026-04-01 16:08:52 -07:00
titleEl . textContent = layoutPreset . title ;
2026-04-01 12:31:56 -07:00
const subtitleEl = document . createElement ( "p" ) ;
subtitleEl . className = "tarot-frame-panel-subtitle" ;
2026-04-01 16:08:52 -07:00
subtitleEl . textContent = layoutPreset . subtitle ;
2026-04-01 12:31:56 -07:00
titleWrapEl . append ( titleEl , subtitleEl ) ;
const countEl = document . createElement ( "span" ) ;
countEl . className = "tarot-frame-panel-count" ;
2026-04-01 19:26:38 -07:00
countEl . textContent = buildPanelCountText ( cards ) ;
2026-04-01 12:31:56 -07:00
headEl . append ( titleWrapEl , countEl ) ;
2026-04-01 16:08:52 -07:00
panelEl . append ( headEl , createLegend ( layoutPreset ) ) ;
2026-04-01 12:31:56 -07:00
2026-04-01 19:26:38 -07:00
const gridViewportEl = document . createElement ( "div" ) ;
gridViewportEl . className = "tarot-frame-grid-viewport" ;
const gridTrackEl = document . createElement ( "div" ) ;
gridTrackEl . className = "tarot-frame-grid-track" ;
2026-04-01 12:31:56 -07:00
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 ) ) ) ;
}
}
2026-04-01 19:26:38 -07:00
gridTrackEl . appendChild ( gridEl ) ;
gridViewportEl . appendChild ( gridTrackEl ) ;
panelEl . appendChild ( gridViewportEl ) ;
2026-04-01 12:31:56 -07:00
tarotFrameBoardEl . appendChild ( panelEl ) ;
2026-04-01 19:26:38 -07:00
centerGridViewport ( ) ;
}
function applyGridZoomState ( ) {
const { tarotFrameBoardEl } = getElements ( ) ;
const panelEl = tarotFrameBoardEl ? . querySelector ( ".tarot-frame-panel--master" ) ;
if ( ! ( panelEl instanceof HTMLElement ) ) {
return ;
}
panelEl . style . setProperty ( "--frame-grid-zoom-scale" , String ( getGridZoomScale ( ) ) ) ;
const countEl = panelEl . querySelector ( ".tarot-frame-panel-count" ) ;
if ( countEl instanceof HTMLElement ) {
countEl . textContent = buildPanelCountText ( ) ;
}
centerGridViewport ( ) ;
}
function setGridZoomStepIndex ( nextIndex ) {
const safeIndex = Math . max ( 0 , Math . min ( FRAME _GRID _ZOOM _STEPS . length - 1 , Number ( nextIndex ) || 0 ) ) ;
state . gridZoomStepIndex = safeIndex ;
applyGridZoomState ( ) ;
setStatus ( ` Frame grid zoom ${ Math . round ( getGridZoomScale ( ) * 100 ) } %. This setting applies to every Frame layout. ` ) ;
2026-04-01 12:31:56 -07:00
}
function syncControls ( ) {
const {
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl ,
tarotFrameLayoutPanelEl ,
2026-04-01 12:31:56 -07:00
tarotFrameSettingsToggleEl ,
tarotFrameSettingsPanelEl ,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl ,
2026-04-01 12:31:56 -07:00
tarotFrameShowInfoEl ,
2026-04-01 16:08:52 -07:00
tarotFrameHouseSettingsEl ,
tarotFrameHouseTopCardsVisibleEl ,
tarotFrameHouseTopInfoHebrewEl ,
tarotFrameHouseTopInfoPlanetEl ,
tarotFrameHouseTopInfoZodiacEl ,
tarotFrameHouseTopInfoTrumpEl ,
tarotFrameHouseTopInfoPathEl ,
tarotFrameHouseTopInfoDateEl ,
tarotFrameHouseBottomCardsVisibleEl ,
tarotFrameHouseBottomInfoZodiacEl ,
tarotFrameHouseBottomInfoDecanEl ,
tarotFrameHouseBottomInfoMonthEl ,
tarotFrameHouseBottomInfoRulerEl ,
tarotFrameHouseBottomInfoDateEl ,
2026-04-01 12:31:56 -07:00
tarotFrameExportWebpEl ,
} = getElements ( ) ;
2026-04-01 16:08:52 -07:00
const layoutPreset = getLayoutPreset ( ) ;
if ( tarotFrameLayoutToggleEl ) {
tarotFrameLayoutToggleEl . setAttribute ( "aria-expanded" , state . layoutMenuOpen ? "true" : "false" ) ;
tarotFrameLayoutToggleEl . textContent = ` Layout: ${ layoutPreset . label } ` ;
tarotFrameLayoutToggleEl . disabled = Boolean ( state . exportInProgress ) ;
}
if ( tarotFrameLayoutPanelEl ) {
tarotFrameLayoutPanelEl . hidden = ! state . layoutMenuOpen ;
}
getLayoutOptionElements ( ) . forEach ( ( button ) => {
const isActive = String ( button . dataset . layoutPresetId || "" ) === layoutPreset . id ;
button . classList . toggle ( "is-active" , isActive ) ;
button . setAttribute ( "aria-checked" , isActive ? "true" : "false" ) ;
button . disabled = Boolean ( state . exportInProgress ) ;
} ) ;
2026-04-01 12:31:56 -07:00
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 ;
}
2026-04-01 19:26:38 -07:00
if ( tarotFrameGridZoomEl ) {
tarotFrameGridZoomEl . value = String ( state . gridZoomStepIndex ) ;
tarotFrameGridZoomEl . disabled = Boolean ( state . exportInProgress ) ;
}
2026-04-01 12:31:56 -07:00
if ( tarotFrameShowInfoEl ) {
tarotFrameShowInfoEl . checked = Boolean ( state . showInfo ) ;
tarotFrameShowInfoEl . disabled = Boolean ( state . exportInProgress ) ;
}
2026-04-01 16:08:52 -07:00
if ( tarotFrameHouseSettingsEl ) {
2026-04-01 19:26:38 -07:00
tarotFrameHouseSettingsEl . hidden = false ;
2026-04-01 16:08:52 -07:00
}
if ( tarotFrameHouseTopCardsVisibleEl ) {
tarotFrameHouseTopCardsVisibleEl . checked = config . getHouseTopCardsVisible ? . ( ) !== false ;
2026-04-01 19:26:38 -07:00
tarotFrameHouseTopCardsVisibleEl . disabled = Boolean ( state . exportInProgress ) ;
2026-04-01 16:08:52 -07:00
}
[
[ 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 ] ) ;
2026-04-01 19:26:38 -07:00
checkbox . disabled = Boolean ( state . exportInProgress ) ;
2026-04-01 16:08:52 -07:00
} ) ;
if ( tarotFrameHouseBottomCardsVisibleEl ) {
tarotFrameHouseBottomCardsVisibleEl . checked = config . getHouseBottomCardsVisible ? . ( ) !== false ;
2026-04-01 19:26:38 -07:00
tarotFrameHouseBottomCardsVisibleEl . disabled = Boolean ( state . exportInProgress ) ;
2026-04-01 16:08:52 -07:00
}
2026-04-01 12:31:56 -07:00
if ( tarotFrameExportWebpEl ) {
const supportsWebp = isExportFormatSupported ( "webp" ) ;
tarotFrameExportWebpEl . hidden = ! supportsWebp ;
tarotFrameExportWebpEl . disabled = Boolean ( state . exportInProgress ) || ! supportsWebp ;
tarotFrameExportWebpEl . textContent = state . exportInProgress ? "Exporting..." : "Export WebP" ;
if ( supportsWebp ) {
tarotFrameExportWebpEl . title = "Download the current frame grid arrangement as a WebP image." ;
}
}
}
function getSlotElement ( slotId ) {
return document . querySelector ( ` .tarot-frame-slot[data-slot-id=" ${ slotId } "] ` ) ;
}
function setHoverSlot ( slotId ) {
const previous = state . drag ? . hoverSlotId ;
if ( previous && previous !== slotId ) {
getSlotElement ( previous ) ? . classList . remove ( "is-drop-target" ) ;
}
if ( state . drag ) {
state . drag . hoverSlotId = slotId || "" ;
}
if ( slotId ) {
getSlotElement ( slotId ) ? . classList . add ( "is-drop-target" ) ;
}
}
function createDragGhost ( card ) {
const ghost = document . createElement ( "div" ) ;
ghost . className = "tarot-frame-drag-ghost" ;
const imageSrc = resolveCardThumbnail ( card ) ;
if ( imageSrc ) {
const image = document . createElement ( "img" ) ;
image . src = imageSrc ;
image . alt = "" ;
ghost . appendChild ( image ) ;
}
if ( state . showInfo ) {
const label = document . createElement ( "span" ) ;
label . className = "tarot-frame-drag-ghost-label" ;
label . textContent = getCardOverlayLabel ( card ) ;
ghost . appendChild ( label ) ;
}
document . body . appendChild ( ghost ) ;
return ghost ;
}
function moveGhost ( ghostEl , clientX , clientY ) {
if ( ! ( ghostEl instanceof HTMLElement ) ) {
return ;
}
ghostEl . style . left = ` ${ clientX } px ` ;
ghostEl . style . top = ` ${ clientY } px ` ;
}
function updateHoverSlotFromPoint ( clientX , clientY , sourceSlotId ) {
const target = document . elementFromPoint ( clientX , clientY ) ;
const slot = target instanceof Element ? target . closest ( ".tarot-frame-slot[data-slot-id]" ) : null ;
const nextSlotId = slot instanceof HTMLElement ? String ( slot . dataset . slotId || "" ) : "" ;
setHoverSlot ( nextSlotId && nextSlotId !== sourceSlotId ? nextSlotId : "" ) ;
}
function detachPointerListeners ( ) {
document . removeEventListener ( "pointermove" , handlePointerMove ) ;
document . removeEventListener ( "pointerup" , handlePointerUp ) ;
document . removeEventListener ( "pointercancel" , handlePointerCancel ) ;
}
function cleanupDrag ( ) {
if ( ! state . drag ) {
return ;
}
setHoverSlot ( "" ) ;
getSlotElement ( state . drag . sourceSlotId ) ? . classList . remove ( "is-drag-source" ) ;
if ( state . drag . ghostEl instanceof HTMLElement ) {
state . drag . ghostEl . remove ( ) ;
}
state . drag = null ;
document . body . classList . remove ( "is-tarot-frame-dragging" ) ;
detachPointerListeners ( ) ;
}
function swapOrMoveSlots ( sourceSlotId , targetSlotId ) {
const sourceCardId = String ( state . slotAssignments . get ( sourceSlotId ) || "" ) ;
const targetCardId = String ( state . slotAssignments . get ( targetSlotId ) || "" ) ;
state . slotAssignments . set ( targetSlotId , sourceCardId ) ;
if ( targetCardId ) {
state . slotAssignments . set ( sourceSlotId , targetCardId ) ;
} else {
state . slotAssignments . delete ( sourceSlotId ) ;
}
}
function describeSlot ( slotId ) {
const [ rowText , columnText ] = String ( slotId || "" ) . split ( ":" ) ;
return ` row ${ rowText || "?" } , column ${ columnText || "?" } ` ;
}
function openCardLightbox ( cardId ) {
const card = getCardMap ( getCards ( ) ) . get ( String ( cardId || "" ) . trim ( ) ) || null ;
if ( ! card ) {
return ;
}
const deckOptions = resolveDeckOptions ( card ) ;
const src = String (
tarotCardImages . resolveTarotCardImage ? . ( card . name , deckOptions )
|| tarotCardImages . resolveTarotCardThumbnail ? . ( card . name , deckOptions )
|| ""
) . trim ( ) ;
if ( ! src ) {
return ;
}
const label = getDisplayCardName ( card ) ;
window . TarotUiLightbox ? . open ? . ( {
src ,
altText : label ,
label ,
cardId : getCardId ( card ) ,
deckId : String ( tarotCardImages . getActiveDeck ? . ( ) || "" ) . trim ( )
} ) ;
}
function handlePointerDown ( event ) {
const target = event . target ;
if ( ! ( target instanceof Element ) || event . button !== 0 ) {
return ;
}
const cardButton = target . closest ( ".tarot-frame-card[data-slot-id][data-card-id]" ) ;
if ( ! ( cardButton instanceof HTMLButtonElement ) ) {
return ;
}
state . drag = {
pointerId : event . pointerId ,
sourceSlotId : String ( cardButton . dataset . slotId || "" ) ,
cardId : String ( cardButton . dataset . cardId || "" ) ,
startX : event . clientX ,
startY : event . clientY ,
started : false ,
hoverSlotId : "" ,
ghostEl : null
} ;
detachPointerListeners ( ) ;
document . addEventListener ( "pointermove" , handlePointerMove ) ;
document . addEventListener ( "pointerup" , handlePointerUp ) ;
document . addEventListener ( "pointercancel" , handlePointerCancel ) ;
}
function handlePointerMove ( event ) {
if ( ! state . drag || event . pointerId !== state . drag . pointerId ) {
return ;
}
const movedEnough = Math . hypot ( event . clientX - state . drag . startX , event . clientY - state . drag . startY ) >= 6 ;
if ( ! state . drag . started && movedEnough ) {
const card = getCardMap ( getCards ( ) ) . get ( state . drag . cardId ) || null ;
if ( ! card ) {
cleanupDrag ( ) ;
return ;
}
state . drag . started = true ;
state . drag . ghostEl = createDragGhost ( card ) ;
getSlotElement ( state . drag . sourceSlotId ) ? . classList . add ( "is-drag-source" ) ;
document . body . classList . add ( "is-tarot-frame-dragging" ) ;
state . suppressClick = true ;
}
if ( ! state . drag . started ) {
return ;
}
moveGhost ( state . drag . ghostEl , event . clientX , event . clientY ) ;
updateHoverSlotFromPoint ( event . clientX , event . clientY , state . drag . sourceSlotId ) ;
event . preventDefault ( ) ;
}
function finishDrop ( ) {
if ( ! state . drag ) {
return ;
}
const sourceSlotId = state . drag . sourceSlotId ;
const targetSlotId = state . drag . hoverSlotId ;
const draggedCard = getCardMap ( getCards ( ) ) . get ( state . drag . cardId ) || null ;
const moved = Boolean ( targetSlotId && targetSlotId !== sourceSlotId ) ;
if ( moved ) {
swapOrMoveSlots ( sourceSlotId , targetSlotId ) ;
render ( ) ;
setStatus ( ` ${ getDisplayCardName ( draggedCard ) } snapped to ${ describeSlot ( targetSlotId ) } . ` ) ;
}
cleanupDrag ( ) ;
if ( ! moved ) {
state . suppressClick = false ;
}
}
function handlePointerUp ( event ) {
if ( ! state . drag || event . pointerId !== state . drag . pointerId ) {
return ;
}
if ( ! state . drag . started ) {
cleanupDrag ( ) ;
return ;
}
finishDrop ( ) ;
}
function handlePointerCancel ( event ) {
if ( ! state . drag || event . pointerId !== state . drag . pointerId ) {
return ;
}
cleanupDrag ( ) ;
state . suppressClick = false ;
}
function handleBoardClick ( event ) {
const target = event . target ;
if ( ! ( target instanceof Element ) ) {
return ;
}
const cardButton = target . closest ( ".tarot-frame-card[data-card-id]" ) ;
if ( ! ( cardButton instanceof HTMLButtonElement ) ) {
return ;
}
if ( state . suppressClick ) {
state . suppressClick = false ;
return ;
}
openCardLightbox ( cardButton . dataset . cardId ) ;
}
function handleNativeDragStart ( event ) {
const target = event . target ;
if ( ! ( target instanceof Element ) ) {
return ;
}
if ( target . closest ( ".tarot-frame-card" ) ) {
event . preventDefault ( ) ;
}
}
function handleDocumentClick ( event ) {
const target = event . target ;
if ( ! ( target instanceof Node ) ) {
return ;
}
2026-04-01 16:08:52 -07:00
const {
tarotFrameSettingsPanelEl ,
tarotFrameSettingsToggleEl ,
tarotFrameLayoutPanelEl ,
tarotFrameLayoutToggleEl
} = getElements ( ) ;
let changed = false ;
if ( state . settingsOpen && ! tarotFrameSettingsPanelEl ? . contains ( target ) && ! tarotFrameSettingsToggleEl ? . contains ( target ) ) {
state . settingsOpen = false ;
changed = true ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
if ( state . layoutMenuOpen && ! tarotFrameLayoutPanelEl ? . contains ( target ) && ! tarotFrameLayoutToggleEl ? . contains ( target ) ) {
state . layoutMenuOpen = false ;
changed = true ;
}
if ( changed ) {
syncControls ( ) ;
}
2026-04-01 12:31:56 -07:00
}
function handleDocumentKeydown ( event ) {
2026-04-01 16:08:52 -07:00
if ( event . key !== "Escape" ) {
2026-04-01 12:31:56 -07:00
return ;
}
2026-04-01 16:08:52 -07:00
let changed = false ;
if ( state . settingsOpen ) {
state . settingsOpen = false ;
changed = true ;
}
if ( state . layoutMenuOpen ) {
state . layoutMenuOpen = false ;
changed = true ;
}
if ( changed ) {
syncControls ( ) ;
}
2026-04-01 12:31:56 -07:00
}
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 ) ;
}
2026-04-01 19:26:38 -07:00
function drawTextFaceToCanvas ( context , x , y , width , height , faceModel ) {
2026-04-01 16:08:52 -07:00
const primaryText = normalizeLabelText ( faceModel ? . primary || "Tarot" ) ;
const secondaryText = normalizeLabelText ( faceModel ? . secondary ) ;
2026-04-01 19:26:38 -07:00
const maxWidth = width - 12 ;
2026-04-01 16:08:52 -07:00
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 ) ;
2026-04-01 19:26:38 -07:00
let currentY = y + ( ( height - totalHeight ) / 2 ) + primaryLineHeight ;
2026-04-01 16:08:52 -07:00
context . textAlign = "center" ;
context . textBaseline = "alphabetic" ;
primaryLines . forEach ( ( line ) => {
context . fillStyle = "#f8fafc" ;
context . font = ` 700 ${ primaryFontSize } px ${ primaryFontFamily } ` ;
2026-04-01 19:26:38 -07:00
context . fillText ( line , x + ( width / 2 ) , currentY , maxWidth ) ;
2026-04-01 16:08:52 -07:00
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 ) => {
2026-04-01 19:26:38 -07:00
context . fillText ( line , x + ( width / 2 ) , currentY , maxWidth ) ;
2026-04-01 16:08:52 -07:00
currentY += secondaryLineHeight ;
} ) ;
}
context . restore ( ) ;
}
2026-04-01 19:26:38 -07:00
function drawSlotToCanvas ( context , x , y , width , height , card , image ) {
2026-04-01 12:31:56 -07:00
if ( ! card ) {
context . save ( ) ;
context . setLineDash ( [ 6 , 6 ] ) ;
context . lineWidth = 1.5 ;
context . strokeStyle = "rgba(148, 163, 184, 0.42)" ;
2026-04-01 19:26:38 -07:00
drawRoundedRectPath ( context , x + 1 , y + 1 , width - 2 , height - 2 , 10 ) ;
2026-04-01 12:31:56 -07:00
context . stroke ( ) ;
context . restore ( ) ;
return ;
}
const cardX = x + EXPORT _CARD _INSET ;
const cardY = y + EXPORT _CARD _INSET ;
2026-04-01 19:26:38 -07:00
const cardWidth = width - ( EXPORT _CARD _INSET * 2 ) ;
const cardHeight = height - ( EXPORT _CARD _INSET * 2 ) ;
2026-04-01 16:08:52 -07:00
const showImage = shouldShowCardImage ( card ) ;
2026-04-01 12:31:56 -07:00
context . save ( ) ;
2026-04-01 19:26:38 -07:00
drawRoundedRectPath ( context , cardX , cardY , cardWidth , cardHeight , 0 ) ;
2026-04-01 12:31:56 -07:00
context . clip ( ) ;
2026-04-01 16:08:52 -07:00
if ( showImage && image ) {
2026-04-01 19:26:38 -07:00
drawImageContain ( context , image , cardX , cardY , cardWidth , cardHeight ) ;
2026-04-01 16:08:52 -07:00
} else if ( showImage ) {
2026-04-01 12:31:56 -07:00
context . fillStyle = EXPORT _PANEL ;
2026-04-01 19:26:38 -07:00
context . fillRect ( cardX , cardY , cardWidth , cardHeight ) ;
2026-04-01 12:31:56 -07:00
context . fillStyle = "#f8fafc" ;
context . textAlign = "center" ;
context . textBaseline = "middle" ;
context . font = "700 14px 'Segoe UI', sans-serif" ;
2026-04-01 19:26:38 -07:00
const lines = wrapCanvasText ( context , getDisplayCardName ( card ) , cardWidth - 18 , 4 ) ;
2026-04-01 12:31:56 -07:00
const lineHeight = 18 ;
2026-04-01 19:26:38 -07:00
let currentY = cardY + ( cardHeight / 2 ) - ( ( ( Math . max ( 1 , lines . length ) - 1 ) * lineHeight ) / 2 ) ;
2026-04-01 12:31:56 -07:00
lines . forEach ( ( line ) => {
2026-04-01 19:26:38 -07:00
context . fillText ( line , cardX + ( cardWidth / 2 ) , currentY , cardWidth - 18 ) ;
2026-04-01 12:31:56 -07:00
currentY += lineHeight ;
} ) ;
2026-04-01 16:08:52 -07:00
} else {
context . fillStyle = EXPORT _PANEL ;
2026-04-01 19:26:38 -07:00
context . fillRect ( cardX , cardY , cardWidth , cardHeight ) ;
drawTextFaceToCanvas ( context , cardX , cardY , cardWidth , cardHeight , buildCardTextFaceModel ( card ) ) ;
2026-04-01 12:31:56 -07:00
}
context . restore ( ) ;
2026-04-01 16:08:52 -07:00
if ( showImage && state . showInfo ) {
2026-04-01 12:31:56 -07:00
const overlayText = getCardOverlayLabel ( card ) ;
if ( overlayText ) {
const overlayHeight = 30 ;
const overlayX = cardX + 4 ;
2026-04-01 19:26:38 -07:00
const overlayY = cardY + cardHeight - overlayHeight - 4 ;
const overlayWidth = cardWidth - 8 ;
2026-04-01 12:31:56 -07:00
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 ;
2026-04-01 19:26:38 -07:00
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 ) ;
2026-04-01 12:31:56 -07:00
const scale = Math . max ( 1.5 , Math . min ( 2 , Number ( window . devicePixelRatio ) || 1 ) ) ;
const canvas = document . createElement ( "canvas" ) ;
2026-04-01 19:26:38 -07:00
canvas . width = Math . ceil ( canvasWidth * scale ) ;
canvas . height = Math . ceil ( canvasHeight * scale ) ;
canvas . style . width = ` ${ canvasWidth } px ` ;
canvas . style . height = ` ${ canvasHeight } px ` ;
2026-04-01 12:31:56 -07:00
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 ;
2026-04-01 19:26:38 -07:00
context . fillRect ( 0 , 0 , canvasWidth , canvasHeight ) ;
2026-04-01 12:31:56 -07:00
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 ) ;
2026-04-01 19:26:38 -07:00
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 ) ;
2026-04-01 12:31:56 -07:00
}
}
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 ,
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl ,
tarotFrameLayoutPanelEl ,
2026-04-01 12:31:56 -07:00
tarotFrameSettingsToggleEl ,
tarotFrameSettingsPanelEl ,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl ,
2026-04-01 12:31:56 -07:00
tarotFrameShowInfoEl ,
2026-04-01 16:08:52 -07:00
tarotFrameHouseTopCardsVisibleEl ,
tarotFrameHouseTopInfoHebrewEl ,
tarotFrameHouseTopInfoPlanetEl ,
tarotFrameHouseTopInfoZodiacEl ,
tarotFrameHouseTopInfoTrumpEl ,
tarotFrameHouseTopInfoPathEl ,
tarotFrameHouseTopInfoDateEl ,
tarotFrameHouseBottomCardsVisibleEl ,
tarotFrameHouseBottomInfoZodiacEl ,
tarotFrameHouseBottomInfoDecanEl ,
tarotFrameHouseBottomInfoMonthEl ,
tarotFrameHouseBottomInfoRulerEl ,
tarotFrameHouseBottomInfoDateEl ,
2026-04-01 12:31:56 -07:00
tarotFrameExportWebpEl
} = getElements ( ) ;
if ( tarotFrameBoardEl ) {
tarotFrameBoardEl . addEventListener ( "pointerdown" , handlePointerDown ) ;
tarotFrameBoardEl . addEventListener ( "click" , handleBoardClick ) ;
tarotFrameBoardEl . addEventListener ( "dragstart" , handleNativeDragStart ) ;
}
2026-04-01 16:08:52 -07:00
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 ;
}
2026-04-01 12:31:56 -07:00
const cards = getCards ( ) ;
if ( ! cards . length ) {
return ;
}
2026-04-01 16:08:52 -07:00
applyLayoutPreset ( option . dataset . layoutPresetId , cards , ` ${ getLayoutPreset ( option . dataset . layoutPresetId ) . label } layout applied to the master grid. ` ) ;
state . layoutMenuOpen = false ;
2026-04-01 12:31:56 -07:00
render ( ) ;
2026-04-01 16:08:52 -07:00
syncControls ( ) ;
2026-04-01 12:31:56 -07:00
} ) ;
}
if ( tarotFrameSettingsToggleEl ) {
tarotFrameSettingsToggleEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
if ( state . exportInProgress ) {
return ;
}
state . settingsOpen = ! state . settingsOpen ;
2026-04-01 16:08:52 -07:00
if ( state . settingsOpen ) {
state . layoutMenuOpen = false ;
}
2026-04-01 12:31:56 -07:00
syncControls ( ) ;
} ) ;
}
if ( tarotFrameSettingsPanelEl ) {
tarotFrameSettingsPanelEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
} ) ;
}
if ( tarotFrameShowInfoEl ) {
tarotFrameShowInfoEl . addEventListener ( "change" , ( ) => {
state . showInfo = Boolean ( tarotFrameShowInfoEl . checked ) ;
render ( ) ;
syncControls ( ) ;
} ) ;
}
2026-04-01 19:26:38 -07:00
if ( tarotFrameGridZoomEl ) {
tarotFrameGridZoomEl . addEventListener ( "change" , ( ) => {
setGridZoomStepIndex ( tarotFrameGridZoomEl . value ) ;
} ) ;
}
2026-04-01 16:08:52 -07:00
[
[ 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 ( ) ;
} ) ;
} ) ;
2026-04-01 12:31:56 -07:00
if ( tarotFrameExportWebpEl ) {
tarotFrameExportWebpEl . addEventListener ( "click" , ( ) => {
exportFrame ( "webp" ) ;
} ) ;
}
document . addEventListener ( "click" , handleDocumentClick ) ;
document . addEventListener ( "keydown" , handleDocumentKeydown ) ;
}
async function ensureTarotFrameSection ( referenceData , magickDataset ) {
if ( typeof config . ensureTarotSection === "function" ) {
await config . ensureTarotSection ( referenceData , magickDataset ) ;
}
const cards = getCards ( ) ;
if ( ! cards . length ) {
setStatus ( "Tarot cards are still loading..." ) ;
return ;
}
const signature = buildCardSignature ( cards ) ;
if ( ! state . layoutReady || state . cardSignature !== signature ) {
state . cardSignature = signature ;
2026-04-01 16:08:52 -07:00
applyLayoutPreset ( state . currentLayoutId , cards ) ;
2026-04-01 12:31:56 -07:00
} else {
setStatus ( state . statusMessage || buildReadyStatus ( cards ) ) ;
}
render ( ) ;
syncControls ( ) ;
}
function init ( nextConfig = { } ) {
config = {
... config ,
... nextConfig
} ;
if ( state . initialized ) {
return ;
}
bindEvents ( ) ;
syncControls ( ) ;
state . initialized = true ;
}
window . TarotFrameUi = {
... ( window . TarotFrameUi || { } ) ,
init ,
ensureTarotFrameSection ,
render ,
resetLayout ,
2026-04-01 16:08:52 -07:00
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 ( ) ;
} ,
2026-04-01 12:31:56 -07:00
exportImage ,
isExportFormatSupported
} ;
} ) ( ) ;