feat: enhance KnowledgeGraph with edge styles, node navigation, and AI analysis scopes; update GraphControls and NodeDetailPanel for improved relation management

This commit is contained in:
RaymondVerhoef
2026-05-27 21:09:05 +02:00
parent 47a738fde3
commit ce276f0296
3 changed files with 296 additions and 117 deletions

View File

@@ -7,18 +7,64 @@ import { useGraphData } from '../../hooks/useGraphData';
import GraphControls from './graph/GraphControls'; import GraphControls from './graph/GraphControls';
import NodeDetailPanel from './graph/NodeDetailPanel'; import NodeDetailPanel from './graph/NodeDetailPanel';
// ── Edge visual style per relation type ────────────────────────────────────────
// stroke — line colour
// dash — SVG stroke-dasharray (null = solid)
const EDGE_STYLE = {
related_to: { stroke: '#94A3B8', dash: '6,3' }, // slate, dashed
depends_on: { stroke: '#F97316', dash: null }, // orange, solid
part_of: { stroke: '#10B981', dash: null }, // emerald, solid
executed_by: { stroke: '#C084FC', dash: '3,3' }, // violet, dotted
};
const NODE_RADIUS = 22; // circle r (20) + white stroke buffer
// ── System prompts per analysis scope ─────────────────────────────────────────
const SYSTEM_PROMPTS = {
full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
Rules:
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
2. Identify topics that are too vague, irrelevant, or malformed (deletions).
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
Do not return the entire graph — only the actions to take.`,
relations: `You are analyzing a knowledge graph for Respellion's learning platform.
Your ONLY task: identify missing logical relations between existing topics.
Suggest new relations using: depends_on, part_of, related_to, executed_by.
Only suggest connections that are clearly implied — do not invent loose associations.
Emit ONLY newRelations. Set merges=[], deletions=[], relevanceUpdates=[].`,
relevance: `You are analyzing a knowledge graph for Respellion's learning platform.
Your ONLY task: re-score the learning_relevance for each topic.
Relevance levels:
core — foundational knowledge every employee must master
standard — important, covered in the normal learning flow
peripheral — supplementary or nice-to-know, lower priority
exclude — operational/administrative, not relevant to learning (e.g. printer guides, HR forms)
Do NOT change topics where relevance_locked=true — omit them from relevanceUpdates entirely.
Emit ONLY relevanceUpdates. Set merges=[], deletions=[], newRelations=[].`,
};
const SCOPE_CONFIRM = {
full: 'Send the entire graph to the AI (Opus) for a full quality pass — merges, deletions, missing relations, and relevance scoring. This may take a minute.',
relations: 'Ask the AI (Sonnet) to suggest missing logical relations between existing topics.',
relevance: 'Ask the AI (Sonnet) to re-score learning relevance for all unlocked topics.',
};
/** /**
* Knowledge-graph admin view. * Knowledge-graph admin view.
* *
* This component is the orchestrator: it owns the D3 canvas, the selected-node * Orchestrator: owns the D3 canvas, selected-node cursor, and AI analysis flow.
* cursor, and the AI analysis flow. All data fetching lives in useGraphData; * Data lives in useGraphData; panel UI lives in GraphControls / NodeDetailPanel.
* all panel UI lives in GraphControls and NodeDetailPanel.
*
* Line-count target: stay below 200 lines. Move any growing logic to a hook.
*/ */
const KnowledgeGraph = () => { const KnowledgeGraph = () => {
const svgRef = useRef(null); const svgRef = useRef(null);
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
const zoomRef = useRef(null); // D3 zoom behaviour — shared with panToNode
const nodesRef = useRef([]); // live node positions (mutated by D3 in-place)
const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
@@ -61,9 +107,10 @@ const KnowledgeGraph = () => {
r => filteredIds.has(r.source) && filteredIds.has(r.target), r => filteredIds.has(r.source) && filteredIds.has(r.target),
); );
// Spread every node/link so D3's simulation mutations don't touch React state. // Spread every datum so D3 simulation mutations don't touch React state.
const nodes = filteredTopics.map(d => ({ ...d })); const nodes = filteredTopics.map(d => ({ ...d }));
const links = filteredRelations.map(d => ({ ...d })); const links = filteredRelations.map(d => ({ ...d }));
nodesRef.current = nodes; // D3 mutates x/y in-place — ref always has latest positions
const svg = d3 const svg = d3
.select(svgRef.current) .select(svgRef.current)
@@ -71,13 +118,31 @@ const KnowledgeGraph = () => {
.attr('width', width) .attr('width', width)
.attr('height', height); .attr('height', height);
// ── Arrowhead markers — one per relation type ──────────────────────────────
const defs = svg.append('defs');
Object.entries(EDGE_STYLE).forEach(([type, style]) => {
defs.append('marker')
.attr('id', `kb-arrow-${type}`)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 10) // tip of arrow at line endpoint
.attr('refY', 0)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5Z')
.attr('fill', style.stroke)
.attr('opacity', 0.85);
});
const g = svg.append('g'); const g = svg.append('g');
svg.call( const zoom = d3.zoom()
d3.zoom()
.scaleExtent([0.1, 4]) .scaleExtent([0.1, 4])
.on('zoom', event => g.attr('transform', event.transform)), .on('zoom', event => g.attr('transform', event.transform));
);
svg.call(zoom);
zoomRef.current = zoom; // expose to panToNode
const color = d3 const color = d3
.scaleOrdinal() .scaleOrdinal()
@@ -91,15 +156,19 @@ const KnowledgeGraph = () => {
.force('center', d3.forceCenter(width / 2, height / 2)) .force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(40)); .force('collide', d3.forceCollide().radius(40));
// ── Edges — coloured and typed ─────────────────────────────────────────────
const link = g const link = g
.append('g') .append('g')
.attr('stroke', 'var(--color-bg-warm)')
.attr('stroke-opacity', 0.6)
.selectAll('line') .selectAll('line')
.data(links) .data(links)
.join('line') .join('line')
.attr('stroke-width', 2); .attr('stroke', d => EDGE_STYLE[d.type]?.stroke ?? '#94A3B8')
.attr('stroke-opacity', 0.7)
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', d => EDGE_STYLE[d.type]?.dash ?? null)
.attr('marker-end', d => `url(#kb-arrow-${d.type})`);
// ── Nodes ──────────────────────────────────────────────────────────────────
const node = g const node = g
.append('g') .append('g')
.selectAll('g') .selectAll('g')
@@ -107,16 +176,16 @@ const KnowledgeGraph = () => {
.join('g') .join('g')
.call( .call(
d3.drag() d3.drag()
.on('start', (event) => { .on('start', event => {
if (!event.active) simulation.alphaTarget(0.3).restart(); if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x; event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y; event.subject.fy = event.subject.y;
}) })
.on('drag', (event) => { .on('drag', event => {
event.subject.fx = event.x; event.subject.fx = event.x;
event.subject.fy = event.y; event.subject.fy = event.y;
}) })
.on('end', (event) => { .on('end', event => {
if (!event.active) simulation.alphaTarget(0); if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null; event.subject.fx = null;
event.subject.fy = null; event.subject.fy = null;
@@ -146,35 +215,65 @@ const KnowledgeGraph = () => {
.attr('fill', 'var(--color-fg)') .attr('fill', 'var(--color-fg)')
.attr('class', 'font-mono select-none pointer-events-none'); .attr('class', 'font-mono select-none pointer-events-none');
// ── Tick — offset line endpoints to circle edge so arrows land cleanly ─────
simulation.on('tick', () => { simulation.on('tick', () => {
link link
.attr('x1', d => d.source.x) .attr('x1', d => d.source.x)
.attr('y1', d => d.source.y) .attr('y1', d => d.source.y)
.attr('x2', d => d.target.x) .attr('x2', d => {
.attr('y2', d => d.target.y); const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dist = Math.hypot(dx, dy) || 1;
return d.target.x - (dx / dist) * NODE_RADIUS;
})
.attr('y2', d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dist = Math.hypot(dx, dy) || 1;
return d.target.y - (dy / dist) * NODE_RADIUS;
});
node.attr('transform', d => `translate(${d.x},${d.y})`); node.attr('transform', d => `translate(${d.x},${d.y})`);
}); });
return () => simulation.stop(); return () => simulation.stop();
}, [dimensions, topics, relations, showExcludeNodes]); }, [dimensions, topics, relations, showExcludeNodes]);
// ── Pan/zoom canvas to a node ────────────────────────────────────────────────
const panToNode = useCallback((nodeId) => {
const n = nodesRef.current.find(nd => nd.id === nodeId);
if (!n || !svgRef.current || !zoomRef.current) return;
const { width, height } = dimensions;
const scale = 2;
d3.select(svgRef.current)
.transition()
.duration(500)
.call(
zoomRef.current.transform,
d3.zoomIdentity
.translate(width / 2 - n.x * scale, height / 2 - n.y * scale)
.scale(scale),
);
}, [dimensions]);
// ── Node navigation (jump from relation row) ─────────────────────────────────
const handleJumpToNode = useCallback((nodeId) => {
panToNode(nodeId);
const topic = topics.find(t => t.id === nodeId);
if (topic) setSelectedNode(topic);
}, [panToNode, topics]);
// ── Node mutation handlers ─────────────────────────────────────────────────── // ── Node mutation handlers ───────────────────────────────────────────────────
const handleNodeSave = useCallback( const handleNodeSave = useCallback(async (updatedTopic) => {
async (updatedTopic) => {
await updateTopic(updatedTopic); await updateTopic(updatedTopic);
setSelectedNode(updatedTopic); setSelectedNode(updatedTopic);
}, }, [updateTopic]);
[updateTopic],
);
const handleNodeDelete = useCallback(async () => { const handleNodeDelete = useCallback(async () => {
if (!selectedNode) return; if (!selectedNode) return;
if ( if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) {
confirm(
`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`,
)
) {
await deleteTopic(selectedNode.id); await deleteTopic(selectedNode.id);
setSelectedNode(null); setSelectedNode(null);
} }
@@ -183,12 +282,9 @@ const KnowledgeGraph = () => {
// ── Snapshot restore ───────────────────────────────────────────────────────── // ── Snapshot restore ─────────────────────────────────────────────────────────
const handleRestore = useCallback(async () => { const handleRestore = useCallback(async () => {
if ( if (!confirm(
!confirm( `Restore the graph to the snapshot from ${new Date(snapshotMeta?.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}?\n\nThis will undo the last Analyze run. The snapshot will be cleared after restoring.`,
`Restore the graph to the snapshot from ${new Date(snapshotMeta?.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}?\n\nThis will undo the last Analyze & Optimize run. The snapshot will be cleared after restoring.`, )) return;
)
)
return;
setIsRestoring(true); setIsRestoring(true);
try { try {
await restoreSnapshot(); await restoreSnapshot();
@@ -198,52 +294,42 @@ const KnowledgeGraph = () => {
} }
}, [restoreSnapshot, snapshotMeta]); }, [restoreSnapshot, snapshotMeta]);
// ── AI graph analysis ──────────────────────────────────────────────────────── // ── AI graph analysis (scoped) ───────────────────────────────────────────────
const analyzeGraph = useCallback(async () => { const analyzeGraph = useCallback(async (scope = 'full') => {
if ( if (!confirm(SCOPE_CONFIRM[scope])) return;
!confirm(
'This will send the entire graph to the AI to evaluate content, merge duplicates, and create new logical relations. This may take a moment. Proceed?',
)
)
return;
setIsAnalyzing(true); setIsAnalyzing(true);
setAnalyzeError(null); setAnalyzeError(null);
try { try {
// Fetch fresh data so analysis reflects any concurrent edits.
const [currentTopics, currentRelations] = await Promise.all([ const [currentTopics, currentRelations] = await Promise.all([
db.getTopics(), db.getTopics(),
db.getRelations(), db.getRelations(),
]); ]);
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion. const tier = scope === 'full' ? 'reasoning' : 'standard';
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
Rules: // For relevance scope, include relevance_locked so the model can skip those.
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges). const compactTopics = currentTopics.map(t => ({
2. Identify topics that are too vague, irrelevant, or malformed (deletions). id: t.id,
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations). label: t.label,
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates). type: t.type,
learning_relevance: t.learning_relevance,
Do not return the entire graph — only the actions to take.`; ...(scope === 'relevance' && t.relevance_locked ? { relevance_locked: true } : {}),
const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({
id, label, type, learning_relevance,
})); }));
const compactRelations = currentRelations.map(({ source, target, type }) => ({ const compactRelations = currentRelations.map(({ source, target, type }) => ({
source, target, type, source, target, type,
})); }));
const llmResult = await callLLM({ const llmResult = await callLLM({
task: 'graph.analyze', task: `graph.analyze.${scope}`,
tier: 'reasoning', tier,
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }], system: [{ type: 'text', text: SYSTEM_PROMPTS[scope], cache_control: { type: 'ephemeral' } }],
user: `Here is the current knowledge graph:\n${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`, user: `Here is the current knowledge graph:\n${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`,
tools: [EMIT_GRAPH_ACTIONS_TOOL], tools: [EMIT_GRAPH_ACTIONS_TOOL],
toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name }, toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name },
maxTokens: 4096, maxTokens: scope === 'full' ? 4096 : 2048,
}); });
const actions = llmResult.toolUses[0]?.input; const actions = llmResult.toolUses[0]?.input;
@@ -252,7 +338,8 @@ Do not return the entire graph — only the actions to take.`;
let updatedTopics = [...currentTopics]; let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations]; let updatedRelations = [...currentRelations];
// Apply merges: remap all edges from the deleted twin to the surviving one. // Full scope: merges + deletions
if (scope === 'full') {
for (const merge of actions.merges ?? []) { for (const merge of actions.merges ?? []) {
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId); updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
updatedRelations = updatedRelations.map(r => ({ updatedRelations = updatedRelations.map(r => ({
@@ -261,8 +348,6 @@ Do not return the entire graph — only the actions to take.`;
target: r.target === merge.deleteId ? merge.keepId : r.target, target: r.target === merge.deleteId ? merge.keepId : r.target,
})); }));
} }
// Apply deletions.
if (actions.deletions?.length) { if (actions.deletions?.length) {
const toDelete = new Set(actions.deletions); const toDelete = new Set(actions.deletions);
updatedTopics = updatedTopics.filter(t => !toDelete.has(t.id)); updatedTopics = updatedTopics.filter(t => !toDelete.has(t.id));
@@ -270,24 +355,29 @@ Do not return the entire graph — only the actions to take.`;
r => !toDelete.has(r.source) && !toDelete.has(r.target), r => !toDelete.has(r.source) && !toDelete.has(r.target),
); );
} }
}
// Add new relations (skip duplicates). // Full + relations scope: new relations
if (scope === 'full' || scope === 'relations') {
for (const rel of actions.newRelations ?? []) { for (const rel of actions.newRelations ?? []) {
const dup = updatedRelations.some( const dup = updatedRelations.some(
r => r.source === rel.source && r.target === rel.target && r.type === rel.type, r => r.source === rel.source && r.target === rel.target && r.type === rel.type,
); );
if (!dup) updatedRelations.push(rel); if (!dup) updatedRelations.push(rel);
} }
}
// Apply relevance updates (skip locked topics). // Full + relevance scope: relevance updates (respect locked topics)
if (scope === 'full' || scope === 'relevance') {
for (const update of actions.relevanceUpdates ?? []) { for (const update of actions.relevanceUpdates ?? []) {
const idx = updatedTopics.findIndex(t => t.id === update.id); const idx = updatedTopics.findIndex(t => t.id === update.id);
if (idx !== -1 && !updatedTopics[idx].relevance_locked) { if (idx !== -1 && !updatedTopics[idx].relevance_locked) {
updatedTopics[idx] = { ...updatedTopics[idx], learning_relevance: update.learning_relevance }; updatedTopics[idx] = { ...updatedTopics[idx], learning_relevance: update.learning_relevance };
} }
} }
}
// Drop any edges that now reference non-existent nodes or are self-loops. // Drop edges that now reference non-existent nodes or are self-loops.
const finalIds = new Set(updatedTopics.map(t => t.id)); const finalIds = new Set(updatedTopics.map(t => t.id));
updatedRelations = updatedRelations.filter( updatedRelations = updatedRelations.filter(
r => r.source !== r.target && finalIds.has(r.source) && finalIds.has(r.target), r => r.source !== r.target && finalIds.has(r.source) && finalIds.has(r.target),
@@ -342,6 +432,7 @@ Do not return the entire graph — only the actions to take.`;
onDelete={handleNodeDelete} onDelete={handleNodeDelete}
onAddRelation={addRelation} onAddRelation={addRelation}
onRemoveRelation={removeRelation} onRemoveRelation={removeRelation}
onJumpToNode={handleJumpToNode}
/> />
</div> </div>
</div> </div>

View File

@@ -1,13 +1,25 @@
import { RotateCcw, RefreshCw, AlertCircle } from 'lucide-react'; import { useState } from 'react';
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown } from 'lucide-react';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import SuggestionsQueue from '../SuggestionsQueue'; import SuggestionsQueue from '../SuggestionsQueue';
const SCOPE_OPTIONS = [
{ value: 'full', label: 'Full Analysis', tier: 'Opus', hint: 'Merges · Deletions · Relations · Relevance' },
{ value: 'relations', label: 'Relations Only', tier: 'Sonnet', hint: 'Suggest missing logical connections' },
{ value: 'relevance', label: 'Relevance Only', tier: 'Sonnet', hint: 'Re-score learning_relevance for all unlocked topics' },
];
/** /**
* Sidebar controls panel: the exclude-nodes toggle, the AI analysis button, * Sidebar controls panel: the exclude-nodes toggle, the AI analysis button
* the snapshot restore row, and the R42 suggestions queue. * (with scope selector), the snapshot restore row, and the R42 suggestions queue.
* *
* Kept intentionally thin — all async logic lives in the KnowledgeGraph * Kept intentionally thin — all async logic lives in the KnowledgeGraph
* orchestrator and is injected via props. * orchestrator and is injected via props.
*
* The scope selector is local state here; it produces a scope string that is
* forwarded to onAnalyze(scope). Scope options map to different AI tiers:
* full → reasoning (Opus) — expensive full quality pass
* relations / relevance → standard (Sonnet) — targeted cheaper sub-task
*/ */
export default function GraphControls({ export default function GraphControls({
showExcludeNodes, showExcludeNodes,
@@ -21,6 +33,10 @@ export default function GraphControls({
onRestore, onRestore,
isRestoring, isRestoring,
}) { }) {
const [scope, setScope] = useState('full');
const selectedOption = SCOPE_OPTIONS.find(o => o.value === scope) ?? SCOPE_OPTIONS[0];
const snapshotLabel = snapshotMeta const snapshotLabel = snapshotMeta
? new Date(snapshotMeta.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) ? new Date(snapshotMeta.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: null; : null;
@@ -38,13 +54,35 @@ export default function GraphControls({
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
{/* Scope selector */}
<div className="relative">
<select
value={scope}
onChange={e => setScope(e.target.value)}
disabled={isAnalyzing || isRestoring || disabled}
className="w-full border border-bg-warm rounded-[var(--r-sm)] pl-3 pr-8 py-1.5 text-xs bg-paper appearance-none disabled:opacity-40 disabled:cursor-not-allowed"
title={selectedOption.hint}
>
{SCOPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>
{o.label} {o.tier}
</option>
))}
</select>
<ChevronDown
size={12}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-muted"
/>
</div>
<p className="text-xs text-fg-muted leading-snug">{selectedOption.hint}</p>
<Button <Button
onClick={onAnalyze} onClick={() => onAnalyze(scope)}
disabled={isAnalyzing || isRestoring || disabled} disabled={isAnalyzing || isRestoring || disabled}
className="w-full flex justify-center items-center gap-2" className="w-full flex justify-center items-center gap-2"
> >
<RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} /> <RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} />
{isAnalyzing ? 'Analyzing Graph…' : 'Analyze & Optimize Graph'} {isAnalyzing ? 'Analyzing…' : 'Analyze & Optimize Graph'}
</Button> </Button>
{/* Restore row — only visible after a bulkSave has written a snapshot */} {/* Restore row — only visible after a bulkSave has written a snapshot */}
@@ -58,7 +96,7 @@ export default function GraphControls({
<RotateCcw size={12} className={isRestoring ? 'animate-spin' : ''} /> <RotateCcw size={12} className={isRestoring ? 'animate-spin' : ''} />
{isRestoring {isRestoring
? 'Restoring…' ? 'Restoring…'
: `Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`} : `Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`}
</button> </button>
)} )}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Trash2, Edit2, Save, X, Plus, Link as LinkIcon } from 'lucide-react'; import { Trash2, Edit2, Save, X, Plus, Link as LinkIcon, ArrowUpRight } from 'lucide-react';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by']; const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by'];
@@ -14,11 +14,12 @@ const RELATION_TYPES = ['related_to', 'depends_on', 'part_of', 'executed_by'];
* Props: * Props:
* selectedNode — the currently selected topic object, or null * selectedNode — the currently selected topic object, or null
* topics — full topic list (used for the relation-target selector) * topics — full topic list (used for the relation-target selector)
* relations — full relation list (to show outgoing edges) * relations — full relation list (to show outgoing + incoming edges)
* onSave(topic) — called with the fully-merged updated topic * onSave(topic) — called with the fully-merged updated topic
* onDelete() — called when the admin confirms deletion * onDelete() — called when the admin confirms deletion
* onAddRelation(rel) — called with { source, target, type } * onAddRelation(rel) — called with { source, target, type }
* onRemoveRelation(s,t,type) — called to drop a specific edge * onRemoveRelation(s,t,type) — called to drop a specific edge
* onJumpToNode(id) — pan canvas to node and select it
*/ */
export default function NodeDetailPanel({ export default function NodeDetailPanel({
selectedNode, selectedNode,
@@ -28,6 +29,7 @@ export default function NodeDetailPanel({
onDelete, onDelete,
onAddRelation, onAddRelation,
onRemoveRelation, onRemoveRelation,
onJumpToNode,
}) { }) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState({}); const [editData, setEditData] = useState({});
@@ -67,6 +69,7 @@ export default function NodeDetailPanel({
}; };
const outgoing = relations.filter(r => r.source === selectedNode?.id); const outgoing = relations.filter(r => r.source === selectedNode?.id);
const incoming = relations.filter(r => r.target === selectedNode?.id);
return ( return (
<div className="flex-1"> <div className="flex-1">
@@ -218,8 +221,10 @@ export default function NodeDetailPanel({
{/* Relations ───────────────────────────────────────────────────── */} {/* Relations ───────────────────────────────────────────────────── */}
<div className="pt-4 border-t border-bg-warm"> <div className="pt-4 border-t border-bg-warm">
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p> {/* Outgoing relations */}
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">
Outgoing ({outgoing.length})
</p>
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
{outgoing.length === 0 && ( {outgoing.length === 0 && (
<p className="text-xs text-fg-muted italic">No outgoing relations.</p> <p className="text-xs text-fg-muted italic">No outgoing relations.</p>
@@ -231,19 +236,64 @@ export default function NodeDetailPanel({
key={idx} key={idx}
className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm" className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm"
> >
<div className="text-xs min-w-0 mr-2"> <div className="text-xs min-w-0 mr-2 flex-1">
<span className="font-mono text-teal">{rel.type}</span> <span className="font-mono text-teal">{rel.type}</span>
{' → '} {' → '}
<span className="truncate">{target ? target.label : rel.target}</span> <span className="truncate">{target ? target.label : rel.target}</span>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0">
{target && (
<button
onClick={() => onJumpToNode?.(rel.target)}
className="text-fg-muted hover:text-teal p-1"
title={`Jump to "${target.label}"`}
>
<ArrowUpRight size={13} />
</button>
)}
<button <button
onClick={() => onRemoveRelation(selectedNode.id, rel.target, rel.type)} onClick={() => onRemoveRelation(selectedNode.id, rel.target, rel.type)}
className="shrink-0 text-fg-muted hover:text-red-500 p-1" className="text-fg-muted hover:text-red-500 p-1"
title="Remove relation" title="Remove relation"
> >
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
</div>
);
})}
</div>
{/* Incoming relations */}
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">
Incoming ({incoming.length})
</p>
<div className="space-y-2 mb-4">
{incoming.length === 0 && (
<p className="text-xs text-fg-muted italic">No incoming relations.</p>
)}
{incoming.map((rel, idx) => {
const source = topics.find(t => t.id === rel.source);
return (
<div
key={idx}
className="flex items-center justify-between bg-bg rounded-[var(--r-sm)] p-2 border border-bg-warm"
>
<div className="text-xs min-w-0 mr-2 flex-1">
<span className="truncate">{source ? source.label : rel.source}</span>
{' → '}
<span className="font-mono text-teal">{rel.type}</span>
</div>
{source && (
<button
onClick={() => onJumpToNode?.(rel.source)}
className="shrink-0 text-fg-muted hover:text-teal p-1"
title={`Jump to "${source.label}"`}
>
<ArrowUpRight size={13} />
</button>
)}
</div>
); );
})} })}
</div> </div>