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

@ -49,6 +49,7 @@ bun run build # production: JS + Zola + pagefind → public/
| Chapter URLs | `/{subjectId}/{chapter-slug}/` (e.g. `/math/quadratic-equations/`) |
| Map | Mermaid DAG view (`?view=tree`) driven by `graph.edges` |
| Graph | `graph.edges` must be **acyclic**; builder throws on cycles |
| Accessibility | Avoid decorative characters that may render as emoji on mobile. Prefer broadly supported symbols/text so desktop and mobile stay visually consistent. |
## Common tasks → where to edit

View file

@ -0,0 +1,10 @@
+++
title = "Linear inequalities in one unknown"
description = "Solve, represent, and apply linear inequalities in one unknown using number lines and interval notation."
template = "chapter.html"
weight = 11
[extra]
subject = "math"
chapter_id = "linear-inequalities-one-unknown"
strand = "Number & Algebra"
+++

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

View file

@ -102,6 +102,10 @@
overflow-y: auto;
}
.catalog-subject-select {
display: none;
}
.catalog-subject-btn {
width: 100%;
text-align: left;
@ -140,6 +144,7 @@
.catalog-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--card-bg);
}
@ -405,6 +410,7 @@
.catalog-content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 32px;
}
@ -471,6 +477,8 @@
height: 100%;
min-height: 0;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
background:
radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--line) 55%, transparent) 1px, transparent 0)
center / 16px 16px;
@ -478,6 +486,7 @@
:global(.catalog-mermaid-view__svg) {
width: 100%;
height: 100%;
touch-action: none;
}
:global(svg[id^="catalog-mermaid-"]) {
@ -860,7 +869,7 @@ a.catalog-chapter-card {
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding-left: 48px;
padding-left: 0px;
}
// -----------------------------------------------------------------------------
@ -1033,11 +1042,16 @@ a.catalog-chapter-card {
.catalog-shell--compare .catalog-content {
padding-top: 8px;
overflow-x: hidden;
}
.catalog-compare-doc {
width: min(56rem, 100%);
max-width: 56rem;
min-width: 0;
box-sizing: border-box;
padding: 8px 24px 48px;
overflow-wrap: anywhere;
}
.catalog-compare-doc__lead {
@ -1077,7 +1091,9 @@ a.catalog-chapter-card {
}
.catalog-compare-doc__table-wrap {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 12px;
@ -1086,6 +1102,7 @@ a.catalog-chapter-card {
.catalog-compare-table {
width: 100%;
min-width: max-content;
border-collapse: collapse;
font-size: 0.82rem;
line-height: 1.45;
@ -1246,15 +1263,24 @@ a.catalog-chapter-card {
}
.catalog-sidebar__nav {
flex-direction: row;
flex-wrap: wrap;
display: block;
padding: 12px;
}
.catalog-subject-select {
display: block;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--card-border);
border-radius: 10px;
font-family: var(--font-body);
font-size: 0.95rem;
color: var(--ink);
background: var(--card-bg);
}
.catalog-subject-btn {
width: auto;
flex: 1 1 auto;
min-width: calc(50% - 4px);
display: none;
}
.catalog-header {
@ -1281,12 +1307,8 @@ a.catalog-chapter-card {
.catalog-header__head {
width: 100%;
}
.catalog-header__intro {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: flex-start;
}
.catalog-view-toggle {

View file

@ -140,239 +140,9 @@
color: var(--ink);
}
.contributing-demo-slot {
margin-top: 28px;
}
.contributing-demo__hint {
margin: 0 0 14px;
font-size: 0.9rem;
color: var(--muted);
}
.contributing-card-demo {
margin-top: 28px;
padding: 22px 24px;
border: 1px solid var(--card-border);
border-radius: 12px;
background: var(--card-bg);
max-width: 22rem;
}
.contributing-card-demo .catalog-chapter-card--map {
margin: 0;
pointer-events: auto;
}
.contributing-page__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
/* Interactive graph demo */
.contributing-graph-demo__layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 20px;
border: 1px solid var(--card-border);
border-radius: 12px;
background: var(--paper);
}
.contributing-graph-demo__node {
min-width: 10rem;
padding: 10px 14px;
border: 1px solid var(--card-border);
border-radius: 10px;
background: var(--card-bg);
font-family: var(--font-heading);
font-size: 0.95rem;
color: var(--ink);
cursor: pointer;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background-color 180ms ease;
}
.contributing-graph-demo__node:hover,
.contributing-graph-demo__node:focus-visible {
border-color: var(--accent);
outline: none;
}
.contributing-graph-demo__node.is-lit {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(154, 107, 46, 0.2);
background: rgba(154, 107, 46, 0.08);
}
.contributing-graph-demo__row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
}
.contributing-graph-demo__fork,
.contributing-graph-demo__join {
display: flex;
align-items: center;
justify-content: center;
gap: 48px;
font-size: 1.1rem;
color: var(--muted);
line-height: 1;
}
/* List / map view demo */
.contributing-views-demo {
border: 1px solid var(--card-border);
border-radius: 12px;
overflow: hidden;
background: var(--card-bg);
}
.contributing-views-demo__tabs {
display: flex;
border-bottom: 1px solid var(--line);
}
.contributing-views-demo__tab {
flex: 1;
padding: 12px 16px;
border: none;
background: transparent;
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
transition: color 180ms ease, background-color 180ms ease;
}
.contributing-views-demo__tab.is-active {
color: var(--accent);
background: rgba(154, 107, 46, 0.08);
}
.contributing-views-demo__tab:hover,
.contributing-views-demo__tab:focus-visible {
color: var(--ink);
outline: none;
}
.contributing-views-demo__panel {
padding: 22px 24px 18px;
min-height: 12rem;
}
.contributing-views-demo__list {
list-style: none;
margin: 0;
padding: 0;
}
.contributing-views-demo__list li {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
padding: 14px 16px;
border: 1px solid var(--card-border);
border-radius: 10px;
background: var(--paper);
}
.contributing-views-demo__list-num {
font-family: var(--mono);
font-size: 0.75rem;
font-weight: 700;
color: var(--muted);
}
.contributing-views-demo__list-title {
flex: 1;
font-family: var(--font-heading);
font-size: 1rem;
}
.contributing-views-demo__list-badge {
padding: 4px 8px;
border-radius: 6px;
font-family: var(--mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #1d4ed8;
background: rgba(59, 130, 246, 0.12);
}
.contributing-views-demo__list-badge.is-muted {
color: var(--muted);
background: rgba(118, 118, 118, 0.12);
}
.contributing-views-demo__map-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.contributing-views-demo__strand {
padding: 6px 12px;
border-radius: 8px;
font-family: var(--mono);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink);
background: rgba(154, 107, 46, 0.15);
}
.contributing-views-demo__map-node {
min-width: 9rem;
padding: 8px 12px;
border: 1px solid var(--card-border);
border-radius: 8px;
background: var(--paper);
font-size: 0.82rem;
text-align: center;
}
.contributing-views-demo__map-node.is-small {
min-width: 7rem;
font-size: 0.78rem;
}
.contributing-views-demo__map-arrow,
.contributing-views-demo__map-fork {
color: var(--muted);
font-size: 1rem;
}
.contributing-views-demo__map-row {
display: flex;
gap: 20px;
}
.contributing-views-demo__caption {
margin: 16px 0 0;
font-size: 0.88rem;
color: var(--muted);
line-height: 1.5;
}
.contributing-views-demo__caption code {
font-family: var(--mono);
font-size: 0.8rem;
}

View file

@ -45,7 +45,6 @@ content/{subject}/{slug}/ → live page (one folder)
</ul>
</div>
<div id="contributing-demo-views" class="contributing-demo-slot"></div>
</section>
<section id="planned-chapter" class="contributing-section">
@ -81,25 +80,6 @@ content/{subject}/{slug}/ → live page (one folder)
<p><code>bun run build:js</code> syncs HTML and bundles widgets — no theme template or <code>main.tsx</code> edits.</p>
</div>
<aside class="contributing-card-demo" aria-label="Map chapter card example">
<p class="contributing-demo__hint">On the map, only the title is clickable — hover to preview the underline.</p>
<div class="catalog-chapter-card catalog-chapter-card--map">
<div class="catalog-chapter-card__body">
<h3 class="catalog-chapter-card__title">
<a class="catalog-chapter-card__title-link" href="{{ config.extra.browse_url | safe }}">
<span class="catalog-chapter-card__title-text">Quadratic equations</span>
<span class="catalog-chapter-card__title-arrow" aria-hidden="true">→</span>
</a>
</h3>
<p class="catalog-chapter-card__description">
Solve quadratics by factorising, completing the square, and the formula.
</p>
<div class="catalog-chapter-card__meta">
<span class="catalog-badge catalog-badge--dse">DSE</span>
</div>
</div>
</div>
</aside>
</section>
<section id="chapter-graph" class="contributing-section">
@ -123,7 +103,6 @@ content/{subject}/{slug}/ → live page (one folder)
<p>Verify at <a href="{{ config.extra.browse_url | safe }}?subject=math&amp;view=tree">catalog map</a>. Mermaid supports pan and zoom.</p>
</div>
<div id="contributing-demo-graph" class="contributing-demo-slot"></div>
</section>
<section id="dev-setup" class="contributing-section">