feat: enhance frontend graph visualization with cluster boundaries and improved rendering

- Add cluster boundary visualization with color-coded type grouping
- Implement new MemoryGraphVisualization component for Cognee integration
- Add TypeScript types for Cognee API integration (CogneeAPI, NodeSet)
- Enhance node swarm materials with better color hierarchy
- Improve edge materials with opacity controls
- Add metaball density rendering for visual clustering
- Update demo and dataset visualization pages with new features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
vasilije 2026-01-12 07:24:32 +01:00
parent 8ae805284e
commit 73b293ed71
13 changed files with 1148 additions and 354 deletions

View file

@ -2,37 +2,94 @@
import { useEffect, useState } from "react";
import { fetch } from "@/utils";
import { adaptCogneeGraphData, validateCogneeGraphResponse } from "@/lib/adaptCogneeGraphData";
import { CogneeGraphResponse } from "@/types/CogneeAPI";
import MemoryGraphVisualization from "@/ui/elements/MemoryGraphVisualization";
import { Edge, Node } from "@/ui/rendering/graph/types";
import GraphVisualization from "@/ui/elements/GraphVisualization";
interface VisualizePageProps {
params: { datasetId: string };
}
export default function Page({ params }: VisualizePageProps) {
const [graphData, setGraphData] = useState<{ nodes: Node[], edges: Edge[] }>();
const [graphData, setGraphData] = useState<{ nodes: Node[], edges: Edge[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function getData() {
const datasetId = (await params).datasetId;
const response = await fetch(`/v1/datasets/${datasetId}/graph`);
const newGraphData = await response.json();
setGraphData(newGraphData);
try {
setLoading(true);
setError(null);
const datasetId = (await params).datasetId;
const response = await fetch(`/v1/datasets/${datasetId}/graph`);
if (!response.ok) {
throw new Error(`Failed to fetch graph data: ${response.statusText}`);
}
const apiData = await response.json();
// Validate API response
if (!validateCogneeGraphResponse(apiData)) {
throw new Error("Invalid graph data format from API");
}
// Adapt Cognee API format to visualization format
const adaptedData = adaptCogneeGraphData(apiData as CogneeGraphResponse);
setGraphData(adaptedData);
} catch (err) {
console.error("Error loading graph data:", err);
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
getData();
}, [params]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-900 via-black to-purple-900">
<div className="text-center">
<div className="text-6xl mb-4 animate-spin"></div>
<div className="text-2xl font-bold text-white">Loading graph data...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-900 via-black to-purple-900">
<div className="text-center max-w-md p-6">
<div className="text-6xl mb-4"></div>
<div className="text-2xl font-bold text-white mb-2">Error Loading Graph</div>
<div className="text-gray-400">{error}</div>
</div>
</div>
);
}
if (!graphData || graphData.nodes.length === 0) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-900 via-black to-purple-900">
<div className="text-center max-w-md p-6">
<div className="text-6xl mb-4">📊</div>
<div className="text-2xl font-bold text-white mb-2">No Graph Data</div>
<div className="text-gray-400">This dataset has no graph data to visualize.</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen">
{graphData && (
<GraphVisualization
nodes={graphData.nodes}
edges={graphData.edges}
config={{
fontSize: 10,
}}
/>
)}
</div>
<MemoryGraphVisualization
nodes={graphData.nodes}
edges={graphData.edges}
title="Cognee Memory Graph"
showControls={true}
/>
);
}

View file

@ -1,279 +1,87 @@
"use client";
import { useState } from "react";
import GraphVisualization from "@/ui/elements/GraphVisualization";
import { Edge, Node } from "@/ui/rendering/graph/types";
import { useState, useMemo } from "react";
import { generateOntologyGraph } from "@/lib/generateOntologyGraph";
import MemoryGraphVisualization from "@/ui/elements/MemoryGraphVisualization";
// Rich mock dataset representing an AI/ML knowledge graph
const mockNodes: Node[] = [
// Core AI Concepts
{ id: "ai", label: "Artificial Intelligence", type: "Concept" },
{ id: "ml", label: "Machine Learning", type: "Concept" },
{ id: "dl", label: "Deep Learning", type: "Concept" },
{ id: "nlp", label: "Natural Language Processing", type: "Concept" },
{ id: "cv", label: "Computer Vision", type: "Concept" },
{ id: "rl", label: "Reinforcement Learning", type: "Concept" },
// ML Algorithms
{ id: "supervised", label: "Supervised Learning", type: "Algorithm" },
{ id: "unsupervised", label: "Unsupervised Learning", type: "Algorithm" },
{ id: "svm", label: "Support Vector Machine", type: "Algorithm" },
{ id: "decision-tree", label: "Decision Tree", type: "Algorithm" },
{ id: "random-forest", label: "Random Forest", type: "Algorithm" },
{ id: "kmeans", label: "K-Means Clustering", type: "Algorithm" },
{ id: "pca", label: "Principal Component Analysis", type: "Algorithm" },
// Deep Learning Architectures
{ id: "neural-net", label: "Neural Network", type: "Architecture" },
{ id: "cnn", label: "Convolutional Neural Network", type: "Architecture" },
{ id: "rnn", label: "Recurrent Neural Network", type: "Architecture" },
{ id: "lstm", label: "Long Short-Term Memory", type: "Architecture" },
{ id: "transformer", label: "Transformer", type: "Architecture" },
{ id: "gnn", label: "Graph Neural Network", type: "Architecture" },
{ id: "gan", label: "Generative Adversarial Network", type: "Architecture" },
{ id: "vae", label: "Variational Autoencoder", type: "Architecture" },
// NLP Technologies
{ id: "bert", label: "BERT", type: "Technology" },
{ id: "gpt", label: "GPT", type: "Technology" },
{ id: "word2vec", label: "Word2Vec", type: "Technology" },
{ id: "attention", label: "Attention Mechanism", type: "Technology" },
{ id: "tokenization", label: "Tokenization", type: "Technology" },
// CV Technologies
{ id: "resnet", label: "ResNet", type: "Technology" },
{ id: "yolo", label: "YOLO", type: "Technology" },
{ id: "segmentation", label: "Image Segmentation", type: "Technology" },
{ id: "detection", label: "Object Detection", type: "Technology" },
// RL Components
{ id: "q-learning", label: "Q-Learning", type: "Algorithm" },
{ id: "dqn", label: "Deep Q-Network", type: "Architecture" },
{ id: "policy-gradient", label: "Policy Gradient", type: "Algorithm" },
{ id: "actor-critic", label: "Actor-Critic", type: "Architecture" },
// Applications
{ id: "chatbot", label: "Chatbot", type: "Application" },
{ id: "recommendation", label: "Recommendation System", type: "Application" },
{ id: "autonomous", label: "Autonomous Vehicles", type: "Application" },
{ id: "medical-imaging", label: "Medical Imaging", type: "Application" },
{ id: "fraud-detection", label: "Fraud Detection", type: "Application" },
// Data & Training
{ id: "dataset", label: "Training Dataset", type: "Data" },
{ id: "feature", label: "Feature Engineering", type: "Data" },
{ id: "augmentation", label: "Data Augmentation", type: "Data" },
{ id: "normalization", label: "Normalization", type: "Data" },
// Optimization
{ id: "gradient-descent", label: "Gradient Descent", type: "Optimization" },
{ id: "adam", label: "Adam Optimizer", type: "Optimization" },
{ id: "backprop", label: "Backpropagation", type: "Optimization" },
{ id: "regularization", label: "Regularization", type: "Optimization" },
{ id: "dropout", label: "Dropout", type: "Optimization" },
];
const mockEdges: Edge[] = [
// Core relationships
{ id: "e1", source: "ml", target: "ai", label: "is subfield of" },
{ id: "e2", source: "dl", target: "ml", label: "is subfield of" },
{ id: "e3", source: "nlp", target: "ai", label: "is subfield of" },
{ id: "e4", source: "cv", target: "ai", label: "is subfield of" },
{ id: "e5", source: "rl", target: "ml", label: "is subfield of" },
// ML paradigms
{ id: "e6", source: "supervised", target: "ml", label: "is paradigm of" },
{ id: "e7", source: "unsupervised", target: "ml", label: "is paradigm of" },
// ML algorithms
{ id: "e8", source: "svm", target: "supervised", label: "implements" },
{ id: "e9", source: "decision-tree", target: "supervised", label: "implements" },
{ id: "e10", source: "random-forest", target: "decision-tree", label: "ensemble of" },
{ id: "e11", source: "kmeans", target: "unsupervised", label: "implements" },
{ id: "e12", source: "pca", target: "unsupervised", label: "implements" },
// Deep Learning
{ id: "e13", source: "neural-net", target: "dl", label: "foundation of" },
{ id: "e14", source: "cnn", target: "neural-net", label: "type of" },
{ id: "e15", source: "rnn", target: "neural-net", label: "type of" },
{ id: "e16", source: "lstm", target: "rnn", label: "variant of" },
{ id: "e17", source: "transformer", target: "neural-net", label: "type of" },
{ id: "e18", source: "gnn", target: "neural-net", label: "type of" },
{ id: "e19", source: "gan", target: "neural-net", label: "type of" },
{ id: "e20", source: "vae", target: "neural-net", label: "type of" },
// CV architectures
{ id: "e21", source: "cnn", target: "cv", label: "used in" },
{ id: "e22", source: "resnet", target: "cnn", label: "implementation of" },
{ id: "e23", source: "yolo", target: "detection", label: "implements" },
{ id: "e24", source: "detection", target: "cv", label: "task in" },
{ id: "e25", source: "segmentation", target: "cv", label: "task in" },
// NLP connections
{ id: "e26", source: "transformer", target: "nlp", label: "used in" },
{ id: "e27", source: "bert", target: "transformer", label: "based on" },
{ id: "e28", source: "gpt", target: "transformer", label: "based on" },
{ id: "e29", source: "attention", target: "transformer", label: "key component of" },
{ id: "e30", source: "word2vec", target: "nlp", label: "technique in" },
{ id: "e31", source: "tokenization", target: "nlp", label: "preprocessing for" },
// RL connections
{ id: "e32", source: "q-learning", target: "rl", label: "algorithm in" },
{ id: "e33", source: "dqn", target: "q-learning", label: "deep version of" },
{ id: "e34", source: "policy-gradient", target: "rl", label: "algorithm in" },
{ id: "e35", source: "actor-critic", target: "policy-gradient", label: "combines" },
// Applications
{ id: "e36", source: "chatbot", target: "nlp", label: "application of" },
{ id: "e37", source: "chatbot", target: "gpt", label: "powered by" },
{ id: "e38", source: "recommendation", target: "ml", label: "application of" },
{ id: "e39", source: "autonomous", target: "rl", label: "application of" },
{ id: "e40", source: "autonomous", target: "cv", label: "application of" },
{ id: "e41", source: "medical-imaging", target: "cv", label: "application of" },
{ id: "e42", source: "medical-imaging", target: "cnn", label: "uses" },
{ id: "e43", source: "fraud-detection", target: "ml", label: "application of" },
// Data & Training
{ id: "e44", source: "dataset", target: "supervised", label: "required for" },
{ id: "e45", source: "feature", target: "ml", label: "preprocessing for" },
{ id: "e46", source: "augmentation", target: "dataset", label: "expands" },
{ id: "e47", source: "normalization", target: "feature", label: "step in" },
// Optimization
{ id: "e48", source: "backprop", target: "neural-net", label: "trains" },
{ id: "e49", source: "gradient-descent", target: "backprop", label: "uses" },
{ id: "e50", source: "adam", target: "gradient-descent", label: "variant of" },
{ id: "e51", source: "regularization", target: "neural-net", label: "improves" },
{ id: "e52", source: "dropout", target: "regularization", label: "technique for" },
// Cross-connections
{ id: "e53", source: "attention", target: "cv", label: "also used in" },
{ id: "e54", source: "gan", target: "augmentation", label: "generates" },
{ id: "e55", source: "transformer", target: "cv", label: "adapted to" },
{ id: "e56", source: "gnn", target: "recommendation", label: "powers" },
];
type GraphMode = "small" | "medium" | "large";
export default function VisualizationDemoPage() {
const [showLegend, setShowLegend] = useState(true);
const [showStats, setShowStats] = useState(true);
const [graphMode, setGraphMode] = useState<GraphMode>("medium");
const [isGenerating, setIsGenerating] = useState(false);
const nodeTypes = Array.from(new Set(mockNodes.map(n => n.type)));
const typeColors: Record<string, string> = {
"Concept": "#5C10F4",
"Algorithm": "#A550FF",
"Architecture": "#0DFF00",
"Technology": "#00D9FF",
"Application": "#FF6B35",
"Data": "#F7B801",
"Optimization": "#FF1E56",
};
// Generate graph based on mode
const { nodes, edges } = useMemo(() => {
console.log(`Generating ${graphMode} ontology graph...`);
setIsGenerating(true);
let result;
switch (graphMode) {
case "small":
result = { ...generateOntologyGraph("simple"), clusters: new Map() };
break;
case "medium":
result = generateOntologyGraph("medium");
break;
case "large":
result = generateOntologyGraph("complex");
break;
}
setTimeout(() => setIsGenerating(false), 500);
return result;
}, [graphMode]);
return (
<div className="flex min-h-screen bg-black text-white">
{/* Main Visualization */}
<div className="flex-1 relative">
<GraphVisualization
nodes={mockNodes}
edges={mockEdges}
config={{
fontSize: 12,
}}
/>
{/* Header Overlay */}
<div className="absolute top-0 left-0 right-0 p-6 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<h1 className="text-3xl font-bold mb-2">AI/ML Knowledge Graph</h1>
<p className="text-gray-400">
Interactive visualization of artificial intelligence concepts and relationships
</p>
<div className="relative min-h-screen">
{isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/90 z-50 backdrop-blur-sm">
<div className="text-center">
<div className="relative">
<div className="text-6xl mb-4 animate-spin"></div>
<div className="absolute inset-0 text-6xl mb-4 animate-ping opacity-20"></div>
</div>
<div className="text-2xl font-bold mb-2 bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
Building Knowledge Graph...
</div>
<div className="text-gray-400">
Creating {
graphMode === "small" ? "~500" :
graphMode === "medium" ? "~1,000" :
"~1,500"
} interconnected nodes
</div>
</div>
</div>
) : null}
{/* Controls */}
<div className="absolute top-6 right-6 flex gap-2">
<button
onClick={() => setShowLegend(!showLegend)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg backdrop-blur-sm transition-colors"
>
{showLegend ? "Hide" : "Show"} Legend
</button>
<button
onClick={() => setShowStats(!showStats)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg backdrop-blur-sm transition-colors"
>
{showStats ? "Hide" : "Show"} Stats
</button>
</div>
{/* Instructions */}
<div className="absolute bottom-6 left-6 bg-black/70 backdrop-blur-md p-4 rounded-lg max-w-md pointer-events-none">
<h3 className="font-semibold mb-2">💡 How to Explore</h3>
<ul className="text-sm text-gray-300 space-y-1">
<li> <strong>Hover</strong> over nodes to see labels</li>
<li> <strong>Zoom in</strong> (scroll) to see connections</li>
<li> <strong>Click & drag</strong> to pan around</li>
<li> <strong>Click</strong> on nodes to select them</li>
</ul>
{/* Mode Selector Overlay */}
<div className="absolute top-6 left-6 z-10 pointer-events-auto">
<div className="flex gap-1 bg-black/70 backdrop-blur-md rounded-lg p-1 border border-purple-500/30">
{(["small", "medium", "large"] as GraphMode[]).map((mode) => (
<button
key={mode}
onClick={() => setGraphMode(mode)}
className={`flex-1 px-3 py-2 rounded transition-all text-sm font-medium ${
graphMode === mode
? "bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg shadow-purple-500/50"
: "hover:bg-white/10 text-gray-300"
}`}
>
{mode === "small" && "500"}
{mode === "medium" && "1K"}
{mode === "large" && "1.5K"}
</button>
))}
</div>
</div>
{/* Legend Panel */}
{showLegend && (
<div className="w-80 bg-gray-900/95 backdrop-blur-md p-6 border-l border-gray-800 overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Node Types</h2>
<div className="space-y-3">
{nodeTypes.map((type) => (
<div key={type} className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: typeColors[type] || "#F4F4F4" }}
/>
<div>
<div className="font-medium">{type}</div>
<div className="text-xs text-gray-400">
{mockNodes.filter(n => n.type === type).length} nodes
</div>
</div>
</div>
))}
</div>
{showStats && (
<div className="mt-8 pt-6 border-t border-gray-800">
<h2 className="text-xl font-bold mb-4">Statistics</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Total Nodes:</span>
<span className="font-semibold">{mockNodes.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Total Edges:</span>
<span className="font-semibold">{mockEdges.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Avg. Connections:</span>
<span className="font-semibold">
{(mockEdges.length * 2 / mockNodes.length).toFixed(1)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Node Types:</span>
<span className="font-semibold">{nodeTypes.length}</span>
</div>
</div>
</div>
)}
<div className="mt-8 pt-6 border-t border-gray-800">
<h3 className="font-semibold mb-2">About This Graph</h3>
<p className="text-sm text-gray-400 leading-relaxed">
This knowledge graph represents the interconnected landscape of
artificial intelligence, machine learning, and deep learning. Nodes
represent concepts, algorithms, architectures, and applications,
while edges show their relationships.
</p>
</div>
</div>
)}
<MemoryGraphVisualization
nodes={nodes}
edges={edges}
title="Memory Retrieval Debugger (Demo)"
showControls={true}
/>
</div>
);
}

View file

@ -0,0 +1,45 @@
/**
* Type definitions for Cognee API responses
* Based on Cognee SDK data point model and graph API
*/
/**
* Cognee DataPoint representation from API
* Corresponds to: cognee/infrastructure/engine/models/DataPoint.py
*/
export interface CogneeDataPoint {
id: string; // UUID
label: string; // Display name
type: string; // Node type (Entity, EntityType, DocumentChunk, etc.)
properties?: Record<string, any>; // Additional metadata
}
/**
* Cognee Edge representation from API
* Corresponds to: cognee/infrastructure/engine/models/Edge.py
*/
export interface CogneeEdge {
source: string; // Source node UUID
target: string; // Target node UUID
label: string; // Relationship type
weight?: number; // Optional weight
weights?: Record<string, number>; // Optional multiple weights
properties?: Record<string, any>; // Additional properties
}
/**
* Cognee Graph API response format
* From: /api/v1/datasets/{dataset_id}/graph
*/
export interface CogneeGraphResponse {
nodes: CogneeDataPoint[];
edges: CogneeEdge[];
}
/**
* Cognee API error response
*/
export interface CogneeAPIError {
detail: string;
status_code: number;
}

View file

@ -0,0 +1,165 @@
/**
* Node Sets: The primary abstraction for grouping nodes
* Replaces fixed types with dynamic, inferred, overlapping groups
*/
export type NodeSetSource =
| "model-inferred" // Created by AI/ML model
| "user-defined" // Manually created by user
| "query-result" // Result of a search query
| "imported" // From external source
| "set-algebra"; // Created by combining other sets
export type NodeSetStability =
| "stable" // Won't change often
| "evolving" // Changes gradually
| "ephemeral"; // Temporary, will be removed
export interface NodeSet {
id: string;
name: string;
description?: string;
// Required properties
nodeIds: string[]; // Member node IDs
size: number; // Number of nodes
definition: string; // How it was created (e.g., "semantic cluster around 'AI'")
stability: NodeSetStability;
source: NodeSetSource;
lastUpdated: Date;
// Confidence metrics
confidence?: number; // 0-1, how confident we are in this grouping
cohesion?: number; // 0-1, how tightly connected members are
// Set algebra metadata
parentSets?: string[]; // If created from other sets
operation?: "union" | "intersect" | "diff";
// Retrieval metadata
retrievalScore?: number; // If this set was retrieved
retrievalSignals?: string[]; // Why it was retrieved
// Visual properties (for rendering)
color?: string;
visible?: boolean;
}
/**
* Set operations
*/
export function unionSets(sets: NodeSet[], name: string): NodeSet {
const allNodeIds = new Set<string>();
sets.forEach(set => set.nodeIds.forEach(id => allNodeIds.add(id)));
return {
id: `union_${Date.now()}`,
name,
nodeIds: Array.from(allNodeIds),
size: allNodeIds.size,
definition: `Union of: ${sets.map(s => s.name).join(", ")}`,
stability: "ephemeral",
source: "set-algebra",
lastUpdated: new Date(),
parentSets: sets.map(s => s.id),
operation: "union",
};
}
export function intersectSets(sets: NodeSet[], name: string): NodeSet {
if (sets.length === 0) {
return {
id: `intersect_${Date.now()}`,
name,
nodeIds: [],
size: 0,
definition: "Empty intersection",
stability: "ephemeral",
source: "set-algebra",
lastUpdated: new Date(),
};
}
const intersection = new Set(sets[0].nodeIds);
sets.slice(1).forEach(set => {
const setIds = new Set(set.nodeIds);
intersection.forEach(id => {
if (!setIds.has(id)) intersection.delete(id);
});
});
return {
id: `intersect_${Date.now()}`,
name,
nodeIds: Array.from(intersection),
size: intersection.size,
definition: `Intersection of: ${sets.map(s => s.name).join(", ")}`,
stability: "ephemeral",
source: "set-algebra",
lastUpdated: new Date(),
parentSets: sets.map(s => s.id),
operation: "intersect",
};
}
export function diffSets(setA: NodeSet, setB: NodeSet, name: string): NodeSet {
const diff = new Set(setA.nodeIds);
setB.nodeIds.forEach(id => diff.delete(id));
return {
id: `diff_${Date.now()}`,
name,
nodeIds: Array.from(diff),
size: diff.size,
definition: `${setA.name} minus ${setB.name}`,
stability: "ephemeral",
source: "set-algebra",
lastUpdated: new Date(),
parentSets: [setA.id, setB.id],
operation: "diff",
};
}
/**
* Retrieval result with explanation
*/
export interface RetrievalResult {
type: "node" | "nodeSet" | "suggestedSet";
// For nodes
nodeId?: string;
nodeLabel?: string;
// For node sets
nodeSet?: NodeSet;
// For suggested sets
suggestedSetDefinition?: string;
suggestedNodeIds?: string[];
// Explanation (critical for trust)
why: string; // Human-readable explanation
similarityScore: number; // 0-1
signals: {
name: string; // e.g., "semantic", "recency", "provenance"
weight: number; // Contribution to final score
value: string | number; // The actual value
}[];
// Confidence
confidence: number; // 0-1, how confident we are in this retrieval
}
/**
* Recall simulation: "If the agent were asked X, what would be retrieved?"
*/
export interface RecallSimulation {
query: string;
rankedMemories: RetrievalResult[];
activatedSets: NodeSet[];
conflicts?: {
nodeId: string;
reason: string;
conflictingSets: string[];
}[];
}

View file

@ -6,11 +6,18 @@ import { useEffect, useRef } from "react";
import { Edge, Node } from "@/ui/rendering/graph/types";
import animate from "@/ui/rendering/animate";
// IMPROVEMENT #8: Extended config for layered view controls
interface GraphVisualizationProps {
nodes: Node[];
edges: Edge[];
className?: string;
config?: { fontSize: number };
config?: {
fontSize?: number;
showNodes?: boolean; // Toggle node visibility
showEdges?: boolean; // Toggle edge/path visibility
showMetaballs?: boolean; // Toggle density cloud visibility
highlightedNodeIds?: Set<string>; // Nodes to highlight (neutral-by-default)
};
}
export default function GraphVisualization({
@ -20,13 +27,32 @@ export default function GraphVisualization({
config,
}: GraphVisualizationProps) {
const visualizationRef = useRef<HTMLDivElement>(null);
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
const visualizationContainer = visualizationRef.current;
if (visualizationContainer) {
animate(nodes, edges, visualizationContainer, config);
// Clean up previous visualization
if (cleanupRef.current) {
cleanupRef.current();
}
// Clear the container
while (visualizationContainer.firstChild) {
visualizationContainer.removeChild(visualizationContainer.firstChild);
}
// Create new visualization
const cleanup = animate(nodes, edges, visualizationContainer, config);
cleanupRef.current = cleanup;
}
return () => {
if (cleanupRef.current) {
cleanupRef.current();
}
};
}, [config, edges, nodes]);
return (

View file

@ -0,0 +1,374 @@
/**
* Memory Graph Visualization
*
* Reusable visualization component with retrieval-first features:
* - Node set inference and display
* - Retrieval search with explanations
* - Neutral-by-default highlighting
* - Type attributes vs. inferred sets separation
*
* Works with any graph data (mock or real Cognee API data)
*/
"use client";
import { useState, useMemo } from "react";
import GraphVisualization from "@/ui/elements/GraphVisualization";
import { Edge, Node } from "@/ui/rendering/graph/types";
import { NodeSet } from "@/types/NodeSet";
import { inferNodeSets, mockRetrievalSearch } from "@/lib/inferNodeSets";
import type { RetrievalResult } from "@/types/NodeSet";
interface MemoryGraphVisualizationProps {
nodes: Node[];
edges: Edge[];
title?: string;
showControls?: boolean;
}
export default function MemoryGraphVisualization({
nodes,
edges,
title = "Memory Retrieval Debugger",
showControls = true,
}: MemoryGraphVisualizationProps) {
const [showLegend, setShowLegend] = useState(true);
// Retrieval-first: search replaces static filtering
const [searchQuery, setSearchQuery] = useState("");
const [retrievalResults, setRetrievalResults] = useState<RetrievalResult[]>([]);
// Node sets: primary abstraction
const [selectedNodeSet, setSelectedNodeSet] = useState<NodeSet | null>(null);
// Layer visibility controls
const [showNodes, setShowNodes] = useState(true);
const [showEdges, setShowEdges] = useState(true);
const [showMetaballs, setShowMetaballs] = useState(false);
// Node Attributes section collapsed by default (secondary concern)
const [showNodeAttributes, setShowNodeAttributes] = useState(false);
// Infer node sets from graph structure (CRITICAL: separate attributes from sets)
const { typeAttributes, inferredSets } = useMemo(() => {
return inferNodeSets(nodes, edges, {
minSetSize: 5,
maxSets: 15,
});
}, [nodes, edges]);
// Neutral-by-default: only highlight nodes that are selected or retrieved
const highlightedNodeIds = useMemo(() => {
const ids = new Set<string>();
// Nodes from retrieval results
retrievalResults.forEach(result => {
if (result.type === "node" && result.nodeId) {
ids.add(result.nodeId);
} else if (result.type === "nodeSet" && result.nodeSet) {
result.nodeSet.nodeIds.forEach(id => ids.add(id));
}
});
// Nodes from selected set
if (selectedNodeSet) {
selectedNodeSet.nodeIds.forEach(id => ids.add(id));
}
return ids;
}, [retrievalResults, selectedNodeSet]);
// Handle retrieval search
const handleSearch = (query: string) => {
setSearchQuery(query);
if (query.trim()) {
const results = mockRetrievalSearch(query, nodes, inferredSets);
setRetrievalResults(results);
} else {
setRetrievalResults([]);
}
};
const handleReset = () => {
setSearchQuery("");
setRetrievalResults([]);
setSelectedNodeSet(null);
};
return (
<div className="flex min-h-screen bg-gradient-to-br from-gray-900 via-black to-purple-900 text-white">
{/* Main Visualization */}
<div className="flex-1 relative">
<GraphVisualization
nodes={nodes}
edges={edges}
config={{
fontSize: 11,
showNodes,
showEdges,
showMetaballs,
highlightedNodeIds,
}}
/>
{/* Header */}
<div className="absolute top-0 left-0 right-0 p-6 bg-gradient-to-b from-black/90 via-black/50 to-transparent pointer-events-none">
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent">
{title}
</h1>
<p className="text-gray-300">
{highlightedNodeIds.size > 0 ? (
<>
<span className="text-purple-400 font-semibold">{highlightedNodeIds.size}</span> retrieved
{" / "}
<span className="text-gray-500">{nodes.length.toLocaleString()} total</span>
{" • "}
<span className="text-sm">{inferredSets.length} inferred sets</span>
</>
) : (
<>
<span>{nodes.length.toLocaleString()} nodes</span>
{" • "}
<span>{inferredSets.length} inferred sets</span>
{" • "}
<span className="text-gray-500">Search to retrieve</span>
</>
)}
</p>
</div>
{showControls && (
<div className="absolute top-6 right-6 flex flex-col gap-3 pointer-events-auto max-w-md">
{/* Retrieval Search */}
<div className="relative">
<input
type="text"
placeholder="🔍 Retrieve memories... (e.g., 'AI', 'Physics')"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 bg-black/70 backdrop-blur-md border border-purple-500/30 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20"
/>
{searchQuery && (
<button
onClick={() => handleSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
>
</button>
)}
</div>
{/* View Controls */}
<div className="flex gap-2">
<button
onClick={() => setShowLegend(!showLegend)}
className="flex-1 px-4 py-2 bg-black/70 hover:bg-black/90 backdrop-blur-md rounded-lg border border-purple-500/30 transition-all"
>
{showLegend ? "Hide" : "Show"} Panel
</button>
{(searchQuery || selectedNodeSet || retrievalResults.length > 0) && (
<button
onClick={handleReset}
className="px-4 py-2 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-500 hover:to-orange-500 rounded-lg transition-all shadow-lg"
>
Reset
</button>
)}
</div>
{/* Layer Visibility Controls */}
<div className="bg-black/70 backdrop-blur-md rounded-lg p-3 border border-purple-500/30">
<div className="text-xs font-semibold text-gray-400 mb-2">Layers</div>
<div className="flex flex-col gap-2">
<button
onClick={() => setShowNodes(!showNodes)}
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-all text-sm ${
showNodes
? "bg-purple-600/30 border border-purple-500/50"
: "bg-white/5 border border-gray-600/30"
}`}
>
<span> Nodes</span>
<span className="text-xs">{showNodes ? "ON" : "OFF"}</span>
</button>
<button
onClick={() => setShowEdges(!showEdges)}
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-all text-sm ${
showEdges
? "bg-amber-600/30 border border-amber-500/50"
: "bg-white/5 border border-gray-600/30"
}`}
>
<span> Paths</span>
<span className="text-xs">{showEdges ? "ON" : "OFF"}</span>
</button>
<button
onClick={() => setShowMetaballs(!showMetaballs)}
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-all text-sm ${
showMetaballs
? "bg-purple-600/20 border border-purple-500/30"
: "bg-white/5 border border-gray-600/30"
}`}
>
<span> Clouds</span>
<span className="text-xs">{showMetaballs ? "ON" : "OFF"}</span>
</button>
</div>
</div>
</div>
)}
</div>
{/* Side Panel */}
{showLegend && (
<div className="w-96 bg-black/95 backdrop-blur-xl border-l border-purple-500/20 overflow-y-auto">
<div className="p-6 space-y-6">
{/* Retrieval Results */}
{retrievalResults.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Retrieved Memories
</h2>
<div className="space-y-3">
{retrievalResults.slice(0, 10).map((result, idx) => (
<div
key={idx}
className="bg-white/5 hover:bg-white/10 p-3 rounded-lg border border-purple-500/20 transition-all"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="font-medium text-sm">
{result.type === "node" ? result.nodeLabel : result.nodeSet?.name}
</div>
<div className="text-xs px-2 py-0.5 bg-purple-600/30 rounded">
{(result.similarityScore * 100).toFixed(0)}%
</div>
</div>
<div className="text-xs text-gray-400 mb-2">
{result.why}
</div>
<div className="flex gap-2 flex-wrap">
{result.signals.map((signal, sidx) => (
<span
key={sidx}
className="text-xs px-2 py-0.5 bg-black/30 rounded"
>
{signal.name}: {(signal.weight * 100).toFixed(0)}%
</span>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Node Attributes - Secondary (NOT sets, just metadata) */}
<div>
<button
onClick={() => setShowNodeAttributes(!showNodeAttributes)}
className="w-full flex items-center justify-between mb-3 text-left"
>
<h3 className="text-sm font-semibold text-gray-400">
Node Attributes {showNodeAttributes ? "▼" : "▶"}
</h3>
<span className="text-xs text-gray-500">
{typeAttributes.size} types
</span>
</button>
{showNodeAttributes && (
<div className="space-y-1 mb-6 pl-2">
{Array.from(typeAttributes.entries())
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<div
key={type}
className="flex items-center justify-between text-xs px-3 py-1.5 bg-white/5 rounded"
>
<span className="text-gray-400">type: {type}</span>
<span className="text-gray-500">({count})</span>
</div>
))}
</div>
)}
</div>
{/* Inferred Node Sets - PRIMARY ABSTRACTION */}
<div>
<h2 className="text-xl font-bold mb-3 flex items-center justify-between">
<span className="bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Node Sets
</span>
{selectedNodeSet && (
<button
onClick={() => setSelectedNodeSet(null)}
className="text-xs px-2 py-1 bg-red-600 hover:bg-red-500 rounded"
>
Clear
</button>
)}
</h2>
<div className="space-y-2">
{inferredSets.map((nodeSet) => {
const isSelected = selectedNodeSet?.id === nodeSet.id;
return (
<button
key={nodeSet.id}
onClick={() => setSelectedNodeSet(isSelected ? null : nodeSet)}
className={`w-full text-left p-3 rounded-lg transition-all ${
isSelected
? "bg-gradient-to-r from-purple-600 to-pink-600 shadow-lg"
: "bg-white/5 hover:bg-white/10"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="font-medium text-sm">{nodeSet.name}</div>
<div className="text-xs px-2 py-0.5 bg-black/30 rounded">
{nodeSet.size}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* Enhanced explanatory section with explicit semantics */}
<div className="pt-6 border-t border-purple-500/20">
<h3 className="font-semibold mb-3 text-purple-400">Visual Elements</h3>
<div className="space-y-2 text-sm text-gray-400">
<div className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5 font-bold text-lg"></span>
<div>
<strong className="text-gray-300">Node Size = Importance:</strong>
<div className="text-xs mt-0.5">Larger = Domain/Field (structural), Smaller = Application (leaf)</div>
</div>
</div>
<div className="flex items-start gap-2">
<span className="text-amber-400 mt-0.5"></span>
<div>
<strong className="text-gray-300">Paths (zoom to see):</strong>
<div className="text-xs mt-0.5">Relationships Hover node to highlight connections</div>
</div>
</div>
<div className="flex items-start gap-2">
<span className="text-purple-400/40 mt-0.5"></span>
<div>
<strong className="text-gray-300">Background Clouds:</strong>
<div className="text-xs mt-0.5">Conceptual Density Visible at far zoom for cluster overview</div>
</div>
</div>
<div className="flex items-start gap-2">
<span className="text-cyan-400/60 mt-0.5"></span>
<div>
<strong className="text-gray-300">Boundary Rings:</strong>
<div className="text-xs mt-0.5">Type Clusters Spatial grouping by semantic category</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,4 +1,5 @@
import { Graph, Node as GraphNode, Link as GraphLink } from "ngraph.graph";
import * as three from "three";
import {
Color,
DataTexture,
@ -25,11 +26,19 @@ import createNodePositionsTexture from "./textures/createNodePositionsTexture";
import createDensityRenderTarget from "./render-targets/createDensityRenderTarget";
import createDensityAccumulatorMesh from "./meshes/createDensityAccumulatorMesh";
import createMetaballMesh from "./meshes/createMetaballMesh";
import createClusterBoundaryMesh, { ClusterInfo } from "./meshes/createClusterBoundaryMesh";
const INITIAL_CAMERA_DISTANCE = 2000;
// Extended config for layered view controls + zoom semantics
interface Config {
fontSize: number;
fontSize?: number;
showNodes?: boolean;
showEdges?: boolean;
showMetaballs?: boolean;
pathFilterMode?: "all" | "hoverOnly" | "strongOnly"; // Path filtering
zoomLevel?: "far" | "mid" | "near"; // Zoom-based semantics
highlightedNodeIds?: Set<string>; // Nodes to highlight (neutral-by-default)
}
export default function animate(
@ -37,45 +46,57 @@ export default function animate(
edges: Edge[],
parentElement: HTMLElement,
config?: Config
): void {
): () => void {
const nodeLabelMap = new Map();
const edgeLabelMap = new Map();
// Enhanced color palette with vibrant, distinguishable colors
const colorPalette = [
new Color("#5C10F4"), // Deep Purple - Primary concepts
new Color("#A550FF"), // Light Purple - Algorithms
new Color("#0DFF00"), // Neon Green - Architectures
new Color("#00D9FF"), // Cyan - Technologies
new Color("#FF6B35"), // Coral - Applications
new Color("#F7B801"), // Golden Yellow - Data
new Color("#FF1E56"), // Hot Pink - Optimization
new Color("#00E5FF"), // Bright Cyan - Additional
new Color("#7DFF8C"), // Mint Green - Additional
new Color("#FFB347"), // Peach - Additional
];
let lastColorIndex = 0;
const colorPerType = new Map();
// Semantic color encoding: hierarchy drives saturation + brightness
const typeColorMap: Record<string, string> = {
"Domain": "#C4B5FD", // Bright Purple - Highest importance
"Field": "#67E8F9", // Bright Cyan - High importance
"Subfield": "#A78BFA", // Medium Purple - Medium-high importance
"Concept": "#5EEAD4", // Teal - Medium importance
"Method": "#6EE7B7", // Green - Medium importance
"Theory": "#F9A8D4", // Pink - Medium importance
"Technology": "#FCA5A5", // Soft Red - Lower importance
"Application": "#71717A", // Desaturated Gray - Background/lowest importance
};
// Size hierarchy: more important = larger
const typeSizeMap: Record<string, number> = {
"Domain": 2.5, // Largest
"Field": 2.0,
"Subfield": 1.6,
"Concept": 1.2,
"Method": 1.1,
"Theory": 1.0,
"Technology": 0.9,
"Application": 0.6, // Smallest
};
function getColorForType(nodeType: string): Color {
if (colorPerType.has(nodeType)) {
return colorPerType.get(nodeType);
const colorHex = typeColorMap[nodeType];
if (colorHex) {
return new Color(colorHex);
}
const color = colorPalette[lastColorIndex % colorPalette.length];
colorPerType.set(nodeType, color);
lastColorIndex += 1;
return color;
// Fallback for unknown types
return new Color("#9CA3AF"); // Gray for unknown types
}
const mousePosition = new Vector2();
// Node related data
const nodeColors = new Float32Array(nodes.length * 3);
const nodeSizes = new Float32Array(nodes.length); // Size per node for hierarchy
const nodeHighlights = new Float32Array(nodes.length); // 1.0 = highlighted, 0.3 = dimmed
const nodeIndices = new Map();
const textureSize = Math.ceil(Math.sqrt(nodes.length));
const nodePositionsData = new Float32Array(textureSize * textureSize * 4);
// Determine which nodes are highlighted
const highlightedIds = config?.highlightedNodeIds;
const hasHighlights = highlightedIds && highlightedIds.size > 0;
let nodeIndex = 0;
function forNode(node: Node) {
const color = getColorForType(node.type);
@ -83,6 +104,16 @@ export default function animate(
nodeColors[nodeIndex * 3 + 1] = color.g;
nodeColors[nodeIndex * 3 + 2] = color.b;
// Set highlight state: if no highlights, all at 1.0; if highlights exist, dim non-highlighted
if (hasHighlights) {
nodeHighlights[nodeIndex] = highlightedIds!.has(node.id) ? 1.0 : 0.3;
} else {
nodeHighlights[nodeIndex] = 1.0; // All visible when no highlights
}
// Store size multiplier based on type
nodeSizes[nodeIndex] = typeSizeMap[node.type] || 1.0;
nodePositionsData[nodeIndex * 4 + 0] = 0.0;
nodePositionsData[nodeIndex * 4 + 1] = 0.0;
nodePositionsData[nodeIndex * 4 + 2] = 0.0;
@ -109,12 +140,17 @@ export default function animate(
// Graph creation and layout
const graph = createGraph(nodes, edges, forNode, forEdge);
// Improved layout parameters for better visualization
// Adaptive layout parameters based on graph size
const nodeCount = nodes.length;
const isLargeGraph = nodeCount > 5000;
const isMassiveGraph = nodeCount > 15000;
// Apple embedding atlas style: stronger repulsion for clear cluster separation
const graphLayout = createForceLayout(graph, {
dragCoefficient: 0.8, // Reduced for smoother movement
springLength: 180, // Slightly tighter clustering
springCoefficient: 0.25, // Stronger connections
gravity: -1200, // Stronger repulsion for better spread
dragCoefficient: isMassiveGraph ? 0.95 : 0.85,
springLength: isMassiveGraph ? 120 : isLargeGraph ? 180 : 220, // Longer springs for spacing
springCoefficient: isMassiveGraph ? 0.12 : isLargeGraph ? 0.15 : 0.18, // Weaker springs
gravity: isMassiveGraph ? -1200 : isLargeGraph ? -1500 : -1800, // Stronger repulsion
});
// Node Mesh
@ -127,6 +163,8 @@ export default function animate(
nodes,
nodePositionsTexture,
nodeColors,
nodeSizes,
nodeHighlights,
INITIAL_CAMERA_DISTANCE
);
@ -137,9 +175,10 @@ export default function animate(
INITIAL_CAMERA_DISTANCE
);
// Density cloud setup
// Density cloud setup - adaptive resolution for performance
const densityCloudScene = new Scene();
const densityCloudTarget = createDensityRenderTarget(512);
const densityResolution = isMassiveGraph ? 256 : isLargeGraph ? 384 : 512;
const densityCloudTarget = createDensityRenderTarget(densityResolution);
const densityAccumulatorMesh = createDensityAccumulatorMesh(
nodes,
@ -167,9 +206,18 @@ export default function animate(
pickNodeFromScene(event);
});
// Group nodes by type for cluster boundaries
const nodesByType = new Map<string, Node[]>();
nodes.forEach(node => {
if (!nodesByType.has(node.type)) {
nodesByType.set(node.type, []);
}
nodesByType.get(node.type)!.push(node);
});
const scene = new Scene();
// Darker background for better contrast with vibrant colors
scene.background = new Color("#0a0a0f");
// Apple embedding atlas style: pure black background
scene.background = new Color("#000000");
const renderer = new WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
@ -259,29 +307,119 @@ export default function animate(
});
// Node picking setup end
// Setup scene - More layout iterations for better initial positioning
for (let i = 0; i < 800; i++) {
// Adaptive layout iterations based on graph size
const layoutIterations = isMassiveGraph ? 300 : isLargeGraph ? 500 : 800;
console.log(`Running ${layoutIterations} layout iterations for ${nodeCount} nodes...`);
for (let i = 0; i < layoutIterations; i++) {
graphLayout.step();
// Progress logging for large graphs
if (isMassiveGraph && i % 50 === 0) {
console.log(`Layout progress: ${((i / layoutIterations) * 100).toFixed(0)}%`);
}
}
console.log("Layout complete!");
let visibleLabels: unknown[] = [];
// Only create entity type labels for smaller graphs (performance optimization)
const entityTypeLabels: [string, unknown][] = [];
for (const node of nodes) {
if (node.type === "EntityType") {
const label = createLabel(node.label, config?.fontSize);
entityTypeLabels.push([node.id, label]);
if (!isMassiveGraph) {
for (const node of nodes) {
if (node.type === "EntityType") {
const label = createLabel(node.label, config?.fontSize);
entityTypeLabels.push([node.id, label]);
}
}
}
// const processingStep = 0;
// Performance monitoring
let frameCount = 0;
let lastFpsUpdate = performance.now();
let currentFps = 60;
// Cluster boundaries
let clusterBoundariesCreated = false;
const clusterBoundaryMeshes: three.Mesh[] = [];
function calculateClusterBoundaries(): ClusterInfo[] {
const clusters: ClusterInfo[] = [];
nodesByType.forEach((typeNodes, nodeType) => {
if (typeNodes.length < 3) return; // Skip small clusters
// Calculate center and radius from actual node positions
let sumX = 0;
let sumY = 0;
typeNodes.forEach(node => {
const pos = graphLayout.getNodePosition(node.id);
sumX += pos.x;
sumY += pos.y;
});
const center = {
x: sumX / typeNodes.length,
y: sumY / typeNodes.length
};
// Calculate radius as max distance from center + padding
let maxDist = 0;
typeNodes.forEach(node => {
const pos = graphLayout.getNodePosition(node.id);
const dist = Math.sqrt(
Math.pow(pos.x - center.x, 2) + Math.pow(pos.y - center.y, 2)
);
maxDist = Math.max(maxDist, dist);
});
clusters.push({
center,
radius: maxDist + 350, // Apple style: more spacing between clusters
color: getColorForType(nodeType)
});
});
return clusters;
}
// Render loop
function render() {
graphLayout.step();
// Adaptive physics updates - skip for large graphs after stabilization
if (!isMassiveGraph || frameCount < 100) {
graphLayout.step();
} else if (frameCount % 2 === 0) {
// Update physics every other frame for massive graphs
graphLayout.step();
}
// FPS monitoring
frameCount++;
const now = performance.now();
if (now - lastFpsUpdate > 1000) {
currentFps = Math.round((frameCount * 1000) / (now - lastFpsUpdate));
frameCount = 0;
lastFpsUpdate = now;
if (isMassiveGraph && currentFps < 30) {
console.warn(`Low FPS detected: ${currentFps} fps`);
}
}
controls.update();
// Create cluster boundaries after layout stabilizes
if (!clusterBoundariesCreated && frameCount === 60) {
const clusters = calculateClusterBoundaries();
clusters.forEach(cluster => {
const mesh = createClusterBoundaryMesh(cluster);
clusterBoundaryMeshes.push(mesh);
scene.add(mesh);
});
clusterBoundariesCreated = true;
}
updateNodePositions(
nodes,
graphLayout,
@ -308,24 +446,53 @@ export default function animate(
// @ts-expect-error uniforms does exist on material
pickingMesh.material.uniforms.camDist.value = Math.floor(camera.zoom * 500);
// Zoom-level semantics: determine what to show based on zoom
let zoomLevel: "far" | "mid" | "near" = "mid";
if (camera.zoom < 1.0) {
zoomLevel = "far"; // Show clusters, domains, density
} else if (camera.zoom > 3.0) {
zoomLevel = "near"; // Show applications, paths, labels
}
edgeMesh.renderOrder = 1;
nodeSwarmMesh.renderOrder = 2;
scene.add(edgeMesh);
scene.add(nodeSwarmMesh);
// IMPROVEMENT #8: Conditional layer rendering based on config and zoom
const showEdges = config?.showEdges !== false && zoomLevel !== "far"; // Hide edges when far
const showNodes = config?.showNodes !== false; // Always show nodes
const showMetaballs = config?.showMetaballs !== false && zoomLevel === "far"; // Only show at far zoom
// Pass 1: draw points into density texture
renderer.setRenderTarget(densityCloudTarget);
renderer.clear();
densityCloudScene.clear();
densityCloudScene.add(densityAccumulatorMesh);
renderer.render(densityCloudScene, camera);
// Path filtering based on hover
const pathFilterMode = config?.pathFilterMode || "all";
const shouldShowPath = pathFilterMode === "all" || (pathFilterMode === "hoverOnly" && pickedNodeIndex >= 0);
// Pass 2: render density map to screen
renderer.setRenderTarget(null);
renderer.clear();
metaballMesh.renderOrder = 0;
scene.add(metaballMesh);
if (showEdges) {
scene.add(edgeMesh);
}
if (showNodes) {
scene.add(nodeSwarmMesh);
}
// Metaball rendering - reduce frequency for massive graphs
const shouldRenderMetaballs = showMetaballs && (!isMassiveGraph || frameCount % 2 === 0);
if (shouldRenderMetaballs) {
// Pass 1: draw points into density texture
renderer.setRenderTarget(densityCloudTarget);
renderer.clear();
densityCloudScene.clear();
densityCloudScene.add(densityAccumulatorMesh);
renderer.render(densityCloudScene, camera);
// Pass 2: render density map to screen
renderer.setRenderTarget(null);
renderer.clear();
metaballMesh.renderOrder = 0;
scene.add(metaballMesh);
} else {
renderer.setRenderTarget(null);
renderer.clear();
}
for (const [nodeId, label] of entityTypeLabels) {
const nodePosition = graphLayout.getNodePosition(nodeId);
@ -364,12 +531,15 @@ export default function animate(
);
pickedNodeLabel.scale.setScalar(textScale);
if (camera.zoom > 2) {
// Adaptive label display based on graph size and zoom
const minZoomForLabels = isMassiveGraph ? 4 : isLargeGraph ? 3 : 2;
const maxLabels = isMassiveGraph ? 5 : isLargeGraph ? 10 : 15;
if (camera.zoom > minZoomForLabels) {
graph.forEachLinkedNode(
pickedNode.id,
(otherNode: GraphNode, edge: GraphLink) => {
// Show more labels when zoomed in further
if (visibleLabels.length > 15) {
if (visibleLabels.length > maxLabels) {
return;
}
@ -431,10 +601,29 @@ export default function animate(
renderer.render(scene, camera);
requestAnimationFrame(render);
animationFrameId = requestAnimationFrame(render);
}
let animationFrameId: number;
render();
// Return cleanup function
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
// Clean up cluster boundaries
clusterBoundaryMeshes.forEach(mesh => {
scene.remove(mesh);
mesh.geometry.dispose();
if (mesh.material instanceof three.Material) {
mesh.material.dispose();
}
});
graphLayout.dispose();
renderer.dispose();
controls.dispose();
};
}
function updateNodePositions(

View file

@ -0,0 +1,48 @@
import * as three from "three";
export default function createClusterBoundaryMaterial(
clusterColor: three.Color
): three.ShaderMaterial {
const material = new three.ShaderMaterial({
transparent: true,
depthWrite: false,
side: three.DoubleSide,
uniforms: {
clusterColor: { value: clusterColor },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision highp float;
uniform vec3 clusterColor;
varying vec2 vUv;
void main() {
// Apple embedding atlas style: soft circular regions
vec2 center = vec2(0.5, 0.5);
float dist = length(vUv - center);
// Soft radial gradient background
float alpha = smoothstep(0.5, 0.25, dist) * 0.12; // More visible background
// Prominent boundary ring (Apple style)
float ring = smoothstep(0.49, 0.47, dist) - smoothstep(0.51, 0.49, dist);
alpha += ring * 0.25; // More prominent border
// Lighter, more vibrant colors for Apple aesthetic
vec3 bgColor = clusterColor * 1.1;
gl_FragColor = vec4(bgColor, alpha);
}
`,
});
return material;
}

View file

@ -13,7 +13,8 @@ export default function createEdgeMaterial(
textureSize: { value: texture.image.width },
camDist: { value: initialCameraDistance },
mousePos: { value: new three.Vector2(9999, 9999) }, // start offscreen
color: { value: new three.Color(0xffffff) },
// Apple embedding atlas style: soft pastel edges
color: { value: new three.Color("#FCD34D") }, // Soft amber for minimalist aesthetic
},
vertexShader: `
attribute vec2 edgeIndices;
@ -24,6 +25,7 @@ export default function createEdgeMaterial(
varying float vFade;
varying float vHighlight;
varying float vEdgePosition; // IMPROVEMENT #2: For directional gradient
vec3 getNodePos(float idx) {
float x = mod(idx, textureSize);
@ -37,6 +39,9 @@ export default function createEdgeMaterial(
vec3 end = getNodePos(edgeIndices.y);
vec3 nodePos = mix(start, end, position.x);
// IMPROVEMENT #2: Pass edge position for gradient
vEdgePosition = position.x;
// Project world-space position to clip-space
vec4 clipPos = projectionMatrix * modelViewMatrix * vec4(nodePos, 1.0);
vec3 ndc = clipPos.xyz / clipPos.w; // normalized device coordinates [-1,1]
@ -44,8 +49,9 @@ export default function createEdgeMaterial(
float distanceFromMouse = length(ndc.xy - mousePos);
vHighlight = smoothstep(0.2, 0.0, distanceFromMouse);
// Apple embedding atlas style: subtle edge opacity
vFade = smoothstep(500.0, 1500.0, camDist);
vFade = 0.2 * clamp(vFade, 0.0, 1.0);
vFade = 0.25 * clamp(vFade, 0.0, 1.0); // Subtle for clean aesthetic
gl_Position = projectionMatrix * modelViewMatrix * vec4(nodePos, 1.0);
}
@ -54,13 +60,24 @@ export default function createEdgeMaterial(
precision highp float;
uniform vec3 color;
varying vec3 vColor;
varying float vFade;
varying float vHighlight;
varying float vEdgePosition; // IMPROVEMENT #2: For directional gradient
void main() {
vec3 finalColor = mix(color, vec3(1.0), vHighlight * 0.8);
float alpha = mix(vFade, 0.8, vHighlight);
// IMPROVEMENT #2: Directional gradient from start to end
// Brighter at start, slightly darker at end for flow direction
float gradientFactor = 1.0 - (vEdgePosition * 0.3); // 30% dimming from start to end
// IMPROVEMENT #2: Add subtle glow effect
vec3 glowColor = vec3(1.0, 0.9, 0.7); // Warm white glow
vec3 baseColor = color * gradientFactor;
vec3 finalColor = mix(baseColor, glowColor, vHighlight * 0.9);
// IMPROVEMENT #2: Increased visibility and glow
float baseAlpha = vFade * 1.5; // Increased visibility
float alpha = mix(baseAlpha, 0.95, vHighlight); // Stronger highlight
gl_FragColor = vec4(finalColor, alpha);
}
`,

View file

@ -35,9 +35,9 @@ export function createMetaballMaterial(fieldTexture: Texture) {
finalColor = accumulatedColor / totalInfluence;
}
// Smooth transition around threshold
// Apple embedding atlas style: very subtle density clouds
float alphaEdge = smoothstep(threshold - smoothing, threshold + smoothing, totalInfluence);
float alpha = alphaEdge * 0.3;
float alpha = alphaEdge * 0.08; // Very subtle for clean Apple aesthetic
if (alpha < 0.01) {
discard;

View file

@ -20,8 +20,12 @@ export default function createNodeSwarmMaterial(
uniform float camDist;
uniform vec2 mousePos;
attribute vec3 nodeColor;
attribute float nodeSize; // Hierarchy-based size multiplier
attribute float nodeHighlight; // Selection-based highlight (1.0 = selected, 0.3 = dimmed)
varying vec3 vColor;
varying float vHighlight;
varying float vSelectionHighlight;
varying vec2 vUv; // IMPROVEMENT #4: For radial halo effect
vec3 getNodePos(float idx) {
float size = textureSize;
@ -33,8 +37,12 @@ export default function createNodeSwarmMaterial(
void main() {
vColor = nodeColor;
vSelectionHighlight = nodeHighlight;
vec3 nodePos = getNodePos(float(gl_InstanceID));
// IMPROVEMENT #4: Pass UV coordinates for halo effect
vUv = position.xy * 0.5 + 0.5; // Convert from [-1,1] to [0,1]
// Project world-space position to clip-space
vec4 clipPos = projectionMatrix * modelViewMatrix * vec4(nodePos, 1.0);
vec3 ndc = clipPos.xyz / clipPos.w; // normalized device coordinates [-1,1]
@ -42,13 +50,14 @@ export default function createNodeSwarmMaterial(
float distanceFromMouse = length(ndc.xy - mousePos);
vHighlight = smoothstep(0.2, 0.0, distanceFromMouse);
float baseNodeSize = 8.0;
// Hierarchy-based sizing: base size * type size multiplier
float baseNodeSize = 7.0;
// Normalize camera distance into [0,1]
float t = clamp((camDist - 500.0) / (2000.0 - 500.0), 0.0, 1.0);
float nodeSize = baseNodeSize * mix(1.1, 1.3, t);
float finalSize = baseNodeSize * nodeSize * mix(1.0, 1.2, t); // Apply hierarchy multiplier
vec3 transformed = nodePos + position * nodeSize;
vec3 transformed = nodePos + position * finalSize;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`,
@ -57,11 +66,36 @@ export default function createNodeSwarmMaterial(
varying vec3 vColor;
varying float vHighlight;
varying float vSelectionHighlight;
varying vec2 vUv; // IMPROVEMENT #4: For radial halo effect
void main() {
vec3 finalColor = mix(vColor, vec3(1.0), vHighlight * 0.3);
gl_FragColor = vec4(finalColor, 1.0);
// gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
// Apple embedding atlas style: subtle radial glow
vec2 center = vec2(0.5, 0.5);
float distFromCenter = length(vUv - center) * 2.0;
// Create sharp node with very subtle glow
float coreRadius = 0.75; // Slightly larger core
float haloRadius = 1.0;
// Core node (solid)
float core = 1.0 - smoothstep(0.0, coreRadius, distFromCenter);
// Very subtle outer glow (Apple aesthetic)
float halo = smoothstep(haloRadius, coreRadius, distFromCenter);
// Subtle color mixing
vec3 haloColor = vColor * 1.15; // Subtle brightness increase
vec3 baseColor = mix(vColor, vec3(1.0), vHighlight * 0.4);
vec3 finalColor = mix(haloColor, baseColor, core);
// Alpha with subtle glow
float alpha = mix(halo * 0.4, 1.0, core); // Reduced halo opacity
// Apply selection-based dimming (neutral-by-default)
alpha *= vSelectionHighlight;
gl_FragColor = vec4(finalColor, alpha);
}
`,
});

View file

@ -0,0 +1,27 @@
import * as three from "three";
import createClusterBoundaryMaterial from "../materials/createClusterBoundaryMaterial";
export interface ClusterInfo {
center: { x: number; y: number };
radius: number;
color: three.Color;
}
export default function createClusterBoundaryMesh(
cluster: ClusterInfo
): three.Mesh {
// Create a circle geometry for the cluster boundary
const geometry = new three.PlaneGeometry(
cluster.radius * 2.5, // Make it larger to encompass the cluster
cluster.radius * 2.5
);
const material = createClusterBoundaryMaterial(cluster.color);
const mesh = new three.Mesh(geometry, material);
// Position the mesh at the cluster center
mesh.position.set(cluster.center.x, cluster.center.y, -100); // Behind everything else
mesh.renderOrder = -1;
return mesh;
}

View file

@ -12,6 +12,8 @@ export default function createNodeSwarmMesh(
nodes: Node[],
nodePositionsTexture: DataTexture,
nodeColors: Float32Array,
nodeSizes: Float32Array,
nodeHighlights: Float32Array,
initialCameraDistance: number
) {
const nodeGeom = new CircleGeometry(2, 16);
@ -22,6 +24,8 @@ export default function createNodeSwarmMesh(
geom.setAttribute("position", nodeGeom.attributes.position);
geom.setAttribute("uv", nodeGeom.attributes.uv);
geom.setAttribute("nodeColor", new InstancedBufferAttribute(nodeColors, 3));
geom.setAttribute("nodeSize", new InstancedBufferAttribute(nodeSizes, 1));
geom.setAttribute("nodeHighlight", new InstancedBufferAttribute(nodeHighlights, 1));
const material = createNodeSwarmMaterial(
nodePositionsTexture,