Add AhaGraph dashboard color mode

This commit is contained in:
Sam Valladares 2026-04-26 14:36:38 -05:00
parent 7a80f5e20f
commit 850ead6390
4 changed files with 111 additions and 15 deletions

View file

@ -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;

View file

@ -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'.

View file

@ -47,10 +47,24 @@ export const MEMORY_STATE_DESCRIPTIONS: Record<MemoryState, string> = {
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<AhaGraphKind, string> = {
aha: '#FFD700',
confusion: '#EF4444',
failure: '#9CA3AF',
};
export const AHAGRAPH_DESCRIPTIONS: Record<AhaGraphKind, string> = {
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<GraphNode, 'tags'>): 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<string, THREE.Sprite>();
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);

View file

@ -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</code>
>
State
</button>
<button
type="button"
role="radio"
aria-checked={colorMode === 'ahagraph'}
onclick={() => (colorMode = 'ahagraph')}
class="px-3 py-1.5 rounded-lg transition {colorMode === 'ahagraph' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Colour by AhaGraph tags (aha / confusion / failure)"
>
AhaGraph
</button>
</div>
<!-- Node count -->
@ -362,6 +384,20 @@ disown</code>
</div>
{/if}
{#if colorMode === 'ahagraph'}
<div class="absolute bottom-4 right-4 z-10 glass rounded-xl px-4 py-3 text-xs">
<div class="text-bright font-semibold mb-2">AhaGraph</div>
<div class="space-y-1.5">
{#each ahagraphLegendEntries as [kind, color]}
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full" style="background: {color}"></span>
<span class="text-dim">{AHAGRAPH_DESCRIPTIONS[kind]}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Temporal playback slider -->
{#if graphData}
<TimeSlider