Files
TaroTime/app/ui-tarot-house.js

1225 lines
39 KiB
JavaScript
Raw Normal View History

2026-03-07 05:17:50 -08:00
(function () {
"use strict";
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-03-08 03:52:25 -07:00
const EXPORT_CARD_WIDTH = 128;
const EXPORT_CARD_HEIGHT = 192;
const EXPORT_CARD_GAP = 10;
const EXPORT_ROW_GAP = 12;
const EXPORT_SECTION_GAP = 18;
const EXPORT_PADDING = 28;
const EXPORT_BACKGROUND = "#151520";
const EXPORT_PANEL = "#18181b";
const EXPORT_BORDER = "#3f3f46";
const EXPORT_FALLBACK_TEXT = "#f4f4f5";
const EXPORT_FORMATS = {
png: {
mimeType: "image/png",
extension: "png",
quality: null
},
webp: {
mimeType: "image/webp",
extension: "webp",
quality: 0.98
}
};
2026-03-07 05:17:50 -08:00
const config = {
resolveTarotCardImage: null,
resolveTarotCardThumbnail: null,
2026-03-07 05:17:50 -08:00
getDisplayCardName: (card) => card?.name || "",
clearChildren: () => {},
normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(),
selectCardById: () => {},
2026-03-08 03:52:25 -07:00
openCardLightbox: () => {},
isHouseFocusMode: () => false,
2026-03-07 05:17:50 -08:00
getCards: () => [],
2026-03-08 03:52:25 -07:00
getSelectedCardId: () => "",
getHouseTopCardsVisible: () => true,
getHouseTopInfoModes: () => ({}),
getHouseBottomCardsVisible: () => true,
getHouseBottomInfoModes: () => ({})
2026-03-07 05:17:50 -08:00
};
let houseImageObserver = null;
2026-03-07 05:17:50 -08:00
function init(nextConfig = {}) {
Object.assign(config, nextConfig || {});
}
function getCardLookupMap(cards) {
const lookup = new Map();
(Array.isArray(cards) ? cards : []).forEach((card) => {
const key = config.normalizeTarotCardLookupName(card?.name);
if (key) {
lookup.set(key, card);
}
});
return lookup;
}
function buildMinorCardName(rankNumber, suit) {
const number = Number(rankNumber);
const suitName = String(suit || "").trim();
const rankName = ({ 1: "Ace", 2: "Two", 3: "Three", 4: "Four", 5: "Five", 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten" })[number];
if (!rankName || !suitName) {
return "";
}
return `${rankName} of ${suitName}`;
}
function buildCourtCardName(rank, suit) {
const rankName = String(rank || "").trim();
const suitName = String(suit || "").trim();
if (!rankName || !suitName) {
return "";
}
return `${rankName} of ${suitName}`;
}
function findCardByLookupName(cardLookupMap, cardName) {
const key = config.normalizeTarotCardLookupName(cardName);
if (!key) {
return null;
}
return cardLookupMap.get(key) || null;
}
function findMajorCardByTrumpNumber(cards, trumpNumber) {
const target = Number(trumpNumber);
if (!Number.isFinite(target)) {
return null;
}
return (Array.isArray(cards) ? cards : []).find((card) => card?.arcana === "Major" && Number(card?.number) === target) || null;
}
2026-03-08 03:52:25 -07:00
function normalizeLabelText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function getCardRelationsByType(card, type) {
if (!card || !Array.isArray(card.relations)) {
return [];
}
return card.relations.filter((relation) => relation?.type === type);
}
function getFirstCardRelationByType(card, type) {
return getCardRelationsByType(card, type)[0] || null;
}
function toRomanNumeral(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
return "";
}
const numerals = [
[10, "X"],
[9, "IX"],
[5, "V"],
[4, "IV"],
[1, "I"]
];
let remaining = number;
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
: getFirstCardRelationByType(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 : "";
if (!primary) {
return null;
}
return {
primary,
secondary,
className: "is-top-hebrew"
};
}
function buildPlanetLabel(card) {
const relation = getFirstCardRelationByType(card, "planetCorrespondence")
|| getFirstCardRelationByType(card, "planet")
|| getFirstCardRelationByType(card, "decanRuler");
const name = normalizeLabelText(relation?.data?.name || relation?.data?.planetId || relation?.id);
const symbol = normalizeLabelText(relation?.data?.symbol);
const primary = normalizeLabelText(symbol ? `${symbol} ${name}` : name);
if (!primary) {
return null;
}
return {
primary: relation?.type === "decanRuler" ? `Ruler: ${primary}` : `Planet: ${primary}`,
secondary: "",
className: ""
};
}
function buildMajorZodiacLabel(card) {
const relation = getFirstCardRelationByType(card, "zodiacCorrespondence")
|| getFirstCardRelationByType(card, "zodiac");
const name = normalizeLabelText(relation?.data?.name || relation?.data?.signName || relation?.id);
const symbol = normalizeLabelText(relation?.data?.symbol);
const primary = normalizeLabelText(symbol ? `${symbol} ${name}` : name);
if (!primary) {
return null;
}
return {
primary: `Zodiac: ${primary}`,
secondary: "",
className: ""
};
}
function buildTrumpNumberLabel(card) {
const number = Number(card?.number);
if (!Number.isFinite(number)) {
return null;
}
const formattedTrumpNumber = number === 0
? "0"
: toRomanNumeral(Math.trunc(number));
return {
primary: `Trump: ${formattedTrumpNumber}`,
secondary: "",
className: ""
};
}
function buildPathNumberLabel(card) {
const pathNumber = Number(card?.kabbalahPathNumber);
if (!Number.isFinite(pathNumber)) {
return null;
}
return {
primary: `Path: ${Math.trunc(pathNumber)}`,
secondary: "",
className: ""
};
}
function buildZodiacLabel(card) {
const zodiacRelation = getFirstCardRelationByType(card, "zodiac");
const decanRelations = getCardRelationsByType(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(getFirstCardRelationByType(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(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
if (rangeLabel) {
return {
primary: rangeLabel,
secondary: dateRange || "",
className: ""
};
}
}
return null;
}
function buildDecanLabel(card) {
const decanRelations = getCardRelationsByType(card, "decan");
if (decanRelations.length === 0) {
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);
if (primary) {
return {
primary,
secondary,
className: ""
};
}
}
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(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
if (!primary) {
return null;
}
return {
primary,
secondary,
className: ""
};
}
function buildDateLabel(card) {
const courtWindow = getFirstCardRelationByType(card, "courtDateWindow")?.data || null;
const decan = getFirstCardRelationByType(card, "decan")?.data || null;
const calendar = getFirstCardRelationByType(card, "calendarMonth")?.data || null;
const primary = normalizeLabelText(courtWindow?.dateRange || decan?.dateRange || calendar?.dateRange || calendar?.name);
const secondary = normalizeLabelText(
calendar?.name && primary !== calendar.name
? calendar.name
: decan?.signName
);
if (!primary) {
return null;
}
return {
primary,
secondary,
className: ""
};
}
function buildMonthLabel(card) {
const monthRelations = getCardRelationsByType(card, "calendarMonth");
const names = [];
const seen = new Set();
monthRelations.forEach((relation) => {
const name = normalizeLabelText(relation?.data?.name);
const key = name.toLowerCase();
if (!name || seen.has(key)) {
return;
}
seen.add(key);
names.push(name);
});
if (!names.length) {
return null;
}
return {
primary: `Month: ${names.join("/")}`,
secondary: "",
className: ""
};
}
function buildRulerLabel(card) {
const rulerRelations = getCardRelationsByType(card, "decanRuler");
const names = [];
const seen = new Set();
rulerRelations.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)) {
return;
}
seen.add(key);
names.push(name);
});
if (!names.length) {
return null;
}
return {
primary: `Ruler: ${names.join("/")}`,
secondary: "",
className: ""
};
}
function getTopInfoModeEnabled(mode) {
const modes = config.getHouseTopInfoModes?.();
return Boolean(modes && modes[mode]);
}
function buildTopInfoLabel(card) {
const lineSet = new Set();
const lines = [];
function pushLine(value) {
const text = normalizeLabelText(value);
const key = text.toLowerCase();
if (!text || lineSet.has(key)) {
return;
}
lineSet.add(key);
lines.push(text);
}
if (getTopInfoModeEnabled("hebrew")) {
const hebrew = buildHebrewLabel(card);
pushLine(hebrew?.primary);
pushLine(hebrew?.secondary);
}
if (getTopInfoModeEnabled("planet")) {
pushLine(buildPlanetLabel(card)?.primary);
}
if (getTopInfoModeEnabled("zodiac")) {
pushLine(buildMajorZodiacLabel(card)?.primary);
}
if (getTopInfoModeEnabled("trump")) {
pushLine(buildTrumpNumberLabel(card)?.primary);
}
if (getTopInfoModeEnabled("path")) {
pushLine(buildPathNumberLabel(card)?.primary);
}
if (!lines.length) {
return null;
}
const hasHebrew = getTopInfoModeEnabled("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 getBottomInfoModeEnabled(mode) {
const modes = config.getHouseBottomInfoModes?.();
return Boolean(modes && modes[mode]);
}
function buildBottomInfoLabel(card) {
const lineSet = new Set();
const lines = [];
function pushLine(value) {
const text = normalizeLabelText(value);
const key = text.toLowerCase();
if (!text || lineSet.has(key)) {
return;
}
lineSet.add(key);
lines.push(text);
}
if (getBottomInfoModeEnabled("zodiac")) {
pushLine(buildZodiacLabel(card)?.primary);
}
if (getBottomInfoModeEnabled("decan")) {
const decanLabel = buildDecanLabel(card);
pushLine(decanLabel?.primary);
if (!getBottomInfoModeEnabled("date")) {
pushLine(decanLabel?.secondary);
}
}
if (getBottomInfoModeEnabled("month")) {
pushLine(buildMonthLabel(card)?.primary);
}
if (getBottomInfoModeEnabled("ruler")) {
pushLine(buildRulerLabel(card)?.primary);
}
if (getBottomInfoModeEnabled("date")) {
pushLine(buildDateLabel(card)?.primary);
}
if (lines.length === 0) {
return null;
}
return {
primary: lines[0],
secondary: lines.slice(1).join(" · "),
className: lines.length >= 3 ? "is-dense" : ""
};
}
function buildHouseCardLabel(card) {
if (!card) {
return null;
}
if (card.arcana === "Major") {
return buildTopInfoLabel(card);
}
return buildBottomInfoLabel(card);
}
function isHouseCardImageVisible(card) {
if (!card) {
return false;
}
if (card.arcana === "Major") {
return config.getHouseTopCardsVisible?.() !== false;
}
return config.getHouseBottomCardsVisible?.() !== false;
}
function buildHouseCardTextFaceModel(card, label) {
const displayName = normalizeLabelText(config.getDisplayCardName(card) || card?.name || "Tarot");
if (card?.arcana !== "Major" && label?.primary) {
return {
primary: displayName || "Tarot",
secondary: [label.primary, label.secondary].filter(Boolean).join(" · "),
className: label.className || ""
};
}
if (label?.primary) {
const fallbackSecondary = displayName && label.primary !== displayName ? displayName : "";
return {
primary: label.primary,
secondary: label.secondary || fallbackSecondary,
className: label.className || ""
};
}
return {
primary: displayName || "Tarot",
secondary: "",
className: ""
};
}
function createHouseCardLabelElement(label) {
if (!label?.primary) {
return null;
}
const labelEl = document.createElement("span");
labelEl.className = `tarot-house-card-label${label.className ? ` ${label.className}` : ""}`;
const primaryEl = document.createElement("span");
primaryEl.className = "tarot-house-card-label-primary";
primaryEl.textContent = label.primary;
labelEl.appendChild(primaryEl);
if (label.secondary) {
const secondaryEl = document.createElement("span");
secondaryEl.className = "tarot-house-card-label-secondary";
secondaryEl.textContent = label.secondary;
labelEl.appendChild(secondaryEl);
}
return labelEl;
}
function createHouseCardTextFaceElement(faceModel) {
const faceEl = document.createElement("span");
faceEl.className = `tarot-house-card-text-face${faceModel?.className ? ` ${faceModel.className}` : ""}`;
const primaryEl = document.createElement("span");
primaryEl.className = "tarot-house-card-text-primary";
primaryEl.textContent = faceModel?.primary || "Tarot";
faceEl.appendChild(primaryEl);
if (faceModel?.secondary) {
const secondaryEl = document.createElement("span");
secondaryEl.className = "tarot-house-card-text-secondary";
secondaryEl.textContent = faceModel.secondary;
faceEl.appendChild(secondaryEl);
}
return faceEl;
}
function disconnectHouseImageObserver() {
if (!houseImageObserver) {
return;
}
houseImageObserver.disconnect();
houseImageObserver = null;
}
function hydrateHouseCardImage(image) {
if (!(image instanceof HTMLImageElement)) {
return;
}
const nextSrc = String(image.dataset.src || "").trim();
if (!nextSrc || image.dataset.imageHydrated === "true") {
return;
}
image.dataset.imageHydrated = "true";
image.classList.add("is-loading");
image.src = nextSrc;
}
function getHouseImageObserver(elements) {
const root = elements?.tarotHouseOfCardsEl?.closest(".tarot-section-house-top") || null;
if (!root || typeof IntersectionObserver !== "function") {
return null;
}
if (houseImageObserver) {
return houseImageObserver;
}
houseImageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
const target = entry.target;
observer.unobserve(target);
hydrateHouseCardImage(target);
});
}, {
root,
rootMargin: "160px 0px",
threshold: 0.01
});
return houseImageObserver;
}
function registerHouseCardImage(image, elements) {
if (!(image instanceof HTMLImageElement)) {
return;
}
const observer = getHouseImageObserver(elements);
if (!observer) {
hydrateHouseCardImage(image);
return;
}
observer.observe(image);
}
2026-03-07 05:17:50 -08:00
function createHouseCardButton(card, elements) {
const button = document.createElement("button");
button.type = "button";
button.className = "tarot-house-card-btn";
if (!card) {
button.disabled = true;
const fallback = document.createElement("span");
fallback.className = "tarot-house-card-fallback";
fallback.textContent = "Missing";
button.appendChild(fallback);
return button;
}
const cardDisplayName = config.getDisplayCardName(card);
2026-03-08 03:52:25 -07:00
const label = buildHouseCardLabel(card);
const showImage = isHouseCardImageVisible(card);
const labelText = label?.secondary
? `${label.primary} - ${label.secondary}`
: label?.primary || "";
button.title = labelText ? `${cardDisplayName || card.name} - ${labelText}` : (cardDisplayName || card.name);
button.setAttribute("aria-label", labelText ? `${cardDisplayName || card.name}, ${labelText}` : (cardDisplayName || card.name));
2026-03-07 05:17:50 -08:00
button.dataset.houseCardId = card.id;
const imageUrl = typeof config.resolveTarotCardThumbnail === "function"
? config.resolveTarotCardThumbnail(card.name)
: (typeof config.resolveTarotCardImage === "function" ? config.resolveTarotCardImage(card.name) : null);
2026-03-07 05:17:50 -08:00
2026-03-08 03:52:25 -07:00
if (showImage && imageUrl) {
2026-03-07 05:17:50 -08:00
const image = document.createElement("img");
image.className = "tarot-house-card-image";
image.alt = "";
image.setAttribute("aria-hidden", "true");
image.loading = "lazy";
image.decoding = "async";
image.fetchPriority = config.isHouseFocusMode?.() === true ? "auto" : "low";
image.dataset.src = imageUrl;
image.addEventListener("load", () => {
image.classList.remove("is-loading");
image.classList.add("is-loaded");
}, { once: true });
image.addEventListener("error", () => {
image.classList.remove("is-loading");
image.classList.remove("is-loaded");
image.dataset.imageHydrated = "false";
});
2026-03-07 05:17:50 -08:00
button.appendChild(image);
registerHouseCardImage(image, elements);
2026-03-08 03:52:25 -07:00
} else if (showImage) {
2026-03-07 05:17:50 -08:00
const fallback = document.createElement("span");
fallback.className = "tarot-house-card-fallback";
fallback.textContent = cardDisplayName || card.name;
button.appendChild(fallback);
2026-03-08 03:52:25 -07:00
} else {
button.classList.add("is-text-only");
button.appendChild(createHouseCardTextFaceElement(buildHouseCardTextFaceModel(card, label)));
}
const labelEl = showImage ? createHouseCardLabelElement(label) : null;
if (labelEl) {
button.appendChild(labelEl);
2026-03-07 05:17:50 -08:00
}
button.addEventListener("click", () => {
config.selectCardById(card.id, elements);
2026-03-08 03:52:25 -07:00
if (config.isHouseFocusMode?.() === true && imageUrl) {
config.openCardLightbox?.(
imageUrl,
cardDisplayName || card.name || "Tarot card enlarged image",
{ cardId: card.id }
);
return;
}
2026-03-07 05:17:50 -08:00
elements?.tarotCardListEl
?.querySelector(`[data-card-id="${card.id}"]`)
?.scrollIntoView({ block: "nearest" });
});
return button;
}
function updateSelection(elements) {
if (!elements?.tarotHouseOfCardsEl) {
return;
}
const selectedCardId = config.getSelectedCardId();
const buttons = elements.tarotHouseOfCardsEl.querySelectorAll(".tarot-house-card-btn[data-house-card-id]");
buttons.forEach((button) => {
const isSelected = button.dataset.houseCardId === selectedCardId;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-current", isSelected ? "true" : "false");
});
}
2026-03-08 03:52:25 -07:00
function loadCardImage(url) {
return new Promise((resolve) => {
if (!url) {
resolve(null);
return;
}
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = url;
});
}
function buildHouseRows(cards, cardLookupMap) {
const trumpRows = HOUSE_TRUMP_ROWS.map((trumpNumbers) =>
(trumpNumbers || []).map((trumpNumber) => findMajorCardByTrumpNumber(cards, trumpNumber))
);
const leftRows = HOUSE_MINOR_NUMBER_BANDS.map((numbers, rowIndex) =>
numbers.map((rankNumber) => findCardByLookupName(cardLookupMap, buildMinorCardName(rankNumber, HOUSE_LEFT_SUITS[rowIndex])))
);
const middleRows = HOUSE_MIDDLE_RANKS.map((rank) =>
HOUSE_MIDDLE_SUITS.map((suit) => findCardByLookupName(cardLookupMap, buildCourtCardName(rank, suit)))
);
const rightRows = HOUSE_MINOR_NUMBER_BANDS.map((numbers, rowIndex) =>
numbers.map((rankNumber) => findCardByLookupName(cardLookupMap, buildMinorCardName(rankNumber, HOUSE_RIGHT_SUITS[rowIndex])))
);
return {
trumpRows,
leftRows,
middleRows,
rightRows
};
}
function drawRoundedRectPath(context, x, y, width, height, radius) {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.beginPath();
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
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 = 4) {
const normalized = normalizeLabelText(text);
if (!normalized) {
return [];
}
const words = normalized.split(/\s+/).filter(Boolean);
if (words.length <= 1) {
return [fitCanvasLabelText(context, normalized, maxWidth)];
}
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 drawTextFaceToCanvas(context, x, y, width, height, faceModel) {
const primaryText = normalizeLabelText(faceModel?.primary || "Tarot");
const secondaryText = normalizeLabelText(faceModel?.secondary);
const maxWidth = width - 20;
context.save();
context.fillStyle = "#f4f4f5";
const primaryFontSize = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 24 : 13;
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);
context.fillStyle = "rgba(250, 250, 250, 0.84)";
context.font = "500 9px 'Segoe UI', sans-serif";
const secondaryLines = secondaryText ? wrapCanvasText(context, secondaryText, maxWidth, 2) : [];
const primaryLineHeight = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 24 : 16;
const secondaryLineHeight = 12;
const totalHeight = (primaryLines.length * primaryLineHeight)
+ (secondaryLines.length ? 8 + (secondaryLines.length * secondaryLineHeight) : 0);
let currentY = y + ((height - totalHeight) / 2) + primaryLineHeight;
context.textAlign = "center";
context.textBaseline = "alphabetic";
context.fillStyle = "#f4f4f5";
context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`;
primaryLines.forEach((line) => {
context.fillText(line, x + (width / 2), currentY, maxWidth);
currentY += primaryLineHeight;
});
if (secondaryLines.length) {
currentY += 4;
context.fillStyle = "rgba(250, 250, 250, 0.84)";
context.font = "500 9px 'Segoe UI', sans-serif";
secondaryLines.forEach((line) => {
context.fillText(line, x + (width / 2), currentY, maxWidth);
currentY += secondaryLineHeight;
});
}
context.restore();
}
function drawCardLabelToCanvas(context, x, y, width, height, label) {
if (!label?.primary) {
return;
}
const hasSecondary = Boolean(label.secondary);
const overlayHeight = hasSecondary ? 34 : 24;
const overlayX = x + 4;
const overlayY = y + height - overlayHeight - 4;
const overlayWidth = width - 8;
const gradient = context.createLinearGradient(overlayX, overlayY, overlayX, overlayY + overlayHeight);
gradient.addColorStop(0, "rgba(9, 9, 11, 0.18)");
gradient.addColorStop(1, "rgba(9, 9, 11, 0.9)");
context.save();
drawRoundedRectPath(context, overlayX, overlayY, overlayWidth, overlayHeight, 6);
context.fillStyle = gradient;
context.fill();
context.textAlign = "center";
const primaryFontSize = label.className === "is-top-hebrew" && label.primary.length <= 3 ? 14 : 11;
context.textBaseline = hasSecondary ? "alphabetic" : "middle";
context.fillStyle = "#fafafa";
context.font = `700 ${primaryFontSize}px 'Segoe UI Symbol', 'Segoe UI', sans-serif`;
const primaryText = fitCanvasLabelText(context, label.primary, overlayWidth - 10);
if (hasSecondary) {
context.fillText(primaryText, x + width / 2, overlayY + 14, overlayWidth - 10);
context.fillStyle = "rgba(250, 250, 250, 0.84)";
context.font = "500 9px 'Segoe UI', sans-serif";
const secondaryText = fitCanvasLabelText(context, label.secondary, overlayWidth - 10);
context.fillText(secondaryText, x + width / 2, overlayY + overlayHeight - 8, overlayWidth - 10);
} else {
context.fillText(primaryText, x + width / 2, overlayY + (overlayHeight / 2), overlayWidth - 10);
}
context.restore();
}
function drawCardToCanvas(context, x, y, width, height, card, image) {
const label = buildHouseCardLabel(card);
const showImage = isHouseCardImageVisible(card);
drawRoundedRectPath(context, x, y, width, height, 8);
context.fillStyle = EXPORT_PANEL;
context.fill();
if (showImage && image) {
context.save();
drawRoundedRectPath(context, x, y, width, height, 8);
context.clip();
context.drawImage(image, x, y, width, height);
context.restore();
} else if (showImage) {
context.fillStyle = "#09090b";
context.fillRect(x, y, width, height);
context.fillStyle = EXPORT_FALLBACK_TEXT;
context.font = "600 12px 'Segoe UI', sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const label = card ? (config.getDisplayCardName(card) || card.name || "Tarot") : "Missing";
const words = String(label).split(/\s+/).filter(Boolean);
const lines = [];
let current = "";
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (next.length > 14 && current) {
lines.push(current);
current = word;
} else {
current = next;
}
});
if (current) {
lines.push(current);
}
const lineHeight = 16;
const startY = y + (height / 2) - ((lines.length - 1) * lineHeight / 2);
lines.slice(0, 4).forEach((line, index) => {
context.fillText(line, x + width / 2, startY + (index * lineHeight), width - 16);
});
} else {
drawTextFaceToCanvas(context, x, y, width, height, buildHouseCardTextFaceModel(card, label));
}
if (showImage) {
drawCardLabelToCanvas(context, x, y, width, height, label);
}
drawRoundedRectPath(context, x, y, width, height, 8);
context.lineWidth = 2;
context.strokeStyle = EXPORT_BORDER;
context.stroke();
}
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Canvas export failed."));
}, "image/png");
});
}
function isExportFormatSupported(format) {
const exportFormat = EXPORT_FORMATS[format];
if (!exportFormat) {
return false;
}
if (format === "png") {
return true;
}
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.png;
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 = "png") {
const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.png;
const cards = config.getCards();
const cardLookupMap = getCardLookupMap(cards);
const houseRows = buildHouseRows(cards, cardLookupMap);
const majorRowWidth = (11 * EXPORT_CARD_WIDTH) + (10 * EXPORT_CARD_GAP);
const leftColumnWidth = (3 * EXPORT_CARD_WIDTH) + (2 * EXPORT_CARD_GAP);
const middleColumnWidth = (4 * EXPORT_CARD_WIDTH) + (3 * EXPORT_CARD_GAP);
const rightColumnWidth = leftColumnWidth;
const usedBottomWidth = leftColumnWidth + middleColumnWidth + rightColumnWidth;
const betweenColumnGap = Math.max(0, (majorRowWidth - usedBottomWidth) / 2);
const contentWidth = majorRowWidth;
const trumpHeight = (houseRows.trumpRows.length * EXPORT_CARD_HEIGHT) + ((houseRows.trumpRows.length - 1) * EXPORT_ROW_GAP);
const bottomHeight = (houseRows.leftRows.length * EXPORT_CARD_HEIGHT) + ((houseRows.leftRows.length - 1) * EXPORT_ROW_GAP);
const contentHeight = trumpHeight + EXPORT_SECTION_GAP + bottomHeight;
const scale = Math.max(2, Math.min(3, Number(window.devicePixelRatio) || 1));
const canvas = document.createElement("canvas");
canvas.width = Math.ceil((contentWidth + (EXPORT_PADDING * 2)) * scale);
canvas.height = Math.ceil((contentHeight + (EXPORT_PADDING * 2)) * scale);
canvas.style.width = `${contentWidth + (EXPORT_PADDING * 2)}px`;
canvas.style.height = `${contentHeight + (EXPORT_PADDING * 2)}px`;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas context is unavailable.");
}
context.scale(scale, scale);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.fillStyle = EXPORT_BACKGROUND;
context.fillRect(0, 0, contentWidth + (EXPORT_PADDING * 2), contentHeight + (EXPORT_PADDING * 2));
const imageCache = new Map();
const imageUrlByCardId = new Map();
cards.forEach((card) => {
const url = typeof config.resolveTarotCardImage === "function"
? config.resolveTarotCardImage(card.name)
: null;
imageUrlByCardId.set(card.id, url || "");
if (url && !imageCache.has(url)) {
imageCache.set(url, loadCardImage(url));
}
});
const resolvedImageByCardId = new Map();
await Promise.all(cards.map(async (card) => {
const url = imageUrlByCardId.get(card.id);
const image = url ? await imageCache.get(url) : null;
resolvedImageByCardId.set(card.id, image || null);
}));
let currentY = EXPORT_PADDING;
houseRows.trumpRows.forEach((row) => {
const rowWidth = (row.length * EXPORT_CARD_WIDTH) + ((Math.max(0, row.length - 1)) * EXPORT_CARD_GAP);
let currentX = EXPORT_PADDING + ((contentWidth - rowWidth) / 2);
row.forEach((card) => {
drawCardToCanvas(context, currentX, currentY, EXPORT_CARD_WIDTH, EXPORT_CARD_HEIGHT, card, card ? resolvedImageByCardId.get(card.id) : null);
currentX += EXPORT_CARD_WIDTH + EXPORT_CARD_GAP;
});
currentY += EXPORT_CARD_HEIGHT + EXPORT_ROW_GAP;
});
currentY = EXPORT_PADDING + trumpHeight + EXPORT_SECTION_GAP;
const columnXs = [
EXPORT_PADDING,
EXPORT_PADDING + leftColumnWidth + betweenColumnGap,
EXPORT_PADDING + leftColumnWidth + betweenColumnGap + middleColumnWidth + betweenColumnGap
];
[houseRows.leftRows, houseRows.middleRows, houseRows.rightRows].forEach((columnRows, columnIndex) => {
let columnY = currentY;
columnRows.forEach((row) => {
let currentX = columnXs[columnIndex];
row.forEach((card) => {
drawCardToCanvas(context, currentX, columnY, EXPORT_CARD_WIDTH, EXPORT_CARD_HEIGHT, card, card ? resolvedImageByCardId.get(card.id) : null);
currentX += EXPORT_CARD_WIDTH + EXPORT_CARD_GAP;
});
columnY += EXPORT_CARD_HEIGHT + EXPORT_ROW_GAP;
});
});
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-house-of-cards-${stamp}.${exportFormat.extension}`;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}
2026-03-07 05:17:50 -08:00
function appendHouseMinorRow(columnEl, cardLookupMap, numbers, suit, elements) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-row";
numbers.forEach((rankNumber) => {
const cardName = buildMinorCardName(rankNumber, suit);
const card = findCardByLookupName(cardLookupMap, cardName);
rowEl.appendChild(createHouseCardButton(card, elements));
});
columnEl.appendChild(rowEl);
}
function appendHouseCourtRow(columnEl, cardLookupMap, rank, elements) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-row";
HOUSE_MIDDLE_SUITS.forEach((suit) => {
const cardName = buildCourtCardName(rank, suit);
const card = findCardByLookupName(cardLookupMap, cardName);
rowEl.appendChild(createHouseCardButton(card, elements));
});
columnEl.appendChild(rowEl);
}
function appendHouseTrumpRow(containerEl, trumpNumbers, elements, cards) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-trump-row";
(trumpNumbers || []).forEach((trumpNumber) => {
const card = findMajorCardByTrumpNumber(cards, trumpNumber);
rowEl.appendChild(createHouseCardButton(card, elements));
});
containerEl.appendChild(rowEl);
}
function render(elements) {
if (!elements?.tarotHouseOfCardsEl) {
return;
}
const cards = config.getCards();
disconnectHouseImageObserver();
2026-03-07 05:17:50 -08:00
config.clearChildren(elements.tarotHouseOfCardsEl);
const cardLookupMap = getCardLookupMap(cards);
const trumpSectionEl = document.createElement("div");
trumpSectionEl.className = "tarot-house-trumps";
HOUSE_TRUMP_ROWS.forEach((trumpRow) => {
appendHouseTrumpRow(trumpSectionEl, trumpRow, elements, cards);
});
const bottomGridEl = document.createElement("div");
bottomGridEl.className = "tarot-house-bottom-grid";
const leftColumnEl = document.createElement("div");
leftColumnEl.className = "tarot-house-column";
HOUSE_MINOR_NUMBER_BANDS.forEach((numbers, rowIndex) => {
appendHouseMinorRow(leftColumnEl, cardLookupMap, numbers, HOUSE_LEFT_SUITS[rowIndex], elements);
});
const middleColumnEl = document.createElement("div");
middleColumnEl.className = "tarot-house-column";
HOUSE_MIDDLE_RANKS.forEach((rank) => {
appendHouseCourtRow(middleColumnEl, cardLookupMap, rank, elements);
});
const rightColumnEl = document.createElement("div");
rightColumnEl.className = "tarot-house-column";
HOUSE_MINOR_NUMBER_BANDS.forEach((numbers, rowIndex) => {
appendHouseMinorRow(rightColumnEl, cardLookupMap, numbers, HOUSE_RIGHT_SUITS[rowIndex], elements);
});
bottomGridEl.append(leftColumnEl, middleColumnEl, rightColumnEl);
elements.tarotHouseOfCardsEl.append(trumpSectionEl, bottomGridEl);
updateSelection(elements);
}
window.TarotHouseUi = {
init,
render,
2026-03-08 03:52:25 -07:00
updateSelection,
exportImage,
isExportFormatSupported
2026-03-07 05:17:50 -08:00
};
})();