diff --git a/src/components/Config/NodeTypeConfig.tsx b/src/components/Config/NodeTypeConfig.tsx index e0be11d..73bb4ee 100644 --- a/src/components/Config/NodeTypeConfig.tsx +++ b/src/components/Config/NodeTypeConfig.tsx @@ -25,6 +25,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { const [newTypeName, setNewTypeName] = useState(''); const [newTypeColor, setNewTypeColor] = useState('#6366f1'); + const [newTypeShape, setNewTypeShape] = useState('rectangle'); const [newTypeDescription, setNewTypeDescription] = useState(''); const [newTypeIcon, setNewTypeIcon] = useState(''); @@ -32,6 +33,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { const [editingId, setEditingId] = useState(null); const [editLabel, setEditLabel] = useState(''); const [editColor, setEditColor] = useState(''); + const [editShape, setEditShape] = useState('rectangle'); const [editIcon, setEditIcon] = useState(''); const [editDescription, setEditDescription] = useState(''); @@ -53,6 +55,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { id, label: newTypeName.trim(), color: newTypeColor, + shape: newTypeShape, icon: newTypeIcon || undefined, description: newTypeDescription.trim() || undefined, }; @@ -62,6 +65,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { // Reset form setNewTypeName(''); setNewTypeColor('#6366f1'); + setNewTypeShape('rectangle'); setNewTypeDescription(''); setNewTypeIcon(''); }; @@ -82,6 +86,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { setEditingId(type.id); setEditLabel(type.label); setEditColor(type.color); + setEditShape(type.shape || 'rectangle'); setEditIcon(type.icon || ''); setEditDescription(type.description || ''); }; @@ -92,6 +97,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { updateNodeType(editingId, { label: editLabel.trim(), color: editColor, + shape: editShape, icon: editIcon || undefined, description: editDescription.trim() || undefined, }); @@ -124,10 +130,12 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { @@ -154,10 +162,12 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { diff --git a/src/components/Config/NodeTypeForm.tsx b/src/components/Config/NodeTypeForm.tsx index 4947380..6a3dd48 100644 --- a/src/components/Config/NodeTypeForm.tsx +++ b/src/components/Config/NodeTypeForm.tsx @@ -1,4 +1,6 @@ import IconSelector from './IconSelector'; +import ShapeSelector from './ShapeSelector'; +import type { NodeShape } from '../../types'; /** * NodeTypeForm - Reusable form fields for creating/editing node types @@ -6,6 +8,7 @@ import IconSelector from './IconSelector'; * Features: * - Name input * - Color picker (visual + text input) + * - Shape selector * - Icon selector * - Description input */ @@ -13,10 +16,12 @@ import IconSelector from './IconSelector'; interface Props { name: string; color: string; + shape: NodeShape; icon: string; description: string; onNameChange: (value: string) => void; onColorChange: (value: string) => void; + onShapeChange: (value: NodeShape) => void; onIconChange: (value: string) => void; onDescriptionChange: (value: string) => void; } @@ -24,10 +29,12 @@ interface Props { const NodeTypeForm = ({ name, color, + shape, icon, description, onNameChange, onColorChange, + onShapeChange, onIconChange, onDescriptionChange, }: Props) => { @@ -67,6 +74,8 @@ const NodeTypeForm = ({ + +
diff --git a/src/components/Config/ShapeSelector.tsx b/src/components/Config/ShapeSelector.tsx new file mode 100644 index 0000000..a027b14 --- /dev/null +++ b/src/components/Config/ShapeSelector.tsx @@ -0,0 +1,196 @@ +import type { NodeShape } from '../../types'; + +interface ShapeSelectorProps { + value: NodeShape; + onChange: (shape: NodeShape) => void; + color?: string; +} + +interface ShapeOption { + id: NodeShape; + label: string; + description: string; +} + +const SHAPE_OPTIONS: ShapeOption[] = [ + { + id: 'rectangle', + label: 'Rectangle', + description: 'Standard rectangular shape, good for general purpose use', + }, + { + id: 'circle', + label: 'Circle', + description: 'Circular shape, best for people and concepts', + }, + { + id: 'roundedRectangle', + label: 'Rounded Rectangle', + description: 'Soft rounded rectangle, suitable for teams and groups', + }, + { + id: 'ellipse', + label: 'Ellipse', + description: 'Oval shape, ideal for processes and stages', + }, + { + id: 'pill', + label: 'Pill', + description: 'Capsule shape, perfect for tags and labels', + }, +]; + +/** + * ShapeSelector - Visual selector for node shapes + * + * Displays all available shape options with visual previews + * and allows users to select a shape for their node type. + */ +const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProps) => { + return ( +
+ +
+ {SHAPE_OPTIONS.map((option) => { + const isSelected = value === option.id; + return ( + + ); + })} +
+
+ ); +}; + +/** + * ShapePreview - Renders a small preview of a shape + */ +const ShapePreview = ({ shape, color, size }: { shape: NodeShape; color: string; size: number }) => { + const svgSize = size; + const strokeWidth = 2; + + switch (shape) { + case 'rectangle': + return ( + + + + ); + + case 'circle': + return ( + + + + ); + + case 'roundedRectangle': + return ( + + + + ); + + case 'ellipse': + return ( + + + + ); + + case 'pill': + return ( + + + + ); + + default: + return null; + } +}; + +export default ShapeSelector; diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index f28d987..7dc60d4 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -8,6 +8,7 @@ import { } from "../../utils/colorUtils"; import { getIconComponent } from "../../utils/iconUtils"; import type { ActorData } from "../../types"; +import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; /** * CustomNode - Represents an actor in the constellation graph @@ -32,6 +33,7 @@ const CustomNode = ({ data, selected }: NodeProps) => { const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type); const nodeColor = nodeTypeConfig?.color || "#6b7280"; const nodeLabel = nodeTypeConfig?.label || "Unknown"; + const nodeShape = nodeTypeConfig?.shape || "rectangle"; const IconComponent = getIconComponent(nodeTypeConfig?.icon); // Determine text color based on background @@ -86,23 +88,9 @@ const CustomNode = ({ data, selected }: NodeProps) => { return (
{/* Connection handles - shown only when selected or connecting */} @@ -166,34 +154,43 @@ const CustomNode = ({ data, selected }: NodeProps) => { }} /> - {/* Node content */} -
- {/* Icon (if available) */} - {IconComponent && ( + {/* Node content with shape renderer */} + +
+ {/* Icon (if available) */} + {IconComponent && ( +
+ +
+ )} + + {/* Main label */}
- + {data.label}
- )} - {/* Main label */} -
- {data.label} + {/* Type as subtle subtitle */} +
+ {nodeLabel} +
- - {/* Type as subtle subtitle */} -
- {nodeLabel} -
-
+
); }; diff --git a/src/components/Nodes/Shapes/CircleShape.tsx b/src/components/Nodes/Shapes/CircleShape.tsx new file mode 100644 index 0000000..39360c4 --- /dev/null +++ b/src/components/Nodes/Shapes/CircleShape.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; + +interface CircleShapeProps { + color: string; + borderColor: string; + textColor: string; + selected?: boolean; + isHighlighted?: boolean; + children: ReactNode; + className?: string; +} + +/** + * CircleShape - Circular/elliptical node shape + * + * Best for: People, concepts, end states + * Characteristics: Compact, no directional bias, works well in radial layouts + * Handles: Positioned at cardinal points (top, right, bottom, left) of bounding box + */ +const CircleShape = ({ + color, + borderColor, + textColor, + selected = false, + isHighlighted = false, + children, + className = '', +}: CircleShapeProps) => { + const shadowStyle = selected + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40` + : isHighlighted + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60` + : '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'; + + return ( +
+ {children} +
+ ); +}; + +export default CircleShape; diff --git a/src/components/Nodes/Shapes/EllipseShape.tsx b/src/components/Nodes/Shapes/EllipseShape.tsx new file mode 100644 index 0000000..04c9994 --- /dev/null +++ b/src/components/Nodes/Shapes/EllipseShape.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; + +interface EllipseShapeProps { + color: string; + borderColor: string; + textColor: string; + selected?: boolean; + isHighlighted?: boolean; + children: ReactNode; + className?: string; +} + +/** + * EllipseShape - Elliptical/oval node shape + * + * Best for: Processes, stages, states, horizontal groupings + * Characteristics: Wider than tall, smooth edges, good for labeled stages + * Handles: Positioned at cardinal points (top, right, bottom, left) of bounding box + */ +const EllipseShape = ({ + color, + borderColor, + textColor, + selected = false, + isHighlighted = false, + children, + className = '', +}: EllipseShapeProps) => { + const shadowStyle = selected + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40` + : isHighlighted + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60` + : '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'; + + return ( +
+ {children} +
+ ); +}; + +export default EllipseShape; diff --git a/src/components/Nodes/Shapes/NodeShapeRenderer.tsx b/src/components/Nodes/Shapes/NodeShapeRenderer.tsx new file mode 100644 index 0000000..3b8ec2f --- /dev/null +++ b/src/components/Nodes/Shapes/NodeShapeRenderer.tsx @@ -0,0 +1,80 @@ +import { ReactNode } from 'react'; +import type { NodeShape } from '../../../types'; +import RectangleShape from './RectangleShape'; +import CircleShape from './CircleShape'; +import RoundedRectangleShape from './RoundedRectangleShape'; +import EllipseShape from './EllipseShape'; +import PillShape from './PillShape'; + +interface NodeShapeRendererProps { + shape: NodeShape; + color: string; + borderColor: string; + textColor: string; + selected?: boolean; + isHighlighted?: boolean; + children: ReactNode; + className?: string; +} + +/** + * NodeShapeRenderer - Renders the appropriate shape component based on shape type + * + * This component acts as a router that selects the correct shape component + * to render based on the NodeTypeConfig.shape property. + * + * All shapes maintain consistent sizing and layout behavior to ensure + * proper alignment with React Flow's edge routing and layout algorithms. + * + * Usage: + * + * + * + */ +const NodeShapeRenderer = ({ + shape, + color, + borderColor, + textColor, + selected = false, + isHighlighted = false, + children, + className = '', +}: NodeShapeRendererProps) => { + const shapeProps = { + color, + borderColor, + textColor, + selected, + isHighlighted, + className, + }; + + switch (shape) { + case 'rectangle': + return {children}; + + case 'circle': + return {children}; + + case 'roundedRectangle': + return {children}; + + case 'ellipse': + return {children}; + + case 'pill': + return {children}; + + default: + // Fallback to rectangle for unknown shapes + return {children}; + } +}; + +export default NodeShapeRenderer; diff --git a/src/components/Nodes/Shapes/PillShape.tsx b/src/components/Nodes/Shapes/PillShape.tsx new file mode 100644 index 0000000..060bda4 --- /dev/null +++ b/src/components/Nodes/Shapes/PillShape.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; + +interface PillShapeProps { + color: string; + borderColor: string; + textColor: string; + selected?: boolean; + isHighlighted?: boolean; + children: ReactNode; + className?: string; +} + +/** + * PillShape - Capsule/pill node shape + * + * Best for: Tags, labels, flow elements, actions + * Characteristics: Fully rounded ends, compact, modern look + * Handles: Positioned at cardinal points (top, right, bottom, left) of bounding box + */ +const PillShape = ({ + color, + borderColor, + textColor, + selected = false, + isHighlighted = false, + children, + className = '', +}: PillShapeProps) => { + const shadowStyle = selected + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40` + : isHighlighted + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60` + : '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'; + + return ( +
+ {children} +
+ ); +}; + +export default PillShape; diff --git a/src/components/Nodes/Shapes/RectangleShape.tsx b/src/components/Nodes/Shapes/RectangleShape.tsx new file mode 100644 index 0000000..dd27338 --- /dev/null +++ b/src/components/Nodes/Shapes/RectangleShape.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from 'react'; + +interface RectangleShapeProps { + color: string; + borderColor: string; + textColor: string; + selected?: boolean; + isHighlighted?: boolean; + children: ReactNode; + className?: string; +} + +/** + * RectangleShape - Standard rectangular node shape + * + * This is the default shape, maintaining the current node appearance. + * Handles are positioned at the midpoints of each side (top, right, bottom, left). + */ +const RectangleShape = ({ + color, + borderColor, + textColor, + selected = false, + isHighlighted = false, + children, + className = '', +}: RectangleShapeProps) => { + // Build shadow style + const shadowStyle = selected + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40` + : isHighlighted + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60` + : '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'; + + return ( +
+ {children} +
+ ); +}; + +export default RectangleShape; diff --git a/src/components/Nodes/Shapes/RoundedRectangleShape.tsx b/src/components/Nodes/Shapes/RoundedRectangleShape.tsx new file mode 100644 index 0000000..f207fa5 --- /dev/null +++ b/src/components/Nodes/Shapes/RoundedRectangleShape.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; + +interface RoundedRectangleShapeProps { + color: string; + borderColor: string; + textColor: string; + selected?: boolean; + isHighlighted?: boolean; + children: ReactNode; + className?: string; +} + +/** + * RoundedRectangleShape - Rounded rectangular node shape + * + * Best for: Softer entities (teams, groups, communities) + * Characteristics: Friendly appearance, similar layout behavior to rectangle + * Handles: Positioned at the midpoints of each side (top, right, bottom, left) + */ +const RoundedRectangleShape = ({ + color, + borderColor, + textColor, + selected = false, + isHighlighted = false, + children, + className = '', +}: RoundedRectangleShapeProps) => { + const shadowStyle = selected + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40` + : isHighlighted + ? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60` + : '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'; + + return ( +
+ {children} +
+ ); +}; + +export default RoundedRectangleShape; diff --git a/src/stores/graphStore.ts b/src/stores/graphStore.ts index b04cfd0..59330ae 100644 --- a/src/stores/graphStore.ts +++ b/src/stores/graphStore.ts @@ -31,12 +31,12 @@ interface GraphStore { edgeTypes: EdgeTypeConfig[]; } -// Default node types +// Default node types with semantic shape assignments const defaultNodeTypes: NodeTypeConfig[] = [ - { id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' }, - { id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' }, - { id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' }, - { id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' }, + { 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 diff --git a/src/stores/persistence/loader.ts b/src/stores/persistence/loader.ts index 286c05d..72d4439 100644 --- a/src/stores/persistence/loader.ts +++ b/src/stores/persistence/loader.ts @@ -140,6 +140,23 @@ export function getCurrentGraphFromDocument(document: ConstellationDocument): { } } +// Migrate node types to include shape property if missing +function migrateNodeTypes(nodeTypes: NodeTypeConfig[]): NodeTypeConfig[] { + return nodeTypes.map(nodeType => { + // If shape property already exists, return as-is + if (nodeType.shape) { + return nodeType; + } + + // Otherwise, add default shape (rectangle) for backward compatibility + console.log(`Migrating node type "${nodeType.id}" to include shape property (defaulting to rectangle)`); + return { + ...nodeType, + shape: 'rectangle' as const, + }; + }); +} + // Deserialize graph state from a document export function deserializeGraphState(document: ConstellationDocument): { nodes: Actor[]; @@ -156,10 +173,13 @@ export function deserializeGraphState(document: ConstellationDocument): { const nodes = deserializeActors(currentGraph.nodes); const edges = deserializeRelations(currentGraph.edges); + // Migrate node types to include shape property + const migratedNodeTypes = migrateNodeTypes(currentGraph.nodeTypes); + return { nodes, edges, - nodeTypes: currentGraph.nodeTypes, + nodeTypes: migratedNodeTypes, edgeTypes: currentGraph.edgeTypes, }; } catch (error) { diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index 36c7df7..31cc752 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -39,10 +39,10 @@ const defaultSettings: WorkspaceSettings = { maxOpenDocuments: 10, autoSaveEnabled: true, defaultNodeTypes: [ - { id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' }, - { id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' }, - { id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' }, - { id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' }, + { 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' }, ], defaultEdgeTypes: [ { id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' }, diff --git a/src/types/index.ts b/src/types/index.ts index c7f3b8a..09549fa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,11 +23,20 @@ export interface RelationData { export type Relation = Edge; +// Node Shape Types +export type NodeShape = + | 'rectangle' + | 'circle' + | 'roundedRectangle' + | 'ellipse' + | 'pill'; + // Node Type Configuration export interface NodeTypeConfig { id: string; label: string; color: string; + shape: NodeShape; icon?: string; description?: string; }