mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-13 13:05:51 +00:00
Enhance curriculum catalog functionality: Update CONTRIBUTING.md to reflect changes in curriculum structure, including the introduction of curriculumCoverage for frameworks. Modify frontend components to support a new comparison view for Mathematics, allowing users to compare curricula. Update catalog data to include "Common Core" and improve curriculum filtering logic. Enhance styles for better visual representation of curriculum badges and Mermaid diagrams. Adjust TypeScript types to accommodate new features and ensure backward compatibility with legacy curriculum data.
This commit is contained in:
parent
ede9a7aa63
commit
5175811579
34 changed files with 3702 additions and 176 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import { createMemo, createSignal, For, lazy, Show, Suspense } from "solid-js";
|
||||
import { subjectToMermaid } from "../lib/catalog-to-mermaid";
|
||||
import { CatalogCompareDoc } from "./catalog-compare-doc";
|
||||
import { CatalogChapterCard } from "./catalog-chapter-card";
|
||||
import { CatalogFiltersMenu } from "./catalog-filters-menu";
|
||||
import { chapterFilterCurriculums } from "../lib/catalog-coverage";
|
||||
import type {
|
||||
CatalogChapter,
|
||||
CatalogData,
|
||||
|
|
@ -14,10 +16,40 @@ const CatalogMermaidView = lazy(() =>
|
|||
import("./catalog-mermaid-view").then((m) => ({ default: m.CatalogMermaidView }))
|
||||
);
|
||||
|
||||
function normalizeChapter(chapter: CatalogChapter): CatalogChapter {
|
||||
const coverage = chapter.curriculumCoverage;
|
||||
if (coverage && typeof coverage === "object" && !Array.isArray(coverage)) {
|
||||
return chapter;
|
||||
}
|
||||
const legacy = chapter.curriculums;
|
||||
if (!legacy?.length) return { ...chapter, curriculumCoverage: {} };
|
||||
const curriculumCoverage: CatalogChapter["curriculumCoverage"] = {};
|
||||
for (const label of legacy) {
|
||||
curriculumCoverage[label] = "core";
|
||||
}
|
||||
return { ...chapter, curriculumCoverage };
|
||||
}
|
||||
|
||||
function normalizeSubject(subject: CatalogSubject): CatalogSubject {
|
||||
return {
|
||||
...subject,
|
||||
strands: (subject.strands ?? []).map((strand) => ({
|
||||
...strand,
|
||||
chapters: (strand.chapters ?? []).map(normalizeChapter),
|
||||
})),
|
||||
graph: subject.graph ?? { edges: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function readCatalogData(): CatalogData | null {
|
||||
const el = document.getElementById("catalog-data");
|
||||
if (!el?.textContent) return null;
|
||||
return JSON.parse(el.textContent) as CatalogData;
|
||||
const raw = JSON.parse(el.textContent) as CatalogData;
|
||||
return {
|
||||
...raw,
|
||||
curriculums: raw.curriculums ?? [],
|
||||
subjects: (raw.subjects ?? []).map(normalizeSubject),
|
||||
};
|
||||
}
|
||||
|
||||
function initialSubjectId(subjects: CatalogSubject[]): string {
|
||||
|
|
@ -31,17 +63,21 @@ function initialSubjectId(subjects: CatalogSubject[]): string {
|
|||
|
||||
function initialViewMode(): CatalogViewMode {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("view") === "tree" ? "tree" : "linear";
|
||||
const view = params.get("view");
|
||||
if (view === "tree") return "tree";
|
||||
if (view === "compare") return "compare";
|
||||
return "linear";
|
||||
}
|
||||
|
||||
function chapterMatchesFilters(chapter: CatalogChapter, activeFilters: Set<string>): boolean {
|
||||
if (activeFilters.size === 0) return true;
|
||||
return chapter.curriculums.some((c) => activeFilters.has(c));
|
||||
return chapterFilterCurriculums(chapter).some((c) => activeFilters.has(c));
|
||||
}
|
||||
|
||||
function countVisibleChapters(subject: CatalogSubject, activeFilters: Set<string>): number {
|
||||
return subject.strands.reduce((total, strand) => {
|
||||
return total + strand.chapters.filter((ch) => chapterMatchesFilters(ch, activeFilters)).length;
|
||||
return (subject.strands ?? []).reduce((total, strand) => {
|
||||
const visible = (strand.chapters ?? []).filter((ch) => chapterMatchesFilters(ch, activeFilters));
|
||||
return total + visible.length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +86,8 @@ function syncUrl(subjectId: string, viewMode: CatalogViewMode) {
|
|||
url.searchParams.set("subject", subjectId);
|
||||
if (viewMode === "tree") {
|
||||
url.searchParams.set("view", "tree");
|
||||
} else if (viewMode === "compare") {
|
||||
url.searchParams.set("view", "compare");
|
||||
} else {
|
||||
url.searchParams.delete("view");
|
||||
}
|
||||
|
|
@ -66,23 +104,36 @@ export function CatalogApp() {
|
|||
const [activeFilters, setActiveFilters] = createSignal<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = createSignal<CatalogViewMode>(initialViewMode());
|
||||
|
||||
const currentSubject = createMemo(
|
||||
() => data.subjects.find((s) => s.id === currentSubjectId()) ?? data.subjects[0]
|
||||
);
|
||||
const currentSubject = createMemo(() => {
|
||||
const subjects = data.subjects ?? [];
|
||||
return subjects.find((s) => s.id === currentSubjectId()) ?? subjects[0];
|
||||
});
|
||||
|
||||
const visibleCount = createMemo(() => countVisibleChapters(currentSubject(), activeFilters()));
|
||||
const visibleCount = createMemo(() => {
|
||||
const subject = currentSubject();
|
||||
return subject ? countVisibleChapters(subject, activeFilters()) : 0;
|
||||
});
|
||||
|
||||
const hasChapterTree = createMemo(() => currentSubject().strands.length > 0);
|
||||
const hasChapterTree = createMemo(() => (currentSubject()?.strands ?? []).length > 0);
|
||||
|
||||
const chapterFilter = createMemo(
|
||||
() => (chapter: CatalogChapter) => chapterMatchesFilters(chapter, activeFilters())
|
||||
);
|
||||
|
||||
const mermaidData = createMemo(() =>
|
||||
subjectToMermaid(currentSubject(), chapterFilter(), currentSubjectId())
|
||||
const mermaidData = createMemo(() => {
|
||||
const subject = currentSubject();
|
||||
if (!subject) return null;
|
||||
return subjectToMermaid(subject, chapterFilter(), currentSubjectId());
|
||||
});
|
||||
|
||||
const showCompareDoc = createMemo(
|
||||
() => currentSubjectId() === "math" && viewMode() === "compare"
|
||||
);
|
||||
|
||||
const statsLabel = createMemo(() => {
|
||||
if (showCompareDoc()) {
|
||||
return "Curriculum comparison for Mathematics";
|
||||
}
|
||||
const count = visibleCount();
|
||||
const filters = activeFilters();
|
||||
const filterText =
|
||||
|
|
@ -91,8 +142,13 @@ export function CatalogApp() {
|
|||
});
|
||||
|
||||
const selectSubject = (subjectId: string) => {
|
||||
if (subjectId !== "math" && viewMode() === "compare") {
|
||||
setViewMode("linear");
|
||||
syncUrl(subjectId, "linear");
|
||||
} else {
|
||||
syncUrl(subjectId, viewMode());
|
||||
}
|
||||
setCurrentSubjectId(subjectId);
|
||||
syncUrl(subjectId, viewMode());
|
||||
};
|
||||
|
||||
const setCatalogView = (mode: CatalogViewMode) => {
|
||||
|
|
@ -118,10 +174,13 @@ export function CatalogApp() {
|
|||
|
||||
const filteredStrands = createMemo(() => {
|
||||
const subject = currentSubject();
|
||||
return subject.strands
|
||||
if (!subject) return [];
|
||||
return (subject.strands ?? [])
|
||||
.map((strand) => ({
|
||||
...strand,
|
||||
chapters: strand.chapters.filter((ch) => chapterMatchesFilters(ch, activeFilters())),
|
||||
chapters: (strand.chapters ?? []).filter((ch) =>
|
||||
chapterMatchesFilters(ch, activeFilters())
|
||||
),
|
||||
}))
|
||||
.filter((strand) => strand.chapters.length > 0);
|
||||
});
|
||||
|
|
@ -129,7 +188,10 @@ export function CatalogApp() {
|
|||
return (
|
||||
<div
|
||||
class="catalog-shell"
|
||||
classList={{ "catalog-shell--tree": viewMode() === "tree" }}
|
||||
classList={{
|
||||
"catalog-shell--tree": viewMode() === "tree",
|
||||
"catalog-shell--compare": showCompareDoc(),
|
||||
}}
|
||||
>
|
||||
<aside class="catalog-sidebar" aria-label="Subjects">
|
||||
<div class="catalog-sidebar__header">
|
||||
|
|
@ -155,12 +217,12 @@ export function CatalogApp() {
|
|||
<div class="catalog-main">
|
||||
<header
|
||||
class="catalog-header"
|
||||
classList={{ "catalog-header--has-banner": Boolean(currentSubject().banner) }}
|
||||
classList={{ "catalog-header--has-banner": Boolean(currentSubject()?.banner) }}
|
||||
>
|
||||
<Show when={currentSubject().banner}>
|
||||
<Show when={currentSubject()?.banner}>
|
||||
<div class="catalog-header__banner" aria-hidden="true">
|
||||
<img
|
||||
src={currentSubject().banner!}
|
||||
src={currentSubject()!.banner!}
|
||||
alt=""
|
||||
width={1400}
|
||||
height={700}
|
||||
|
|
@ -170,7 +232,7 @@ export function CatalogApp() {
|
|||
</Show>
|
||||
<div class="catalog-header__head">
|
||||
<div class="catalog-header__intro">
|
||||
<h1 class="catalog-header__title">{currentSubject().name}</h1>
|
||||
<h1 class="catalog-header__title">{currentSubject()?.name ?? "Catalog"}</h1>
|
||||
<p class="catalog-header__stats">{statsLabel()}</p>
|
||||
</div>
|
||||
<div class="catalog-view-toggle" role="tablist" aria-label="Chapter layout">
|
||||
|
|
@ -192,21 +254,37 @@ export function CatalogApp() {
|
|||
>
|
||||
Map
|
||||
</button>
|
||||
<Show when={currentSubjectId() === "math"}>
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-view-toggle__btn"
|
||||
role="tab"
|
||||
aria-selected={viewMode() === "compare" ? "true" : "false"}
|
||||
onClick={() => setCatalogView("compare")}
|
||||
>
|
||||
Compare
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="catalog-header__filters">
|
||||
<CatalogFiltersMenu
|
||||
curriculums={data.curriculums}
|
||||
activeFilters={activeFilters}
|
||||
onToggle={toggleFilter}
|
||||
/>
|
||||
</div>
|
||||
<Show when={!showCompareDoc()}>
|
||||
<div class="catalog-header__filters">
|
||||
<CatalogFiltersMenu
|
||||
curriculums={data.curriculums ?? []}
|
||||
activeFilters={activeFilters}
|
||||
onToggle={toggleFilter}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="catalog-content"
|
||||
classList={{ "catalog-content--tree": viewMode() === "tree" }}
|
||||
>
|
||||
<Show
|
||||
when={showCompareDoc()}
|
||||
fallback={
|
||||
<Show
|
||||
when={hasChapterTree() && visibleCount() > 0}
|
||||
fallback={
|
||||
|
|
@ -262,6 +340,10 @@ export function CatalogApp() {
|
|||
</Suspense>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<CatalogCompareDoc />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { For, Show } from "solid-js";
|
||||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
import { chapterFilterCurriculums } from "../lib/catalog-coverage";
|
||||
import { curriculumBadgeClass, tierBadgeLabel } from "../lib/catalog-badge";
|
||||
|
||||
export type CatalogChapterCardProps = {
|
||||
|
|
@ -29,7 +30,7 @@ export function CatalogChapterCard(props: CatalogChapterCardProps) {
|
|||
<p class="catalog-chapter-card__description">{props.chapter.description}</p>
|
||||
</Show>
|
||||
<div class="catalog-chapter-card__meta">
|
||||
<For each={props.chapter.curriculums}>
|
||||
<For each={chapterFilterCurriculums(props.chapter)}>
|
||||
{(curriculum) => (
|
||||
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
|
||||
)}
|
||||
|
|
|
|||
24
frontend/src/components/catalog-compare-doc.tsx
Normal file
24
frontend/src/components/catalog-compare-doc.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createSignal, onMount, Show } from "solid-js";
|
||||
|
||||
function readCompareTemplateHtml(): string {
|
||||
const el = document.getElementById("catalog-compare-template");
|
||||
return el?.innerHTML?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function CatalogCompareDoc() {
|
||||
const [html, setHtml] = createSignal("");
|
||||
|
||||
onMount(() => {
|
||||
setHtml(readCompareTemplateHtml());
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={html()}>
|
||||
<article
|
||||
class="catalog-compare-doc book-prose"
|
||||
aria-label="Mathematics curriculum comparison"
|
||||
innerHTML={html()}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ export function CatalogFiltersMenu(props: CatalogFiltersMenuProps) {
|
|||
/>
|
||||
<span class="catalog-filter-menu__option-label">All</span>
|
||||
</label>
|
||||
<For each={props.curriculums}>
|
||||
<For each={props.curriculums ?? []}>
|
||||
{(curriculum) => (
|
||||
<label class="catalog-filter-menu__option">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { createEffect, onCleanup, onMount } from "solid-js";
|
||||
import type { MermaidCatalogDiagram } from "../lib/catalog-to-mermaid";
|
||||
import { siteCanvasTheme, watchSiteTheme } from "../lib/catalog-canvas-theme";
|
||||
import { mermaidInitializeOptions } from "../lib/catalog-mermaid-theme";
|
||||
import { enhanceStrandClusterHeaders } from "../lib/catalog-mermaid-strand-headers";
|
||||
|
||||
type CatalogMermaidViewProps = {
|
||||
diagram: MermaidCatalogDiagram;
|
||||
|
|
@ -84,7 +86,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
mermaidModule.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "loose",
|
||||
theme: theme === "dark" ? "dark" : "default",
|
||||
...mermaidInitializeOptions(theme),
|
||||
flowchart: {
|
||||
htmlLabels: true,
|
||||
curve: "monotoneY",
|
||||
|
|
@ -109,6 +111,8 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
renderedSvg.style.height = "100%";
|
||||
renderedSvg.style.width = "100%";
|
||||
|
||||
enhanceStrandClusterHeaders(renderedSvg, theme);
|
||||
|
||||
panZoom = svgPanZoomModule(renderedSvg, {
|
||||
zoomEnabled: true,
|
||||
panEnabled: true,
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ function GraphPrereqDemo() {
|
|||
}
|
||||
|
||||
function CatalogViewsDemo() {
|
||||
const [view, setView] = createSignal<"list" | "map">("list");
|
||||
const [view, setView] = createSignal<"list" | "map" | "compare">("list");
|
||||
|
||||
return (
|
||||
<div class="contributing-views-demo" aria-label="Catalog list and map views">
|
||||
|
|
@ -104,9 +104,22 @@ function CatalogViewsDemo() {
|
|||
>
|
||||
Map
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="contributing-views-demo__tab"
|
||||
classList={{ "is-active": view() === "compare" }}
|
||||
aria-selected={view() === "compare"}
|
||||
onClick={() => setView("compare")}
|
||||
>
|
||||
Compare
|
||||
</button>
|
||||
</div>
|
||||
<div class="contributing-views-demo__panel" role="tabpanel">
|
||||
<Show
|
||||
when={view() === "compare"}
|
||||
fallback={
|
||||
<Show
|
||||
when={view() === "list"}
|
||||
fallback={
|
||||
<div class="contributing-views-demo__map">
|
||||
|
|
@ -148,6 +161,13 @@ function CatalogViewsDemo() {
|
|||
List order follows the <code>chapters[]</code> array — your table of contents.
|
||||
</p>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<p class="contributing-views-demo__caption">
|
||||
Mathematics only: curricula comparison doc at{" "}
|
||||
<code>?view=compare</code> (HTML partial, not JSON).
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export function curriculumBadgeClass(curriculum: string): string {
|
|||
"A-Level": "catalog-badge--a-level",
|
||||
AP: "catalog-badge--ap",
|
||||
IGCSE: "catalog-badge--igcse",
|
||||
"Common Core": "catalog-badge--common-core",
|
||||
};
|
||||
return map[curriculum] ?? "catalog-badge--default";
|
||||
}
|
||||
|
|
|
|||
23
frontend/src/lib/catalog-coverage.test.ts
Normal file
23
frontend/src/lib/catalog-coverage.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { chapterFilterCurriculums, coverageRole } from "./catalog-coverage";
|
||||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
|
||||
describe("catalog-coverage", () => {
|
||||
test("reads role from curriculumCoverage", () => {
|
||||
const chapter: CatalogChapter = {
|
||||
slug: "quadratic-equations",
|
||||
title: "Quadratic equations",
|
||||
description: "",
|
||||
status: "live",
|
||||
curriculumCoverage: {
|
||||
DSE: "core",
|
||||
IGCSE: "core",
|
||||
IB: "extension",
|
||||
},
|
||||
};
|
||||
expect(coverageRole(chapter, "DSE")).toBe("core");
|
||||
expect(coverageRole(chapter, "IB")).toBe("extension");
|
||||
expect(coverageRole(chapter, "AP")).toBeNull();
|
||||
expect(chapterFilterCurriculums(chapter)).toEqual(["DSE", "IGCSE", "IB"]);
|
||||
});
|
||||
});
|
||||
53
frontend/src/lib/catalog-coverage.ts
Normal file
53
frontend/src/lib/catalog-coverage.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { CatalogChapter, CatalogChapterTier } from "../../../data/catalog.types";
|
||||
|
||||
export type CurriculumCoverageRole = "core" | "extension" | "related";
|
||||
|
||||
export type CurriculumCoverage = Partial<Record<string, CurriculumCoverageRole>>;
|
||||
|
||||
/** Columns on the Compare chapter matrix (includes frameworks beyond filter chips). */
|
||||
export const COMPARE_MATRIX_COLUMNS = [
|
||||
"Foundation",
|
||||
"DSE",
|
||||
"IGCSE",
|
||||
"A-Level",
|
||||
"AP",
|
||||
"IB",
|
||||
"Common Core",
|
||||
"Singapore",
|
||||
"UK GCSE",
|
||||
"Gaokao",
|
||||
] as const;
|
||||
|
||||
export type CompareMatrixColumn = (typeof COMPARE_MATRIX_COLUMNS)[number];
|
||||
|
||||
export function coverageRole(
|
||||
chapter: CatalogChapter,
|
||||
framework: string
|
||||
): CurriculumCoverageRole | null {
|
||||
const coverage = chapter.curriculumCoverage;
|
||||
if (coverage && framework in coverage) {
|
||||
return coverage[framework] ?? null;
|
||||
}
|
||||
if (chapter.curriculums?.includes(framework)) {
|
||||
return "core";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Framework labels used for catalog filter chips (any mapped role counts). */
|
||||
export function chapterFilterCurriculums(chapter: CatalogChapter | undefined): string[] {
|
||||
if (!chapter) return [];
|
||||
const coverage = chapter.curriculumCoverage;
|
||||
if (coverage && typeof coverage === "object" && !Array.isArray(coverage)) {
|
||||
return Object.keys(coverage);
|
||||
}
|
||||
if (Array.isArray(chapter.curriculums)) {
|
||||
return chapter.curriculums;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** DSE compulsory vs M2-style extension when tier is set. */
|
||||
export function defaultDseRole(tier?: CatalogChapterTier): CurriculumCoverageRole {
|
||||
return tier === "non-foundation" ? "extension" : "core";
|
||||
}
|
||||
9
frontend/src/lib/catalog-mermaid-strand-headers.test.ts
Normal file
9
frontend/src/lib/catalog-mermaid-strand-headers.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { isStrandClusterId } from "./catalog-mermaid-strand-headers";
|
||||
|
||||
describe("catalog-mermaid-strand-headers", () => {
|
||||
test("isStrandClusterId matches strand subgraph ids", () => {
|
||||
expect(isStrandClusterId("flowchart-strand_number_algebra-1")).toBe(true);
|
||||
expect(isStrandClusterId("flowchart-root-0")).toBe(false);
|
||||
});
|
||||
});
|
||||
99
frontend/src/lib/catalog-mermaid-strand-headers.ts
Normal file
99
frontend/src/lib/catalog-mermaid-strand-headers.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import {
|
||||
mermaidThemeVariables,
|
||||
type MermaidThemeMode,
|
||||
} from "./catalog-mermaid-theme";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** Header band height — must fit title line-height + descenders inside foreignObject */
|
||||
export const STRAND_HEADER_HEIGHT = 44;
|
||||
const STRAND_HEADER_PAD_X = 16;
|
||||
|
||||
function applySvgFill(el: SVGGraphicsElement, fill: string): void {
|
||||
el.setAttribute("fill", fill);
|
||||
el.style.fill = fill;
|
||||
}
|
||||
|
||||
export function isStrandClusterId(id: string): boolean {
|
||||
return id.includes("strand_");
|
||||
}
|
||||
|
||||
export function isStrandCluster(cluster: SVGGElement): boolean {
|
||||
return isStrandClusterId(cluster.id ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid subgraph titles are plain labels on the cluster border. Overlay a header band
|
||||
* and tuck the title into it (Cursor-style subgraph chrome).
|
||||
*/
|
||||
export function enhanceStrandClusterHeaders(
|
||||
svg: SVGSVGElement,
|
||||
mode: MermaidThemeMode = "light"
|
||||
): void {
|
||||
const colors = mermaidThemeVariables(mode);
|
||||
const clusters = svg.querySelectorAll<SVGGElement>("g.cluster");
|
||||
for (const cluster of clusters) {
|
||||
if (!isStrandCluster(cluster)) continue;
|
||||
if (cluster.querySelector(".catalog-mermaid-strand-header")) continue;
|
||||
|
||||
const frameRect = cluster.querySelector<SVGRectElement>(":scope > rect");
|
||||
const labelGroup = cluster.querySelector<SVGGElement>(":scope > g.cluster-label");
|
||||
if (!frameRect || !labelGroup) continue;
|
||||
|
||||
const bbox = cluster.getBBox();
|
||||
if (bbox.width <= 0 || bbox.height <= 0) continue;
|
||||
|
||||
cluster.classList.add("catalog-mermaid-strand-cluster");
|
||||
|
||||
const header = document.createElementNS(SVG_NS, "rect");
|
||||
header.setAttribute("class", "catalog-mermaid-strand-header");
|
||||
header.setAttribute("x", String(bbox.x));
|
||||
header.setAttribute("y", String(bbox.y));
|
||||
header.setAttribute("width", String(bbox.width));
|
||||
header.setAttribute("height", String(STRAND_HEADER_HEIGHT));
|
||||
applySvgFill(header, colors.secondaryColor);
|
||||
|
||||
const rule = document.createElementNS(SVG_NS, "line");
|
||||
rule.setAttribute("class", "catalog-mermaid-strand-header__rule");
|
||||
rule.setAttribute("x1", String(bbox.x));
|
||||
rule.setAttribute("y1", String(bbox.y + STRAND_HEADER_HEIGHT));
|
||||
rule.setAttribute("x2", String(bbox.x + bbox.width));
|
||||
rule.setAttribute("y2", String(bbox.y + STRAND_HEADER_HEIGHT));
|
||||
rule.setAttribute("stroke", colors.clusterBorder);
|
||||
|
||||
frameRect.classList.add("catalog-mermaid-strand-frame");
|
||||
applySvgFill(frameRect, colors.clusterBkg);
|
||||
frameRect.setAttribute("rx", "0");
|
||||
frameRect.setAttribute("ry", "0");
|
||||
frameRect.setAttribute("stroke", colors.clusterBorder);
|
||||
cluster.insertBefore(header, frameRect.nextSibling);
|
||||
cluster.insertBefore(rule, header.nextSibling);
|
||||
|
||||
labelGroup.setAttribute("transform", `translate(${bbox.x}, ${bbox.y})`);
|
||||
|
||||
const foreignObject = labelGroup.querySelector<SVGForeignObjectElement>("foreignObject");
|
||||
if (foreignObject) {
|
||||
foreignObject.setAttribute("class", "catalog-mermaid-strand-label-fo");
|
||||
foreignObject.setAttribute("overflow", "visible");
|
||||
foreignObject.setAttribute("width", String(Math.max(bbox.width, 120)));
|
||||
foreignObject.setAttribute("height", String(STRAND_HEADER_HEIGHT));
|
||||
}
|
||||
|
||||
for (const el of labelGroup.querySelectorAll<HTMLElement>("div, span, p, header")) {
|
||||
el.style.overflow = "visible";
|
||||
el.style.border = "none";
|
||||
el.style.borderRadius = "0";
|
||||
el.style.background = "transparent";
|
||||
el.style.boxShadow = "none";
|
||||
}
|
||||
|
||||
const labelRoot = labelGroup.querySelector<HTMLElement>("div");
|
||||
if (labelRoot) {
|
||||
labelRoot.style.height = `${STRAND_HEADER_HEIGHT}px`;
|
||||
labelRoot.style.width = "100%";
|
||||
labelRoot.style.boxSizing = "border-box";
|
||||
labelRoot.style.padding = `0 ${STRAND_HEADER_PAD_X}px`;
|
||||
labelRoot.style.display = "flex";
|
||||
labelRoot.style.alignItems = "center";
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/src/lib/catalog-mermaid-theme.test.ts
Normal file
16
frontend/src/lib/catalog-mermaid-theme.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { mermaidInitializeOptions, mermaidThemeVariables } from "./catalog-mermaid-theme";
|
||||
|
||||
describe("catalog-mermaid-theme", () => {
|
||||
test("uses base theme with distinct cluster and secondary colors", () => {
|
||||
const dark = mermaidThemeVariables("dark");
|
||||
expect(dark.clusterBkg).not.toBe(dark.secondaryColor);
|
||||
expect(dark.clusterBkg).toBe("#202020");
|
||||
expect(dark.secondaryColor).toBe("#3d3a36");
|
||||
|
||||
const init = mermaidInitializeOptions("light");
|
||||
expect(init.theme).toBe("base");
|
||||
expect(init.themeVariables.clusterBkg).toBe("#f0efec");
|
||||
expect(init.themeVariables.secondaryColor).toBe("#e3e1dc");
|
||||
});
|
||||
});
|
||||
69
frontend/src/lib/catalog-mermaid-theme.ts
Normal file
69
frontend/src/lib/catalog-mermaid-theme.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Mermaid only applies custom cluster/node colors via theme "base" + themeVariables (hex).
|
||||
* CSS cannot override Mermaid's inline SVG fills on cluster rects.
|
||||
*/
|
||||
|
||||
export type MermaidThemeMode = "light" | "dark";
|
||||
|
||||
export type CatalogMermaidThemeVariables = {
|
||||
darkMode: boolean;
|
||||
background: string;
|
||||
primaryColor: string;
|
||||
primaryTextColor: string;
|
||||
primaryBorderColor: string;
|
||||
secondaryColor: string;
|
||||
tertiaryColor: string;
|
||||
clusterBkg: string;
|
||||
clusterBorder: string;
|
||||
lineColor: string;
|
||||
titleColor: string;
|
||||
mainBkg: string;
|
||||
nodeBorder: string;
|
||||
edgeLabelBackground: string;
|
||||
};
|
||||
|
||||
/** Strand title band (secondary) vs subgraph body (clusterBkg / tertiary). */
|
||||
export function mermaidThemeVariables(mode: MermaidThemeMode): CatalogMermaidThemeVariables {
|
||||
if (mode === "dark") {
|
||||
return {
|
||||
darkMode: true,
|
||||
background: "#1a1917",
|
||||
primaryColor: "#2a2826",
|
||||
primaryTextColor: "#eceae4",
|
||||
primaryBorderColor: "#4a4844",
|
||||
secondaryColor: "#3d3a36",
|
||||
tertiaryColor: "#202020",
|
||||
clusterBkg: "#202020",
|
||||
clusterBorder: "#4a4844",
|
||||
lineColor: "#c4924a",
|
||||
titleColor: "#eceae4",
|
||||
mainBkg: "#2a2826",
|
||||
nodeBorder: "#4a4844",
|
||||
edgeLabelBackground: "#2a2826",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
darkMode: false,
|
||||
background: "#f5f4f1",
|
||||
primaryColor: "#faf9f7",
|
||||
primaryTextColor: "#1a1a1a",
|
||||
primaryBorderColor: "#e8e8e8",
|
||||
secondaryColor: "#e3e1dc",
|
||||
tertiaryColor: "#f0efec",
|
||||
clusterBkg: "#f0efec",
|
||||
clusterBorder: "#e8e8e8",
|
||||
lineColor: "#9a6b2e",
|
||||
titleColor: "#1a1a1a",
|
||||
mainBkg: "#faf9f7",
|
||||
nodeBorder: "#e8e8e8",
|
||||
edgeLabelBackground: "#f5f4f1",
|
||||
};
|
||||
}
|
||||
|
||||
export function mermaidInitializeOptions(mode: MermaidThemeMode) {
|
||||
return {
|
||||
theme: "base" as const,
|
||||
themeVariables: mermaidThemeVariables(mode),
|
||||
};
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ function chapter(
|
|||
title: slug,
|
||||
description: "desc",
|
||||
status: "planned",
|
||||
curriculums: ["DSE"],
|
||||
curriculumCoverage: { DSE: "core" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -55,6 +55,8 @@ describe("subjectToMermaid", () => {
|
|||
expect(diagram!.rootNodeId).toBe("chapter_a");
|
||||
|
||||
expect(source).toContain("flowchart TB");
|
||||
expect(source).toContain("catalog-mermaid-strand__title");
|
||||
expect(source).toContain("Strand A");
|
||||
expect(source.match(/chapter_a --> chapter_c/g)?.length).toBe(1);
|
||||
expect(source).toContain('chapter_b -->|after| chapter_c');
|
||||
expect(source).toContain('click chapter_a href "/math/a/"');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CatalogChapter, CatalogGraphEdge, CatalogSubject } from "../../../data/catalog.types";
|
||||
import { chapterFilterCurriculums } from "./catalog-coverage";
|
||||
import { curriculumBadgeClass, tierBadgeLabel } from "./catalog-badge";
|
||||
|
||||
export type MermaidCatalogDiagram = {
|
||||
|
|
@ -11,7 +12,7 @@ function collectVisibleChapters(
|
|||
matches: (chapter: CatalogChapter) => boolean
|
||||
): Map<string, CatalogChapter> {
|
||||
const visible = new Map<string, CatalogChapter>();
|
||||
for (const strand of subject.strands) {
|
||||
for (const strand of subject.strands ?? []) {
|
||||
for (const chapter of strand.chapters) {
|
||||
if (matches(chapter)) {
|
||||
visible.set(chapter.slug, chapter);
|
||||
|
|
@ -82,7 +83,7 @@ function selectRootSlug(
|
|||
}
|
||||
}
|
||||
|
||||
for (const strand of subject.strands) {
|
||||
for (const strand of subject.strands ?? []) {
|
||||
for (const chapter of strand.chapters) {
|
||||
if (rootCandidates.has(chapter.slug)) {
|
||||
return chapter.slug;
|
||||
|
|
@ -98,7 +99,7 @@ function chapterNodeLabel(chapter: CatalogChapter): string {
|
|||
const description = chapter.description
|
||||
? `<p class='catalog-mermaid-node__description'>${escapeLabel(chapter.description)}</p>`
|
||||
: "";
|
||||
const curriculumBadges = chapter.curriculums
|
||||
const curriculumBadges = chapterFilterCurriculums(chapter)
|
||||
.map(
|
||||
(curriculum) =>
|
||||
`<span class='catalog-badge ${curriculumBadgeClass(curriculum)}'>${escapeLabel(curriculum)}</span>`
|
||||
|
|
@ -128,11 +129,13 @@ export function subjectToMermaid(
|
|||
const rootSlug = selectRootSlug(subject, visible, edges);
|
||||
const lines: string[] = ["flowchart TB"];
|
||||
|
||||
for (const strand of subject.strands) {
|
||||
for (const strand of subject.strands ?? []) {
|
||||
const strandChapters = strand.chapters.filter((chapter) => visible.has(chapter.slug));
|
||||
if (strandChapters.length === 0) continue;
|
||||
|
||||
lines.push(` subgraph strand_${strand.id.replaceAll(/[^a-zA-Z0-9_]/g, "_")}["${escapeLabel(strand.title)}"]`);
|
||||
const strandKey = strand.id.replaceAll(/[^a-zA-Z0-9_]/g, "_");
|
||||
const strandHeader = `<header class='catalog-mermaid-strand__header'><span class='catalog-mermaid-strand__title'>${escapeLabel(strand.title)}</span></header>`;
|
||||
lines.push(` subgraph strand_${strandKey}["${strandHeader}"]`);
|
||||
lines.push(" direction TB");
|
||||
for (const chapter of strandChapters) {
|
||||
const id = nodeId(chapter.slug);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue