From c66f8850a9debaa0d6dc32c351dcc5868a4a46cf Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Tue, 19 May 2026 11:57:50 -0700 Subject: [PATCH] Update TypeScript configuration to exclude test files, enhance CatalogMermaidView component with improved SVG handling and zoom functionality, and modify catalog-to-mermaid logic to include rootNodeId in diagram output. Adjust styles for z-index in catalog components for better layering. --- .../src/components/catalog-mermaid-view.tsx | 360 +++++++++++++++++- frontend/src/lib/catalog-to-mermaid.test.ts | 1 + frontend/src/lib/catalog-to-mermaid.ts | 38 +- themes/openfreebooks/sass/_catalog.scss | 4 +- tsconfig.json | 3 +- 5 files changed, 389 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/catalog-mermaid-view.tsx b/frontend/src/components/catalog-mermaid-view.tsx index a4188d4..f028aae 100644 --- a/frontend/src/components/catalog-mermaid-view.tsx +++ b/frontend/src/components/catalog-mermaid-view.tsx @@ -7,10 +7,13 @@ type CatalogMermaidViewProps = { }; type MermaidModule = typeof import("mermaid"); -type SvgPanZoomModule = typeof import("svg-pan-zoom")["default"]; +type SvgPanZoomModule = typeof import("svg-pan-zoom"); +const PAN_ZOOM_MIN = 0.3; +const PAN_ZOOM_MAX = 8; export function CatalogMermaidView(props: CatalogMermaidViewProps) { let container!: HTMLDivElement; + let renderedSvg: SVGSVGElement | undefined; let panZoom: SvgPanZoom.Instance | undefined; let resizeObserver: ResizeObserver | undefined; @@ -27,15 +30,43 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { const ensureModules = async () => { if (!mermaidModule || !svgPanZoomModule) { - const [{ default: mermaid }, { default: svgPanZoom }] = await Promise.all([ + const [mermaidImport, svgPanZoomImport] = await Promise.all([ import("mermaid"), import("svg-pan-zoom"), ]); + const svgPanZoomFromDefault = ( + svgPanZoomImport as unknown as { + default?: SvgPanZoomModule; + } + ).default; + const svgPanZoom = svgPanZoomFromDefault ?? (svgPanZoomImport as unknown as SvgPanZoomModule); + const mermaid = mermaidImport.default; mermaidModule = mermaid; svgPanZoomModule = svgPanZoom; } }; + const fitWholeBoard = () => { + if (!panZoom) return; + panZoom.updateBBox(); + panZoom.resize(); + panZoom.fit(); + panZoom.contain(); + panZoom.center(); + }; + + const waitForContainerLayout = (el: HTMLElement): Promise => + new Promise((resolve) => { + const check = () => { + if (el.clientWidth > 1 && el.clientHeight > 1) { + resolve(); + return; + } + requestAnimationFrame(check); + }; + check(); + }); + const renderDiagram = async () => { const source = props.diagram.source; const theme = siteCanvasTheme(); @@ -45,6 +76,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { const generation = ++renderGeneration; disposePanZoom(); + renderedSvg = undefined; await ensureModules(); if (generation !== renderGeneration || !mermaidModule || !svgPanZoomModule) return; @@ -70,7 +102,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { container.innerHTML = svg; bindFunctions?.(container); - const renderedSvg = container.querySelector("svg"); + renderedSvg = container.querySelector("svg") ?? undefined; if (!renderedSvg) return; renderedSvg.classList.add("catalog-mermaid-view__svg"); renderedSvg.removeAttribute("style"); @@ -83,15 +115,21 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { controlIconsEnabled: false, fit: true, center: true, - minZoom: 0.3, - maxZoom: 8, + minZoom: PAN_ZOOM_MIN, + maxZoom: PAN_ZOOM_MAX, dblClickZoomEnabled: true, mouseWheelZoomEnabled: true, }); - panZoom.resize(); - panZoom.fit(); - panZoom.center(); + await waitForContainerLayout(container); + if (document.fonts) { + await document.fonts.ready; + } + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + if (generation !== renderGeneration) return; + fitWholeBoard(); }; onMount(() => { @@ -102,9 +140,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { }); resizeObserver = new ResizeObserver(() => { - panZoom?.resize(); - panZoom?.fit(); - panZoom?.center(); + fitWholeBoard(); }); resizeObserver.observe(container); @@ -114,6 +150,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { resizeObserver?.disconnect(); resizeObserver = undefined; disposePanZoom(); + renderedSvg = undefined; }); }); @@ -125,8 +162,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { const resetView = () => { if (!panZoom) return; panZoom.reset(); - panZoom.fit(); - panZoom.center(); + fitWholeBoard(); }; const zoomIn = () => { @@ -137,6 +173,297 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) { panZoom?.zoomOut(); }; + const resolveRootNode = (): SVGGElement | null => { + if (!renderedSvg || !props.diagram.rootNodeId) return null; + const escapedRootNodeId = + globalThis.CSS && typeof globalThis.CSS.escape === "function" + ? globalThis.CSS.escape(props.diagram.rootNodeId) + : props.diagram.rootNodeId.replaceAll(/["\\]/g, "\\$&"); + return ( + renderedSvg.querySelector(`g.node[data-id="${escapedRootNodeId}"]`) ?? + renderedSvg.querySelector(`g.node#${escapedRootNodeId}`) ?? + renderedSvg.querySelector(`g[id*="${escapedRootNodeId}"]`) + ); + }; + + const zoomToRoot = () => { + if (!panZoom || !renderedSvg || !props.diagram.rootNodeId) { + console.log("[catalog-mermaid] zoomToRoot fallback", { + hasPanZoom: Boolean(panZoom), + hasSvg: Boolean(renderedSvg), + rootNodeId: props.diagram.rootNodeId, + reason: "missing prerequisites", + }); + resetView(); + return; + } + + const rootNode = resolveRootNode(); + if (!rootNode) { + console.log("[catalog-mermaid] zoomToRoot fallback", { + rootNodeId: props.diagram.rootNodeId, + reason: "root node not found in SVG", + }); + resetView(); + return; + } + + panZoom.updateBBox(); + panZoom.resize(); + + const rootBounds = rootNode.getBBox(); + const sizes = panZoom.getSizes(); + const finiteOr = (value: number, fallback: number): number => + Number.isFinite(value) ? value : fallback; + const viewportWidth = finiteOr(sizes.width, container.clientWidth); + const viewportHeight = finiteOr(sizes.height, container.clientHeight); + const minZoom = PAN_ZOOM_MIN; + const maxZoom = Math.max(PAN_ZOOM_MAX, minZoom); + const currentZoom = finiteOr(panZoom.getZoom(), finiteOr(sizes.realZoom, 1)); + const rootWidth = Math.max(finiteOr(rootBounds.width, 0), 1); + const rootHeight = Math.max(finiteOr(rootBounds.height, 0), 1); + + // Treat viewport as a 5x5 grid and place root at top-middle cell center. + const gridColumns = 5; + const gridRows = 5; + const cellWidth = viewportWidth / gridColumns; + const cellHeight = viewportHeight / gridRows; + const targetViewportX = cellWidth * 2.5; + const targetViewportY = cellHeight * 0.5; + + // Fit root node within a single grid cell with some breathing room. + const cellFitPadding = 0.8; + const zoomToFitCellX = (cellWidth * cellFitPadding) / rootWidth; + const zoomToFitCellY = (cellHeight * cellFitPadding) / rootHeight; + const rootRectBeforeZoom = rootNode.getBoundingClientRect(); + const currentRootScreenHeight = Math.max(finiteOr(rootRectBeforeZoom.height, 0), 1); + const targetRootScreenHeight = viewportHeight / 4; + const magnification = targetRootScreenHeight / currentRootScreenHeight; + const rawTargetZoom = currentZoom * magnification; + const rootZoomFloor = Math.min(maxZoom, Math.max(minZoom, 1)); + const targetZoom = Math.min(maxZoom, Math.max(rootZoomFloor, rawTargetZoom)); + + if (!Number.isFinite(targetZoom) || targetZoom <= 0) { + console.log("[catalog-mermaid] zoomToRoot fallback", { + rootNodeId: props.diagram.rootNodeId, + reason: "non-finite targetZoom", + targetZoom, + }); + resetView(); + return; + } + + console.log("[catalog-mermaid] zoomToRoot", { + rootNodeId: props.diagram.rootNodeId, + rootNodeDomId: rootNode.id, + rootNodeDataId: rootNode.getAttribute("data-id"), + rootBounds: { + x: rootBounds.x, + y: rootBounds.y, + width: rootBounds.width, + height: rootBounds.height, + }, + viewport: { + width: viewportWidth, + height: viewportHeight, + minZoom, + maxZoom, + currentZoom, + }, + grid: { + columns: gridColumns, + rows: gridRows, + cellWidth, + cellHeight, + targetViewportX, + targetViewportY, + }, + zoom: { + zoomToFitCellX, + zoomToFitCellY, + currentRootScreenHeight, + targetRootScreenHeight, + magnification, + rawTargetZoom, + rootZoomFloor, + zoomMode: "root-height-quarter-viewport", + targetZoom, + }, + targetViewport: { + x: targetViewportX, + y: targetViewportY, + }, + }); + + panZoom.zoom(targetZoom); + + requestAnimationFrame(() => { + if (!panZoom) return; + + const containerRect = container.getBoundingClientRect(); + const rootRect = rootNode.getBoundingClientRect(); + const rootCenterScreenX = rootRect.left + rootRect.width / 2; + const rootCenterScreenY = rootRect.top + rootRect.height / 2; + const targetScreenX = containerRect.left + targetViewportX; + const targetScreenY = containerRect.top + targetViewportY; + const deltaX = targetScreenX - rootCenterScreenX; + const deltaY = targetScreenY - rootCenterScreenY; + + if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) { + console.log("[catalog-mermaid] zoomToRoot fallback", { + rootNodeId: props.diagram.rootNodeId, + reason: "non-finite delta pan", + deltaX, + deltaY, + }); + resetView(); + return; + } + + const currentPan = panZoom.getPan(); + const nextPan = { + x: currentPan.x + deltaX, + y: currentPan.y + deltaY, + }; + if (!Number.isFinite(nextPan.x) || !Number.isFinite(nextPan.y)) { + console.log("[catalog-mermaid] zoomToRoot fallback", { + rootNodeId: props.diagram.rootNodeId, + reason: "non-finite next pan", + nextPan, + }); + resetView(); + return; + } + + panZoom.pan(nextPan); + console.log("[catalog-mermaid] zoomToRoot applied", { + targetZoom, + currentPan, + nextPan, + targetScreen: { x: targetScreenX, y: targetScreenY }, + rootCenterScreen: { x: rootCenterScreenX, y: rootCenterScreenY }, + delta: { x: deltaX, y: deltaY }, + }); + }); + }; + + const snapshotView = () => { + if (!panZoom || !renderedSvg) { + console.log("[catalog-mermaid] snapshot fallback", { + hasPanZoom: Boolean(panZoom), + hasSvg: Boolean(renderedSvg), + reason: "missing prerequisites", + }); + return; + } + + panZoom.updateBBox(); + panZoom.resize(); + + const finiteOr = (value: number, fallback: number): number => + Number.isFinite(value) ? value : fallback; + const sizes = panZoom.getSizes(); + const viewportWidth = finiteOr(sizes.width, container.clientWidth); + const viewportHeight = finiteOr(sizes.height, container.clientHeight); + const minZoom = PAN_ZOOM_MIN; + const maxZoom = Math.max(PAN_ZOOM_MAX, minZoom); + const currentZoom = finiteOr(panZoom.getZoom(), finiteOr(sizes.realZoom, 1)); + const currentPan = panZoom.getPan(); + const rootNode = resolveRootNode(); + const rootBounds = rootNode?.getBBox(); + + let desired: Record | null = null; + if (rootNode && rootBounds) { + const rootWidth = Math.max(finiteOr(rootBounds.width, 0), 1); + const rootHeight = Math.max(finiteOr(rootBounds.height, 0), 1); + const cellWidth = viewportWidth / 5; + const cellHeight = viewportHeight / 5; + const targetViewportX = cellWidth * 2.5; + const targetViewportY = cellHeight * 0.5; + const zoomToFitCellX = (cellWidth * 0.8) / rootWidth; + const zoomToFitCellY = (cellHeight * 0.8) / rootHeight; + const currentRootScreenHeight = Math.max(finiteOr(rootNode.getBoundingClientRect().height, 0), 1); + const targetRootScreenHeight = viewportHeight / 4; + const magnification = targetRootScreenHeight / currentRootScreenHeight; + const rawTargetZoom = currentZoom * magnification; + const rootZoomFloor = Math.min(maxZoom, Math.max(minZoom, 1)); + const targetZoom = Math.min(maxZoom, Math.max(rootZoomFloor, rawTargetZoom)); + const containerRect = container.getBoundingClientRect(); + const rootRect = rootNode.getBoundingClientRect(); + const rootCenterScreenX = rootRect.left + rootRect.width / 2; + const rootCenterScreenY = rootRect.top + rootRect.height / 2; + const targetScreenX = containerRect.left + targetViewportX; + const targetScreenY = containerRect.top + targetViewportY; + const deltaX = targetScreenX - rootCenterScreenX; + const deltaY = targetScreenY - rootCenterScreenY; + desired = { + targetZoom, + targetPanFromCurrent: { + x: currentPan.x + deltaX, + y: currentPan.y + deltaY, + }, + targetScreen: { + x: targetScreenX, + y: targetScreenY, + }, + rootCenterScreen: { + x: rootCenterScreenX, + y: rootCenterScreenY, + }, + deltaPan: { + x: deltaX, + y: deltaY, + }, + currentRootScreenHeight, + targetRootScreenHeight, + magnification, + rawTargetZoom, + rootZoomFloor, + zoomToFitCellX, + zoomToFitCellY, + zoomMode: "root-height-quarter-viewport", + grid: { + columns: 5, + rows: 5, + targetViewportX, + targetViewportY, + cellWidth, + cellHeight, + }, + }; + } + + console.log("[catalog-mermaid] snapshot", { + rootNodeId: props.diagram.rootNodeId, + rootNodeDomId: rootNode?.id ?? null, + rootNodeDataId: rootNode?.getAttribute("data-id") ?? null, + rootBounds: rootBounds + ? { + x: rootBounds.x, + y: rootBounds.y, + width: rootBounds.width, + height: rootBounds.height, + } + : null, + actual: { + viewport: { + width: viewportWidth, + height: viewportHeight, + minZoom, + maxZoom, + }, + zoom: currentZoom, + pan: currentPan, + }, + desired, + svg: { + viewBox: renderedSvg.getAttribute("viewBox"), + width: renderedSvg.getAttribute("width"), + height: renderedSvg.getAttribute("height"), + }, + }); + }; + return (
Reset view + + {/* hidden for now, as we don't have a use case for it yet */} +
); diff --git a/frontend/src/lib/catalog-to-mermaid.test.ts b/frontend/src/lib/catalog-to-mermaid.test.ts index 7706864..2d4715f 100644 --- a/frontend/src/lib/catalog-to-mermaid.test.ts +++ b/frontend/src/lib/catalog-to-mermaid.test.ts @@ -52,6 +52,7 @@ describe("subjectToMermaid", () => { const diagram = subjectToMermaid(subject, () => true, "math"); expect(diagram).not.toBeNull(); const source = diagram!.source; + expect(diagram!.rootNodeId).toBe("chapter_a"); expect(source).toContain("flowchart TB"); expect(source.match(/chapter_a --> chapter_c/g)?.length).toBe(1); diff --git a/frontend/src/lib/catalog-to-mermaid.ts b/frontend/src/lib/catalog-to-mermaid.ts index 91c3468..2c67a03 100644 --- a/frontend/src/lib/catalog-to-mermaid.ts +++ b/frontend/src/lib/catalog-to-mermaid.ts @@ -3,6 +3,7 @@ import { curriculumBadgeClass, tierBadgeLabel } from "./catalog-badge"; export type MermaidCatalogDiagram = { source: string; + rootNodeId: string | null; }; function collectVisibleChapters( @@ -61,6 +62,37 @@ function classListNodeIds( return ids; } +function selectRootSlug( + subject: CatalogSubject, + visible: Map, + edges: CatalogGraphEdge[] +): string | null { + const indegree = new Map(); + for (const slug of visible.keys()) { + indegree.set(slug, 0); + } + for (const edge of edges) { + indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1); + } + + const rootCandidates = new Set(); + for (const [slug, degree] of indegree) { + if (degree === 0) { + rootCandidates.add(slug); + } + } + + for (const strand of subject.strands) { + for (const chapter of strand.chapters) { + if (rootCandidates.has(chapter.slug)) { + return chapter.slug; + } + } + } + + return visible.keys().next().value ?? null; +} + function chapterNodeLabel(chapter: CatalogChapter): string { const title = `

${escapeLabel(chapter.title)}

`; const description = chapter.description @@ -93,6 +125,7 @@ export function subjectToMermaid( if (visible.size === 0) return null; const edges = buildEdgeList(subject, visible); + const rootSlug = selectRootSlug(subject, visible, edges); const lines: string[] = ["flowchart TB"]; for (const strand of subject.strands) { @@ -132,5 +165,8 @@ export function subjectToMermaid( lines.push(" classDef liveNode stroke-width:2px;"); lines.push(" classDef plannedNode stroke-dasharray: 5 3;"); - return { source: lines.join("\n") }; + return { + source: lines.join("\n"), + rootNodeId: rootSlug ? nodeId(rootSlug) : null, + }; } diff --git a/themes/openfreebooks/sass/_catalog.scss b/themes/openfreebooks/sass/_catalog.scss index 0c50d93..c6c6552 100644 --- a/themes/openfreebooks/sass/_catalog.scss +++ b/themes/openfreebooks/sass/_catalog.scss @@ -159,7 +159,7 @@ background: var(--card-bg); overflow: visible; position: relative; - z-index: 2; + z-index: 10; } .catalog-header__head { @@ -370,7 +370,7 @@ position: absolute; right: 20px; top: 20px; - z-index: 4; + z-index: 2; display: flex; flex-wrap: wrap; justify-content: flex-end; diff --git a/tsconfig.json b/tsconfig.json index 882b6e3..eb2baad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,6 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["frontend/src"] + "include": ["frontend/src"], + "exclude": ["frontend/src/**/*.test.ts"] }