diff --git a/app/card-images.js b/app/card-images.js
index 2ecd0b1..7f1bf7e 100644
--- a/app/card-images.js
+++ b/app/card-images.js
@@ -106,12 +106,48 @@
fit: "inside",
quality: 82
};
+ const standardMajorCardNames = [
+ "Fool",
+ "Magus",
+ "High Priestess",
+ "Empress",
+ "Emperor",
+ "Hierophant",
+ "Lovers",
+ "Chariot",
+ "Lust",
+ "Hermit",
+ "Fortune",
+ "Justice",
+ "Hanged Man",
+ "Death",
+ "Art",
+ "Devil",
+ "Tower",
+ "Star",
+ "Moon",
+ "Sun",
+ "Aeon",
+ "Universe"
+ ];
+ const standardMinorRanks = ["Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Knight", "Queen", "Prince", "Princess"];
+ const standardMinorSuits = ["Wands", "Cups", "Swords", "Disks"];
+ const standardDeckCardNames = buildStandardDeckCardNames();
let deckManifestSources = buildDeckManifestSources();
const manifestCache = new Map();
const cardBackCache = new Map();
const cardBackThumbnailCache = new Map();
+ const imagePreloadCache = new Map();
+ const deckImagePreloadCache = new Map();
+ const deckPreloadStatus = {
+ activeDeckId: DEFAULT_DECK_ID,
+ selectedDeckPhase: "idle",
+ backgroundPhase: "idle",
+ warmedDeckIds: []
+ };
+ let backgroundDeckWarmupPromise = null;
let activeDeckId = DEFAULT_DECK_ID;
function getApiBaseUrl() {
@@ -219,6 +255,82 @@
return { resolvedDeckId, trumpNumber };
}
+ function buildStandardDeckCardNames() {
+ const cardNames = [...standardMajorCardNames];
+
+ standardMinorSuits.forEach((suit) => {
+ standardMinorRanks.forEach((rank) => {
+ cardNames.push(`${rank} of ${suit}`);
+ });
+ });
+
+ return cardNames;
+ }
+
+ function deferPreload(callback) {
+ if (typeof callback !== "function") {
+ return Promise.resolve([]);
+ }
+
+ if (typeof window.requestIdleCallback === "function") {
+ return new Promise((resolve) => {
+ window.requestIdleCallback(() => {
+ Promise.resolve(callback()).then(resolve).catch(() => resolve([]));
+ }, { timeout: 1200 });
+ });
+ }
+
+ return Promise.resolve().then(callback).catch(() => []);
+ }
+
+ function emitDeckPreloadStatus() {
+ const snapshot = {
+ activeDeckId: deckPreloadStatus.activeDeckId,
+ selectedDeckPhase: deckPreloadStatus.selectedDeckPhase,
+ backgroundPhase: deckPreloadStatus.backgroundPhase,
+ warmedDeckIds: [...deckPreloadStatus.warmedDeckIds],
+ warmedDeckCount: deckPreloadStatus.warmedDeckIds.length,
+ totalDeckCount: Object.keys(getDeckManifestSources()).length
+ };
+
+ document.dispatchEvent(new CustomEvent("tarot:deck-cache-status", {
+ detail: snapshot
+ }));
+
+ return snapshot;
+ }
+
+ function setDeckPreloadStatus(partialStatus) {
+ if (!partialStatus || typeof partialStatus !== "object") {
+ return emitDeckPreloadStatus();
+ }
+
+ if (Object.prototype.hasOwnProperty.call(partialStatus, "activeDeckId")) {
+ deckPreloadStatus.activeDeckId = normalizeDeckId(partialStatus.activeDeckId);
+ }
+
+ if (Object.prototype.hasOwnProperty.call(partialStatus, "selectedDeckPhase")) {
+ deckPreloadStatus.selectedDeckPhase = String(partialStatus.selectedDeckPhase || "idle");
+ }
+
+ if (Object.prototype.hasOwnProperty.call(partialStatus, "backgroundPhase")) {
+ deckPreloadStatus.backgroundPhase = String(partialStatus.backgroundPhase || "idle");
+ }
+
+ if (Array.isArray(partialStatus.warmedDeckIds)) {
+ deckPreloadStatus.warmedDeckIds = Array.from(new Set(partialStatus.warmedDeckIds.map((deckId) => normalizeDeckId(deckId))));
+ }
+
+ return emitDeckPreloadStatus();
+ }
+
+ function markDeckAsWarmed(deckId) {
+ const normalizedDeckId = normalizeDeckId(deckId);
+ if (!deckPreloadStatus.warmedDeckIds.includes(normalizedDeckId)) {
+ deckPreloadStatus.warmedDeckIds = [...deckPreloadStatus.warmedDeckIds, normalizedDeckId];
+ }
+ }
+
function parseMinorCard(cardName) {
const match = String(cardName || "")
.trim()
@@ -461,6 +573,15 @@
manifestCache.clear();
cardBackCache.clear();
cardBackThumbnailCache.clear();
+ imagePreloadCache.clear();
+ deckImagePreloadCache.clear();
+ backgroundDeckWarmupPromise = null;
+ setDeckPreloadStatus({
+ activeDeckId,
+ selectedDeckPhase: "idle",
+ backgroundPhase: "idle",
+ warmedDeckIds: []
+ });
setActiveDeck(activeDeckId);
}
@@ -824,6 +945,212 @@
return thumbnailPath || null;
}
+ function preloadImageUrl(url) {
+ const normalizedUrl = String(url || "").trim();
+ if (!normalizedUrl) {
+ return Promise.resolve(false);
+ }
+
+ if (imagePreloadCache.has(normalizedUrl)) {
+ return imagePreloadCache.get(normalizedUrl);
+ }
+
+ const preloadPromise = new Promise((resolve) => {
+ const image = new Image();
+ let settled = false;
+
+ function finalize(result) {
+ if (settled) {
+ return;
+ }
+
+ settled = true;
+ image.onload = null;
+ image.onerror = null;
+ resolve(result);
+ }
+
+ image.decoding = "async";
+ image.onload = () => finalize(true);
+ image.onerror = () => finalize(false);
+ image.src = normalizedUrl;
+
+ if (image.complete) {
+ finalize(Boolean(image.naturalWidth));
+ }
+ });
+
+ imagePreloadCache.set(normalizedUrl, preloadPromise);
+ return preloadPromise;
+ }
+
+ async function preloadImageUrls(urls, concurrency = 6) {
+ const queue = Array.from(new Set(Array.isArray(urls) ? urls.map((entry) => String(entry || "").trim()).filter(Boolean) : []));
+ if (queue.length === 0) {
+ return [];
+ }
+
+ const limit = Math.max(1, Number.isInteger(concurrency) ? concurrency : 6);
+ const results = [];
+ let cursor = 0;
+
+ async function consumeQueue() {
+ while (cursor < queue.length) {
+ const currentIndex = cursor;
+ cursor += 1;
+ results[currentIndex] = await preloadImageUrl(queue[currentIndex]);
+ }
+ }
+
+ await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, () => consumeQueue()));
+ return results;
+ }
+
+ function buildDeckImagePreloadUrls(deckId, options = {}) {
+ const normalizedDeckId = normalizeDeckId(deckId);
+ const includeFull = options.includeFull !== false;
+ const includeThumbnails = options.includeThumbnails === true;
+ const includeCardBack = options.includeCardBack !== false;
+ const urls = new Set();
+
+ if (includeFull) {
+ standardDeckCardNames.forEach((cardName) => {
+ const imagePath = resolveWithDeck(normalizedDeckId, cardName, "full");
+ if (imagePath) {
+ urls.add(imagePath);
+ }
+ });
+ }
+
+ if (includeThumbnails) {
+ standardDeckCardNames.forEach((cardName) => {
+ const thumbnailPath = resolveWithDeck(normalizedDeckId, cardName, "thumbnail");
+ if (thumbnailPath) {
+ urls.add(thumbnailPath);
+ }
+ });
+ }
+
+ if (includeCardBack) {
+ const cardBackImage = resolveTarotCardBackImage(normalizedDeckId);
+ if (cardBackImage) {
+ urls.add(cardBackImage);
+ }
+
+ if (includeThumbnails) {
+ const cardBackThumbnail = resolveTarotCardBackThumbnail(normalizedDeckId);
+ if (cardBackThumbnail) {
+ urls.add(cardBackThumbnail);
+ }
+ }
+ }
+
+ return Array.from(urls);
+ }
+
+ function preloadDeckImages(deckId, options = {}) {
+ const normalizedDeckId = normalizeDeckId(deckId);
+ const cacheKey = JSON.stringify({
+ deckId: normalizedDeckId,
+ includeFull: options.includeFull !== false,
+ includeThumbnails: options.includeThumbnails === true,
+ includeCardBack: options.includeCardBack !== false
+ });
+
+ if (!options.force && deckImagePreloadCache.has(cacheKey)) {
+ return deckImagePreloadCache.get(cacheKey);
+ }
+
+ if (!options.background) {
+ setDeckPreloadStatus({
+ activeDeckId: normalizedDeckId,
+ selectedDeckPhase: "loading"
+ });
+ }
+
+ const preloadPromise = preloadImageUrls(buildDeckImagePreloadUrls(normalizedDeckId, options), options.concurrency)
+ .then((result) => {
+ markDeckAsWarmed(normalizedDeckId);
+ if (!options.background) {
+ setDeckPreloadStatus({
+ activeDeckId: normalizedDeckId,
+ selectedDeckPhase: "ready"
+ });
+ } else {
+ emitDeckPreloadStatus();
+ }
+ return result;
+ })
+ .catch((error) => {
+ if (!options.background) {
+ setDeckPreloadStatus({
+ activeDeckId: normalizedDeckId,
+ selectedDeckPhase: "error"
+ });
+ }
+ throw error;
+ });
+ deckImagePreloadCache.set(cacheKey, preloadPromise);
+ return preloadPromise;
+ }
+
+ async function preloadAllDeckImages(options = {}) {
+ const deckIds = Object.keys(getDeckManifestSources());
+ const startDeckId = options.startDeckId ? normalizeDeckId(options.startDeckId) : "";
+ const orderedDeckIds = startDeckId && deckIds.includes(startDeckId)
+ ? [startDeckId, ...deckIds.filter((deckId) => deckId !== startDeckId)]
+ : deckIds;
+ const results = [];
+
+ for (const deckId of orderedDeckIds) {
+ results.push(await preloadDeckImages(deckId, options));
+ }
+
+ return results;
+ }
+
+ function scheduleDeckImagePreload(deckId, options = {}) {
+ return deferPreload(() => preloadDeckImages(deckId, options));
+ }
+
+ function scheduleBackgroundDeckWarmup(deckId, options = {}) {
+ if (backgroundDeckWarmupPromise) {
+ return backgroundDeckWarmupPromise;
+ }
+
+ const normalizedDeckId = normalizeDeckId(deckId);
+ const warmupOptions = {
+ ...options,
+ startDeckId: normalizedDeckId,
+ background: true,
+ concurrency: Number.isInteger(options.concurrency) ? options.concurrency : 2
+ };
+
+ setDeckPreloadStatus({
+ activeDeckId: normalizedDeckId,
+ backgroundPhase: "loading"
+ });
+
+ backgroundDeckWarmupPromise = scheduleDeckImagePreload(normalizedDeckId, warmupOptions)
+ .then(() => deferPreload(() => preloadAllDeckImages(warmupOptions)))
+ .then((result) => {
+ setDeckPreloadStatus({
+ activeDeckId: normalizedDeckId,
+ backgroundPhase: "ready"
+ });
+ return result;
+ })
+ .catch(() => {
+ setDeckPreloadStatus({
+ activeDeckId: normalizedDeckId,
+ backgroundPhase: "error"
+ });
+ return [];
+ });
+
+ return backgroundDeckWarmupPromise;
+ }
+
function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) {
const manifest = getDeckManifest(deckId);
const fallbackName = String(cardName || "").trim();
@@ -908,6 +1235,12 @@
function setActiveDeck(deckId) {
activeDeckId = normalizeDeckId(deckId);
getDeckManifest(activeDeckId);
+ setDeckPreloadStatus({
+ activeDeckId,
+ selectedDeckPhase: "idle"
+ });
+ scheduleDeckImagePreload(activeDeckId);
+ scheduleBackgroundDeckWarmup(activeDeckId);
return activeDeckId;
}
@@ -933,6 +1266,9 @@
resolveTarotCardThumbnail,
resolveTarotCardBackImage,
resolveTarotCardBackThumbnail,
+ preloadDeckImages,
+ preloadAllDeckImages,
+ getDeckPreloadStatus: () => emitDeckPreloadStatus(),
getTarotCardDisplayName,
getTarotCardSearchAliases,
setActiveDeck,
diff --git a/app/styles.css b/app/styles.css
index 7334865..cb354d6 100644
--- a/app/styles.css
+++ b/app/styles.css
@@ -7049,6 +7049,9 @@
font-size: 12px;
line-height: 1.4;
}
+ .settings-cache-status {
+ min-height: 1.4em;
+ }
.settings-actions {
margin-top: 12px;
display: flex;
diff --git a/app/ui-settings.js b/app/ui-settings.js
index c9c855e..0ff82b3 100644
--- a/app/ui-settings.js
+++ b/app/ui-settings.js
@@ -33,6 +33,7 @@
timeFormatEl: document.getElementById("time-format"),
birthDateEl: document.getElementById("birth-date"),
tarotDeckEl: document.getElementById("tarot-deck"),
+ tarotDeckCacheStatusEl: document.getElementById("tarot-deck-cache-status"),
stellariumBackgroundEl: document.getElementById("stellarium-background"),
stellariumBackgroundHintEl: document.getElementById("stellarium-background-hint"),
apiBaseUrlEl: document.getElementById("api-base-url"),
@@ -122,6 +123,50 @@
}
}
+ function formatDeckCacheStatus(status) {
+ const activeDeckId = String(status?.activeDeckId || normalizeTarotDeck(getElements().tarotDeckEl?.value)).trim().toLowerCase();
+ const totalDeckCount = Number(status?.totalDeckCount) || 0;
+ const warmedDeckCount = Number(status?.warmedDeckCount) || 0;
+ const warmedAllDecks = totalDeckCount > 0 && warmedDeckCount >= totalDeckCount;
+
+ if (status?.selectedDeckPhase === "loading") {
+ return "Caching selected deck images to this browser...";
+ }
+
+ if (status?.selectedDeckPhase === "error") {
+ return "Selected deck cache warmup hit an error. Images will still load on demand.";
+ }
+
+ if (status?.backgroundPhase === "loading") {
+ if (warmedDeckCount > 0 && totalDeckCount > 0) {
+ return `Selected deck cached. Warming remaining decks in background (${warmedDeckCount}/${totalDeckCount}).`;
+ }
+ return "Selected deck cached. Warming remaining decks in background...";
+ }
+
+ if (status?.backgroundPhase === "error") {
+ return "Selected deck cached. Background warmup for other decks stopped early.";
+ }
+
+ if (status?.selectedDeckPhase === "ready" || warmedAllDecks) {
+ if (warmedAllDecks) {
+ return "Selected deck cached. All available decks are warmed in this browser.";
+ }
+ return `Selected deck cached and ready for fullscreen use (${activeDeckId}).`;
+ }
+
+ return "Deck cache idle.";
+ }
+
+ function syncDeckCacheStatus(status) {
+ const { tarotDeckCacheStatusEl } = getElements();
+ if (!tarotDeckCacheStatusEl) {
+ return;
+ }
+
+ tarotDeckCacheStatusEl.textContent = formatDeckCacheStatus(status);
+ }
+
function syncSky(geo, options) {
if (typeof config.onSyncSkyBackground === "function") {
config.onSyncSkyBackground(geo, options);
@@ -373,6 +418,7 @@
if (window.TarotCardImages?.setActiveDeck) {
window.TarotCardImages.setActiveDeck(normalized.tarotDeck);
}
+ syncDeckCacheStatus(window.TarotCardImages?.getDeckPreloadStatus?.());
applyExternalSettings(normalized);
return normalized;
}
@@ -577,6 +623,11 @@
document.addEventListener("connection:updated", () => {
syncConnectionInputs();
syncTarotDeckInputOptions();
+ syncDeckCacheStatus(window.TarotCardImages?.getDeckPreloadStatus?.());
+ });
+
+ document.addEventListener("tarot:deck-cache-status", (event) => {
+ syncDeckCacheStatus(event?.detail);
});
}
diff --git a/index.html b/index.html
index 0149be5..5aae648 100644
--- a/index.html
+++ b/index.html
@@ -125,6 +125,7 @@
+ Deck cache idle.