mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-13 13:05:51 +00:00
Update navigation URLs in zola.toml and main.tsx to reflect new catalog structure, enhance math curriculum JSON with curriculum identifiers, and integrate catalog styles in SASS. Improve particle background generation logic and visual effects in particle-background.ts.
This commit is contained in:
parent
07a76333bd
commit
992275cc29
21 changed files with 34810 additions and 35 deletions
235
frontend/src/components/catalog-app.tsx
Normal file
235
frontend/src/components/catalog-app.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { createMemo, createSignal, For, Show } from "solid-js";
|
||||
import type { CatalogChapter, CatalogData, CatalogStrand, CatalogSubject } from "../../../data/catalog.types";
|
||||
|
||||
function readCatalogData(): CatalogData | null {
|
||||
const el = document.getElementById("catalog-data");
|
||||
if (!el?.textContent) return null;
|
||||
return JSON.parse(el.textContent) as CatalogData;
|
||||
}
|
||||
|
||||
function initialSubjectId(subjects: CatalogSubject[]): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromUrl = params.get("subject");
|
||||
if (fromUrl && subjects.some((s) => s.id === fromUrl)) {
|
||||
return fromUrl;
|
||||
}
|
||||
return subjects[0]?.id ?? "math";
|
||||
}
|
||||
|
||||
function curriculumBadgeClass(curriculum: string): string {
|
||||
const map: Record<string, string> = {
|
||||
DSE: "catalog-badge--dse",
|
||||
IB: "catalog-badge--ib",
|
||||
"A-Level": "catalog-badge--a-level",
|
||||
AP: "catalog-badge--ap",
|
||||
IGCSE: "catalog-badge--igcse",
|
||||
};
|
||||
return map[curriculum] ?? "catalog-badge--default";
|
||||
}
|
||||
|
||||
function chapterMatchesFilters(chapter: CatalogChapter, activeFilters: Set<string>): boolean {
|
||||
if (activeFilters.size === 0) return true;
|
||||
return chapter.curriculums.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;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function syncSubjectUrl(subjectId: string) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("subject", subjectId);
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
export function CatalogApp() {
|
||||
const data = readCatalogData();
|
||||
if (!data) {
|
||||
return <p class="catalog-error">Catalog data could not be loaded.</p>;
|
||||
}
|
||||
|
||||
const [currentSubjectId, setCurrentSubjectId] = createSignal(initialSubjectId(data.subjects));
|
||||
const [activeFilters, setActiveFilters] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const currentSubject = createMemo(
|
||||
() => data.subjects.find((s) => s.id === currentSubjectId()) ?? data.subjects[0]
|
||||
);
|
||||
|
||||
const visibleCount = createMemo(() => countVisibleChapters(currentSubject(), activeFilters()));
|
||||
|
||||
const hasChapterTree = createMemo(() => currentSubject().strands.length > 0);
|
||||
|
||||
const statsLabel = createMemo(() => {
|
||||
const count = visibleCount();
|
||||
const filters = activeFilters();
|
||||
const filterText =
|
||||
filters.size === 0 ? "all" : `filtered (${Array.from(filters).join(", ")})`;
|
||||
return `Showing ${count} ${filterText} chapter${count === 1 ? "" : "s"}`;
|
||||
});
|
||||
|
||||
const selectSubject = (subjectId: string) => {
|
||||
setCurrentSubjectId(subjectId);
|
||||
syncSubjectUrl(subjectId);
|
||||
};
|
||||
|
||||
const toggleFilter = (curriculum: string) => {
|
||||
if (curriculum === "ALL") {
|
||||
setActiveFilters(new Set<string>());
|
||||
return;
|
||||
}
|
||||
setActiveFilters((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(curriculum)) {
|
||||
next.delete(curriculum);
|
||||
} else {
|
||||
next.add(curriculum);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredStrands = createMemo(() => {
|
||||
const subject = currentSubject();
|
||||
return subject.strands
|
||||
.map((strand) => ({
|
||||
...strand,
|
||||
chapters: strand.chapters.filter((ch) => chapterMatchesFilters(ch, activeFilters())),
|
||||
}))
|
||||
.filter((strand) => strand.chapters.length > 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="catalog-shell">
|
||||
<aside class="catalog-sidebar" aria-label="Subjects">
|
||||
<div class="catalog-sidebar__header">
|
||||
<p class="catalog-sidebar__kicker">{data.title}</p>
|
||||
<p class="catalog-sidebar__subtitle">{data.subtitle}</p>
|
||||
</div>
|
||||
<nav class="catalog-sidebar__nav" aria-label="Subject navigation">
|
||||
<For each={data.subjects}>
|
||||
{(subject) => (
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-subject-btn"
|
||||
classList={{ "is-active": currentSubjectId() === subject.id }}
|
||||
onClick={() => selectSubject(subject.id)}
|
||||
>
|
||||
{subject.name}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<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-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>
|
||||
</header>
|
||||
|
||||
<div class="catalog-content">
|
||||
<Show
|
||||
when={hasChapterTree() && visibleCount() > 0}
|
||||
fallback={
|
||||
<div class="catalog-empty">
|
||||
<h2 class="catalog-empty__title">
|
||||
{hasChapterTree() ? "No chapters found" : "Coming soon"}
|
||||
</h2>
|
||||
<p class="catalog-empty__text">
|
||||
{hasChapterTree()
|
||||
? "There are no chapters in this subject matching your selected curriculum filters. Try selecting All to see everything."
|
||||
: "Chapters for this subject are on the roadmap. Mathematics is available now — select it from the sidebar."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredStrands()}>
|
||||
{(strand: CatalogStrand) => (
|
||||
<section class="catalog-strand" aria-labelledby={`strand-${strand.id}`}>
|
||||
<h2 id={`strand-${strand.id}`} class="catalog-strand__title">
|
||||
<span class="catalog-strand__marker" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
{strand.title}
|
||||
</h2>
|
||||
<ol class="catalog-tree">
|
||||
<For each={strand.chapters}>
|
||||
{(chapter, index) => (
|
||||
<li class="catalog-tree__item">
|
||||
<ChapterCard chapter={chapter} index={index()} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard(props: { chapter: CatalogChapter; index: number }) {
|
||||
const number = () => String(props.index + 1).padStart(2, "0");
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div class="catalog-chapter-card__main">
|
||||
<span class="catalog-chapter-card__number">{number()}</span>
|
||||
<span class="catalog-chapter-card__title">{props.chapter.title}</span>
|
||||
</div>
|
||||
<div class="catalog-chapter-card__meta">
|
||||
<For each={props.chapter.curriculums}>
|
||||
{(curriculum) => (
|
||||
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.chapter.status === "planned"}>
|
||||
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.chapter.status === "live"}
|
||||
fallback={<div class="catalog-chapter-card catalog-chapter-card--planned">{inner}</div>}
|
||||
>
|
||||
<a class="catalog-chapter-card" href={`/math/${props.chapter.slug}/`}>
|
||||
{inner}
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
@ -178,17 +178,55 @@ type TreeBranch = {
|
|||
ex: number;
|
||||
ey: number;
|
||||
ez: number;
|
||||
length: number;
|
||||
thickStart: number;
|
||||
thickEnd: number;
|
||||
isTerminal: boolean;
|
||||
};
|
||||
|
||||
/** Volume proxy for even density: longer / thicker segments get more particles. */
|
||||
function segmentParticleWeight(length: number, thickStart: number, thickEnd: number) {
|
||||
const avgThick = (thickStart + thickEnd) * 0.5;
|
||||
return Math.max(0.001, length * avgThick * avgThick);
|
||||
}
|
||||
|
||||
function branchParticleWeight(branch: TreeBranch) {
|
||||
return segmentParticleWeight(branch.length, branch.thickStart, branch.thickEnd);
|
||||
}
|
||||
|
||||
function rootParticleWeight(segment: RootSegment) {
|
||||
return segmentParticleWeight(segment.length, segment.thickStart, segment.thickEnd);
|
||||
}
|
||||
|
||||
function buildWeightCdf<T>(items: T[], weight: (item: T) => number) {
|
||||
const cdf: number[] = [];
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
total += weight(item);
|
||||
cdf.push(total);
|
||||
}
|
||||
return { cdf, total };
|
||||
}
|
||||
|
||||
function pickWeighted<T>(items: T[], cdf: number[], total: number) {
|
||||
const target = Math.random() * total;
|
||||
let lo = 0;
|
||||
let hi = cdf.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (cdf[mid] < target) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return items[lo];
|
||||
}
|
||||
|
||||
type RootSegment = {
|
||||
sx: number;
|
||||
sz: number;
|
||||
ex: number;
|
||||
ez: number;
|
||||
bend: number;
|
||||
length: number;
|
||||
thickStart: number;
|
||||
thickEnd: number;
|
||||
depth: number;
|
||||
|
|
@ -260,11 +298,11 @@ function generateDetailedTree(
|
|||
isFlower: Float32Array,
|
||||
palette: ThemePalette
|
||||
) {
|
||||
const landCount = Math.floor(count * (25000 / 150000));
|
||||
const landCount = Math.floor(count * (30000 / 150000));
|
||||
const rootCount = Math.floor(count * (15000 / 150000));
|
||||
const trunkCount = Math.floor(count * (3000 / 150000));
|
||||
const branchParticleCount = Math.floor(count * (55000 / 150000));
|
||||
const junctionBudget = Math.floor(count * (4000 / 150000));
|
||||
const trunkCount = Math.floor(count * (6000 / 150000));
|
||||
const branchParticleCount = Math.floor(count * (43000 / 150000));
|
||||
const junctionBudget = Math.floor(count * (7000 / 150000));
|
||||
const flowerPlotCount = Math.max(120, Math.floor(count * (1000 / 150000)));
|
||||
|
||||
let pIdx = 0;
|
||||
|
|
@ -294,7 +332,7 @@ function generateDetailedTree(
|
|||
const ex = sx + Math.cos(angle) * length;
|
||||
const ez = sz + Math.sin(angle) * length;
|
||||
const thickEnd = Math.max(0.018, thickStart * (0.42 + Math.random() * 0.15));
|
||||
rootSegments.push({ sx, sz, ex, ez, bend: curve, thickStart, thickEnd, depth });
|
||||
rootSegments.push({ sx, sz, ex, ez, bend: curve, length, thickStart, thickEnd, depth });
|
||||
|
||||
if (depth >= maxDepth) return;
|
||||
|
||||
|
|
@ -331,8 +369,10 @@ function generateDetailedTree(
|
|||
);
|
||||
}
|
||||
|
||||
const rootPick = buildWeightCdf(rootSegments, rootParticleWeight);
|
||||
|
||||
for (let i = 0; i < rootCount && pIdx < count; i += 1, pIdx += 1) {
|
||||
const segment = rootSegments[Math.floor(Math.random() * rootSegments.length)];
|
||||
const segment = pickWeighted(rootSegments, rootPick.cdf, rootPick.total);
|
||||
const t = Math.pow(Math.random(), 0.82);
|
||||
const omt = 1 - t;
|
||||
const midX = (segment.sx + segment.ex) * 0.5 + Math.cos(Math.atan2(segment.ez - segment.sz, segment.ex - segment.sx) + Math.PI / 2) * segment.bend;
|
||||
|
|
@ -360,7 +400,12 @@ function generateDetailedTree(
|
|||
const fx = r * Math.cos(theta);
|
||||
const fz = r * Math.sin(theta);
|
||||
const fh = 0.35 + Math.random() * 0.55;
|
||||
const magenta = Math.random() > 0.5;
|
||||
const flowerVariant = Math.floor(Math.random() * 3);
|
||||
const flowerColors: [number, number, number][] = [
|
||||
[0.95, 0.14, 0.2], // red
|
||||
[0.72, 0.2, 0.98], // purple
|
||||
[1, 0.88, 0.14], // yellow
|
||||
];
|
||||
const stemSegments = 8;
|
||||
|
||||
for (let j = 0; j < stemSegments; j += 1) {
|
||||
|
|
@ -375,12 +420,10 @@ function generateDetailedTree(
|
|||
positions[pIdx * 3] = fx;
|
||||
positions[pIdx * 3 + 1] = GROUND_Y + fh;
|
||||
positions[pIdx * 3 + 2] = fz;
|
||||
if (magenta) {
|
||||
setColorExact(colors, pIdx, 1, 0.2, 0.8);
|
||||
} else {
|
||||
setColorExact(colors, pIdx, 1, 0.9, 0.2);
|
||||
}
|
||||
isFlower[pIdx] = 1;
|
||||
const [fr, fg, fb] = flowerColors[flowerVariant];
|
||||
setColorExact(colors, pIdx, fr, fg, fb);
|
||||
// 1 = red, 2 = purple, 3 = yellow (0 = not a flower)
|
||||
isFlower[pIdx] = flowerVariant + 1;
|
||||
pIdx += 1;
|
||||
}
|
||||
|
||||
|
|
@ -425,6 +468,7 @@ function generateDetailedTree(
|
|||
ex,
|
||||
ey,
|
||||
ez,
|
||||
length,
|
||||
thickStart,
|
||||
thickEnd,
|
||||
isTerminal,
|
||||
|
|
@ -508,9 +552,12 @@ function generateDetailedTree(
|
|||
|
||||
const branchOrange: ShapeColors = palette.helix;
|
||||
const branchOrangeTip: ShapeColors = palette.sphere;
|
||||
const branchPick = buildWeightCdf(branches, branchParticleWeight);
|
||||
const terminalPick =
|
||||
terminalBranches.length > 0 ? buildWeightCdf(terminalBranches, branchParticleWeight) : null;
|
||||
|
||||
for (let i = 0; i < branchParticleCount && pIdx < count; i += 1, pIdx += 1) {
|
||||
const b = branches[Math.floor(Math.random() * branches.length)];
|
||||
const b = pickWeighted(branches, branchPick.cdf, branchPick.total);
|
||||
const t =
|
||||
Math.random() < 0.45
|
||||
? Math.pow(Math.random(), 2.2) * 0.35
|
||||
|
|
@ -526,8 +573,8 @@ function generateDetailedTree(
|
|||
}
|
||||
|
||||
while (pIdx < count) {
|
||||
if (terminalBranches.length === 0) break;
|
||||
const b = terminalBranches[Math.floor(Math.random() * terminalBranches.length)];
|
||||
if (!terminalPick) break;
|
||||
const b = pickWeighted(terminalBranches, terminalPick.cdf, terminalPick.total);
|
||||
const t = Math.pow(Math.random(), 0.6);
|
||||
const pos = evalBezier(b.sx, b.sy, b.sz, b.cx, b.cy, b.cz, b.ex, b.ey, b.ez, t);
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
|
|
@ -590,8 +637,10 @@ const vertexShader = `
|
|||
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
varying float vFlowerGlint;
|
||||
|
||||
void main() {
|
||||
vFlowerGlint = 0.0;
|
||||
float m = clamp(uMorph, 0.0, ${MORPH_MAX}.0);
|
||||
float t = fract(m);
|
||||
float ease = t < 0.5 ? 4.0 * t * t * t : 1.0 - pow(-2.0 * t + 2.0, 3.0) / 2.0;
|
||||
|
|
@ -630,10 +679,21 @@ const vertexShader = `
|
|||
}
|
||||
|
||||
if (aIsFlower > 0.5) {
|
||||
float blink = 0.5 + 0.5 * sin(uTime * 5.0 + pos4.x * 12.0 + pos4.z * 10.0);
|
||||
finalPos.y += blink * 0.35 * treeInfluence;
|
||||
finalCol += finalCol * blink * 2.5 * treeInfluence;
|
||||
float flowerKind = floor(aIsFlower + 0.1);
|
||||
float phase = uTime * 7.0 + pos4.x * 19.0 + pos4.z * 14.0 + flowerKind * 2.4;
|
||||
float twinkle = 0.5 + 0.5 * sin(phase);
|
||||
float sparkle = pow(max(0.0, sin(phase * 3.1)), 10.0);
|
||||
float shimmer = 0.5 + twinkle * 0.45 + sparkle * 1.1;
|
||||
|
||||
finalPos.y += twinkle * 0.28 * treeInfluence;
|
||||
finalCol *= shimmer;
|
||||
finalCol += vec3(1.0, 0.96, 0.88) * sparkle * 1.35 * treeInfluence;
|
||||
vFlowerGlint = (sparkle * 0.95 + twinkle * 0.35) * treeInfluence;
|
||||
} else {
|
||||
vFlowerGlint = 0.0;
|
||||
}
|
||||
} else {
|
||||
vFlowerGlint = 0.0;
|
||||
}
|
||||
|
||||
float sphereInfluence = max(0.0, 1.0 - abs(m - 1.0));
|
||||
|
|
@ -670,7 +730,11 @@ const vertexShader = `
|
|||
finalPos.z += sin(finalPos.x * 1.5 + finalPos.y * 1.5 + uTime * 1.6) * 0.06 * idleWobble;
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(finalPos, 1.0);
|
||||
gl_PointSize = (13.0 * uPixelRatio * uPointScale) * (1.0 / -mvPosition.z);
|
||||
float pointSize = (13.0 * uPixelRatio * uPointScale) * (1.0 / -mvPosition.z);
|
||||
if (vFlowerGlint > 0.0) {
|
||||
pointSize *= 1.0 + vFlowerGlint * 0.75;
|
||||
}
|
||||
gl_PointSize = pointSize;
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
vColor = finalCol;
|
||||
vAlpha = 1.0;
|
||||
|
|
@ -680,9 +744,11 @@ const vertexShader = `
|
|||
const fragmentShader = `
|
||||
uniform float uParticleAlpha;
|
||||
uniform float uColorBoost;
|
||||
uniform float uTime;
|
||||
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
varying float vFlowerGlint;
|
||||
|
||||
void main() {
|
||||
vec2 center = vec2(0.5, 0.5);
|
||||
|
|
@ -690,6 +756,17 @@ const fragmentShader = `
|
|||
if (dist > 0.5) discard;
|
||||
float alpha = smoothstep(0.5, 0.08, dist);
|
||||
vec3 color = clamp(vColor * uColorBoost, 0.0, 1.0);
|
||||
|
||||
if (vFlowerGlint > 0.0) {
|
||||
vec2 uv = gl_PointCoord - center;
|
||||
float ang = atan(uv.y, uv.x);
|
||||
float star = pow(abs(sin(ang * 4.0 + uTime * 9.0)), 3.0);
|
||||
float core = 1.0 - smoothstep(0.0, 0.22, dist);
|
||||
float glint = star * core * vFlowerGlint;
|
||||
color += vec3(1.0, 0.98, 0.9) * glint * 1.4;
|
||||
alpha = min(1.0, alpha * (1.0 + vFlowerGlint * 0.45));
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(color, alpha * uParticleAlpha * vAlpha);
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function readConfig() {
|
|||
return {
|
||||
brand: "Open Free Books",
|
||||
homeUrl: "/",
|
||||
browseUrl: "/browse/",
|
||||
browseUrl: "/catalog/",
|
||||
aboutUrl: "/about/",
|
||||
githubUrl: "https://github.com/openfreebooks/openfreebooks",
|
||||
};
|
||||
|
|
@ -77,4 +77,12 @@ if (quadraticRoot) {
|
|||
});
|
||||
}
|
||||
|
||||
const catalogRoot = document.getElementById("catalog-app");
|
||||
if (catalogRoot) {
|
||||
void import("./components/catalog-app").then(({ CatalogApp }) => {
|
||||
catalogRoot.replaceChildren();
|
||||
render(() => <CatalogApp />, catalogRoot);
|
||||
});
|
||||
}
|
||||
|
||||
mountFooterYear();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue