diff --git a/frontend/src/components/KnowledgeGraph.test.tsx b/frontend/src/components/KnowledgeGraph.test.tsx index 0e437d5..09391bc 100644 --- a/frontend/src/components/KnowledgeGraph.test.tsx +++ b/frontend/src/components/KnowledgeGraph.test.tsx @@ -5,9 +5,13 @@ * and the `react-force-graph-3d` library: * 1. Renders without crashing on empty data. * 2. `graphData` memo produces the {nodes, links:{source,target,strength}} shape. - * 3. `nodeColor` returns "#ffffff" for the highlighted node and an - * `hsl(...)` shade for everything else. - * 4. `nodeVal` scales 4..10 with `mastery_score`. + * 3. `nodeColor` returns the brand --accent for the highlighted node + * and a deterministic hex shade for everything else (hex, not + * `hsl(...)`, because Three.js's Color parser only accepts the + * comma-separated HSL form and silently renders space-separated + * HSL as black). + * 4. `nodeVal` scales 4..10 with `mastery_score`, and course-root + * nodes (`is_subject_root: true`) render at a fixed larger size. * 5. `onNodeClick` whitelists the original GraphNode by id so * library-injected fields (x/y/z, vx/vy/vz, fx/fy/fz, * __threeObj, ...) never leak to callers. @@ -149,7 +153,7 @@ describe("KnowledgeGraph (3D) — adapter behavior", () => { expect(graphData.nodes[0]).not.toBe(nodes[0]); }); - it("nodeColor returns the brand accent for the highlighted id and an hsl() shade otherwise", () => { + it("nodeColor returns the brand accent for the highlighted id and a hex shade otherwise", () => { render( { // against the cream light theme; the accent pops on both themes. expect(nodeColor({ id: "abc", color: "#88aa55" })).toBe("#8a9a5b"); - // Non-highlight branch: deterministic hsl(...) string from shadeFor. + // Non-highlight branch: deterministic 7-char hex from shadeFor. + // Hex (not hsl) because Three.js parses hex reliably; the modern + // space-separated `hsl(120 50% 50%)` syntax silently renders black. const other = nodeColor({ id: "xyz", color: "#88aa55" }); - expect(other.startsWith("hsl(")).toBe(true); + expect(other).toMatch(/^#[0-9a-f]{6}$/); }); - it("nodeVal scales 4..10 with mastery_score (0 -> 4, 1 -> 10)", () => { + it("nodeVal scales 4..10 with mastery_score and pins course-root nodes larger", () => { render(); expect(lastProps).not.toBeNull(); const nodeVal = lastProps!.nodeVal as (n: object) => number; + // Concept nodes scale linearly with mastery. expect(nodeVal({ mastery_score: 0 })).toBe(4); expect(nodeVal({ mastery_score: 1 })).toBe(10); + + // Course-root nodes anchor the family — fixed larger size that + // dominates any concept node regardless of mastery. + expect(nodeVal({ is_subject_root: true, mastery_score: 0 })).toBe(22); + expect(nodeVal({ is_subject_root: true, mastery_score: 1 })).toBe(22); }); it("onNodeClick whitelists the original GraphNode by id so lib-injected fields never leak", () => { diff --git a/frontend/src/components/KnowledgeGraph.tsx b/frontend/src/components/KnowledgeGraph.tsx index 439694b..0804c8a 100644 --- a/frontend/src/components/KnowledgeGraph.tsx +++ b/frontend/src/components/KnowledgeGraph.tsx @@ -25,7 +25,7 @@ import React from "react"; import dynamic from "next/dynamic"; -import type { GraphEdge, GraphNode } from "@/lib/data"; +import { hashSeed, type GraphEdge, type GraphNode } from "@/lib/data"; // `react-force-graph-3d`'s default export touches `document` at // module evaluation, so it can't be SSR'd. ssr: false ensures the @@ -60,12 +60,6 @@ type Props = { // tone, and produces identical output across pages because it depends // only on the stable inputs (no per-screen overrides). -function hashId(id: string): number { - let h = 0; - for (let i = 0; i < id.length; i++) h = ((h << 5) - h + id.charCodeAt(i)) | 0; - return Math.abs(h); -} - function hexToHsl(hex: string): { h: number; s: number; l: number } | null { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return null; @@ -87,17 +81,53 @@ function hexToHsl(hex: string): { h: number; s: number; l: number } | null { return { h, s: s * 100, l: l * 100 }; } +function hslToHex(h: number, s: number, l: number): string { + const sN = s / 100; + const lN = l / 100; + const c = (1 - Math.abs(2 * lN - 1)) * sN; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = lN - c / 2; + let r = 0, + g = 0, + b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const to = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, "0"); + return `#${to(r)}${to(g)}${to(b)}`; +} + +// Brand sage in HSL — used as a final fallback when the input color +// isn't parseable hex (e.g. callers pass through `var(--c-sage)`, +// which Three.js can't resolve). Precomputed so the call site doesn't +// need a non-null assertion that would silently break if anyone +// changed FALLBACK to an unparseable value. +const FALLBACK_HSL = { h: 75, s: 26, l: 48 } as const; + function shadeFor(baseHex: string, nodeId: string): string { - const hsl = hexToHsl(baseHex); - if (!hsl) return baseHex; - const seed = hashId(nodeId); - const dh = (seed % 51) - 25; + const hsl = hexToHsl(baseHex) ?? FALLBACK_HSL; + const seed = hashSeed(nodeId); + // Hue variance is intentionally narrow (±10°) so each course family + // reads as a single color band — children clearly belong to their + // parent course rather than drifting into a sibling's hue. Within- + // family distinction comes from saturation/lightness instead. + const dh = (seed % 21) - 10; const ds = ((seed >> 5) % 17) - 8; const dl = ((seed >> 10) % 25) - 12; const h = (hsl.h + dh + 360) % 360; const s = Math.max(20, Math.min(85, hsl.s + ds)); const l = Math.max(28, Math.min(62, hsl.l + dl)); - return `hsl(${h.toFixed(0)} ${s.toFixed(0)}% ${l.toFixed(0)}%)`; + // Return hex (#RRGGBB), not `hsl(...)`. Three.js's Color.setStyle + // only accepts comma-separated `hsl(h, s%, l%)`, not the modern + // space-separated form; mismatches silently render black. Hex is + // unambiguous across consumers. + return hslToHex(h, s, l); } // ── Component ────────────────────────────────────────────────────── @@ -207,9 +237,10 @@ export function KnowledgeGraph({ const nodeVal = React.useCallback((raw: object) => { const n = raw as FG3DNode; - // Bigger nodes for higher mastery so the eye lands on what the - // student has built up. Range ~4..10 — keeps small nodes visible - // without dwarfing the rest. + // Course (root) nodes anchor each family — render them noticeably + // larger than concept nodes so the eye lands on the family center + // first. Concept nodes scale 4..10 with mastery_score. + if (n.is_subject_root) return 22; return 4 + (typeof n.mastery_score === "number" ? n.mastery_score : 0) * 6; }, []); diff --git a/frontend/src/components/screens/Dashboard.tsx b/frontend/src/components/screens/Dashboard.tsx index 1b354cd..d649a2c 100644 --- a/frontend/src/components/screens/Dashboard.tsx +++ b/frontend/src/components/screens/Dashboard.tsx @@ -21,7 +21,7 @@ import { type Assignment, } from "@/lib/api"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import type { GraphNode, GraphEdge } from "@/lib/data"; +import { apiToGraphNode, type GraphNode, type GraphEdge } from "@/lib/data"; const QUOTES = [ "Learning is the only thing the mind never exhausts, never fears, and never regrets. — da Vinci", @@ -32,21 +32,6 @@ const QUOTES = [ "Tell me and I forget. Teach me and I remember. Involve me and I learn. — Franklin", ]; -function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { - const course = courses.find((c) => c.course_name === n.subject); - return { - id: n.id, - name: n.concept_name, - subject: n.subject, - color: n.course_color || course?.color || "var(--c-sage)", - is_subject_root: n.is_subject_root, - mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, - mastery_score: n.mastery_score, - course_id: n.course_id || course?.course_id || "", - last_studied_at: n.last_studied_at || undefined, - }; -} - function apiToGraphEdge(e: ApiEdge): GraphEdge { return { source: e.source as string, target: e.target as string, strength: e.strength }; } diff --git a/frontend/src/components/screens/Learn.tsx b/frontend/src/components/screens/Learn.tsx index ab753b8..2d671d4 100644 --- a/frontend/src/components/screens/Learn.tsx +++ b/frontend/src/components/screens/Learn.tsx @@ -33,22 +33,7 @@ import { type EnrolledCourse, } from "@/lib/api"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import type { GraphNode, GraphEdge } from "@/lib/data"; - -function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { - const course = courses.find((c) => c.course_name === n.subject); - return { - id: n.id, - name: n.concept_name, - subject: n.subject, - color: n.course_color || course?.color || "var(--c-sage)", - is_subject_root: n.is_subject_root, - mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, - mastery_score: n.mastery_score, - course_id: n.course_id || course?.course_id || "", - last_studied_at: n.last_studied_at || undefined, - }; -} +import { apiToGraphNode, type GraphNode, type GraphEdge } from "@/lib/data"; function apiToGraphEdge(e: ApiEdge): GraphEdge { return { source: e.source as string, target: e.target as string, strength: e.strength }; diff --git a/frontend/src/components/screens/Tree.tsx b/frontend/src/components/screens/Tree.tsx index b22959c..b935737 100644 --- a/frontend/src/components/screens/Tree.tsx +++ b/frontend/src/components/screens/Tree.tsx @@ -12,7 +12,7 @@ import { getGraph, getCourses, getSessions, deleteGraphNode, type EnrolledCourse import { useToast } from "../ToastProvider"; import { useConfirm } from "@/lib/useConfirm"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import type { GraphNode, GraphEdge } from "@/lib/data"; +import { apiToGraphNode, type GraphNode, type GraphEdge } from "@/lib/data"; type Tier = "all" | "mastered" | "learning" | "struggling" | "unexplored"; @@ -23,21 +23,6 @@ const TIER_META: Record, { label: string; color: string }> unexplored: { label: "Unexplored", color: "#9a9a9a" }, }; -function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { - const course = courses.find((c) => c.course_name === n.subject); - return { - id: n.id, - name: n.concept_name, - subject: n.subject, - color: n.course_color || course?.color || "var(--c-sage)", - is_subject_root: n.is_subject_root, - mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, - mastery_score: n.mastery_score, - course_id: n.course_id || course?.course_id || "", - last_studied_at: n.last_studied_at || undefined, - }; -} - export function Tree() { const router = useRouter(); const search = useSearchParams(); diff --git a/frontend/src/lib/data.test.ts b/frontend/src/lib/data.test.ts new file mode 100644 index 0000000..94330dc --- /dev/null +++ b/frontend/src/lib/data.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { apiToGraphNode, hashSeed, paletteFor } from "./data"; +import type { GraphNode as ApiNode } from "./types"; +import type { EnrolledCourse } from "./api"; + +function makeApiNode(over: Partial = {}): ApiNode { + return { + id: "n1", + concept_name: "Eigenvalues", + mastery_score: 0.5, + mastery_tier: "learning", + times_studied: 3, + last_studied_at: null, + subject: "Linear Algebra", + course_id: "c1", + course_color: null, + color: null, + is_subject_root: false, + ...over, + }; +} + +function makeCourse(over: Partial = {}): EnrolledCourse { + return { + enrollment_id: "e1", + course_id: "c1", + course_code: "MATH 242", + course_name: "Linear Algebra", + school: "BU", + department: "MATH", + color: null, + nickname: null, + node_count: 8, + enrolled_at: "2026-01-01", + ...over, + }; +} + +// First entry of COURSE_PALETTE in data.ts. Asserted directly so the +// palette stays internal to data.ts. (Darkened sage — chosen to clear +// WCAG AA 3:1 contrast against the cream `--bg`.) +const PALETTE_FIRST = "#7a874f"; + +describe("paletteFor", () => { + it("returns the first palette entry for empty / nullish seeds", () => { + expect(paletteFor(null)).toBe(PALETTE_FIRST); + expect(paletteFor(undefined)).toBe(PALETTE_FIRST); + expect(paletteFor("")).toBe(PALETTE_FIRST); + }); + + it("is deterministic for a given seed", () => { + expect(paletteFor("course-42")).toBe(paletteFor("course-42")); + }); + + it("produces multiple distinct outputs across varied seeds", () => { + const colors = new Set(["alpha", "beta", "gamma", "delta"].map(paletteFor)); + expect(colors.size).toBeGreaterThanOrEqual(2); + }); +}); + +describe("apiToGraphNode color resolution", () => { + it("prefers n.course_color over everything else", () => { + const n = makeApiNode({ course_color: "#abcdef" }); + const course = makeCourse({ color: "#fedcba" }); + expect(apiToGraphNode(n, [course]).color).toBe("#abcdef"); + }); + + it("falls back to course?.color when course_color is missing", () => { + const n = makeApiNode({ course_color: null }); + const course = makeCourse({ color: "#123456" }); + expect(apiToGraphNode(n, [course]).color).toBe("#123456"); + }); + + it("falls back to paletteFor when both course_color and course.color are missing", () => { + const n = makeApiNode({ course_color: null, course_id: "c1" }); + const course = makeCourse({ color: null, course_id: "c1" }); + expect(apiToGraphNode(n, [course]).color).toBe(paletteFor("c1")); + }); + + it("seeds palette from course.course_id, not n.course_id (round-2 fix)", () => { + // Round 2 fixed a bug where two nodes in the same family could + // hash to different palette colors if some carried n.course_id + // and others fell through to subject. The hoisted adapter must + // prefer the course-record id so all family members agree. + const n = makeApiNode({ + course_color: null, + course_id: "different-from-course", + }); + const course = makeCourse({ color: null, course_id: "stable-family-id" }); + expect(apiToGraphNode(n, [course]).color).toBe(paletteFor("stable-family-id")); + }); + + it("remaps subject_root tier to mastered", () => { + const n = makeApiNode({ mastery_tier: "subject_root", is_subject_root: true }); + expect(apiToGraphNode(n, []).mastery_tier).toBe("mastered"); + }); +}); + +describe("hashSeed", () => { + it("is deterministic", () => { + expect(hashSeed("hello")).toBe(hashSeed("hello")); + }); + + it("returns a non-negative integer for typical inputs", () => { + for (const s of ["", "x", "concept-1", "a longer string with spaces"]) { + const h = hashSeed(s); + expect(Number.isInteger(h)).toBe(true); + expect(h).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/frontend/src/lib/data.ts b/frontend/src/lib/data.ts index 495014e..a1ea94a 100644 --- a/frontend/src/lib/data.ts +++ b/frontend/src/lib/data.ts @@ -1,3 +1,6 @@ +import type { GraphNode as ApiNode } from "@/lib/types"; +import type { EnrolledCourse } from "@/lib/api"; + export type Course = { id: string; course_code: string; @@ -5,6 +8,37 @@ export type Course = { color: string; }; +// Brand-aligned course palette. Used as a deterministic fallback when +// the backend / enrolled-course record doesn't supply a per-course +// color, so each course family still reads as its own hue rather than +// every concept collapsing onto one fallback color. +// +// Each entry hits ≥3:1 contrast against the cream `--bg` (#faf8f3) — +// WCAG AA threshold for non-text UI elements. Sage and ochre were +// nudged darker (from #8a9a5b → #7a874f and #c89c4a → #a87d2e) so the +// nodes stay legible on the light theme. Brand --accent (#8a9a5b) +// remains unchanged elsewhere; this palette is only the backend-color +// fallback. +const COURSE_PALETTE = [ + "#7a874f", "#3e6f8a", "#7b4b99", "#b4562c", + "#3f8a7c", "#a87d2e", "#a06b8e", "#6b8a3e", +]; + +// DJB2-ish string hash → non-negative integer. Shared by `paletteFor` +// (palette index seeding) and `KnowledgeGraph`'s `shadeFor` (per-node +// HSL jitter seeding). Keep the body identical across consumers so the +// same input always maps to the same downstream color. +export function hashSeed(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + return Math.abs(h); +} + +export function paletteFor(seed: string | null | undefined): string { + if (!seed) return COURSE_PALETTE[0]; + return COURSE_PALETTE[hashSeed(seed) % COURSE_PALETTE.length]; +} + export type GraphNode = { id: string; name: string; @@ -23,6 +57,32 @@ export type GraphEdge = { strength: number; }; +// Adapter from the backend `ApiNode` shape to the frontend `GraphNode` +// shape consumed by `KnowledgeGraph`. Hoisted here from Tree/Learn/ +// Dashboard so the three screens share a single source of truth. +export function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { + const course = courses.find((c) => c.course_name === n.subject); + return { + id: n.id, + name: n.concept_name, + subject: n.subject, + // Resolved hex (not CSS custom property): the 3D KnowledgeGraph + // feeds this into Three.js which can't resolve `var(--…)`. + // Seed prefers the course-record id so every node in the same + // family hashes to the same palette color, even if some nodes + // arrive without `n.course_id` set. + color: + n.course_color || + course?.color || + paletteFor(course?.course_id || n.course_id || n.subject), + is_subject_root: n.is_subject_root, + mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, + mastery_score: n.mastery_score, + course_id: n.course_id || course?.course_id || "", + last_studied_at: n.last_studied_at || undefined, + }; +} + export type Assignment = { id: string; title: string;