-
Notifications
You must be signed in to change notification settings - Fork 0
Mind map ↔ knowledge graph bidirectional sync #292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import React from 'react'; | ||
| import { RefreshCw } from 'lucide-react'; | ||
| import { useGraphSyncStore } from '../store/graph-sync-store'; | ||
|
|
||
| interface SyncToggleProps { | ||
| tooltip?: string; | ||
| } | ||
|
|
||
| const SyncToggle: React.FC<SyncToggleProps> = ({ | ||
| tooltip = "Sync mind map with knowledge graph" | ||
| }) => { | ||
| const { syncEnabled, setSyncEnabled } = useGraphSyncStore(); | ||
|
|
||
| return ( | ||
| <button | ||
| onClick={() => setSyncEnabled(!syncEnabled)} | ||
| className={`filter-chip ${syncEnabled ? 'active' : ''}`} | ||
| aria-pressed={syncEnabled} | ||
| title={tooltip} | ||
| aria-label={tooltip} | ||
| style={{ | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: 'var(--space-2)', | ||
| position: 'relative' | ||
| }} | ||
| > | ||
| <RefreshCw size={14} className={syncEnabled ? 'animate-spin-slow' : ''} /> | ||
| {syncEnabled ? 'Sync On' : 'Sync Off'} | ||
| {syncEnabled && ( | ||
| <span style={{ | ||
| position: 'absolute', | ||
| top: '2px', | ||
| right: '2px', | ||
| width: '6px', | ||
| height: '6px', | ||
| background: 'var(--status-success)', | ||
| borderRadius: '50%', | ||
| boxShadow: '0 0 4px var(--status-success)' | ||
| }} /> | ||
| )} | ||
| </button> | ||
| ); | ||
| }; | ||
|
|
||
| export default SyncToggle; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { render, act } from '@testing-library/react'; | ||
| import React from 'react'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| import { useGraphSyncStore } from '../../store/graph-sync-store'; | ||
| import { setupMindMapSyncListeners } from '../mindmap/sync-adapter'; | ||
| import { setupGraphSyncListeners } from '../graph/sync-adapter'; | ||
| import Graph from 'graphology'; | ||
|
|
||
| // Mock MindElixir as it's hard to render in Vitest/Happy-dom without a real canvas | ||
| const mockMindMap = { | ||
| bus: { | ||
| addListener: vi.fn(), | ||
| }, | ||
| findEle: vi.fn(), | ||
| addChild: vi.fn(), | ||
| updateNodeStyle: vi.fn(), | ||
| refresh: vi.fn(), | ||
| removeNodes: vi.fn(), | ||
| }; | ||
|
|
||
| describe('Sync Integration', () => { | ||
| beforeEach(() => { | ||
| useGraphSyncStore.getState().clearEvents(); | ||
| useGraphSyncStore.getState().setSyncEnabled(true); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('should sync node addition from mind map to graph', () => { | ||
| const graph = new Graph(); | ||
| setupGraphSyncListeners(graph); | ||
|
|
||
| // Simulate setupMindMapSyncListeners | ||
| let mindMapListener: (op: any) => void = () => {}; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| mockMindMap.bus.addListener.mockImplementation((name, cb) => { | ||
| if (name === 'operation') mindMapListener = cb; | ||
| }); | ||
| setupMindMapSyncListeners(mockMindMap as any); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| // 1. Add node in mind map | ||
| act(() => { | ||
| mindMapListener({ | ||
| name: 'addChild', | ||
| obj: { id: 'test-node', topic: 'Test Node' } | ||
| }); | ||
| }); | ||
|
|
||
| // Verify event emitted | ||
| expect(useGraphSyncStore.getState().pendingEvents).toHaveLength(1); | ||
| expect(useGraphSyncStore.getState().pendingEvents[0].type).toBe('node:add'); | ||
|
|
||
| // 2. Consume in graph (simulating the effect in GraphView) | ||
| const events = useGraphSyncStore.getState().consumeEvents('graph'); | ||
| expect(events).toHaveLength(1); | ||
|
|
||
| act(() => { | ||
| const payload: any = events[0].payload; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| graph.addNode(payload.id, { label: payload.label }); | ||
| }); | ||
|
|
||
| expect(graph.hasNode('test-node')).toBe(true); | ||
| expect(graph.getNodeAttribute('test-node', 'label')).toBe('Test Node'); | ||
| }); | ||
|
|
||
| it('should sync node update from graph to mind map', () => { | ||
| const graph = new Graph(); | ||
| setupGraphSyncListeners(graph); | ||
|
|
||
| // 1. Add node in graph | ||
| act(() => { | ||
| graph.addNode('node-1', { label: 'Initial' }); | ||
| }); | ||
|
|
||
| // Should have emitted node:add | ||
| expect(useGraphSyncStore.getState().pendingEvents).toHaveLength(1); | ||
| useGraphSyncStore.getState().consumeEvents('mindmap'); // Clear | ||
|
|
||
| // 2. Update node in graph | ||
| act(() => { | ||
| graph.setNodeAttribute('node-1', 'label', 'Updated'); | ||
| }); | ||
|
|
||
| const events = useGraphSyncStore.getState().pendingEvents; | ||
| expect(events.some(e => e.type === 'node:update')).toBe(true); | ||
| const updateEvent = events.find(e => e.type === 'node:update'); | ||
| expect((updateEvent?.payload as any).label).toBe('Updated'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,9 @@ | |
| import { logger } from '../../lib/logger'; | ||
| import { perf } from '../../lib/perf'; | ||
| import { applyCircularLayout, applyForceLayout, applyHierarchicalLayout } from '../../lib/graph-layout'; | ||
| import { useGraphSyncStore } from '../../store/graph-sync-store'; | ||
| import { setupGraphSyncListeners } from './sync-adapter'; | ||
| import type { SharedNode, SharedEdge } from '../../store/graph-sync-types'; | ||
| import { useGraphKeyboardNavigation } from './GraphKeyboardNav'; | ||
| import { useGraphTouchGestures } from './GraphTouchHandler'; | ||
| import { useGraphSnapshotManager } from './GraphSnapshotManager'; | ||
|
|
@@ -298,6 +301,12 @@ | |
| }; | ||
| }, [effectiveData, selectedNode, focusMode, snapshotMode, setFocusMode, setSelectedNode, layout, layoutSettings, links, graphSize]); | ||
|
|
||
| useEffect(() => { | ||
| if (!graphRef.current) return; | ||
| const cleanup = setupGraphSyncListeners(graphRef.current); | ||
| return cleanup; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| if (!selectedNode) { | ||
| setSelectedEntityClaims([]); | ||
|
|
@@ -330,6 +339,62 @@ | |
| void sigma.getCamera().animatedReset({ duration: 400 }); | ||
| }, [layout, effectiveData.entities, links]); | ||
|
|
||
| // Sync effect to consume events | ||
| useEffect(() => { | ||
| const unsubscribe = useGraphSyncStore.subscribe((state) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!state.syncEnabled || !graphRef.current || state.pendingEvents.length === 0) return; | ||
|
|
||
| const events = useGraphSyncStore.getState().consumeEvents('graph'); | ||
| if (events.length === 0) return; | ||
|
|
||
| const graph = graphRef.current; | ||
| events.forEach(event => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (event.type === 'node:add') { | ||
| const payload = event.payload as SharedNode; | ||
| if (!graph.hasNode(payload.id)) { | ||
| graph.addNode(payload.id, { | ||
| label: payload.label, | ||
| size: 10, | ||
| color: '#2563eb', | ||
| x: Math.random(), | ||
| y: Math.random() | ||
| }); | ||
| } | ||
| } else if (event.type === 'node:update') { | ||
| const payload = event.payload as SharedNode; | ||
| if (graph.hasNode(payload.id)) { | ||
| const currentLabel = graph.getNodeAttribute(payload.id, 'label'); | ||
| if (currentLabel !== payload.label) { | ||
| console.warn(`[Sync Conflict] Graph node ${payload.id} has different label. Local: ${currentLabel}, Incoming: ${payload.label}. Applying last-writer wins.`); | ||
| graph.mergeNodeAttributes(payload.id, { label: payload.label }); | ||
| } | ||
| } | ||
| } else if (event.type === 'node:remove') { | ||
| const payload = event.payload as SharedNode; | ||
| if (graph.hasNode(payload.id)) { | ||
| graph.dropNode(payload.id); | ||
| } | ||
| } else if (event.type === 'edge:add') { | ||
| const payload = event.payload as SharedEdge; | ||
| if (graph.hasNode(payload.from) && graph.hasNode(payload.to) && !graph.hasEdge(payload.id)) { | ||
| graph.addEdgeWithKey(payload.id, payload.from, payload.to, { | ||
| label: payload.label, | ||
| size: 2, | ||
| color: '#94a3b8' | ||
| }); | ||
| } | ||
| } else if (event.type === 'edge:remove') { | ||
| const payload = event.payload as SharedEdge; | ||
| if (graph.hasEdge(payload.id)) { | ||
| graph.dropEdge(payload.id); | ||
| } | ||
| } | ||
| }); | ||
| if (sigmaInstance.current) sigmaInstance.current.refresh(); | ||
| }); | ||
| return () => unsubscribe(); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| return () => { | ||
| sigmaInstance.current?.kill(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import type Graph from 'graphology'; | ||
| import { useGraphSyncStore } from '../../store/graph-sync-store'; | ||
| import { SharedNode, SharedEdge } from '../../store/graph-sync-types'; | ||
|
|
||
| export function setupGraphSyncListeners( | ||
| graph: Graph | ||
| ) { | ||
| const store = useGraphSyncStore.getState(); | ||
|
|
||
| const onNodeAdded = ({ key, attributes }: any) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!useGraphSyncStore.getState().syncEnabled) return; | ||
| store.emitEvent({ | ||
| type: 'node:add', | ||
| source: 'graph', | ||
| payload: { id: key, label: attributes.label } as SharedNode, | ||
| }); | ||
| }; | ||
|
|
||
| const onNodeAttributesUpdated = ({ key, attributes }: any) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!useGraphSyncStore.getState().syncEnabled) return; | ||
| store.emitEvent({ | ||
| type: 'node:update', | ||
| source: 'graph', | ||
| payload: { id: key, label: attributes.label } as SharedNode, | ||
| }); | ||
| }; | ||
|
|
||
| const onNodeDropped = ({ key }: any) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!useGraphSyncStore.getState().syncEnabled) return; | ||
| store.emitEvent({ | ||
| type: 'node:remove', | ||
| source: 'graph', | ||
| payload: { id: key, label: '' } as SharedNode, | ||
| }); | ||
| }; | ||
|
|
||
| const onEdgeAdded = ({ key, source, target, attributes }: any) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!useGraphSyncStore.getState().syncEnabled) return; | ||
| store.emitEvent({ | ||
| type: 'edge:add', | ||
| source: 'graph', | ||
| payload: { id: key, from: source, to: target, label: attributes.label } as SharedEdge, | ||
| }); | ||
| }; | ||
|
|
||
| const onEdgeDropped = ({ key }: any) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!useGraphSyncStore.getState().syncEnabled) return; | ||
| // We don't have from/to here, but id should be enough for removal if shared | ||
| store.emitEvent({ | ||
| type: 'edge:remove', | ||
| source: 'graph', | ||
| payload: { id: key, from: '', to: '' } as SharedEdge, | ||
| }); | ||
| }; | ||
|
|
||
| graph.on('nodeAdded', onNodeAdded); | ||
| graph.on('nodeAttributesUpdated', onNodeAttributesUpdated); | ||
| graph.on('nodeDropped', onNodeDropped); | ||
| graph.on('edgeAdded', onEdgeAdded); | ||
| graph.on('edgeDropped', onEdgeDropped); | ||
|
|
||
| return () => { | ||
| graph.off('nodeAdded', onNodeAdded); | ||
| graph.off('nodeAttributesUpdated', onNodeAttributesUpdated); | ||
| graph.off('nodeDropped', onNodeDropped); | ||
| graph.off('edgeAdded', onEdgeAdded); | ||
| graph.off('edgeDropped', onEdgeDropped); | ||
| }; | ||
| } | ||
|
Comment on lines
+5
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused variables are generally considered a code smell and should be avoided.