diff --git a/app.js b/app.js index 962aa51..139922f 100644 --- a/app.js +++ b/app.js @@ -448,6 +448,7 @@ window.TarotSpreadUi?.init?.({ window.TarotFrameUi?.init?.({ ensureTarotSection, getCards: () => window.TarotSectionUi?.getCards?.() || [], + openCardLightbox: (cardId, options = {}) => window.TarotSectionUi?.openCardLightboxById?.(cardId, options), getHouseTopCardsVisible: () => window.TarotSectionUi?.getHouseTopCardsVisible?.() !== false, getHouseTopInfoModes: () => window.TarotSectionUi?.getHouseTopInfoModes?.() || {}, getHouseBottomCardsVisible: () => window.TarotSectionUi?.getHouseBottomCardsVisible?.() !== false, diff --git a/app/styles.css b/app/styles.css index 8175939..c3429d8 100644 --- a/app/styles.css +++ b/app/styles.css @@ -1286,6 +1286,8 @@ transition: transform 120ms ease, filter 120ms ease; -webkit-user-select: none; user-select: none; + touch-action: none; + -webkit-touch-callout: none; } .tarot-frame-card.is-empty { @@ -1306,6 +1308,17 @@ transform: none; } + @media (pointer: coarse) { + .tarot-frame-card { + cursor: grab; + } + + .tarot-frame-card:hover { + transform: none; + filter: none; + } + } + .tarot-frame-card-image, .tarot-frame-card-fallback { display: block; diff --git a/app/tarot-database-assembly.js b/app/tarot-database-assembly.js index fd6d106..edf7467 100644 --- a/app/tarot-database-assembly.js +++ b/app/tarot-database-assembly.js @@ -212,6 +212,10 @@ const windowDecans = windowDecanIds .map((decanId) => decanById.get(decanId) || null) .filter(Boolean); + const windowSignIds = tarotDb.courtSignWindows?.[cardName] || []; + const windowSigns = windowSignIds + .map((signId) => signById[signId] || null) + .filter(Boolean); const explicitWindowRange = buildTokenDateRange( tarotDb.courtDateRanges?.[cardName]?.start, tarotDb.courtDateRanges?.[cardName]?.end @@ -240,6 +244,24 @@ ) ); + if (meta?.decan?.tarotMinorArcana) { + dynamicRelations.push( + createRelation( + "tarotCard", + `${meta.signId}-${meta.index}-${rankKey}-${suitKey}-decan-card`, + `Decan card: ${meta.decan.tarotMinorArcana} (${meta.signName} decan ${meta.index})`, + { + cardName: meta.decan.tarotMinorArcana, + decanId: meta.decan.id || `${meta.signId}-${meta.index}`, + signId: meta.signId, + signName: meta.signName, + decanIndex: meta.index, + dateRange: meta.dateRange?.label || null + } + ) + ); + } + const ruler = planets?.[meta?.decan?.rulerPlanetId] || null; if (ruler) { dynamicRelations.push( @@ -290,9 +312,87 @@ } }); - if (windowDecans.length) { - const firstRange = windowDecans[0].dateRange; - const lastRange = windowDecans[windowDecans.length - 1].dateRange; + windowSigns.forEach((sign) => { + const signDateRange = buildTokenDateRange(sign?.start, sign?.end); + const signName = sign?.name || sign?.id || ""; + const signSymbol = sign?.symbol || ""; + const relatedDecans = Array.isArray(decansBySign?.[sign.id]) ? decansBySign[sign.id] : []; + + dynamicRelations.push( + createRelation( + "signWindow", + `${sign.id}-${rankKey}-${suitKey}`, + `Sign window: ${signSymbol} ${signName} (0°–30°)${signDateRange ? ` · ${signDateRange.label}` : ""}`.trim(), + { + signId: sign.id, + signName, + signSymbol, + startDegree: 0, + endDegree: 30, + dateStart: signDateRange?.startToken || null, + dateEnd: signDateRange?.endToken || null, + dateRange: signDateRange?.label || null + } + ) + ); + + relatedDecans.forEach((decan) => { + if (!decan?.tarotMinorArcana) { + return; + } + + dynamicRelations.push( + createRelation( + "tarotCard", + `${sign.id}-${decan.id || decan.tarotMinorArcana}-${rankKey}-${suitKey}-sign-card`, + `Sign card: ${decan.tarotMinorArcana} (${signName})`, + { + cardName: decan.tarotMinorArcana, + decanId: decan.id || null, + signId: sign.id, + signName, + signSymbol, + dateRange: signDateRange?.label || null + } + ) + ); + }); + + if (signDateRange?.start && signDateRange?.end) { + const monthNumbers = listMonthNumbersBetween(signDateRange.start, signDateRange.end); + monthNumbers.forEach((monthNo) => { + const monthId = monthIdByNumber[monthNo]; + const monthName = monthNameByNumber[monthNo] || `Month ${monthNo}`; + const monthKey = `${monthId}:${sign.id}:sign-window`; + if (!monthId || monthKeys.has(monthKey)) { + return; + } + monthKeys.add(monthKey); + + dynamicRelations.push( + createRelation( + "calendarMonth", + `${monthId}-${sign.id}-${rankKey}-${suitKey}-sign-window`, + `Calendar month: ${monthName} (${signName} sign window)`, + { + monthId, + name: monthName, + monthOrder: monthNo, + signId: sign.id, + signName, + dateRange: signDateRange?.label || null + } + ) + ); + }); + } + }); + + if (windowDecans.length || windowSigns.length) { + const firstRange = windowDecans.length ? windowDecans[0].dateRange : null; + const lastRange = windowDecans.length ? windowDecans[windowDecans.length - 1].dateRange : null; + const firstSignRange = windowSigns.length ? buildTokenDateRange(windowSigns[0]?.start, windowSigns[0]?.end) : null; + const lastSignRange = windowSigns.length ? buildTokenDateRange(windowSigns[windowSigns.length - 1]?.start, windowSigns[windowSigns.length - 1]?.end) : null; const fallbackWindowRange = firstRange && lastRange ? { start: firstRange.start, @@ -301,7 +401,15 @@ endToken: lastRange.endToken, label: `${formatMonthDayLabel(firstRange.start)}–${formatMonthDayLabel(lastRange.end)}` } - : null; + : (firstSignRange && lastSignRange + ? { + start: firstSignRange.start, + end: lastSignRange.end, + startToken: firstSignRange.startToken, + endToken: lastSignRange.endToken, + label: `${formatMonthDayLabel(firstSignRange.start)}–${formatMonthDayLabel(lastSignRange.end)}` + } + : null); const windowRange = explicitWindowRange || fallbackWindowRange; const windowLabel = windowRange?.label || "--"; @@ -314,7 +422,8 @@ dateStart: windowRange?.startToken || null, dateEnd: windowRange?.endToken || null, dateRange: windowLabel, - decanIds: windowDecanIds + decanIds: windowDecanIds, + signIds: windowSignIds } ) ); diff --git a/app/tarot-database-builders.js b/app/tarot-database-builders.js index bd948a1..203434a 100644 --- a/app/tarot-database-builders.js +++ b/app/tarot-database-builders.js @@ -10,6 +10,7 @@ const rankInfo = config?.rankInfo && typeof config.rankInfo === "object" ? config.rankInfo : {}; const courtInfo = config?.courtInfo && typeof config.courtInfo === "object" ? config.courtInfo : {}; const courtDecanWindows = config?.courtDecanWindows && typeof config.courtDecanWindows === "object" ? config.courtDecanWindows : {}; + const courtSignWindows = config?.courtSignWindows && typeof config.courtSignWindows === "object" ? config.courtSignWindows : {}; const majorAliases = config?.majorAliases && typeof config.majorAliases === "object" ? config.majorAliases : {}; const minorNumeralAliases = config?.minorNumeralAliases && typeof config.minorNumeralAliases === "object" ? config.minorNumeralAliases : {}; const monthNameByNumber = config?.monthNameByNumber && typeof config.monthNameByNumber === "object" ? config.monthNameByNumber : {}; @@ -30,6 +31,7 @@ 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, + courtSignWindows: hasDb && db.courtSignWindows && typeof db.courtSignWindows === "object" ? db.courtSignWindows : courtSignWindows, courtDateRanges: hasDb && db.courtDateRanges && typeof db.courtDateRanges === "object" ? db.courtDateRanges : {} }; } diff --git a/app/tarot-database.js b/app/tarot-database.js index d7b3713..354f701 100644 --- a/app/tarot-database.js +++ b/app/tarot-database.js @@ -397,6 +397,13 @@ "Prince of Cups": ["libra-3", "scorpio-1", "scorpio-2"] }; + const COURT_SIGN_WINDOWS = { + "Princess of Wands": ["cancer", "leo", "virgo"], + "Princess of Cups": ["libra", "scorpio", "sagittarius"], + "Princess of Swords": ["capricorn", "aquarius", "pisces"], + "Princess of Disks": ["aries", "taurus", "gemini"] + }; + const MONTH_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const MAJOR_HEBREW_LETTER_ID_BY_CARD = { @@ -474,6 +481,7 @@ rankInfo: RANK_INFO, courtInfo: COURT_INFO, courtDecanWindows: COURT_DECAN_WINDOWS, + courtSignWindows: COURT_SIGN_WINDOWS, majorAliases: MAJOR_ALIASES, minorNumeralAliases: MINOR_NUMERAL_ALIASES, monthNameByNumber: MONTH_NAME_BY_NUMBER, diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js index fe1b61f..298d828 100644 --- a/app/ui-tarot-detail.js +++ b/app/ui-tarot-detail.js @@ -20,6 +20,152 @@ findSephirahForMinorCard } = dependencies || {}; + function buildDecanRelationKey(signId, decanIndex) { + const normalizedSignId = String(signId || "").trim().toLowerCase(); + const normalizedIndex = Number(decanIndex); + if (!normalizedSignId || !Number.isFinite(normalizedIndex)) { + return ""; + } + + return `${normalizedSignId}-${normalizedIndex}`; + } + + function buildSignRelationKey(signId) { + return String(signId || "").trim().toLowerCase(); + } + + function buildDecanSummaryRelations(relations) { + const decanRelations = (relations || []).filter((relation) => relation?.type === "decan"); + const signWindowRelations = (relations || []).filter((relation) => relation?.type === "signWindow"); + if (!decanRelations.length && !signWindowRelations.length) { + return []; + } + + function normalizeInlineDateRange(value) { + return String(value || "") + .replace(/[–—]/g, " - ") + .replace(/\s*-\s*/g, " - ") + .replace(/\s+/g, " ") + .trim(); + } + + const rulerByDecanKey = new Map(); + const cardByDecanKey = new Map(); + const cardsBySignKey = new Map(); + + (relations || []).forEach((relation) => { + if (relation?.type === "decanRuler") { + const key = buildDecanRelationKey(relation?.data?.signId, relation?.data?.decanIndex); + if (key && !rulerByDecanKey.has(key)) { + rulerByDecanKey.set(key, relation); + } + } + + if (relation?.type === "tarotCard") { + const decanKey = buildDecanRelationKey(relation?.data?.signId, relation?.data?.decanIndex); + if (decanKey && !cardByDecanKey.has(decanKey)) { + cardByDecanKey.set(decanKey, relation); + return; + } + + const signKey = buildSignRelationKey(relation?.data?.signId); + if (!signKey) { + return; + } + + const entries = cardsBySignKey.get(signKey) || []; + entries.push(relation); + cardsBySignKey.set(signKey, entries); + } + }); + + const decanSummaries = decanRelations.map((relation) => { + const signId = relation?.data?.signId; + const signName = String(relation?.data?.signName || "").trim(); + const signSymbol = String(relation?.data?.signSymbol || relation?.data?.symbol || "").trim(); + const decanIndex = Number(relation?.data?.index); + const startDegree = Number(relation?.data?.startDegree); + const endDegree = Number(relation?.data?.endDegree); + const dateRange = String(relation?.data?.dateRange || "").trim(); + const decanKey = buildDecanRelationKey(signId, decanIndex); + const rulerRelation = rulerByDecanKey.get(decanKey) || null; + const cardRelation = cardByDecanKey.get(decanKey) || null; + const rulerSymbol = String(rulerRelation?.data?.symbol || "").trim(); + const rulerName = String(rulerRelation?.data?.name || "").trim(); + const rulerLabel = `${rulerSymbol} ${rulerName}`.replace(/\s+/g, " ").trim(); + const decanCardName = String(cardRelation?.data?.cardName || "").trim(); + const decanCardLabel = decanCardName + ? String(getDisplayCardName?.(decanCardName) || decanCardName).trim() + : ""; + const signLabel = `${signSymbol} ${signName}`.replace(/\s+/g, " ").trim(); + const degreeLabel = Number.isFinite(startDegree) && Number.isFinite(endDegree) + ? `(${startDegree}°-${endDegree}°)` + : ""; + const dateLabel = normalizeInlineDateRange(dateRange); + const summaryParts = [ + rulerLabel, + decanCardLabel, + [signLabel, degreeLabel].filter(Boolean).join(" ").trim(), + dateLabel + ].filter(Boolean); + + return { + type: decanCardName ? "tarotCard" : "decan", + id: cardRelation?.id || `${decanKey}-summary`, + label: summaryParts.join(": "), + data: { + ...(cardRelation?.data || relation?.data || {}), + signId, + signName, + signSymbol, + decanIndex, + dateRange, + cardName: decanCardName || cardRelation?.data?.cardName || "" + }, + __key: `decanSummary|${decanKey}|${cardRelation?.data?.cardName || ""}` + }; + }); + + const signSummaries = signWindowRelations.map((relation) => { + const signId = relation?.data?.signId; + const signKey = buildSignRelationKey(signId); + const signName = String(relation?.data?.signName || "").trim(); + const signSymbol = String(relation?.data?.signSymbol || relation?.data?.symbol || "").trim(); + const startDegree = Number(relation?.data?.startDegree); + const endDegree = Number(relation?.data?.endDegree); + const dateRange = String(relation?.data?.dateRange || "").trim(); + const cardLabels = [...new Set((cardsBySignKey.get(signKey) || []) + .map((entry) => String(getDisplayCardName?.(entry?.data?.cardName) || entry?.data?.cardName || "").trim()) + .filter(Boolean))]; + const signLabel = `${signSymbol} ${signName}`.replace(/\s+/g, " ").trim(); + const degreeLabel = Number.isFinite(startDegree) && Number.isFinite(endDegree) + ? `(${startDegree}°-${endDegree}°)` + : ""; + const dateLabel = normalizeInlineDateRange(dateRange); + const summaryParts = [ + cardLabels.join(", "), + [signLabel, degreeLabel].filter(Boolean).join(" ").trim(), + dateLabel + ].filter(Boolean); + + return { + type: "signWindow", + id: relation?.id || `${signKey}-summary`, + label: summaryParts.join(": "), + data: { + ...(relation?.data || {}), + signId, + signName, + signSymbol, + cardNames: cardLabels + }, + __key: `signSummary|${signKey}` + }; + }); + + return [...decanSummaries, ...signSummaries]; + } + function renderStaticRelationGroup(targetEl, cardEl, relations) { clearChildren(targetEl); if (!targetEl || !cardEl) return; @@ -48,12 +194,20 @@ return true; }); + const decanSummaryRelations = buildDecanSummaryRelations(dedupedRelations); + const hasDecanSummaryRelations = decanSummaryRelations.length > 0; + const hasSignWindowRelations = decanSummaryRelations.some((relation) => relation?.type === "signWindow"); + const planetRelations = dedupedRelations.filter((relation) => - relation.type === "planetCorrespondence" || relation.type === "decanRuler" || relation.type === "planet" + relation.type === "planetCorrespondence" + || relation.type === "planet" + || (!hasDecanSummaryRelations && relation.type === "decanRuler") ); const zodiacRelations = dedupedRelations.filter((relation) => - relation.type === "zodiacCorrespondence" || relation.type === "zodiac" || relation.type === "decan" + relation.type === "zodiacCorrespondence" + || relation.type === "zodiac" + || (!hasDecanSummaryRelations && relation.type === "decan") ); const courtDateRelations = dedupedRelations.filter((relation) => relation.type === "courtDateWindow"); @@ -63,9 +217,9 @@ const elementRelations = buildElementRelationsForCard(card, baseElementRelations); const tetragrammatonRelations = buildTetragrammatonRelationsForCard(card); const smallCardRulershipRelation = buildSmallCardRulershipRelation(card); - const zodiacRelationsWithRulership = smallCardRulershipRelation - ? [...zodiacRelations, smallCardRulershipRelation] - : zodiacRelations; + const zodiacRelationsWithRulership = hasDecanSummaryRelations + ? [...decanSummaryRelations, ...(smallCardRulershipRelation ? [smallCardRulershipRelation] : [])] + : [...zodiacRelations, ...(smallCardRulershipRelation ? [smallCardRulershipRelation] : [])]; const smallCardCourtLinkRelations = buildSmallCardCourtLinkRelations(card, dedupedRelations); const mergedCourtDateRelations = [...courtDateRelations, ...smallCardCourtLinkRelations]; const cubeRelations = buildCubeRelationsForCard(card); @@ -166,6 +320,8 @@ elementRelations, tetragrammatonRelations, zodiacRelationsWithRulership, + hasDecanSummaryRelations, + hasSignWindowRelations, mergedCourtDateRelations, hebrewRelations, cubeRelations, @@ -183,7 +339,10 @@ const groups = [ { title: "Letter", relations: detailRelations.hebrewRelations }, { title: "Planet / Ruler", relations: detailRelations.planetRelations }, - { title: "Sign / Decan", relations: detailRelations.zodiacRelationsWithRulership }, + { + title: detailRelations.hasSignWindowRelations ? "Signs" : (detailRelations.hasDecanSummaryRelations ? "Decan" : "Sign / Decan"), + relations: detailRelations.zodiacRelationsWithRulership + }, { title: "Element", relations: detailRelations.elementRelations }, { title: "Tetragrammaton", relations: detailRelations.tetragrammatonRelations }, { title: "Dates", relations: detailRelations.mergedCourtDateRelations }, diff --git a/app/ui-tarot-frame.js b/app/ui-tarot-frame.js index 65df5f2..c4a60f4 100644 --- a/app/ui-tarot-frame.js +++ b/app/ui-tarot-frame.js @@ -192,6 +192,7 @@ let config = { ensureTarotSection: null, getCards: () => [], + openCardLightbox: () => {}, getHouseTopCardsVisible: () => true, getHouseTopInfoModes: () => ({}), getHouseBottomCardsVisible: () => true, @@ -1449,6 +1450,16 @@ return; } + if (state.drag.sourceButton instanceof HTMLElement && typeof state.drag.sourceButton.releasePointerCapture === "function") { + try { + if (state.drag.sourceButton.hasPointerCapture?.(state.drag.pointerId)) { + state.drag.sourceButton.releasePointerCapture(state.drag.pointerId); + } + } catch (_error) { + // Ignore pointer-capture release failures during cleanup. + } + } + setHoverSlot(""); getSlotElement(state.drag.sourceSlotId)?.classList.remove("is-drag-source"); if (state.drag.ghostEl instanceof HTMLElement) { @@ -1482,6 +1493,13 @@ return; } + if (typeof config.openCardLightbox === "function") { + config.openCardLightbox(getCardId(card), { + onSelectCardId: () => {} + }); + return; + } + const deckOptions = resolveDeckOptions(card); const src = String( tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions) @@ -1522,9 +1540,22 @@ startY: event.clientY, started: false, hoverSlotId: "", - ghostEl: null + ghostEl: null, + sourceButton: cardButton }; + if (typeof cardButton.setPointerCapture === "function") { + try { + cardButton.setPointerCapture(event.pointerId); + } catch (_error) { + // Ignore pointer-capture failures and continue with document listeners. + } + } + + if (String(event.pointerType || "").toLowerCase() === "touch") { + event.preventDefault(); + } + detachPointerListeners(); document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); diff --git a/app/ui-tarot.js b/app/ui-tarot.js index f5d588c..65ae547 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -795,6 +795,48 @@ return request; } + function openCardLightboxById(cardIdToOpen, options = {}) { + const normalizedCardId = String(cardIdToOpen || "").trim(); + if (!normalizedCardId) { + return; + } + + const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId); + if (!primaryCardRequest?.src) { + return; + } + + const activeDeckId = String(getActiveDeck?.() || primaryCardRequest.deckId || "").trim(); + const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeDeckId); + const onSelectCardId = typeof options?.onSelectCardId === "function" + ? options.onSelectCardId + : (nextCardId) => { + const latestElements = getElements(); + selectCardById(nextCardId, latestElements); + scrollCardIntoView(nextCardId, latestElements); + }; + + window.TarotUiLightbox?.open?.({ + src: primaryCardRequest.src, + altText: primaryCardRequest.altText, + label: primaryCardRequest.label, + cardId: primaryCardRequest.cardId, + deckId: primaryCardRequest.deckId || activeDeckId, + deckLabel: primaryCardRequest.deckLabel || "", + compareDetails: primaryCardRequest.compareDetails || [], + allowOverlayCompare: true, + allowDeckCompare: true, + activeDeckId, + activeDeckLabel: primaryCardRequest.deckLabel || "", + availableCompareDecks, + maxCompareDecks: 2, + sequenceIds: state.cards.map((card) => card.id), + resolveCardById: buildLightboxCardRequestById, + resolveDeckCardById: buildDeckLightboxCardRequest, + onSelectCardId + }); + } + function renderList(elements) { if (!elements?.tarotCardListEl) { return; @@ -858,31 +900,15 @@ selectCardById, openCardLightbox: (src, altText, options = {}) => { const cardId = String(options?.cardId || "").trim(); - const primaryCardRequest = cardId ? buildLightboxCardRequestById(cardId) : null; - const activeDeckId = String(getActiveDeck?.() || primaryCardRequest?.deckId || "").trim(); - const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeDeckId); + if (cardId) { + openCardLightboxById(cardId); + return; + } + window.TarotUiLightbox?.open?.({ - src: primaryCardRequest?.src || src, - altText: primaryCardRequest?.altText || altText || "Tarot card enlarged image", - label: primaryCardRequest?.label || altText || "Tarot card enlarged image", - cardId: primaryCardRequest?.cardId || cardId, - deckId: primaryCardRequest?.deckId || activeDeckId, - deckLabel: primaryCardRequest?.deckLabel || "", - compareDetails: primaryCardRequest?.compareDetails || [], - allowOverlayCompare: true, - allowDeckCompare: Boolean(cardId), - activeDeckId, - activeDeckLabel: primaryCardRequest?.deckLabel || "", - availableCompareDecks, - maxCompareDecks: 2, - sequenceIds: state.cards.map((card) => card.id), - resolveCardById: buildLightboxCardRequestById, - resolveDeckCardById: buildDeckLightboxCardRequest, - onSelectCardId: (nextCardId) => { - const latestElements = getElements(); - selectCardById(nextCardId, latestElements); - scrollCardIntoView(nextCardId, latestElements); - } + src, + altText: altText || "Tarot card enlarged image", + label: altText || "Tarot card enlarged image" }); }, shouldOpenCardLightboxOnSelect: (latestElements) => Boolean( @@ -1116,6 +1142,7 @@ ensureTarotSection, selectCardByTrump, selectCardByName, + openCardLightboxById, getCards: () => state.cards, getHouseTopCardsVisible: () => state.houseTopCardsVisible, getHouseTopInfoModes: () => ({ ...state.houseTopInfoModes }), diff --git a/index.html b/index.html index 4fa741f..9dfb156 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +