mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-08 20:25:16 +02:00
Add AhaGraph dashboard color mode
This commit is contained in:
parent
7a80f5e20f
commit
850ead6390
4 changed files with 111 additions and 15 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue