diff --git a/packages/ioa/src/components/GraphPanel.tsx b/packages/ioa/src/components/GraphPanel.tsx index 4b765c5..cd29336 100644 --- a/packages/ioa/src/components/GraphPanel.tsx +++ b/packages/ioa/src/components/GraphPanel.tsx @@ -1,4 +1,4 @@ -import { type ComponentType, useMemo } from 'react' +import { type ComponentType, useEffect, useMemo, useRef, useState } from 'react' import * as dagre from 'dagre' import { Background, @@ -11,6 +11,7 @@ import { type Node as FlowNode, type NodeProps, type NodeTypes, + type ReactFlowInstance, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import type { ForumThread } from '../types' @@ -33,7 +34,8 @@ type GraphNodeData = { } type GraphFlowNode = FlowNode -type GraphFlowEdge = FlowEdge<{ kind: 'refs.messages' }> +type GraphRelation = string +type GraphFlowEdge = FlowEdge<{ kind: GraphRelation }> // -- Node card -- @@ -74,30 +76,48 @@ interface GraphEdgeMeta { id: string source: string target: string + kind: GraphRelation +} + +// Applications may project their own entities and relations without teaching +// this IOA component about application metadata or scheduling semantics. +export interface GraphExtension { + nodes: { id: string; title: string; kind: string; meta?: string; messageId?: string }[] + edges: { source: string; target: string; label: string }[] } function buildGraphFlow( thread: ForumThread, selectedMessageId: string, -): { nodes: GraphFlowNode[]; edges: GraphFlowEdge[]; edgeCount: number; externalRefCount: number } { + extension?: GraphExtension, +): { nodes: GraphFlowNode[]; edges: GraphFlowEdge[]; edgeCount: number; relationLabels: string[]; externalRefCount: number } { const messageIds = new Set(thread.messages.map(m => m.id!)) + const descriptions = new Map((extension?.nodes ?? []).map(node => [node.id, node])) + const nodeIds = new Set([...messageIds, ...descriptions.keys()]) const graphEdges: GraphEdgeMeta[] = [] + const graphEdgeIDs = new Set() let externalRefCount = 0 + const addEdge = (source: string, target: string, kind: GraphRelation) => { + const id = `${kind}:${source}->${target}` + if (graphEdgeIDs.has(id)) return + graphEdgeIDs.add(id) + graphEdges.push({ id, source, target, kind }) + } for (const message of thread.messages) { for (const refId of messageRefIds(message)) { if (messageIds.has(refId)) { - graphEdges.push({ - id: `${message.id}__${refId}`, - source: refId, - target: message.id!, - }) + addEdge(refId, message.id!, 'refs.messages') } else { externalRefCount++ } } } + for (const edge of extension?.edges ?? []) { + if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) addEdge(edge.source, edge.target, edge.label) + } + const graph = new dagre.graphlib.Graph<{ width: number; height: number }>() graph.setGraph({ rankdir: 'TB', @@ -109,8 +129,8 @@ function buildGraphFlow( }) graph.setDefaultEdgeLabel(() => ({})) - for (const message of thread.messages) { - graph.setNode(message.id!, { width: 260, height: 86 }) + for (const id of nodeIds) { + graph.setNode(id, { width: 260, height: 86 }) } for (const edge of graphEdges) { graph.setEdge(edge.source, edge.target) @@ -135,6 +155,7 @@ function buildGraphFlow( const messageById = new Map(thread.messages.map(m => [m.id!, m])) const flowNodes: GraphFlowNode[] = rawNodes.map(n => { const message = messageById.get(n.id) + const description = descriptions.get(n.id) const active = n.id === selectedMessageId const ct = message ? detectContentType(message.content, message.content_type) : 'unknown' const content = message?.content as Record | undefined @@ -148,10 +169,10 @@ function buildGraphFlow( selected: active, draggable: false, data: { - messageId: n.id, - title, - kind: ct, - meta: message?.sender ? shortId(message.sender) : '', + messageId: description?.messageId ?? (message ? n.id : ''), + title: description?.title ?? title, + kind: description?.kind ?? ct, + meta: description?.meta ?? (message?.sender ? shortId(message.sender) : ''), refCount: message ? messageRefIds(message).length : 0, active, }, @@ -160,7 +181,9 @@ function buildGraphFlow( const flowEdges: GraphFlowEdge[] = graphEdges.map(edge => { const active = Boolean(selectedMessageId && (edge.source === selectedMessageId || edge.target === selectedMessageId)) - const stroke = active ? 'hsl(var(--primary))' : 'hsl(var(--border))' + const stroke = active + ? 'hsl(var(--primary))' + : edge.kind !== 'refs.messages' ? 'hsl(var(--success))' : 'hsl(var(--border))' return { id: edge.id, source: edge.source, @@ -168,12 +191,18 @@ function buildGraphFlow( type: 'smoothstep', animated: active, markerEnd: { type: MarkerType.ArrowClosed, color: stroke }, - style: { stroke, strokeWidth: active ? 2.2 : 1.4 }, - data: { kind: 'refs.messages' as const }, + style: { stroke, strokeWidth: active ? 2.2 : 1.4, strokeDasharray: edge.kind !== 'refs.messages' ? '6 4' : undefined }, + data: { kind: edge.kind }, } }) - return { nodes: flowNodes, edges: flowEdges, edgeCount: graphEdges.length, externalRefCount } + return { + nodes: flowNodes, + edges: flowEdges, + edgeCount: graphEdges.length, + relationLabels: [...new Set(graphEdges.map(edge => edge.kind).filter(kind => kind !== 'refs.messages'))], + externalRefCount, + } } // -- Graph panel -- @@ -183,6 +212,7 @@ export interface GraphPanelProps { selectedMessageId: string onSelectMessage: (messageId: string) => void mode: 'side' | 'dialog' + extension?: GraphExtension onCollapse?: () => void } @@ -192,11 +222,29 @@ export function GraphPanel({ onSelectMessage, mode, onCollapse, + extension, }: GraphPanelProps) { const layout = useMemo( - () => buildGraphFlow(thread, selectedMessageId), - [selectedMessageId, thread], + () => buildGraphFlow(thread, selectedMessageId, extension), + [selectedMessageId, thread, extension], ) + const [flow, setFlow] = useState | null>(null) + const graphRef = useRef(null) + useEffect(() => { + const selected = layout.nodes.find(node => node.id === selectedMessageId) + if (!flow || !selected || !graphRef.current) return + let frame = 0 + const center = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + void flow.setCenter(selected.position.x + 130, selected.position.y + 43, { zoom: 0.75 }) + }) + } + const observer = new ResizeObserver(center) + observer.observe(graphRef.current) + center() + return () => { observer.disconnect(); cancelAnimationFrame(frame) } + }, [flow, layout, selectedMessageId]) return (
Message Graph
- {thread.messageCount} messages · {layout.edgeCount} refs + {thread.messageCount} messages · {layout.edgeCount} links
@@ -227,7 +275,7 @@ export function GraphPanel({ )}
-
+
{layout.nodes.length === 0 ? (
@@ -238,9 +286,10 @@ export function GraphPanel({ nodes={layout.nodes} edges={layout.edges} nodeTypes={GRAPH_NODE_TYPES} + onInit={setFlow} fitView fitViewOptions={{ padding: 0.18 }} - minZoom={0.35} + minZoom={0.05} maxZoom={1.6} nodesDraggable={false} nodesConnectable={false} @@ -248,7 +297,7 @@ export function GraphPanel({ panOnDrag zoomOnScroll zoomOnPinch - onNodeClick={(_, node) => onSelectMessage(String(node.data.messageId))} + onNodeClick={(_, node) => { if (node.data.messageId) onSelectMessage(String(node.data.messageId)) }} > @@ -260,6 +309,12 @@ export function GraphPanel({ refs.messages + {layout.relationLabels.map(label => ( + + + {label} + + ))} {layout.externalRefCount > 0 && {layout.externalRefCount} external refs}
diff --git a/packages/ioa/src/index.ts b/packages/ioa/src/index.ts index c06cba4..9b75cc0 100644 --- a/packages/ioa/src/index.ts +++ b/packages/ioa/src/index.ts @@ -19,7 +19,7 @@ export { Timeline } from './components/Timeline' export type { TimelineProps } from './components/Timeline' export { GraphPanel, CollapsedGraphButton } from './components/GraphPanel' -export type { GraphPanelProps, CollapsedGraphButtonProps } from './components/GraphPanel' +export type { GraphExtension, GraphPanelProps, CollapsedGraphButtonProps } from './components/GraphPanel' export { TimelineEntry } from './components/TimelineEntry' export type { TimelineEntryProps, TimelineEntryTone } from './components/TimelineEntry'