diff --git a/src/App.tsx b/src/App.tsx
index 7a8798f..d751c5b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -7,8 +7,10 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
SaveOutlined,
+ FolderOpenOutlined,
} from '@ant-design/icons';
import { saveConceptSketch } from './utils/saveGraph';
+import { loadConceptSketch } from './utils/loadGraph';
import { useGraphLayersTreeStore } from './stores/TreeStore';
const { Header, Content, Sider } = Layout;
@@ -72,7 +74,19 @@ const App: React.FC = () => {
-
+
+ }
+ onClick={loadConceptSketch}
+ title="Load JSON"
+ style={{
+ fontSize: '16px',
+ width: 32,
+ height: 32,
+ color: colorBgContainer,
+ }}
+ />
}
diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx
index 1694e34..fe39a10 100644
--- a/src/components/Graph.tsx
+++ b/src/components/Graph.tsx
@@ -8,6 +8,7 @@ import NodeContextMenu from "./NodeContextMenu";
import NodeRenameModal from "./NodeRenameModal";
import { useGraphsStore } from "../stores/GraphsStore";
import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore";
+import { useLoadStore } from "../stores/LoadStore";
export class GraphModel {
@@ -85,6 +86,7 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch store.has);
const anyNodeSelected = useSelectedNodesStore(store => store.hasAny);
const deselectAllNodes = useSelectedNodesStore(store => store.clear);
+ const loadCount = useLoadStore(state => state.loadCount);
useEffect(() => {
setGraphPath(graphsPath);
@@ -110,6 +112,18 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch {
+ if (loadCount === 0) return;
+ const state = useGraphsStore.getState() as { graphsById: Map };
+ const mainGraph = state.graphsById.get('main');
+ if (mainGraph) setGraph(mainGraph);
+ selectGraphId('main');
+ setGraphsPath([createPathSegment('main', 'Main')]);
+ openNodeContext(null);
+ openContextMenu(false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loadCount]);
+
useEffect(() => {
if (nodeContext?.nodeId === graphId) {
graphsPath.push(createPathSegment(nodeContext.nodeId, nodeContext.nodeName));
diff --git a/src/stores/LoadStore.ts b/src/stores/LoadStore.ts
new file mode 100644
index 0000000..8c81a77
--- /dev/null
+++ b/src/stores/LoadStore.ts
@@ -0,0 +1,11 @@
+import { create } from 'zustand';
+
+interface LoadStore {
+ loadCount: number;
+ signal: () => void;
+}
+
+export const useLoadStore = create((set) => ({
+ loadCount: 0,
+ signal: () => set((state) => ({ loadCount: state.loadCount + 1 })),
+}));
diff --git a/src/stores/TreeStore.tsx b/src/stores/TreeStore.tsx
index 38afa6a..ff36b9b 100644
--- a/src/stores/TreeStore.tsx
+++ b/src/stores/TreeStore.tsx
@@ -10,6 +10,7 @@ export interface TreeStore {
tree: TreeDataNode[];
add: (childNode: NodeContext, parentNodeId: string | undefined) => void;
remove: (nodeId: string) => void;
+ reset: () => void;
}
export const useGraphLayersTreeStore = create()((set) => ({
@@ -87,7 +88,13 @@ export const useGraphLayersTreeStore = create()((set) => ({
parentIdByChildId: state.parentIdByChildId,
tree: createTree([...state.rootNodes], nodesFlatById)
}
- })
+ }),
+ reset: () => set({
+ nodesFlatById: new Map(),
+ parentIdByChildId: new Map(),
+ rootNodes: [],
+ tree: [],
+ }),
}));
export function createTree(nodes: TreeDataNode[], nodesFlatById: Map): TreeDataNode[] {
diff --git a/src/utils/loadGraph.ts b/src/utils/loadGraph.ts
new file mode 100644
index 0000000..0972538
--- /dev/null
+++ b/src/utils/loadGraph.ts
@@ -0,0 +1,64 @@
+import type { GraphModel, NodeContext } from '../components/Graph';
+import { useGraphsStore } from '../stores/GraphsStore';
+import { useGraphLayersTreeStore } from '../stores/TreeStore';
+import { useLoadStore } from '../stores/LoadStore';
+
+interface SavedEdge { id: string; from: string; to: string; }
+interface SavedNode { id: string; label?: string; subgraph?: SavedGraph; }
+interface SavedGraph { id: string; nodes: SavedNode[]; edges: SavedEdge[]; }
+
+function populateStores(
+ graph: SavedGraph,
+ graphsById: Map,
+ treeAdd: (node: NodeContext, parentId: string | undefined) => void,
+): void {
+ graphsById.set(graph.id, {
+ nodes: graph.nodes.map(n => ({ id: n.id, label: n.label })),
+ edges: graph.edges.map(e => ({ id: e.id, from: e.from, to: e.to })),
+ });
+
+ for (const node of graph.nodes) {
+ if (node.subgraph) {
+ const nodeContext: NodeContext = {
+ nodeId: node.id,
+ nodeName: node.label,
+ coords: { x: 0, y: 0 },
+ };
+ treeAdd(nodeContext, graph.id === 'main' ? undefined : graph.id);
+ populateStores(node.subgraph, graphsById, treeAdd);
+ }
+ }
+}
+
+export function loadConceptSketch(): void {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json,application/json';
+ input.onchange = () => {
+ const file = input.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const data = JSON.parse(e.target?.result as string) as SavedGraph;
+ if (!data.id || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) {
+ throw new Error('Invalid concept-sketch JSON format');
+ }
+
+ const graphsState = useGraphsStore.getState() as { graphsById: Map };
+ graphsState.graphsById.clear();
+
+ useGraphLayersTreeStore.getState().reset();
+
+ populateStores(data, graphsState.graphsById, useGraphLayersTreeStore.getState().add);
+
+ useLoadStore.getState().signal();
+ } catch (err) {
+ console.error('Failed to load concept sketch:', err);
+ }
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+}