Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions frontend/src/components/KnowledgeGraph.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
<KnowledgeGraph
nodes={[makeNode({ id: "abc" })]}
Expand All @@ -165,19 +169,27 @@ describe("KnowledgeGraph (3D) — adapter behavior", () => {
// 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(<KnowledgeGraph nodes={[makeNode()]} edges={[]} />);

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", () => {
Expand Down
61 changes: 46 additions & 15 deletions frontend/src/components/KnowledgeGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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 ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -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;
}, []);

Expand Down
17 changes: 1 addition & 16 deletions frontend/src/components/screens/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 };
}
Expand Down
17 changes: 1 addition & 16 deletions frontend/src/components/screens/Learn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
17 changes: 1 addition & 16 deletions frontend/src/components/screens/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -23,21 +23,6 @@ const TIER_META: Record<Exclude<Tier, "all">, { 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();
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/lib/data.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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);
}
});
});
Loading