added overlay function for tarot cards
This commit is contained in:
@@ -20,6 +20,28 @@
|
||||
[18, 17, 15, 14, 13, 9, 8, 7, 6, 5, 4],
|
||||
[11]
|
||||
];
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
resolveTarotCardImage: null,
|
||||
@@ -27,8 +49,14 @@
|
||||
clearChildren: () => {},
|
||||
normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(),
|
||||
selectCardById: () => {},
|
||||
openCardLightbox: () => {},
|
||||
isHouseFocusMode: () => false,
|
||||
getCards: () => [],
|
||||
getSelectedCardId: () => ""
|
||||
getSelectedCardId: () => "",
|
||||
getHouseTopCardsVisible: () => true,
|
||||
getHouseTopInfoModes: () => ({}),
|
||||
getHouseBottomCardsVisible: () => true,
|
||||
getHouseBottomInfoModes: () => ({})
|
||||
};
|
||||
|
||||
function init(nextConfig = {}) {
|
||||
@@ -81,6 +109,494 @@
|
||||
return (Array.isArray(cards) ? cards : []).find((card) => card?.arcana === "Major" && Number(card?.number) === target) || null;
|
||||
}
|
||||
|
||||
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 createHouseCardButton(card, elements) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
@@ -96,28 +612,49 @@
|
||||
}
|
||||
|
||||
const cardDisplayName = config.getDisplayCardName(card);
|
||||
button.title = cardDisplayName || card.name;
|
||||
button.setAttribute("aria-label", cardDisplayName || card.name);
|
||||
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));
|
||||
button.dataset.houseCardId = card.id;
|
||||
const imageUrl = typeof config.resolveTarotCardImage === "function"
|
||||
? config.resolveTarotCardImage(card.name)
|
||||
: null;
|
||||
|
||||
if (imageUrl) {
|
||||
if (showImage && imageUrl) {
|
||||
const image = document.createElement("img");
|
||||
image.className = "tarot-house-card-image";
|
||||
image.src = imageUrl;
|
||||
image.alt = cardDisplayName || card.name;
|
||||
button.appendChild(image);
|
||||
} else {
|
||||
} else if (showImage) {
|
||||
const fallback = document.createElement("span");
|
||||
fallback.className = "tarot-house-card-fallback";
|
||||
fallback.textContent = cardDisplayName || card.name;
|
||||
button.appendChild(fallback);
|
||||
} else {
|
||||
button.classList.add("is-text-only");
|
||||
button.appendChild(createHouseCardTextFaceElement(buildHouseCardTextFaceModel(card, label)));
|
||||
}
|
||||
|
||||
const labelEl = showImage ? createHouseCardLabelElement(label) : null;
|
||||
if (labelEl) {
|
||||
button.appendChild(labelEl);
|
||||
}
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
config.selectCardById(card.id, elements);
|
||||
if (config.isHouseFocusMode?.() === true && imageUrl) {
|
||||
config.openCardLightbox?.(
|
||||
imageUrl,
|
||||
cardDisplayName || card.name || "Tarot card enlarged image",
|
||||
{ cardId: card.id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
elements?.tarotCardListEl
|
||||
?.querySelector(`[data-card-id="${card.id}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
@@ -140,6 +677,380 @@
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function appendHouseMinorRow(columnEl, cardLookupMap, numbers, suit, elements) {
|
||||
const rowEl = document.createElement("div");
|
||||
rowEl.className = "tarot-house-row";
|
||||
@@ -222,6 +1133,8 @@
|
||||
window.TarotHouseUi = {
|
||||
init,
|
||||
render,
|
||||
updateSelection
|
||||
updateSelection,
|
||||
exportImage,
|
||||
isExportFormatSupported
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user