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.

This commit is contained in:
Hananoshika Yomaru 2026-05-19 11:57:50 -07:00
parent f6150faaa3
commit c66f8850a9
5 changed files with 389 additions and 17 deletions

View file

@ -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<void> =>
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<SVGSVGElement>("svg");
renderedSvg = container.querySelector<SVGSVGElement>("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<void>((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<SVGGElement>(`g.node[data-id="${escapedRootNodeId}"]`) ??
renderedSvg.querySelector<SVGGElement>(`g.node#${escapedRootNodeId}`) ??
renderedSvg.querySelector<SVGGElement>(`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<string, unknown> | 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 (
<div class="catalog-mermaid-stage">
<div
@ -155,6 +482,13 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
<button type="button" class="catalog-mermaid-controls__btn" onClick={resetView}>
Reset view
</button>
<button type="button" class="catalog-mermaid-controls__btn" onClick={zoomToRoot}>
Zoom to root
</button>
{/* hidden for now, as we don't have a use case for it yet */}
<button type="button" class="catalog-mermaid-controls__btn" onClick={snapshotView} style="display: none;">
Snapshot
</button>
</div>
</div>
);

View file

@ -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);

View file

@ -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<string, CatalogChapter>,
edges: CatalogGraphEdge[]
): string | null {
const indegree = new Map<string, number>();
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<string>();
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 = `<h3 class='catalog-mermaid-node__title'>${escapeLabel(chapter.title)}</h3>`;
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,
};
}

View file

@ -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;

View file

@ -10,5 +10,6 @@
"noEmit": true,
"skipLibCheck": true
},
"include": ["frontend/src"]
"include": ["frontend/src"],
"exclude": ["frontend/src/**/*.test.ts"]
}