Refactor CatalogApp component to integrate CatalogFiltersMenu for improved filtering functionality, update layout structure in catalog-canvas-view for better readability, and enhance SASS styles for catalog header and filter menu. Remove unused functions and streamline canvas rendering logic in catalog-to-canvas.

This commit is contained in:
Hananoshika Yomaru 2026-05-19 09:30:03 -07:00
parent 81b316e08d
commit 9ddcce79c6
15 changed files with 2351 additions and 294 deletions

View file

@ -2,6 +2,7 @@ import { createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense
import { subjectToCanvas } from "../lib/catalog-to-canvas";
import { watchSiteTheme } from "../lib/catalog-canvas-theme";
import { CatalogChapterCard } from "./catalog-chapter-card";
import { CatalogFiltersMenu } from "./catalog-filters-menu";
import type {
CatalogChapter,
CatalogData,
@ -151,7 +152,10 @@ export function CatalogApp() {
});
return (
<div class="catalog-shell">
<div
class="catalog-shell"
classList={{ "catalog-shell--tree": viewMode() === "tree" }}
>
<aside class="catalog-sidebar" aria-label="Subjects">
<div class="catalog-sidebar__header">
<p class="catalog-sidebar__kicker">{data.title}</p>
@ -175,11 +179,11 @@ export function CatalogApp() {
<div class="catalog-main">
<header class="catalog-header">
<div class="catalog-header__intro">
<h1 class="catalog-header__title">{currentSubject().name}</h1>
<p class="catalog-header__stats">{statsLabel()}</p>
</div>
<div class="catalog-header__controls">
<div class="catalog-header__head">
<div class="catalog-header__intro">
<h1 class="catalog-header__title">{currentSubject().name}</h1>
<p class="catalog-header__stats">{statsLabel()}</p>
</div>
<div class="catalog-view-toggle" role="tablist" aria-label="Chapter layout">
<button
type="button"
@ -200,31 +204,13 @@ export function CatalogApp() {
Map
</button>
</div>
<div class="catalog-filters-wrap">
<p class="catalog-filters__label">Filter by curriculum</p>
<div class="catalog-filters" role="group" aria-label="Curriculum filters">
<button
type="button"
class="catalog-filter-btn"
classList={{ "is-active": activeFilters().size === 0 }}
onClick={() => toggleFilter("ALL")}
>
All
</button>
<For each={data.curriculums}>
{(curriculum) => (
<button
type="button"
class="catalog-filter-btn"
classList={{ "is-active": activeFilters().has(curriculum) }}
onClick={() => toggleFilter(curriculum)}
>
{curriculum}
</button>
)}
</For>
</div>
</div>
</div>
<div class="catalog-header__filters">
<CatalogFiltersMenu
curriculums={data.curriculums}
activeFilters={activeFilters}
onToggle={toggleFilter}
/>
</div>
</header>

View file

@ -13,14 +13,14 @@ import {
} from "json-canvas-viewer";
import type { CatalogChapter } from "../../../data/catalog.types";
import { renderCatalogChapterCardElement } from "./catalog-chapter-card";
import { chapterSlugFromNodeId, relayoutCanvasChapterHeights } from "../lib/catalog-to-canvas";
import { measureRenderedMapChapterCards } from "../lib/catalog-chapter-card-layout";
import { chapterSlugFromNodeId } from "../lib/catalog-to-canvas";
import {
syncViewerTheme,
VIEWER_THEME_COLORS,
siteCanvasTheme,
watchSiteTheme,
} from "../lib/catalog-canvas-theme";
import { focusReadableCatalogView } from "../lib/catalog-canvas-viewport";
type CatalogCanvasViewProps = {
canvas: JSONCanvas;
@ -54,21 +54,6 @@ function bindPageScrollWheel(container: HTMLElement): () => void {
return () => container.removeEventListener("wheel", onWheel, { capture: true });
}
function waitForFrames(count = 2): Promise<void> {
return new Promise((resolve) => {
let remaining = count;
const step = () => {
remaining -= 1;
if (remaining <= 0) {
resolve();
return;
}
requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
}
function buildNodeComponents(
subjectId: string,
chapters: Record<string, CatalogChapter>
@ -115,20 +100,7 @@ export function CatalogCanvasView(props: CatalogCanvasViewProps) {
lastSerialized = cacheKey;
viewer.load({ canvas });
syncViewerTheme(viewer);
await waitForFrames();
if (generation !== loadGeneration || !viewer) return;
const measured = measureRenderedMapChapterCards(container);
const fitted = relayoutCanvasChapterHeights(canvas, measured);
if (!fitted) return;
const fittedKey = `${serializeCanvas(fitted)}|${chaptersKey(chapters)}`;
if (fittedKey === lastSerialized) return;
lastSerialized = fittedKey;
viewer.load({ canvas: fitted });
syncViewerTheme(viewer);
focusReadableCatalogView(viewer, container);
};
onMount(() => {

View file

@ -0,0 +1,94 @@
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
type CatalogFiltersMenuProps = {
curriculums: string[];
activeFilters: () => Set<string>;
onToggle: (curriculum: string) => void;
};
function filtersSummary(active: Set<string>): string {
if (active.size === 0) return "All";
return Array.from(active).join(", ");
}
export function CatalogFiltersMenu(props: CatalogFiltersMenuProps) {
const [open, setOpen] = createSignal(false);
let root!: HTMLDivElement;
const summary = createMemo(() => filtersSummary(props.activeFilters()));
const close = () => setOpen(false);
onMount(() => {
const onDocumentClick = (event: MouseEvent) => {
if (!open() || !root.contains(event.target as Node)) {
close();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
close();
}
};
document.addEventListener("click", onDocumentClick);
document.addEventListener("keydown", onKeyDown);
onCleanup(() => {
document.removeEventListener("click", onDocumentClick);
document.removeEventListener("keydown", onKeyDown);
});
});
return (
<div class="catalog-filter-menu" ref={root}>
<button
type="button"
class="catalog-filter-menu__trigger"
aria-expanded={open() ? "true" : "false"}
aria-haspopup="true"
aria-controls="catalog-filter-menu-panel"
onClick={(event) => {
event.stopPropagation();
setOpen((isOpen) => !isOpen);
}}
>
<span class="catalog-filter-menu__trigger-kicker">Filter by curriculum</span>
<span class="catalog-filter-menu__trigger-value">{summary()}</span>
<span class="catalog-filter-menu__chevron" aria-hidden="true" />
</button>
<Show when={open()}>
<div
id="catalog-filter-menu-panel"
class="catalog-filter-menu__panel"
role="group"
aria-label="Curriculum filters"
onClick={(event) => event.stopPropagation()}
>
<label class="catalog-filter-menu__option">
<input
type="checkbox"
class="catalog-filter-menu__checkbox"
checked={props.activeFilters().size === 0}
onChange={() => props.onToggle("ALL")}
/>
<span class="catalog-filter-menu__option-label">All</span>
</label>
<For each={props.curriculums}>
{(curriculum) => (
<label class="catalog-filter-menu__option">
<input
type="checkbox"
class="catalog-filter-menu__checkbox"
checked={props.activeFilters().has(curriculum)}
onChange={() => props.onToggle(curriculum)}
/>
<span class="catalog-filter-menu__option-label">{curriculum}</span>
</label>
)}
</For>
</div>
</Show>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { internal, type JSONCanvasViewerInterface } from "json-canvas-viewer";
import { CHAPTER_NODE_WIDTH, STRAND_GAP_X } from "./catalog-to-canvas";
/** Roughly one strand column plus peek of the next — readable card text, not full board. */
const READABLE_COLUMNS_IN_VIEW = 1.15;
const MIN_READABLE_SCALE = 0.78;
const MAX_READABLE_SCALE = 1.08;
export function focusReadableCatalogView(
viewer: JSONCanvasViewerInterface,
container: HTMLElement
): void {
const dm = viewer.container.get(internal.DataManager);
const bounds = dm.data.nodeBounds;
if (bounds.width <= 0 || bounds.height <= 0) return;
const viewportW = container.clientWidth;
const viewportH = container.clientHeight;
if (viewportW < 1 || viewportH < 1) return;
const columnSpan = CHAPTER_NODE_WIDTH + STRAND_GAP_X;
const scaleByWidth = viewportW / (columnSpan * READABLE_COLUMNS_IN_VIEW);
const scale = Math.min(MAX_READABLE_SCALE, Math.max(MIN_READABLE_SCALE, scaleByWidth));
const focusX = bounds.minX + Math.min(bounds.width * 0.28, columnSpan * 0.65);
const focusY = bounds.minY + Math.min(bounds.height * 0.22, 280);
dm.data.scale = scale;
dm.data.offsetX = viewportW / 2 - focusX * scale;
dm.data.offsetY = viewportH / 2 - focusY * scale;
}

View file

@ -9,7 +9,7 @@ import { measureAllMapChapterCardHeights } from "./catalog-chapter-card-layout";
export const CHAPTER_NODE_WIDTH = 300;
/** Horizontal gap between strand columns (groups must not overlap). */
const STRAND_GAP_X = 112;
export const STRAND_GAP_X = 112;
const GAP_Y = 48;
const GROUP_PAD = 28;
@ -327,7 +327,12 @@ export function relayoutCanvasChapterHeights(
for (const node of chapterNodes) {
const slug = chapterSlugFromNodeId(node.id);
if (!slug) continue;
mergedHeights.set(slug, Math.max(node.height ?? 0, heightBySlug.get(slug) ?? 0));
const measured = heightBySlug.get(slug);
if (measured !== undefined) {
mergedHeights.set(slug, measured);
} else {
mergedHeights.set(slug, node.height ?? 0);
}
}
const slots: ChapterLayoutSlot[] = chapterNodes.map((node, index) => {