Enhance catalog functionality and accessibility: Add a subject selection dropdown in the catalog sidebar for improved navigation. Update the SKILL.md to include accessibility guidelines for mobile compatibility. Refactor the CatalogMermaidView component to streamline zoom and pan functionalities, removing unused SVG pan-zoom integration. Update the catalog-to-mermaid logic to include live node href mappings for better interactivity. Adjust styles for the catalog subject select to ensure consistent display across devices.

This commit is contained in:
Hananoshika Yomaru 2026-05-20 23:33:43 -07:00
parent 562c225884
commit 89b6b6c41a
9 changed files with 383 additions and 632 deletions

View file

@ -199,6 +199,16 @@ export function CatalogApp() {
<p class="catalog-sidebar__subtitle">{data.subtitle}</p>
</div>
<nav class="catalog-sidebar__nav" aria-label="Subject navigation">
<select
class="catalog-subject-select"
aria-label="Select subject"
value={currentSubjectId()}
onChange={(event) => selectSubject(event.currentTarget.value)}
>
<For each={data.subjects}>
{(subject) => <option value={subject.id}>{subject.name}</option>}
</For>
</select>
<For each={data.subjects}>
{(subject) => (
<button

View file

@ -9,53 +9,24 @@ type CatalogMermaidViewProps = {
};
type MermaidModule = typeof import("mermaid");
type SvgPanZoomModule = typeof import("svg-pan-zoom");
const PAN_ZOOM_MIN = 0.3;
const PAN_ZOOM_MAX = 8;
type ViewBox = { x: number; y: number; width: number; height: number };
const ZOOM_MIN = 0.3;
const ZOOM_MAX = 8;
const ZOOM_STEP = 1.2;
const ROOT_ZOOM_MULTIPLIER = 2.2;
export function CatalogMermaidView(props: CatalogMermaidViewProps) {
let container!: HTMLDivElement;
let renderedSvg: SVGSVGElement | undefined;
let panZoom: SvgPanZoom.Instance | undefined;
let resizeObserver: ResizeObserver | undefined;
let renderGeneration = 0;
let lastSource = "";
let lastTheme: "light" | "dark" = siteCanvasTheme();
let mermaidModule: MermaidModule["default"] | undefined;
let svgPanZoomModule: SvgPanZoomModule | undefined;
const disposePanZoom = () => {
panZoom?.destroy();
panZoom = undefined;
};
const ensureModules = async () => {
if (!mermaidModule || !svgPanZoomModule) {
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();
};
let baseViewBox: ViewBox | undefined;
let currentViewBox: ViewBox | undefined;
const waitForContainerLayout = (el: HTMLElement): Promise<void> =>
new Promise((resolve) => {
@ -69,6 +40,106 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
check();
});
const parseViewBox = (svg: SVGSVGElement): ViewBox => {
const raw = svg.getAttribute("viewBox");
if (raw) {
const nums = raw.split(/\s+/).map((value) => Number(value));
if (nums.length === 4 && nums.every(Number.isFinite)) {
return { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
}
}
return {
x: 0,
y: 0,
width: svg.viewBox.baseVal.width || svg.width.baseVal.value || 1000,
height: svg.viewBox.baseVal.height || svg.height.baseVal.value || 1000,
};
};
const applyViewBox = (next: ViewBox) => {
if (!renderedSvg) return;
currentViewBox = next;
renderedSvg.setAttribute("viewBox", `${next.x} ${next.y} ${next.width} ${next.height}`);
};
const currentScale = (): number => {
if (!baseViewBox || !currentViewBox || !currentViewBox.width) return 1;
return baseViewBox.width / currentViewBox.width;
};
const clampScale = (scale: number): number => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, scale));
const zoomAtClientPoint = (scaleFactor: number, clientX: number, clientY: number) => {
if (!renderedSvg || !currentViewBox || !baseViewBox) return;
const rect = container.getBoundingClientRect();
if (rect.width <= 1 || rect.height <= 1) return;
const oldScale = currentScale();
const targetScale = clampScale(oldScale * scaleFactor);
const effectiveFactor = targetScale / oldScale;
if (!Number.isFinite(effectiveFactor) || effectiveFactor <= 0 || effectiveFactor === 1) return;
const ux = (clientX - rect.left) / rect.width;
const uy = (clientY - rect.top) / rect.height;
const focalX = currentViewBox.x + ux * currentViewBox.width;
const focalY = currentViewBox.y + uy * currentViewBox.height;
const nextWidth = currentViewBox.width / effectiveFactor;
const nextHeight = currentViewBox.height / effectiveFactor;
const nextX = focalX - ux * nextWidth;
const nextY = focalY - uy * nextHeight;
applyViewBox({ x: nextX, y: nextY, width: nextWidth, height: nextHeight });
};
const panByPixels = (deltaX: number, deltaY: number) => {
if (!currentViewBox) return;
const rect = container.getBoundingClientRect();
if (rect.width <= 1 || rect.height <= 1) return;
const nextX = currentViewBox.x - deltaX * (currentViewBox.width / rect.width);
const nextY = currentViewBox.y - deltaY * (currentViewBox.height / rect.height);
applyViewBox({ ...currentViewBox, x: nextX, y: nextY });
};
const fitWholeBoard = () => {
if (!baseViewBox) return;
applyViewBox({ ...baseViewBox });
};
const ensureModules = async () => {
if (!mermaidModule) {
const mermaidImport = await import("mermaid");
mermaidModule = mermaidImport.default;
}
};
const resolveLiveHrefFromNode = (node: SVGGElement): string | null => {
const byNodeId = props.diagram.liveNodeHrefById;
const dataId = node.getAttribute("data-id");
if (dataId && byNodeId[dataId]) return byNodeId[dataId];
if (node.id && byNodeId[node.id]) return byNodeId[node.id];
if (node.id) {
for (const [key, href] of Object.entries(byNodeId)) {
if (node.id.includes(key)) return href;
}
}
return null;
};
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 renderDiagram = async () => {
const source = props.diagram.source;
const theme = siteCanvasTheme();
@ -77,11 +148,10 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
lastTheme = theme;
const generation = ++renderGeneration;
disposePanZoom();
renderedSvg = undefined;
await ensureModules();
if (generation !== renderGeneration || !mermaidModule || !svgPanZoomModule) return;
if (generation !== renderGeneration || !mermaidModule) return;
mermaidModule.initialize({
startOnLoad: false,
@ -113,18 +183,6 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
enhanceStrandClusterHeaders(renderedSvg, theme);
panZoom = svgPanZoomModule(renderedSvg, {
zoomEnabled: true,
panEnabled: true,
controlIconsEnabled: false,
fit: true,
center: true,
minZoom: PAN_ZOOM_MIN,
maxZoom: PAN_ZOOM_MAX,
dblClickZoomEnabled: true,
mouseWheelZoomEnabled: true,
});
await waitForContainerLayout(container);
if (document.fonts) {
await document.fonts.ready;
@ -132,13 +190,209 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
if (generation !== renderGeneration) return;
if (generation !== renderGeneration || !renderedSvg) return;
baseViewBox = parseViewBox(renderedSvg);
fitWholeBoard();
};
const resetView = () => {
fitWholeBoard();
};
const zoomIn = () => {
const rect = container.getBoundingClientRect();
zoomAtClientPoint(ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2);
};
const zoomOut = () => {
const rect = container.getBoundingClientRect();
zoomAtClientPoint(1 / ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2);
};
const zoomToRoot = () => {
if (!baseViewBox || !renderedSvg) {
resetView();
return;
}
const rootNode = resolveRootNode();
if (!rootNode) {
resetView();
return;
}
const box = rootNode.getBBox();
if (!Number.isFinite(box.width) || !Number.isFinite(box.height) || box.width <= 0 || box.height <= 0) {
resetView();
return;
}
const targetScale = clampScale((baseViewBox.height / box.height) * (1 / ROOT_ZOOM_MULTIPLIER));
const nextWidth = baseViewBox.width / targetScale;
const nextHeight = baseViewBox.height / targetScale;
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
applyViewBox({
x: centerX - nextWidth / 2,
y: centerY - nextHeight / 2,
width: nextWidth,
height: nextHeight,
});
};
onMount(() => {
void renderDiagram();
let pinchDistance = 0;
let isTouchPanning = false;
let hasTouchPanMoved = false;
let suppressNextClick = false;
let lastTouchX = 0;
let lastTouchY = 0;
let isMousePanning = false;
let lastMouseX = 0;
let lastMouseY = 0;
const onContainerClick = (event: MouseEvent) => {
if (suppressNextClick) {
suppressNextClick = false;
return;
}
const target = event.target instanceof Element ? event.target : null;
if (!target) return;
if (target.closest("a[href]")) return;
const node = target.closest("g.node");
if (!(node instanceof SVGGElement)) return;
const href = resolveLiveHrefFromNode(node);
if (!href) return;
window.location.assign(href);
};
const onWheel = (event: WheelEvent) => {
const scaleFactor = Math.exp(-event.deltaY * 0.002);
zoomAtClientPoint(scaleFactor, event.clientX, event.clientY);
if (event.cancelable) event.preventDefault();
};
const onDblClick = (event: MouseEvent) => {
zoomAtClientPoint(ZOOM_STEP, event.clientX, event.clientY);
if (event.cancelable) event.preventDefault();
};
const onMouseDown = (event: MouseEvent) => {
if (event.button !== 0) return;
isMousePanning = true;
lastMouseX = event.clientX;
lastMouseY = event.clientY;
if (event.cancelable) event.preventDefault();
};
const onMouseMove = (event: MouseEvent) => {
if (!isMousePanning) return;
const deltaX = event.clientX - lastMouseX;
const deltaY = event.clientY - lastMouseY;
lastMouseX = event.clientX;
lastMouseY = event.clientY;
panByPixels(deltaX, deltaY);
if (event.cancelable) event.preventDefault();
};
const onMouseUp = () => {
isMousePanning = false;
};
const touchDistance = (touches: TouchList): number => {
if (touches.length < 2) return 0;
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
};
const touchMidpoint = (touches: TouchList): { x: number; y: number } => ({
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
});
const onTouchStart = (event: TouchEvent) => {
if (event.touches.length >= 2) {
pinchDistance = touchDistance(event.touches);
isTouchPanning = false;
hasTouchPanMoved = false;
if (event.cancelable) event.preventDefault();
return;
}
if (event.touches.length === 1) {
isTouchPanning = true;
hasTouchPanMoved = false;
pinchDistance = 0;
lastTouchX = event.touches[0].clientX;
lastTouchY = event.touches[0].clientY;
}
};
const onTouchMove = (event: TouchEvent) => {
if (event.touches.length >= 2) {
const nextDistance = touchDistance(event.touches);
if (!pinchDistance || !nextDistance) {
pinchDistance = nextDistance;
if (event.cancelable) event.preventDefault();
return;
}
const scaleDelta = nextDistance / pinchDistance;
const midpoint = touchMidpoint(event.touches);
if (Number.isFinite(scaleDelta) && scaleDelta > 0) {
zoomAtClientPoint(scaleDelta, midpoint.x, midpoint.y);
}
pinchDistance = nextDistance;
isTouchPanning = false;
hasTouchPanMoved = true;
if (event.cancelable) event.preventDefault();
return;
}
if (!isTouchPanning || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaX = touch.clientX - lastTouchX;
const deltaY = touch.clientY - lastTouchY;
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
if (Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5) {
hasTouchPanMoved = true;
panByPixels(deltaX, deltaY);
if (event.cancelable) event.preventDefault();
}
};
const onTouchEnd = (event: TouchEvent) => {
if (event.touches.length >= 2) return;
pinchDistance = 0;
isTouchPanning = false;
if (hasTouchPanMoved) {
suppressNextClick = true;
}
hasTouchPanMoved = false;
};
const onGestureEvent = (event: Event) => {
if (event.cancelable) event.preventDefault();
};
container.addEventListener("click", onContainerClick);
container.addEventListener("wheel", onWheel, { passive: false });
container.addEventListener("dblclick", onDblClick);
container.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove, { passive: false });
window.addEventListener("mouseup", onMouseUp);
container.addEventListener("touchstart", onTouchStart, { passive: false });
container.addEventListener("touchmove", onTouchMove, { passive: false });
container.addEventListener("touchend", onTouchEnd, { passive: true });
container.addEventListener("touchcancel", onTouchEnd, { passive: true });
container.addEventListener("gesturestart", onGestureEvent as EventListener, { passive: false });
container.addEventListener("gesturechange", onGestureEvent as EventListener, { passive: false });
container.addEventListener("gestureend", onGestureEvent as EventListener, { passive: false });
const stopThemeWatch = watchSiteTheme(() => {
void renderDiagram();
});
@ -150,11 +404,25 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
onCleanup(() => {
renderGeneration += 1;
container.removeEventListener("click", onContainerClick);
container.removeEventListener("wheel", onWheel);
container.removeEventListener("dblclick", onDblClick);
container.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
container.removeEventListener("touchstart", onTouchStart);
container.removeEventListener("touchmove", onTouchMove);
container.removeEventListener("touchend", onTouchEnd);
container.removeEventListener("touchcancel", onTouchEnd);
container.removeEventListener("gesturestart", onGestureEvent as EventListener);
container.removeEventListener("gesturechange", onGestureEvent as EventListener);
container.removeEventListener("gestureend", onGestureEvent as EventListener);
stopThemeWatch();
resizeObserver?.disconnect();
resizeObserver = undefined;
disposePanZoom();
renderedSvg = undefined;
baseViewBox = undefined;
currentViewBox = undefined;
});
});
@ -163,311 +431,6 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
void renderDiagram();
});
const resetView = () => {
if (!panZoom) return;
panZoom.reset();
fitWholeBoard();
};
const zoomIn = () => {
panZoom?.zoomIn();
};
const zoomOut = () => {
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
@ -489,10 +452,6 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
<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

@ -5,6 +5,7 @@ import { curriculumBadgeClass, tierBadgeLabel } from "./catalog-badge";
export type MermaidCatalogDiagram = {
source: string;
rootNodeId: string | null;
liveNodeHrefById: Record<string, string>;
};
function collectVisibleChapters(
@ -128,6 +129,7 @@ export function subjectToMermaid(
const edges = buildEdgeList(subject, visible);
const rootSlug = selectRootSlug(subject, visible, edges);
const lines: string[] = ["flowchart TB"];
const liveNodeHrefById: Record<string, string> = {};
for (const strand of subject.strands ?? []) {
const strandChapters = strand.chapters.filter((chapter) => visible.has(chapter.slug));
@ -154,7 +156,10 @@ export function subjectToMermaid(
for (const [slug, chapter] of visible) {
if (chapter.status !== "live") continue;
lines.push(` click ${nodeId(slug)} href "/${subjectId}/${slug}/"`);
const liveHref = `/${subjectId}/${slug}/`;
const id = nodeId(slug);
lines.push(` click ${id} href "${liveHref}"`);
liveNodeHrefById[id] = liveHref;
}
const liveIds = classListNodeIds(visible, "live");
@ -171,5 +176,6 @@ export function subjectToMermaid(
return {
source: lines.join("\n"),
rootNodeId: rootSlug ? nodeId(rootSlug) : null,
liveNodeHrefById,
};
}

View file

@ -159,10 +159,4 @@ if (catalogRoot) {
});
}
if (document.getElementById("contributing-demo-graph") || document.getElementById("contributing-demo-views")) {
void import("./components/contributing-demos").then(({ mountContributingDemos }) => {
mountContributingDemos();
});
}
mountFooterYear();