Skip to content

Commit 1d25a08

Browse files
committed
feat: integrate react-force-graph-2d for enhanced graph visualization
- Added dynamic import for ForceGraph2D to enable client-side rendering. - Refactored BasicGraphView to utilize ForceGraph2D for rendering graphs. - Implemented responsive dimensions for the graph container using ResizeObserver. - Enhanced graph data structure to include color mapping and node properties. - Added a legend to display group colors for better visualization context. - Updated package.json and package-lock.json to include react-force-graph-2d dependency.
1 parent c0d4771 commit 1d25a08

4 files changed

Lines changed: 527 additions & 128 deletions

File tree

app/api/chat/route.js

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -745,13 +745,12 @@ function getToolConfig() {
745745
tools.push({
746746
type: 'function',
747747
name: 'create_basic_graph',
748-
description: 'Create a lightweight graph specification for UI rendering. Use this to visualise connectivity as nodes and edges.',
748+
description: 'Create a lightweight graph specification for UI rendering. Use this to visualise connectivity as nodes and edges. IMPORTANT: Always set the "group" field on every node to a shared biological category (e.g. neurotransmitter type like "cholinergic", "GABAergic", "glutamatergic"; or system/region like "visual system", "central complex"; or cell class like "sensory neuron", "interneuron") so that nodes are colour-coded meaningfully. Choose the most informative grouping for the specific query context.',
749749
parameters: {
750750
type: 'object',
751751
properties: {
752752
title: { type: 'string', description: 'Optional graph title' },
753753
directed: { type: 'boolean', description: 'Whether edges are directed (default true)' },
754-
layout: { type: 'string', enum: ['circle', 'radial'], description: 'Simple layout hint (default circle)' },
755754
nodes: {
756755
type: 'array',
757756
description: 'Graph nodes',
@@ -760,11 +759,10 @@ function getToolConfig() {
760759
properties: {
761760
id: { type: 'string', description: 'Unique node identifier' },
762761
label: { type: 'string', description: 'Display label for the node' },
763-
group: { type: 'string', description: 'Optional group/type' },
764-
color: { type: 'string', description: 'Optional color in #RRGGBB format' },
762+
group: { type: 'string', description: 'REQUIRED: Shared biological category for colour-coding. Use neurotransmitter type (cholinergic, GABAergic, glutamatergic), system/region (visual system, central complex), cell class (sensory neuron, interneuron, projection neuron), or other contextually meaningful grouping.' },
765763
size: { type: 'number', description: 'Optional relative node size (1-3 recommended)' }
766764
},
767-
required: ['id']
765+
required: ['id', 'group']
768766
}
769767
},
770768
edges: {
@@ -2517,7 +2515,8 @@ TOOL SELECTION:
25172515
- Questions about FlyBase genes/alleles/insertions/stocks: use vfb_resolve_entity first (if unresolved), then vfb_find_stocks
25182516
- Questions about split-GAL4 combination names/synonyms (for example MB002B, SS04495): use vfb_resolve_combination first, then vfb_find_combo_publications (and optionally vfb_find_stocks if the user asks for lines)
25192517
- Questions about comparative connectivity between neuron classes across datasets: use vfb_query_connectivity (optionally vfb_list_connectome_datasets first to pick valid dataset symbols)
2520-
- For connectivity questions, call vfb_query_connectivity directly with the neuron class labels or FBbt IDs the user mentions — do NOT manually run NeuronsPartHere or vfb_search_terms first. The server handles term resolution and will return requires_user_selection if disambiguation is needed.
2518+
- For connectivity questions, call vfb_query_connectivity directly with the FULL neuron class labels or FBbt IDs the user mentions — do NOT manually run NeuronsPartHere or vfb_search_terms first. The server handles term resolution and will return requires_user_selection if disambiguation is needed.
2519+
- IMPORTANT: When the user gives a multi-word neuron name like "adult ellipsoid body ring neuron", pass the ENTIRE phrase as the label. Do NOT break it into sub-terms (e.g. do NOT search for "ellipsoid body" separately). Always use the longest, most specific term the user provides.
25212520
- For directional requests like "connections from X to Y" or "between X and Y", treat X as upstream (presynaptic) and Y as downstream (postsynaptic), and prefer vfb_query_connectivity over a single-term run_query.
25222521
- Do not infer identity from examples in this prompt. Only map IDs to labels (or labels to IDs) using tool outputs from this turn.
25232522
- Never claim "TERM_A (ID) is TERM_B" unless vfb_get_term_info confirms that exact mapping.
@@ -2543,7 +2542,7 @@ TOOL ERRORS AND TIMEOUTS:
25432542
- VFB MCP queries (especially non-cached ones like vfb_query_connectivity and live vfb_run_query) can take considerable time. Do NOT treat slow responses as failures.
25442543
- If a tool returns a timeout error, try an alternative approach (e.g. narrower query, different tool) rather than giving up. Always present whatever partial results you have gathered so far.
25452544
- Never tell the user a query "failed" or "timed out" without first attempting at least one alternative path.
2546-
- CRITICAL: When vfb_query_connectivity returns connectivity data successfully, present those results immediately. Do NOT make additional tool calls (vfb_run_query, vfb_get_term_info) to "enrich" the connectivity answer — this wastes time and risks timeouts that obscure the successful results.
2545+
- CRITICAL: When vfb_query_connectivity returns connectivity data successfully, present those results immediately AND call create_basic_graph with the top connections. Do NOT make additional tool calls (vfb_run_query, vfb_get_term_info) to "enrich" the connectivity answer — this wastes time and risks timeouts that obscure the successful results.
25472546
- If supplementary tool calls fail but the primary query succeeded, present the successful results and ignore the supplementary failures. Never lead your response with error messages when you have valid data to show.
25482547
25492548
TOOL ECONOMY:
@@ -2566,10 +2565,14 @@ FORMATTING VFB REFERENCES (response text only — NOT for tool parameters):
25662565
- Only use thumbnail URLs that actually appear in tool results
25672566
25682567
GRAPH VISUALS:
2569-
- Graph rendering is optional and should be used only when it improves clarity for this specific answer.
2570-
- For connectivity answers, use at most one concise graph (typically 4-20 nodes) when a visual summary is clearer than text alone.
2571-
- Keep graph specs focused on the strongest relationships and avoid very dense or exhaustive graphs.
2572-
- Skip graph output when a short table or plain-language summary is clearer.
2568+
- ALWAYS call create_basic_graph when vfb_query_connectivity returns connectivity data. Do not wait for the user to ask for a graph — include it automatically alongside the text summary.
2569+
- For connectivity answers, create one concise graph (typically 4-20 nodes) highlighting the strongest relationships.
2570+
- Keep graph specs focused and avoid very dense or exhaustive graphs — pick the top connections by weight.
2571+
- Every node MUST have a meaningful "group" field for colour-coding. Choose the most informative biological grouping for the context:
2572+
* Neurotransmitter type (cholinergic, GABAergic, glutamatergic, etc.) when NT data is available
2573+
* Brain region/system (visual system, central complex, mushroom body, etc.) when comparing across regions
2574+
* Cell class (sensory neuron, interneuron, projection neuron, motor neuron, etc.) as a general fallback
2575+
* The LLM should use its knowledge of Drosophila neurobiology to assign the most useful grouping
25732576
25742577
TOOL RELAY:
25752578
- You can request server-side tool execution using the tool relay protocol.
@@ -3009,7 +3012,7 @@ function buildToolPolicyCorrectionMessage({
30093012
'- Choose the smallest set of tools that best answers the user request.',
30103013
'- For VFB query-type questions, prefer vfb_get_term_info + vfb_run_query as the first pass because vfb_run_query is typically cached and fast.',
30113014
'- Use more specialized tools (for example vfb_query_connectivity, vfb_resolve_entity, vfb_find_stocks, vfb_resolve_combination, vfb_find_combo_publications) when deeper refinement is needed.',
3012-
'- If the result is connectivity-heavy and a graph would help, consider create_basic_graph for a compact node/edge view.',
3015+
'- When connectivity data is returned, ALWAYS call create_basic_graph to visualise the connections as a node/edge graph with meaningful group labels for colour-coding.',
30133016
'- Prefer direct data tools over documentation search when the question asks for concrete VFB data.',
30143017
'- If existing tool outputs already answer the question, provide the final answer instead of requesting more tools.'
30153018
]

app/page.js

Lines changed: 124 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import { useState, useEffect, useRef, memo, useCallback, useMemo } from 'react'
44
import { useSearchParams } from 'next/navigation'
5+
import dynamic from 'next/dynamic'
56
import ReactMarkdown from 'react-markdown'
67
import { NEGATIVE_FEEDBACK_REASON_CODES } from '../lib/feedback.js'
78

9+
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
10+
811
const FEEDBACK_REASON_LABELS = {
912
helpful: 'Helpful',
1013
wrong: 'Wrong',
@@ -26,47 +29,84 @@ function hashString(value = '') {
2629
return Math.abs(hash)
2730
}
2831

29-
const BasicGraphView = memo(function BasicGraphView({ graph, graphKey }) {
30-
const width = 640
31-
const height = 360
32-
const centerX = width / 2
33-
const centerY = height / 2
32+
const BasicGraphView = memo(function BasicGraphView({ graph }) {
33+
const containerRef = useRef(null)
34+
const fgRef = useRef(null)
35+
const [dimensions, setDimensions] = useState({ width: 640, height: 400 })
3436

3537
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
3638
const edges = Array.isArray(graph?.edges) ? graph.edges : []
37-
if (nodes.length === 0 || edges.length === 0) return null
38-
39-
const radius = Math.max(90, Math.min(width, height) / 2 - 64)
40-
const positionedNodes = nodes.map((node, index) => {
41-
const angle = nodes.length === 1
42-
? 0
43-
: ((2 * Math.PI * index) / nodes.length) - (Math.PI / 2)
4439

40+
// Build color map from groups
41+
const groupColorMap = useMemo(() => {
42+
const map = {}
43+
const groups = [...new Set(nodes.map(n => n.group || '').filter(Boolean))]
44+
groups.forEach((g, i) => { map[g] = GRAPH_PALETTE[i % GRAPH_PALETTE.length] })
45+
return map
46+
}, [nodes])
47+
48+
// Build graph data for force-graph (must use fresh objects each render to avoid mutation issues)
49+
const graphData = useMemo(() => {
50+
const nodeIds = new Set(nodes.map(n => String(n.id)))
4551
return {
46-
...node,
47-
x: centerX + (radius * Math.cos(angle)),
48-
y: centerY + (radius * Math.sin(angle))
52+
nodes: nodes.map(n => ({
53+
id: String(n.id),
54+
label: n.label || n.id,
55+
group: n.group || '',
56+
color: n.color || groupColorMap[n.group] || GRAPH_PALETTE[hashString(n.label || n.id) % GRAPH_PALETTE.length],
57+
size: n.size || 1
58+
})),
59+
links: edges
60+
.filter(e => nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)))
61+
.map(e => ({
62+
source: String(e.source),
63+
target: String(e.target),
64+
label: e.label || (Number.isFinite(Number(e.weight)) ? String(e.weight) : ''),
65+
weight: Number(e.weight) || 1
66+
}))
4967
}
50-
})
51-
52-
const nodeById = new Map(positionedNodes.map(node => [String(node.id), node]))
53-
const visibleEdges = edges
54-
.map(edge => ({
55-
...edge,
56-
source: String(edge.source),
57-
target: String(edge.target)
58-
}))
59-
.filter(edge => nodeById.has(edge.source) && nodeById.has(edge.target))
68+
}, [nodes, edges, groupColorMap])
69+
70+
// Measure container width
71+
useEffect(() => {
72+
if (!containerRef.current) return
73+
const ro = new ResizeObserver(entries => {
74+
for (const entry of entries) {
75+
const w = entry.contentRect.width
76+
if (w > 0) setDimensions({ width: w, height: Math.max(350, Math.min(500, w * 0.6)) })
77+
}
78+
})
79+
ro.observe(containerRef.current)
80+
return () => ro.disconnect()
81+
}, [])
82+
83+
// Zoom to fit after initial layout settles
84+
useEffect(() => {
85+
const timer = setTimeout(() => {
86+
if (fgRef.current) fgRef.current.zoomToFit(300, 40)
87+
}, 800)
88+
return () => clearTimeout(timer)
89+
}, [graphData])
90+
91+
if (nodes.length === 0 || edges.length === 0) return null
92+
93+
const isDirected = graph?.directed !== false
94+
const maxWeight = Math.max(1, ...graphData.links.map(l => l.weight))
6095

61-
const markerId = `arrow-${hashString(String(graphKey || graph?.title || 'graph'))}`
96+
// Build legend entries from groups
97+
const legendEntries = [...new Set(nodes.map(n => n.group).filter(Boolean))].map(g => ({
98+
group: g,
99+
color: groupColorMap[g] || '#888'
100+
}))
62101

63102
return (
64-
<div style={{
103+
<div ref={containerRef} style={{
65104
marginTop: '10px',
66105
border: '1px solid #2a2a2a',
67106
borderRadius: '8px',
68107
backgroundColor: '#0f0f12',
69-
padding: '10px'
108+
padding: '10px',
109+
overflow: 'hidden'
70110
}}>
71111
{graph?.title && (
72112
<div style={{
@@ -78,91 +118,62 @@ const BasicGraphView = memo(function BasicGraphView({ graph, graphKey }) {
78118
{graph.title}
79119
</div>
80120
)}
81-
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={graph?.title || 'Network graph visualization'} style={{ width: '100%', height: 'auto', display: 'block' }}>
82-
{graph?.directed !== false && (
83-
<defs>
84-
<marker
85-
id={markerId}
86-
viewBox="0 0 10 10"
87-
refX="8"
88-
refY="5"
89-
markerWidth="6"
90-
markerHeight="6"
91-
orient="auto-start-reverse"
92-
>
93-
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7280" />
94-
</marker>
95-
</defs>
96-
)}
97-
98-
{visibleEdges.map((edge, index) => {
99-
const sourceNode = nodeById.get(edge.source)
100-
const targetNode = nodeById.get(edge.target)
101-
const rawWeight = Number(edge.weight)
102-
const strokeWidth = Number.isFinite(rawWeight)
103-
? Math.min(4, Math.max(1, 1 + (Math.log10(rawWeight + 1))))
104-
: 1.4
105-
const midX = (sourceNode.x + targetNode.x) / 2
106-
const midY = (sourceNode.y + targetNode.y) / 2
107-
const edgeText = edge.label || (Number.isFinite(rawWeight) ? `${rawWeight}` : '')
108-
109-
return (
110-
<g key={`edge-${index}-${edge.source}-${edge.target}`}>
111-
<line
112-
x1={sourceNode.x}
113-
y1={sourceNode.y}
114-
x2={targetNode.x}
115-
y2={targetNode.y}
116-
stroke="#6b7280"
117-
strokeWidth={strokeWidth}
118-
opacity={0.9}
119-
markerEnd={graph?.directed === false ? undefined : `url(#${markerId})`}
120-
/>
121-
{edgeText && (
122-
<text
123-
x={midX}
124-
y={midY - 5}
125-
fill="#cbd5e1"
126-
fontSize="11"
127-
textAnchor="middle"
128-
style={{ paintOrder: 'stroke', stroke: '#0f0f12', strokeWidth: 3 }}
129-
>
130-
{edgeText}
131-
</text>
132-
)}
133-
</g>
134-
)
135-
})}
136-
137-
{positionedNodes.map((node, index) => {
138-
const groupKey = String(node.group || node.label || node.id)
139-
const color = (typeof node.color === 'string' && /^#[0-9a-f]{6}$/i.test(node.color))
140-
? node.color
141-
: GRAPH_PALETTE[hashString(groupKey) % GRAPH_PALETTE.length]
142-
const nodeRadius = Math.min(18, Math.max(7, 8 + (Number(node.size) || 1)))
143-
return (
144-
<g key={`node-${node.id}-${index}`}>
145-
<circle
146-
cx={node.x}
147-
cy={node.y}
148-
r={nodeRadius}
149-
fill={color}
150-
stroke="#111827"
151-
strokeWidth="1.5"
152-
/>
153-
<text
154-
x={node.x}
155-
y={node.y + nodeRadius + 13}
156-
fill="#e5e7eb"
157-
fontSize="12"
158-
textAnchor="middle"
159-
>
160-
{node.label || node.id}
161-
</text>
162-
</g>
163-
)
164-
})}
165-
</svg>
121+
{legendEntries.length > 1 && (
122+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '6px', fontSize: '0.72em' }}>
123+
{legendEntries.map(e => (
124+
<span key={e.group} style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', color: '#ccc' }}>
125+
<span style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: e.color, display: 'inline-block' }} />
126+
{e.group}
127+
</span>
128+
))}
129+
</div>
130+
)}
131+
<ForceGraph2D
132+
ref={fgRef}
133+
graphData={graphData}
134+
width={dimensions.width - 20}
135+
height={dimensions.height}
136+
backgroundColor="#0f0f12"
137+
nodeRelSize={6}
138+
nodeVal={n => Math.max(1, (n.size || 1) * 1.5)}
139+
nodeColor={n => n.color}
140+
nodeLabel={n => `${n.label}${n.group ? ` (${n.group})` : ''}`}
141+
nodeCanvasObject={(node, ctx, globalScale) => {
142+
const r = Math.max(3, 4 + (node.size || 1) * 2)
143+
ctx.beginPath()
144+
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
145+
ctx.fillStyle = node.color
146+
ctx.fill()
147+
ctx.strokeStyle = '#1a1a2e'
148+
ctx.lineWidth = 0.5
149+
ctx.stroke()
150+
// Draw label when zoomed in enough
151+
if (globalScale > 0.7) {
152+
const label = node.label || node.id
153+
const fontSize = Math.max(3, 10 / globalScale)
154+
ctx.font = `${fontSize}px sans-serif`
155+
ctx.textAlign = 'center'
156+
ctx.textBaseline = 'top'
157+
ctx.fillStyle = '#e5e7eb'
158+
ctx.fillText(label, node.x, node.y + r + 2)
159+
}
160+
}}
161+
linkColor={() => '#4b5563'}
162+
linkWidth={link => Math.max(0.5, 1 + (link.weight / maxWeight) * 3)}
163+
linkDirectionalArrowLength={isDirected ? 5 : 0}
164+
linkDirectionalArrowRelPos={1}
165+
linkLabel={link => link.label}
166+
linkCurvature={link => {
167+
// Curve parallel edges between same node pairs
168+
const key = [link.source?.id || link.source, link.target?.id || link.target].sort().join('-')
169+
const rev = [link.target?.id || link.target, link.source?.id || link.source].sort().join('-')
170+
return key === rev ? 0 : 0.15
171+
}}
172+
d3VelocityDecay={0.3}
173+
cooldownTicks={80}
174+
enableZoomInteraction={true}
175+
enablePanInteraction={true}
176+
/>
166177
</div>
167178
)
168179
})
@@ -224,7 +235,6 @@ const ChatMessage = memo(function ChatMessage({
224235
<BasicGraphView
225236
key={`${msg.id}-graph-${graphIndex}`}
226237
graph={graph}
227-
graphKey={`${msg.id}-${graphIndex}`}
228238
/>
229239
))}
230240
</div>

0 commit comments

Comments
 (0)