import { create } from 'zustand'; import { addEdge as rfAddEdge } from '@xyflow/react'; import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig, RelationData, GroupData, GraphActions } from '../types'; import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants'; import { migrateTangibleConfigs } from '../utils/tangibleMigration'; import { migrateRelationHandlesArray } from '../utils/handleMigration'; /** * ⚠️ IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS ⚠️ * * This is the low-level graph store. All mutation operations should go through * the `useGraphWithHistory` hook to ensure undo/redo history is tracked. * * ✅ CORRECT: Use `useGraphWithHistory()` in components * ❌ WRONG: Use `useGraphStore()` directly (bypasses history tracking) * * Exception: Read-only access in presentation components (CustomNode, CustomEdge) * is acceptable. * * See: src/hooks/useGraphWithHistory.ts */ interface GraphStore { nodes: Actor[]; edges: Relation[]; groups: Group[]; nodeTypes: NodeTypeConfig[]; edgeTypes: EdgeTypeConfig[]; labels: LabelConfig[]; tangibles: TangibleConfig[]; } // Default node types with semantic shape assignments const defaultNodeTypes: NodeTypeConfig[] = [ { id: 'person', label: 'Person', color: '#3b82f6', shape: 'circle', icon: 'Person', description: 'Individual person' }, { id: 'organization', label: 'Organization', color: '#10b981', shape: 'rectangle', icon: 'Business', description: 'Company or group' }, { id: 'system', label: 'System', color: '#f59e0b', shape: 'roundedRectangle', icon: 'Computer', description: 'Technical system' }, { id: 'concept', label: 'Concept', color: '#8b5cf6', shape: 'roundedRectangle', icon: 'Lightbulb', description: 'Abstract concept' }, ]; // Default edge types const defaultEdgeTypes: EdgeTypeConfig[] = [ { id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' }, { id: 'reports-to', label: 'Reports To', color: '#10b981', style: 'solid' }, { id: 'depends-on', label: 'Depends On', color: '#f59e0b', style: 'dashed' }, { id: 'influences', label: 'Influences', color: '#8b5cf6', style: 'dotted' }, ]; // Initial state - starts empty, documents are loaded by workspaceStore const initialState: GraphStore = { nodes: [], edges: [], groups: [], nodeTypes: defaultNodeTypes, edgeTypes: defaultEdgeTypes, labels: [], tangibles: [], }; export const useGraphStore = create((set) => ({ nodes: initialState.nodes, edges: initialState.edges, groups: initialState.groups, nodeTypes: initialState.nodeTypes, edgeTypes: initialState.edgeTypes, labels: initialState.labels, tangibles: initialState.tangibles, // Node operations addNode: (node: Actor) => set((state) => ({ nodes: [...state.nodes, node], })), updateNode: (id: string, updates: Partial) => set((state) => { // Validate and filter labels if present let validatedData = updates.data; if (updates.data?.labels) { const validLabelIds = new Set(state.labels.map((l) => l.id)); const filteredLabels = updates.data.labels.filter((labelId) => validLabelIds.has(labelId) ); validatedData = { ...updates.data, labels: filteredLabels.length > 0 ? filteredLabels : undefined, }; } return { nodes: state.nodes.map((node) => node.id === id ? { ...node, ...updates, data: validatedData ? { ...node.data, ...validatedData } : node.data, } : node ), }; }), deleteNode: (id: string) => set((state) => ({ nodes: state.nodes.filter((node) => node.id !== id), edges: state.edges.filter( (edge) => edge.source !== id && edge.target !== id ), })), // Edge operations addEdge: (edge: Relation) => set((state) => ({ edges: rfAddEdge(edge, state.edges) as Relation[], })), updateEdge: (id: string, data: Partial) => set((state) => { // Validate and filter labels if present let validatedData = data; if (data.labels) { const validLabelIds = new Set(state.labels.map((l) => l.id)); const filteredLabels = data.labels.filter((labelId) => validLabelIds.has(labelId) ); validatedData = { ...data, labels: filteredLabels.length > 0 ? filteredLabels : undefined, }; } return { edges: state.edges.map((edge) => edge.id === id ? { ...edge, data: { ...edge.data, ...validatedData } as RelationData } : edge ), }; }), deleteEdge: (id: string) => set((state) => ({ edges: state.edges.filter((edge) => edge.id !== id), })), // Node type operations addNodeType: (nodeType: NodeTypeConfig) => set((state) => ({ nodeTypes: [...state.nodeTypes, nodeType], })), updateNodeType: (id: string, updates: Partial>) => set((state) => ({ nodeTypes: state.nodeTypes.map((type) => type.id === id ? { ...type, ...updates } : type ), })), deleteNodeType: (id: string) => set((state) => { // Remove node type ID from tangible filters.actorTypes arrays const updatedTangibles = state.tangibles.map((tangible) => { if (tangible.mode === 'filter' && tangible.filters?.actorTypes) { return { ...tangible, filters: { ...tangible.filters, actorTypes: tangible.filters.actorTypes.filter((typeId) => typeId !== id), }, }; } return tangible; }); return { nodeTypes: state.nodeTypes.filter((type) => type.id !== id), tangibles: updatedTangibles, }; }), // Edge type operations addEdgeType: (edgeType: EdgeTypeConfig) => set((state) => ({ edgeTypes: [...state.edgeTypes, edgeType], })), updateEdgeType: (id: string, updates: Partial>) => set((state) => ({ edgeTypes: state.edgeTypes.map((type) => type.id === id ? { ...type, ...updates } : type ), })), deleteEdgeType: (id: string) => set((state) => { // Remove edge type ID from tangible filters.relationTypes arrays const updatedTangibles = state.tangibles.map((tangible) => { if (tangible.mode === 'filter' && tangible.filters?.relationTypes) { return { ...tangible, filters: { ...tangible.filters, relationTypes: tangible.filters.relationTypes.filter((typeId) => typeId !== id), }, }; } return tangible; }); return { edgeTypes: state.edgeTypes.filter((type) => type.id !== id), tangibles: updatedTangibles, }; }), // Label operations addLabel: (label: LabelConfig) => set((state) => ({ labels: [...state.labels, label], })), updateLabel: (id: string, updates: Partial>) => set((state) => ({ labels: state.labels.map((label) => label.id === id ? { ...label, ...updates } : label ), })), deleteLabel: (id: string) => set((state) => { // Remove label from all nodes and edges const updatedNodes = state.nodes.map((node) => ({ ...node, data: { ...node.data, labels: node.data.labels?.filter((labelId) => labelId !== id), }, })); const updatedEdges = state.edges.map((edge) => ({ ...edge, data: edge.data ? { ...edge.data, labels: edge.data.labels?.filter((labelId) => labelId !== id), } : edge.data, })); // Remove label from tangible filterLabels arrays (old format) and filters.labels (new format) const updatedTangibles = state.tangibles.map((tangible) => { if (tangible.mode === 'filter') { const updates: Partial = {}; // Handle old format if (tangible.filterLabels) { updates.filterLabels = tangible.filterLabels.filter((labelId) => labelId !== id); } // Handle new format if (tangible.filters?.labels) { updates.filters = { ...tangible.filters, labels: tangible.filters.labels.filter((labelId) => labelId !== id), }; } return { ...tangible, ...updates }; } return tangible; }); return { labels: state.labels.filter((label) => label.id !== id), nodes: updatedNodes, edges: updatedEdges, tangibles: updatedTangibles, }; }), // Tangible operations addTangible: (tangible: TangibleConfig) => set((state) => ({ tangibles: [...state.tangibles, tangible], })), updateTangible: (id: string, updates: Partial>) => set((state) => ({ tangibles: state.tangibles.map((tangible) => tangible.id === id ? { ...tangible, ...updates } : tangible ), })), deleteTangible: (id: string) => set((state) => ({ tangibles: state.tangibles.filter((tangible) => tangible.id !== id), })), setTangibles: (tangibles: TangibleConfig[]) => set({ tangibles, }), // Group operations addGroup: (group: Group) => set((state) => ({ groups: [...state.groups, group], })), updateGroup: (id: string, updates: Partial) => set((state) => ({ groups: state.groups.map((group) => group.id === id ? { ...group, data: { ...group.data, ...updates } } : group ), })), deleteGroup: (id: string, ungroupActors = true) => set((state) => { if (ungroupActors) { // Remove group and unparent actors (keep them at their current absolute positions) // Note: parentId is a React Flow v11+ property for parent-child relationships const group = state.groups.find((g) => g.id === id); const updatedNodes = state.nodes.map((node) => { const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' }; if (nodeWithParent.parentId === id && group) { // Convert relative position to absolute position return { ...node, parentId: undefined, extent: undefined, position: { x: group.position.x + node.position.x, y: group.position.y + node.position.y, } }; } return node; }); return { groups: state.groups.filter((group) => group.id !== id), nodes: updatedNodes, }; } else { // Delete group AND all actors inside const nodeWithParent = (node: Actor) => node as Actor & { parentId?: string }; const updatedNodes = state.nodes.filter((node) => nodeWithParent(node).parentId !== id); // Delete all edges connected to deleted actors const deletedNodeIds = new Set( state.nodes.filter((node) => nodeWithParent(node).parentId === id).map((node) => node.id) ); const updatedEdges = state.edges.filter( (edge) => !deletedNodeIds.has(edge.source) && !deletedNodeIds.has(edge.target) ); return { groups: state.groups.filter((group) => group.id !== id), nodes: updatedNodes, edges: updatedEdges, }; } }), addActorToGroup: (actorId: string, groupId: string) => set((state) => { const group = state.groups.find((g) => g.id === groupId); const actor = state.nodes.find((n) => n.id === actorId); if (!group || !actor) return state; // Calculate new group bounds to include the actor const actorWidth = 150; // Approximate node width const actorHeight = 80; // Approximate node height const padding = 20; const actorAbsX = actor.position.x; const actorAbsY = actor.position.y; // Current group bounds const groupX = group.position.x; const groupY = group.position.y; const groupWidth = typeof group.style?.width === 'number' ? group.style.width : 200; const groupHeight = typeof group.style?.height === 'number' ? group.style.height : 200; // Calculate new bounds const newMinX = Math.min(groupX, actorAbsX - padding); const newMinY = Math.min(groupY, actorAbsY - padding); const newMaxX = Math.max(groupX + groupWidth, actorAbsX + actorWidth + padding); const newMaxY = Math.max(groupY + groupHeight, actorAbsY + actorHeight + padding); const newGroupX = newMinX; const newGroupY = newMinY; const newGroupWidth = newMaxX - newMinX; const newGroupHeight = newMaxY - newMinY; // Calculate position delta for existing child nodes const deltaX = groupX - newGroupX; const deltaY = groupY - newGroupY; // Update actor to be child of group with relative position const updatedNodes = state.nodes.map((node) => { const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' }; if (node.id === actorId) { // New actor: convert to relative position return { ...node, parentId: groupId, extent: 'parent' as const, position: { x: actorAbsX - newGroupX, y: actorAbsY - newGroupY, }, }; } else if (nodeWithParent.parentId === groupId) { // Existing child: adjust position due to group position change return { ...node, position: { x: node.position.x + deltaX, y: node.position.y + deltaY, }, }; } return node; }); // Update group's position, size, and actorIds const updatedGroups = state.groups.map((g) => g.id === groupId ? { ...g, position: { x: newGroupX, y: newGroupY }, style: { ...g.style, width: newGroupWidth, height: newGroupHeight, }, data: { ...g.data, actorIds: [...g.data.actorIds, actorId], }, } : g ); return { nodes: updatedNodes, groups: updatedGroups, }; }), removeActorFromGroup: (actorId: string, groupId: string) => set((state) => { const group = state.groups.find((g) => g.id === groupId); // Update actor to remove parent and convert to absolute position const updatedNodes = state.nodes.map((node) => { if (node.id === actorId && group) { return { ...node, parentId: undefined, extent: undefined, position: { x: group.position.x + node.position.x, y: group.position.y + node.position.y, }, }; } return node; }); // Update group's actorIds const updatedGroups = state.groups.map((g) => g.id === groupId ? { ...g, data: { ...g.data, actorIds: g.data.actorIds.filter((id) => id !== actorId), }, } : g ); return { nodes: updatedNodes, groups: updatedGroups, }; }), toggleGroupMinimized: (groupId: string) => set((state) => { const group = state.groups.find((g) => g.id === groupId); if (!group) return state; const isMinimized = !group.data.minimized; // Update group's minimized state and child nodes const updatedGroups = state.groups.map((g) => { if (g.id !== groupId) return g; if (isMinimized) { // Minimizing: store original dimensions in metadata const currentWidth = typeof g.style?.width === 'number' ? g.style.width : 300; const currentHeight = typeof g.style?.height === 'number' ? g.style.height : 200; return { ...g, data: { ...g.data, minimized: true, metadata: { ...g.data.metadata, originalWidth: currentWidth, originalHeight: currentHeight, }, }, style: { ...g.style, width: MINIMIZED_GROUP_WIDTH, height: MINIMIZED_GROUP_HEIGHT, }, }; } else { // Maximizing: restore original dimensions from metadata const originalWidth = (g.data.metadata?.originalWidth as number) || 300; const originalHeight = (g.data.metadata?.originalHeight as number) || 200; // Remove the stored dimensions from metadata // eslint-disable-next-line @typescript-eslint/no-unused-vars const { originalWidth: _, originalHeight: __, ...restMetadata } = g.data.metadata || {}; return { ...g, data: { ...g.data, minimized: false, metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined, }, style: { ...g.style, width: originalWidth, height: originalHeight, }, }; } }); // When minimizing, hide child nodes; when maximizing, show them const updatedNodes = state.nodes.map((node) => { const nodeWithParent = node as Actor & { parentId?: string }; if (nodeWithParent.parentId === groupId) { return { ...node, hidden: isMinimized, }; } return node; }); return { groups: updatedGroups, nodes: updatedNodes, }; }), // Utility operations clearGraph: () => set({ nodes: [], edges: [], groups: [], }), setNodes: (nodes: Actor[]) => set({ nodes, }), setEdges: (edges: Relation[]) => set({ edges, }), setGroups: (groups: Group[]) => set({ groups, }), setNodeTypes: (nodeTypes: NodeTypeConfig[]) => set({ nodeTypes, }), setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) => set({ edgeTypes, }), setLabels: (labels: LabelConfig[]) => set({ labels, }), // NOTE: exportToFile and importFromFile have been removed // Import/export is now handled by the workspace-level system // See: workspaceStore.importDocumentFromFile() and workspaceStore.exportDocument() loadGraphState: (data) => { // Build set of valid group IDs to check for orphaned parentId references const validGroupIds = new Set((data.groups || []).map((g) => g.id)); // Sanitize nodes - remove parentId if the referenced group doesn't exist // This handles timeline states created before groups feature was implemented const sanitizedNodes = data.nodes.map((node) => { const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' }; if (nodeWithParent.parentId && !validGroupIds.has(nodeWithParent.parentId)) { // Remove orphaned parent reference // eslint-disable-next-line @typescript-eslint/no-unused-vars const { parentId, extent, ...cleanNode } = nodeWithParent; return cleanNode as Actor; } return node; }); // Apply tangible migration for backward compatibility const migratedTangibles = data.tangibles ? migrateTangibleConfigs(data.tangibles) : []; // Apply handle migration for backward compatibility (remove old 4-position handles) const migratedEdges = migrateRelationHandlesArray(data.edges); // Atomic update: all state changes happen in a single set() call set({ nodes: sanitizedNodes, edges: migratedEdges, groups: data.groups || [], nodeTypes: data.nodeTypes, edgeTypes: data.edgeTypes, labels: data.labels || [], tangibles: migratedTangibles, }); }, }));