From aa3f23c92cb16b099491662839f55d4c41bf5e67 Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 12 Mar 2026 21:01:32 -0700 Subject: [PATCH] update ui webp export --- app.js | 5 + app/styles.css | 207 +++++++++++++++++---------------- app/ui-chrome.js | 32 +----- app/ui-cube.js | 246 ++++++++++++++++++++++++++++++++++++++- app/ui-kabbalah.js | 249 +++++++++++++++++++++++++++++++++++++++- app/ui-navigation.js | 4 + app/ui-section-state.js | 12 +- app/ui-tarot-house.js | 6 +- app/ui-tarot.js | 11 +- index.html | 209 +++++++++++++++++---------------- 10 files changed, 741 insertions(+), 240 deletions(-) diff --git a/app.js b/app.js index c97c4c2..baf0871 100644 --- a/app.js +++ b/app.js @@ -36,6 +36,7 @@ const timelineSectionEl = document.getElementById("timeline-section"); const calendarSectionEl = document.getElementById("calendar-section"); const holidaySectionEl = document.getElementById("holiday-section"); const tarotSectionEl = document.getElementById("tarot-section"); +const tarotHouseSectionEl = document.getElementById("tarot-house-section"); const astronomySectionEl = document.getElementById("astronomy-section"); const natalSectionEl = document.getElementById("natal-section"); const planetSectionEl = document.getElementById("planet-section"); @@ -59,6 +60,7 @@ const openCalendarTimelineEl = document.getElementById("open-calendar-timeline") const openCalendarMonthsEl = document.getElementById("open-calendar-months"); const openHolidaysEl = document.getElementById("open-holidays"); const openTarotEl = document.getElementById("open-tarot"); +const openTarotHouseEl = document.getElementById("open-tarot-house"); const openAstronomyEl = document.getElementById("open-astronomy"); const openPlanetsEl = document.getElementById("open-planets"); const openCyclesEl = document.getElementById("open-cycles"); @@ -399,6 +401,7 @@ sectionStateUi.init?.({ calendarSectionEl, holidaySectionEl, tarotSectionEl, + tarotHouseSectionEl, astronomySectionEl, natalSectionEl, planetSectionEl, @@ -422,6 +425,7 @@ sectionStateUi.init?.({ openCalendarMonthsEl, openHolidaysEl, openTarotEl, + openTarotHouseEl, openAstronomyEl, openPlanetsEl, openCyclesEl, @@ -517,6 +521,7 @@ navigationUi.init?.({ openCalendarMonthsEl, openHolidaysEl, openTarotEl, + openTarotHouseEl, openAstronomyEl, openPlanetsEl, openCyclesEl, diff --git a/app/styles.css b/app/styles.css index 9e6aaf0..dc69749 100644 --- a/app/styles.css +++ b/app/styles.css @@ -18,6 +18,7 @@ justify-content: space-between; align-items: center; gap: 10px; + flex-wrap: wrap; border-bottom: 1px solid #27272a; background: #18181b; min-width: 0; @@ -43,7 +44,9 @@ color: #fbbf24; } .topbar-menu-toggle { - display: none; + display: inline-flex; + align-items: center; + justify-content: center; padding: 7px 12px; border-radius: 999px; border: 1px solid #3f3f46; @@ -59,55 +62,53 @@ background: #3f3f46; } .topbar-actions { - display: flex; - align-items: center; - gap: 8px; - flex: 1 1 auto; - min-width: 0; - justify-content: flex-start; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: 132px; - margin-bottom: -130px; - pointer-events: none; + display: none; + flex: 1 0 100%; + width: 100%; + padding: 12px; + margin: 4px 0 0; + border: 1px solid #2f2f39; + border-radius: 16px; + background: linear-gradient(180deg, rgba(24, 24, 34, 0.98), rgba(12, 12, 18, 0.98)); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34); + gap: 10px; + max-height: calc(100svh - 88px); + overflow-y: auto; + } + .topbar.is-menu-open .topbar-actions { + display: grid; + grid-template-columns: 1fr; } .topbar-actions > * { - pointer-events: auto; - } - .topbar-actions::-webkit-scrollbar { - height: 6px; + width: 100%; } .topbar-dropdown { - position: relative; - display: inline-flex; - align-items: center; + display: grid; + width: 100%; } .topbar-dropdown-menu { - position: absolute; - top: 100%; - left: 0; - min-width: 140px; + position: static; + min-width: 0; + width: 100%; display: none; grid-template-columns: 1fr; gap: 4px; padding: 6px; - border-radius: 10px; - border: 1px solid #3f3f46; - background: #18181b; - box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); + margin-top: 6px; + border-radius: 12px; + border: 1px solid #31313d; + background: rgba(10, 10, 16, 0.92); + box-shadow: none; z-index: 40; } .topbar-dropdown.is-open .topbar-dropdown-menu { display: grid; } - .topbar-dropdown:hover .topbar-dropdown-menu, - .topbar-dropdown:focus-within .topbar-dropdown-menu { - display: grid; - } .topbar-sub-trigger { width: 100%; text-align: left; font-size: 13px; + min-height: 40px; padding: 6px 10px; } .topbar-sub-trigger.is-active { @@ -115,6 +116,12 @@ border-color: #52525b; } .settings-trigger { + width: 100%; + min-height: 42px; + display: flex; + align-items: center; + justify-content: flex-start; + text-align: left; padding: 7px 12px; border-radius: 8px; border: 1px solid #3f3f46; @@ -146,70 +153,15 @@ min-height: 38px; } .topbar-menu-toggle { - display: inline-flex; - align-items: center; - justify-content: center; min-height: 38px; margin-left: auto; } .topbar-actions { - display: none; - flex: 1 0 100%; - width: 100%; - padding: 12px; - margin: 4px 0 0; - border: 1px solid #2f2f39; - border-radius: 16px; - background: linear-gradient(180deg, rgba(24, 24, 34, 0.98), rgba(12, 12, 18, 0.98)); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34); - overflow: visible; - pointer-events: auto; - gap: 10px; max-height: calc(100svh - 88px); - overflow-y: auto; - } - .topbar.is-menu-open .topbar-actions { - display: grid; - grid-template-columns: 1fr; - } - .topbar-actions > * { - width: 100%; - } - .topbar-dropdown { - display: grid; - width: 100%; - } - .topbar-dropdown-menu { - position: static; - min-width: 0; - width: 100%; - margin-top: 6px; - padding: 6px; - border-radius: 12px; - border-color: #31313d; - background: rgba(10, 10, 16, 0.92); - box-shadow: none; - } - .topbar-dropdown:hover .topbar-dropdown-menu, - .topbar-dropdown:focus-within .topbar-dropdown-menu { - display: none; - } - .topbar-dropdown.is-open .topbar-dropdown-menu { - display: grid; } .settings-trigger { - width: 100%; - min-height: 42px; - display: flex; - align-items: center; - justify-content: flex-start; - text-align: left; padding: 10px 12px; } - .topbar-sub-trigger { - min-height: 40px; - font-size: 13px; - } } @media (max-width: 640px) { .topbar { @@ -366,9 +318,18 @@ box-sizing: border-box; overflow: hidden; } + #tarot-house-section { + height: calc(100vh - 61px); + background: #18181b; + box-sizing: border-box; + overflow: hidden; + } #tarot-section[hidden] { display: none; } + #tarot-house-section[hidden] { + display: none; + } #planet-section[hidden] { display: none; } @@ -1172,22 +1133,11 @@ box-sizing: border-box; } - #tarot-browse-view.is-house-focus { - grid-template-rows: minmax(0, 1fr); + #tarot-house-view.is-house-focus { + padding: 18px; } - #tarot-browse-view.is-house-focus .tarot-section-house-top { - max-height: none; - height: 100%; - padding: 14px; - overflow: auto; - } - - #tarot-browse-view.is-house-focus .tarot-layout { - display: none; - } - - #tarot-browse-view.is-house-focus .tarot-house-layout { + #tarot-house-view.is-house-focus .tarot-house-layout { --tarot-house-card-gap: clamp(4px, 0.6vw, 8px); --tarot-house-row-gap: clamp(6px, 0.9vw, 10px); --tarot-house-section-gap: clamp(12px, 1.4vw, 16px); @@ -1197,7 +1147,7 @@ align-content: start; } - #tarot-browse-view.is-house-focus .tarot-house-trumps { + #tarot-house-view.is-house-focus .tarot-house-trumps { overflow-x: hidden; } @@ -1802,6 +1752,10 @@ display: block; } + .cube-export-btn[hidden] { + display: none; + } + .cube-rotation-controls { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); @@ -2285,6 +2239,33 @@ margin: 0; } + .kab-export-btn { + margin-left: auto; + padding: 5px 10px; + border: 1px solid #3f3f46; + border-radius: 6px; + background: #18181b; + color: #d4d4d8; + cursor: pointer; + font-size: 12px; + line-height: 1.2; + } + + .kab-export-btn:hover:not(:disabled) { + background: #27272a; + border-color: #52525b; + color: #f4f4f5; + } + + .kab-export-btn:disabled { + opacity: 0.65; + cursor: progress; + } + + .kab-export-btn[hidden] { + display: none; + } + .kab-detail-panel { min-width: 0; overflow: auto; @@ -2783,7 +2764,7 @@ min-height: 0; overflow: hidden; display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); grid-row: 1 / -1; } @@ -2791,6 +2772,28 @@ display: none !important; } + #tarot-house-view { + min-height: 0; + height: 100%; + overflow: auto; + padding: 14px; + box-sizing: border-box; + background: #151520; + } + + #tarot-house-view .tarot-section-house-top { + max-height: none; + height: auto; + padding: 0; + border-bottom: none; + overflow: visible; + background: transparent; + } + + #tarot-house-view .tarot-house-card { + gap: 14px; + } + /* ── Tarot Spread View ─────────────────────────────── */ #tarot-spread-view { display: flex; diff --git a/app/ui-chrome.js b/app/ui-chrome.js index 25b6376..71e9f83 100644 --- a/app/ui-chrome.js +++ b/app/ui-chrome.js @@ -5,7 +5,6 @@ const DETAIL_COLLAPSE_STORAGE_PREFIX = "tarot-detail-collapsed:v2:"; const DEFAULT_DATASET_ENTRY_COLLAPSED = false; const DEFAULT_DATASET_DETAIL_COLLAPSED = true; - const MOBILE_TOPBAR_MEDIA_QUERY = "(max-width: 900px)"; const sidebarControllers = new WeakMap(); const detailControllers = new WeakMap(); const AUTO_COLLAPSE_ENTRY_SELECTOR = [ @@ -354,10 +353,6 @@ }; } - function isMobileTopbarViewport() { - return window.matchMedia(MOBILE_TOPBAR_MEDIA_QUERY).matches; - } - function setTopbarMenuOpen(isOpen) { const { topbarEl, menuToggleEl } = getTopbarElements(); if (!(topbarEl instanceof HTMLElement) || !(menuToggleEl instanceof HTMLButtonElement)) { @@ -407,18 +402,13 @@ if (!isDropdownTrigger || isMenuItem) { window.requestAnimationFrame(() => { - if (isMobileTopbarViewport()) { - setTopbarMenuOpen(false); - } + setTopbarMenuOpen(false); }); } }); document.addEventListener("click", (event) => { const clickTarget = event.target; - if (!isMobileTopbarViewport()) { - return; - } if (clickTarget instanceof Node && topbarEl.contains(clickTarget)) { return; } @@ -426,12 +416,6 @@ setTopbarMenuOpen(false); }); - window.addEventListener("resize", () => { - if (!isMobileTopbarViewport()) { - setTopbarMenuOpen(false); - } - }); - document.addEventListener("keydown", (event) => { if (event.key === "Escape") { setTopbarMenuOpen(false); @@ -463,20 +447,6 @@ setTopbarDropdownOpen(dropdownEl, false); - dropdownEl.addEventListener("mouseenter", () => { - if (isMobileTopbarViewport()) { - return; - } - setTopbarDropdownOpen(dropdownEl, true); - }); - - dropdownEl.addEventListener("mouseleave", () => { - if (isMobileTopbarViewport()) { - return; - } - setTopbarDropdownOpen(dropdownEl, false); - }); - dropdownEl.addEventListener("focusout", (event) => { const nextTarget = event.relatedTarget; if (!(nextTarget instanceof Node) || !dropdownEl.contains(nextTarget)) { diff --git a/app/ui-cube.js b/app/ui-cube.js index a927a33..64308be 100644 --- a/app/ui-cube.js +++ b/app/ui-cube.js @@ -16,9 +16,20 @@ selectedConnectorId: null, selectedWallId: null, selectedEdgeId: null, - focusMode: false + focusMode: false, + exportInProgress: false, + exportFormat: "" }; + const CUBE_EXPORT_FORMATS = { + webp: { + mimeType: "image/webp", + extension: "webp", + quality: 0.98 + } + }; + const CUBE_EXPORT_BACKGROUND = "#111118"; + const CUBE_VERTICES = [ [-1, -1, -1], [1, -1, -1], @@ -124,6 +135,7 @@ const cubeChassisUi = window.CubeChassisUi || {}; const cubeMathHelpers = window.CubeMathHelpers || {}; const cubeSelectionHelpers = window.CubeSelectionHelpers || {}; + let webpExportSupported = null; function getElements() { return { @@ -136,6 +148,7 @@ rotateDownEl: document.getElementById("cube-rotate-down"), rotateResetEl: document.getElementById("cube-rotate-reset"), focusToggleEl: document.getElementById("cube-focus-toggle"), + exportWebpEl: document.getElementById("cube-export-webp"), markerModeEl: document.getElementById("cube-marker-mode"), connectorToggleEl: document.getElementById("cube-connector-toggle"), primalToggleEl: document.getElementById("cube-primal-toggle"), @@ -344,6 +357,233 @@ } } + function isExportFormatSupported(format) { + const exportFormat = CUBE_EXPORT_FORMATS[format]; + if (!exportFormat) { + return false; + } + + if (format === "webp" && typeof webpExportSupported === "boolean") { + return webpExportSupported; + } + + const probeCanvas = document.createElement("canvas"); + const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType); + const isSupported = dataUrl.startsWith(`data:${exportFormat.mimeType}`); + if (format === "webp") { + webpExportSupported = isSupported; + } + return isSupported; + } + + function syncExportControls(elements) { + if (!(elements?.exportWebpEl instanceof HTMLButtonElement)) { + return; + } + + const supportsWebp = isExportFormatSupported("webp"); + elements.exportWebpEl.hidden = !supportsWebp; + elements.exportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp; + elements.exportWebpEl.textContent = state.exportInProgress && state.exportFormat === "webp" + ? "Exporting..." + : "Export WebP"; + + if (supportsWebp) { + elements.exportWebpEl.title = "Download the current cube view as a WebP image."; + } + } + + function copyComputedStyles(sourceEl, targetEl) { + if (!(sourceEl instanceof Element) || !(targetEl instanceof Element)) { + return; + } + + const computedStyle = window.getComputedStyle(sourceEl); + Array.from(computedStyle).forEach((propertyName) => { + targetEl.style.setProperty( + propertyName, + computedStyle.getPropertyValue(propertyName), + computedStyle.getPropertyPriority(propertyName) + ); + }); + + targetEl.style.setProperty("animation", "none"); + targetEl.style.setProperty("transition", "none"); + } + + function inlineSvgStyles(sourceNode, targetNode) { + if (!(sourceNode instanceof Element) || !(targetNode instanceof Element)) { + return; + } + + copyComputedStyles(sourceNode, targetNode); + + const sourceChildren = Array.from(sourceNode.children); + const targetChildren = Array.from(targetNode.children); + const childCount = Math.min(sourceChildren.length, targetChildren.length); + for (let index = 0; index < childCount; index += 1) { + inlineSvgStyles(sourceChildren[index], targetChildren[index]); + } + } + + function absolutizeSvgImageLinks(svgEl) { + if (!(svgEl instanceof SVGSVGElement)) { + return; + } + + svgEl.querySelectorAll("image").forEach((imageEl) => { + const href = imageEl.getAttribute("href") + || imageEl.getAttributeNS("http://www.w3.org/1999/xlink", "href"); + if (!href) { + return; + } + + const absoluteHref = new URL(href, document.baseURI).href; + imageEl.setAttribute("href", absoluteHref); + imageEl.setAttributeNS("http://www.w3.org/1999/xlink", "href", absoluteHref); + }); + } + + function prepareSvgMarkupForExport(svgEl) { + if (!(svgEl instanceof SVGSVGElement)) { + throw new Error("Cube view is not ready to export yet."); + } + + const bounds = svgEl.getBoundingClientRect(); + const viewBox = svgEl.viewBox?.baseVal || null; + const width = Math.max( + 240, + Math.round(bounds.width), + Number.isFinite(viewBox?.width) ? Math.round(viewBox.width) : 0 + ); + const height = Math.max( + 220, + Math.round(bounds.height), + Number.isFinite(viewBox?.height) ? Math.round(viewBox.height) : 0 + ); + + const clone = svgEl.cloneNode(true); + if (!(clone instanceof SVGSVGElement)) { + throw new Error("Cube export could not clone the current SVG view."); + } + + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + clone.setAttribute("width", String(width)); + clone.setAttribute("height", String(height)); + clone.setAttribute("preserveAspectRatio", "xMidYMid meet"); + + inlineSvgStyles(svgEl, clone); + absolutizeSvgImageLinks(clone); + + const backgroundRect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + backgroundRect.setAttribute("x", "0"); + backgroundRect.setAttribute("y", "0"); + backgroundRect.setAttribute("width", "100%"); + backgroundRect.setAttribute("height", "100%"); + backgroundRect.setAttribute("fill", CUBE_EXPORT_BACKGROUND); + backgroundRect.setAttribute("pointer-events", "none"); + clone.insertBefore(backgroundRect, clone.firstChild); + + return { + width, + height, + markup: new XMLSerializer().serializeToString(clone) + }; + } + + function loadSvgImage(markup) { + return new Promise((resolve, reject) => { + const svgBlob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" }); + const svgUrl = URL.createObjectURL(svgBlob); + const image = new Image(); + + image.decoding = "async"; + image.onload = () => { + URL.revokeObjectURL(svgUrl); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(svgUrl); + reject(new Error("Cube export renderer could not load the current SVG view.")); + }; + image.src = svgUrl; + }); + } + + function canvasToBlobByFormat(canvas, format) { + const exportFormat = CUBE_EXPORT_FORMATS[format]; + if (!exportFormat) { + return Promise.reject(new Error("Unsupported export format.")); + } + + 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 exportCubeView(format = "webp") { + const exportFormat = CUBE_EXPORT_FORMATS[format]; + if (!exportFormat || state.exportInProgress) { + return; + } + + const elements = getElements(); + const svgEl = elements.viewContainerEl?.querySelector("svg.cube-svg"); + if (!(svgEl instanceof SVGSVGElement)) { + window.alert("Cube view is not ready to export yet."); + return; + } + + state.exportInProgress = true; + state.exportFormat = format; + syncExportControls(elements); + + try { + const { width, height, markup } = prepareSvgMarkupForExport(svgEl); + const image = await loadSvgImage(markup); + const scale = Math.max(2, Math.min(4, Number(window.devicePixelRatio) || 1)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.ceil(width * scale)); + canvas.height = Math.max(1, Math.ceil(height * scale)); + + 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 = CUBE_EXPORT_BACKGROUND; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + + 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 = `cube-of-space-${stamp}.${exportFormat.extension}`; + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + } catch (error) { + window.alert(error instanceof Error ? error.message : "Unable to export the current cube view."); + } finally { + state.exportInProgress = false; + state.exportFormat = ""; + syncExportControls(getElements()); + } + } + function bindRotationControls(elements) { if (state.controlsBound) { return; @@ -358,6 +598,9 @@ state.focusMode = !state.focusMode; syncFocusControls(getElements()); }); + elements.exportWebpEl?.addEventListener("click", () => { + void exportCubeView("webp"); + }); elements.markerModeEl?.addEventListener("change", (event) => { const nextMode = normalizeId(event?.target?.value); @@ -695,6 +938,7 @@ function render(elements) { syncFocusControls(elements); + syncExportControls(elements); if (elements?.markerModeEl) { elements.markerModeEl.value = state.markerDisplayMode; diff --git a/app/ui-kabbalah.js b/app/ui-kabbalah.js index c6b668d..f1d0edb 100644 --- a/app/ui-kabbalah.js +++ b/app/ui-kabbalah.js @@ -58,11 +58,22 @@ showPathNumbers: true, showPathTarotCards: false, selectedSephiraNumber: null, - selectedPathNumber: null + selectedPathNumber: null, + exportInProgress: false, + exportFormat: "" }; + const TREE_EXPORT_FORMATS = { + webp: { + mimeType: "image/webp", + extension: "webp", + quality: 0.98 + } + }; + const TREE_EXPORT_BACKGROUND = "#02030a"; const kabbalahDetailUi = window.KabbalahDetailUi || {}; const kabbalahViewsUi = window.KabbalahViewsUi || {}; + let webpExportSupported = null; if ( typeof kabbalahViewsUi.renderTree !== "function" @@ -259,6 +270,7 @@ pathLetterToggleEl: document.getElementById("kab-path-letter-toggle"), pathNumberToggleEl: document.getElementById("kab-path-number-toggle"), pathTarotToggleEl: document.getElementById("kab-path-tarot-toggle"), + treeExportWebpEl: document.getElementById("kab-tree-export-webp"), roseCrossContainerEl: document.getElementById("kab-rose-cross-container"), roseDetailNameEl: document.getElementById("kab-rose-detail-name"), roseDetailSubEl: document.getElementById("kab-rose-detail-sub"), @@ -476,6 +488,233 @@ kabbalahViewsUi.renderTree(getViewRenderContext(elements)); } + function isExportFormatSupported(format) { + const exportFormat = TREE_EXPORT_FORMATS[format]; + if (!exportFormat) { + return false; + } + + if (format === "webp" && typeof webpExportSupported === "boolean") { + return webpExportSupported; + } + + const probeCanvas = document.createElement("canvas"); + const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType); + const isSupported = dataUrl.startsWith(`data:${exportFormat.mimeType}`); + if (format === "webp") { + webpExportSupported = isSupported; + } + return isSupported; + } + + function syncExportControls(elements) { + if (!(elements?.treeExportWebpEl instanceof HTMLButtonElement)) { + return; + } + + const supportsWebp = isExportFormatSupported("webp"); + elements.treeExportWebpEl.hidden = !supportsWebp; + elements.treeExportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp; + elements.treeExportWebpEl.textContent = state.exportInProgress && state.exportFormat === "webp" + ? "Exporting..." + : "Export WebP"; + + if (supportsWebp) { + elements.treeExportWebpEl.title = "Download the current Tree of Life view as a WebP image."; + } + } + + function copyComputedStyles(sourceEl, targetEl) { + if (!(sourceEl instanceof Element) || !(targetEl instanceof Element)) { + return; + } + + const computedStyle = window.getComputedStyle(sourceEl); + Array.from(computedStyle).forEach((propertyName) => { + targetEl.style.setProperty( + propertyName, + computedStyle.getPropertyValue(propertyName), + computedStyle.getPropertyPriority(propertyName) + ); + }); + + targetEl.style.setProperty("animation", "none"); + targetEl.style.setProperty("transition", "none"); + } + + function inlineSvgStyles(sourceNode, targetNode) { + if (!(sourceNode instanceof Element) || !(targetNode instanceof Element)) { + return; + } + + copyComputedStyles(sourceNode, targetNode); + + const sourceChildren = Array.from(sourceNode.children); + const targetChildren = Array.from(targetNode.children); + const childCount = Math.min(sourceChildren.length, targetChildren.length); + for (let index = 0; index < childCount; index += 1) { + inlineSvgStyles(sourceChildren[index], targetChildren[index]); + } + } + + function absolutizeSvgImageLinks(svgEl) { + if (!(svgEl instanceof SVGSVGElement)) { + return; + } + + svgEl.querySelectorAll("image").forEach((imageEl) => { + const href = imageEl.getAttribute("href") + || imageEl.getAttributeNS("http://www.w3.org/1999/xlink", "href"); + if (!href) { + return; + } + + const absoluteHref = new URL(href, document.baseURI).href; + imageEl.setAttribute("href", absoluteHref); + imageEl.setAttributeNS("http://www.w3.org/1999/xlink", "href", absoluteHref); + }); + } + + function prepareSvgMarkupForExport(svgEl) { + if (!(svgEl instanceof SVGSVGElement)) { + throw new Error("Tree view is not ready to export yet."); + } + + const bounds = svgEl.getBoundingClientRect(); + const viewBox = svgEl.viewBox?.baseVal || null; + const width = Math.max( + 240, + Math.round(bounds.width), + Number.isFinite(viewBox?.width) ? Math.round(viewBox.width) : 0 + ); + const height = Math.max( + 470, + Math.round(bounds.height), + Number.isFinite(viewBox?.height) ? Math.round(viewBox.height) : 0 + ); + + const clone = svgEl.cloneNode(true); + if (!(clone instanceof SVGSVGElement)) { + throw new Error("Tree export could not clone the current SVG view."); + } + + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + clone.setAttribute("width", String(width)); + clone.setAttribute("height", String(height)); + clone.setAttribute("preserveAspectRatio", "xMidYMid meet"); + + inlineSvgStyles(svgEl, clone); + absolutizeSvgImageLinks(clone); + + const backgroundRect = document.createElementNS(NS, "rect"); + backgroundRect.setAttribute("x", "0"); + backgroundRect.setAttribute("y", "0"); + backgroundRect.setAttribute("width", "100%"); + backgroundRect.setAttribute("height", "100%"); + backgroundRect.setAttribute("fill", TREE_EXPORT_BACKGROUND); + backgroundRect.setAttribute("pointer-events", "none"); + clone.insertBefore(backgroundRect, clone.firstChild); + + return { + width, + height, + markup: new XMLSerializer().serializeToString(clone) + }; + } + + function loadSvgImage(markup) { + return new Promise((resolve, reject) => { + const svgBlob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" }); + const svgUrl = URL.createObjectURL(svgBlob); + const image = new Image(); + + image.decoding = "async"; + image.onload = () => { + URL.revokeObjectURL(svgUrl); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(svgUrl); + reject(new Error("Tree export renderer could not load the current SVG view.")); + }; + image.src = svgUrl; + }); + } + + function canvasToBlobByFormat(canvas, format) { + const exportFormat = TREE_EXPORT_FORMATS[format]; + if (!exportFormat) { + return Promise.reject(new Error("Unsupported export format.")); + } + + 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 exportTreeView(format = "webp") { + const exportFormat = TREE_EXPORT_FORMATS[format]; + if (!exportFormat || state.exportInProgress) { + return; + } + + const elements = getElements(); + const svgEl = elements.treeContainerEl?.querySelector("svg.kab-svg"); + if (!(svgEl instanceof SVGSVGElement)) { + window.alert("Tree view is not ready to export yet."); + return; + } + + state.exportInProgress = true; + state.exportFormat = format; + syncExportControls(elements); + + try { + const { width, height, markup } = prepareSvgMarkupForExport(svgEl); + const image = await loadSvgImage(markup); + const scale = Math.max(2, Math.min(4, Number(window.devicePixelRatio) || 1)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.ceil(width * scale)); + canvas.height = Math.max(1, Math.ceil(height * scale)); + + 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 = TREE_EXPORT_BACKGROUND; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + + 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 = `tree-of-life-${stamp}.${exportFormat.extension}`; + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + } catch (error) { + window.alert(error instanceof Error ? error.message : "Unable to export the current Tree of Life view."); + } finally { + state.exportInProgress = false; + state.exportFormat = ""; + syncExportControls(getElements()); + } + } + function renderCurrentSelection(elements) { if (!state.tree) { return; @@ -538,6 +777,14 @@ bindPathDisplayToggle(elements.pathNumberToggleEl, "showPathNumbers"); bindPathDisplayToggle(elements.pathTarotToggleEl, "showPathTarotCards"); + syncExportControls(elements); + if (elements.treeExportWebpEl && !elements.treeExportWebpEl.dataset.bound) { + elements.treeExportWebpEl.addEventListener("click", () => { + void exportTreeView("webp"); + }); + elements.treeExportWebpEl.dataset.bound = "true"; + } + renderTree(elements); renderCurrentSelection(elements); renderRoseCross(elements); diff --git a/app/ui-navigation.js b/app/ui-navigation.js index b1c1484..543fdb4 100644 --- a/app/ui-navigation.js +++ b/app/ui-navigation.js @@ -46,6 +46,10 @@ } }); + bindClick(elements.openTarotHouseEl, () => { + setActiveSection(getActiveSection() === "tarot-house" ? "home" : "tarot-house"); + }); + bindClick(elements.openAstronomyEl, () => { setActiveSection(getActiveSection() === "astronomy" ? "home" : "astronomy"); }); diff --git a/app/ui-section-state.js b/app/ui-section-state.js index 3743eb4..3ce210a 100644 --- a/app/ui-section-state.js +++ b/app/ui-section-state.js @@ -7,6 +7,7 @@ "calendar", "holidays", "tarot", + "tarot-house", "astronomy", "planets", "cycles", @@ -88,6 +89,8 @@ const isHolidaysOpen = activeSection === "holidays"; const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen; const isTarotOpen = activeSection === "tarot"; + const isTarotHouseOpen = activeSection === "tarot-house"; + const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen; const isAstronomyOpen = activeSection === "astronomy"; const isPlanetOpen = activeSection === "planets"; const isCyclesOpen = activeSection === "cycles"; @@ -113,6 +116,7 @@ setHidden(elements.calendarSectionEl, !isCalendarOpen); setHidden(elements.holidaySectionEl, !isHolidaysOpen); setHidden(elements.tarotSectionEl, !isTarotOpen); + setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen); setHidden(elements.astronomySectionEl, !isAstronomyOpen); setHidden(elements.planetSectionEl, !isPlanetOpen); setHidden(elements.cyclesSectionEl, !isCyclesOpen); @@ -137,7 +141,8 @@ toggleActive(elements.openCalendarTimelineEl, isTimelineOpen); toggleActive(elements.openCalendarMonthsEl, isCalendarOpen); toggleActive(elements.openHolidaysEl, isHolidaysOpen); - setPressed(elements.openTarotEl, isTarotOpen); + setPressed(elements.openTarotEl, isTarotMenuOpen); + toggleActive(elements.openTarotHouseEl, isTarotHouseOpen); config.tarotSpreadUi?.applyViewState?.(); setPressed(elements.openAstronomyEl, isAstronomyMenuOpen); toggleActive(elements.openPlanetsEl, isPlanetOpen); @@ -185,6 +190,11 @@ return; } + if (isTarotHouseOpen) { + ensure.ensureTarotSection?.(referenceData, magickDataset); + return; + } + if (isPlanetOpen) { ensure.ensurePlanetSection?.(referenceData, magickDataset); return; diff --git a/app/ui-tarot-house.js b/app/ui-tarot-house.js index 92ad743..b826b3d 100644 --- a/app/ui-tarot-house.js +++ b/app/ui-tarot-house.js @@ -51,6 +51,7 @@ normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(), selectCardById: () => {}, openCardLightbox: () => {}, + shouldOpenCardLightboxOnSelect: () => false, isHouseFocusMode: () => false, getCards: () => [], getSelectedCardId: () => "", @@ -730,8 +731,11 @@ } button.addEventListener("click", () => { + const shouldOpenLightbox = Boolean(config.isHouseFocusMode?.()) + || Boolean(config.shouldOpenCardLightboxOnSelect?.(elements, card)); + config.selectCardById(card.id, elements); - if (config.isHouseFocusMode?.() === true && imageUrl) { + if (shouldOpenLightbox && imageUrl) { config.openCardLightbox?.( imageUrl, cardDisplayName || card.name || "Tarot card enlarged image", diff --git a/app/ui-tarot.js b/app/ui-tarot.js index 758cbec..f381f67 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -244,6 +244,8 @@ function getElements() { return { + tarotSectionEl: document.getElementById("tarot-section"), + tarotHouseSectionEl: document.getElementById("tarot-house-section"), tarotCardListEl: document.getElementById("tarot-card-list"), tarotSearchInputEl: document.getElementById("tarot-search-input"), tarotSearchClearEl: document.getElementById("tarot-search-clear"), @@ -278,6 +280,7 @@ tarotKabPathEl: document.getElementById("tarot-kab-path"), tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"), tarotBrowseViewEl: document.getElementById("tarot-browse-view"), + tarotHouseViewEl: document.getElementById("tarot-house-view"), tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"), tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"), tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"), @@ -539,8 +542,8 @@ } function syncHouseControls(elements) { - if (elements?.tarotBrowseViewEl) { - elements.tarotBrowseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode)); + if (elements?.tarotHouseViewEl) { + elements.tarotHouseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode)); } if (elements?.tarotHouseTopCardsVisibleEl) { @@ -838,6 +841,10 @@ } }); }, + shouldOpenCardLightboxOnSelect: (latestElements) => Boolean( + latestElements?.tarotHouseSectionEl instanceof HTMLElement + && latestElements.tarotHouseSectionEl.hidden === false + ), isHouseFocusMode: () => state.houseFocusMode, getCards: () => state.cards, getSelectedCardId: () => state.selectedCardId, diff --git a/index.html b/index.html index 11c4f4b..42a1dac 100644 --- a/index.html +++ b/index.html @@ -16,58 +16,59 @@ - +
-
- - -
-
- -