constellation-analyzer/src/stores/graphStore.ts
Jan-Henrik Bruhn c9c888d0ac Implement whole-node easy-connect handle system with floating edges
Migrated from 4-position handle system (top/right/bottom/left) to React Flow's
easy-connect pattern where the entire node surface is connectable and edges
dynamically route to the nearest point on the node border.

Key changes:
- Migration utility removes old 4-position handle references for backwards compatibility
- Full-coverage invisible handles on CustomNode and GroupNode (maximized state)
- Floating edges use node.measured dimensions and node.internals.positionAbsolute
- useInternalNode hook for correct absolute positioning of nodes in groups
- All edges now omit handle fields, allowing dynamic border calculations

This improves UX by making nodes easier to connect (whole surface vs tiny handles)
and edges intelligently route to optimal connection points.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 13:01:04 +01:00

659 lines
20 KiB
TypeScript

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<GraphStore & GraphActions>((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<Actor>) =>
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<RelationData>) =>
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<Omit<NodeTypeConfig, 'id'>>) =>
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<Omit<EdgeTypeConfig, 'id'>>) =>
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<Omit<LabelConfig, 'id'>>) =>
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<typeof tangible> = {};
// 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<Omit<TangibleConfig, 'id'>>) =>
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<GroupData>) =>
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,
});
},
}));