diff --git a/apps/dashboard/src/lib/components/Graph3D.svelte b/apps/dashboard/src/lib/components/Graph3D.svelte index 7a55436..8c0f769 100644 --- a/apps/dashboard/src/lib/components/Graph3D.svelte +++ b/apps/dashboard/src/lib/components/Graph3D.svelte @@ -20,9 +20,9 @@ centerId: string; events?: VestigeEvent[]; isDreaming?: boolean; - /// v2.0.8: colour mode for node spheres. "type" tints by node type - /// (fact/concept/event/…); "state" tints by FSRS accessibility bucket - /// (active/dormant/silent/unavailable). Toggled live from the graph page. + /// Colour mode for node spheres. "type" tints by node type, "state" + /// tints by FSRS accessibility bucket, and "ahagraph" tints by + /// learning tags from AhaGraph. colorMode?: ColorMode; onSelect?: (nodeId: string) => void; onGraphMutation?: (mutation: GraphMutation) => void; diff --git a/apps/dashboard/src/lib/graph/__tests__/color-mode.test.ts b/apps/dashboard/src/lib/graph/__tests__/color-mode.test.ts index 6eb1bbe..e59fb9d 100644 --- a/apps/dashboard/src/lib/graph/__tests__/color-mode.test.ts +++ b/apps/dashboard/src/lib/graph/__tests__/color-mode.test.ts @@ -16,7 +16,9 @@ vi.mock('three', async () => { import { NodeManager, getMemoryState, + getAhaGraphColor, getNodeColor, + AHAGRAPH_COLORS, MEMORY_STATE_COLORS, MEMORY_STATE_DESCRIPTIONS, type MemoryState, @@ -210,6 +212,31 @@ describe('getNodeColor — state mode', () => { }); }); +describe('getNodeColor — AhaGraph mode', () => { + it.each([ + [['ahagraph', 'aha'], AHAGRAPH_COLORS.aha], + [['ahagraph', 'confusion'], AHAGRAPH_COLORS.confusion], + [['ahagraph', 'weak-spot'], AHAGRAPH_COLORS.confusion], + [['ahagraph', 'failure'], AHAGRAPH_COLORS.failure], + [['ahagraph', 'guardrail'], AHAGRAPH_COLORS.failure], + ] as Array<[string[], string]>)('maps tags %j to %s', (tags, color) => { + const node = makeNode({ type: 'concept', tags }); + expect(getAhaGraphColor(node)).toBe(color); + expect(getNodeColor(node, 'ahagraph')).toBe(color); + }); + + it('prioritizes aha when a note also mentions confusion tags', () => { + const node = makeNode({ type: 'note', tags: ['ahagraph', 'aha', 'confusion'] }); + expect(getNodeColor(node, 'ahagraph')).toBe(AHAGRAPH_COLORS.aha); + }); + + it('falls back to node type when no AhaGraph learning tag is present', () => { + const node = makeNode({ type: 'event', tags: ['ahagraph'] }); + expect(getAhaGraphColor(node)).toBeNull(); + expect(getNodeColor(node, 'ahagraph')).toBe(NODE_TYPE_COLORS.event); + }); +}); + // ---------------------------------------------------------------------------- // NodeManager — default state + colorMode field // ---------------------------------------------------------------------------- @@ -236,6 +263,11 @@ describe('NodeManager — colorMode field', () => { expect(manager.colorMode).toBe('state'); }); + it('setColorMode("ahagraph") updates the field', () => { + manager.setColorMode('ahagraph'); + expect(manager.colorMode).toBe('ahagraph'); + }); + it('setColorMode("type") is no-op when already "type" (idempotent early return)', () => { // Spy on the meshMap iteration indirectly: if the early-return fires, // calling setColorMode on an empty manager still leaves us in 'type'. diff --git a/apps/dashboard/src/lib/graph/nodes.ts b/apps/dashboard/src/lib/graph/nodes.ts index b075851..8aaa528 100644 --- a/apps/dashboard/src/lib/graph/nodes.ts +++ b/apps/dashboard/src/lib/graph/nodes.ts @@ -47,10 +47,24 @@ export const MEMORY_STATE_DESCRIPTIONS: Record = { unavailable: 'Needs reinforcement (< 10%)', }; -/// Color mode controls whether node spheres are tinted by node type -/// (fact / concept / event / …) or by FSRS memory state. +export type AhaGraphKind = 'aha' | 'confusion' | 'failure'; + +export const AHAGRAPH_COLORS: Record = { + aha: '#FFD700', + confusion: '#EF4444', + failure: '#9CA3AF', +}; + +export const AHAGRAPH_DESCRIPTIONS: Record = { + aha: 'Aha moments and breakthroughs', + confusion: 'Confusions and weak spots', + failure: 'Failures and guardrails', +}; + +/// Color mode controls whether node spheres are tinted by node type, +/// FSRS memory state, or AhaGraph learning tags. /// Type mode is the long-standing default; state mode is the v2.0.8 addition. -export type ColorMode = 'type' | 'state'; +export type ColorMode = 'type' | 'state' | 'ahagraph'; /// Pick a hex colour for a node given the active colour mode. /// Falls back to the grey `unavailable` tone if the node's type is unknown. @@ -58,9 +72,20 @@ export function getNodeColor(node: GraphNode, mode: ColorMode): string { if (mode === 'state') { return MEMORY_STATE_COLORS[getMemoryState(node.retention)]; } + if (mode === 'ahagraph') { + return getAhaGraphColor(node) ?? NODE_TYPE_COLORS[node.type] ?? '#8B95A5'; + } return NODE_TYPE_COLORS[node.type] || '#8B95A5'; } +export function getAhaGraphColor(node: Pick): string | null { + const tags = new Set((node.tags ?? []).map((tag) => tag.toLowerCase())); + if (tags.has('aha')) return AHAGRAPH_COLORS.aha; + if (tags.has('confusion') || tags.has('weak-spot')) return AHAGRAPH_COLORS.confusion; + if (tags.has('failure') || tags.has('guardrail')) return AHAGRAPH_COLORS.failure; + return null; +} + // Shared radial-gradient texture used for every node's glow Sprite. // Without a map, THREE.Sprite renders as a flat coloured plane — additive- // blending + UnrealBloomPass then amplifies its square edges into the @@ -139,8 +164,8 @@ export class NodeManager { labelSprites = new Map(); hoveredNode: string | null = null; selectedNode: string | null = null; - /// v2.0.8: colour nodes by FSRS memory state (active/dormant/silent/unavailable) - /// instead of node type. Switched at runtime via `setColorMode`. + /// Colour nodes by type, FSRS state, or AhaGraph learning tags. + /// Switched at runtime via `setColorMode`. colorMode: ColorMode = 'type'; private materializingNodes: MaterializingNode[] = []; @@ -161,12 +186,15 @@ export class NodeManager { for (const [id, mesh] of this.meshMap) { const retention = (mesh.userData.retention as number | undefined) ?? 0; const type = (mesh.userData.type as string | undefined) ?? 'fact'; + const tags = Array.isArray(mesh.userData.tags) + ? (mesh.userData.tags as string[]) + : []; const stubNode = { id, label: '', type, retention, - tags: [], + tags, createdAt: '', updatedAt: '', isCenter: false, @@ -236,7 +264,7 @@ export class NodeManager { const mesh = new THREE.Mesh(geometry, material); mesh.position.copy(pos); mesh.scale.setScalar(initialScale); - mesh.userData = { nodeId: node.id, type: node.type, retention: node.retention }; + mesh.userData = { nodeId: node.id, type: node.type, retention: node.retention, tags: node.tags }; this.meshMap.set(node.id, mesh); this.group.add(mesh); diff --git a/apps/dashboard/src/routes/(app)/graph/+page.svelte b/apps/dashboard/src/routes/(app)/graph/+page.svelte index a831cd3..1df2683 100644 --- a/apps/dashboard/src/routes/(app)/graph/+page.svelte +++ b/apps/dashboard/src/routes/(app)/graph/+page.svelte @@ -10,7 +10,7 @@ import { graphState } from '$stores/graph-state.svelte'; import type { GraphResponse, GraphNode, GraphEdge, Memory } from '$types'; import type { GraphMutation } from '$lib/graph/events'; - import type { ColorMode } from '$lib/graph/nodes'; + import { AHAGRAPH_COLORS, AHAGRAPH_DESCRIPTIONS, type ColorMode } from '$lib/graph/nodes'; import { filterByDate } from '$lib/graph/temporal'; let graphData: GraphResponse | null = $state(null); @@ -22,10 +22,12 @@ let maxNodes = $state(150); let temporalEnabled = $state(false); let temporalDate = $state(new Date()); - // v2.0.8: colour spheres by node type (default) or by FSRS memory state - // (Active / Dormant / Silent / Unavailable). Legend overlay renders when - // state mode is active. + // Colour spheres by node type, FSRS memory state, or AhaGraph learning tags. let colorMode: ColorMode = $state('type'); + const ahagraphLegendEntries = Object.entries(AHAGRAPH_COLORS) as Array<[ + keyof typeof AHAGRAPH_COLORS, + string + ]>; // Live counts that update on mutations let liveNodeCount = $state(0); @@ -78,7 +80,17 @@ } } - onMount(() => loadGraph()); + onMount(() => { + const requestedMode = new URLSearchParams(window.location.search).get('colorMode'); + if (isColorMode(requestedMode)) { + colorMode = requestedMode; + } + void loadGraph(); + }); + + function isColorMode(value: string | null): value is ColorMode { + return value === 'type' || value === 'state' || value === 'ahagraph'; + } async function loadGraph(query?: string, centerId?: string) { loading = true; @@ -292,6 +304,16 @@ disown > State + @@ -362,6 +384,20 @@ disown {/if} + {#if colorMode === 'ahagraph'} +
+
AhaGraph
+
+ {#each ahagraphLegendEntries as [kind, color]} +
+ + {AHAGRAPH_DESCRIPTIONS[kind]} +
+ {/each} +
+
+ {/if} + {#if graphData}