feat: add node shape variants with five distinct shapes

Implements configurable shape variants for actor nodes, allowing visual
differentiation of node types beyond just color.

Features:
- Five shape options: rectangle, circle, rounded rectangle, ellipse, pill
- All shapes use pure CSS (border-radius) for consistent behavior
- Auto-grow with content
- Perfect shadow and selection/highlight effects
- Proper React Flow handle alignment
- Shape selector UI with visual previews
- Migration logic for existing documents (defaults to rectangle)

Shape characteristics:
- Rectangle: Standard, general purpose
- Circle: Round, for people and concepts
- Rounded Rectangle: Soft edges, friendly appearance
- Ellipse: Oval/horizontal, for processes and stages
- Pill: Capsule, compact for tags and labels

Technical approach:
- Uses border-radius for all shapes (no clip-path)
- Ensures boxShadow follows shape contours properly
- Each shape component maintains consistent props interface
- NodeShapeRenderer routes to appropriate shape component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-16 15:34:24 +02:00
parent 084a3bb486
commit e0784ff3d8
14 changed files with 633 additions and 48 deletions

View file

@ -25,6 +25,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
const [newTypeName, setNewTypeName] = useState(''); const [newTypeName, setNewTypeName] = useState('');
const [newTypeColor, setNewTypeColor] = useState('#6366f1'); const [newTypeColor, setNewTypeColor] = useState('#6366f1');
const [newTypeShape, setNewTypeShape] = useState<import('../../types').NodeShape>('rectangle');
const [newTypeDescription, setNewTypeDescription] = useState(''); const [newTypeDescription, setNewTypeDescription] = useState('');
const [newTypeIcon, setNewTypeIcon] = useState(''); const [newTypeIcon, setNewTypeIcon] = useState('');
@ -32,6 +33,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState(''); const [editLabel, setEditLabel] = useState('');
const [editColor, setEditColor] = useState(''); const [editColor, setEditColor] = useState('');
const [editShape, setEditShape] = useState<import('../../types').NodeShape>('rectangle');
const [editIcon, setEditIcon] = useState(''); const [editIcon, setEditIcon] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
@ -53,6 +55,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
id, id,
label: newTypeName.trim(), label: newTypeName.trim(),
color: newTypeColor, color: newTypeColor,
shape: newTypeShape,
icon: newTypeIcon || undefined, icon: newTypeIcon || undefined,
description: newTypeDescription.trim() || undefined, description: newTypeDescription.trim() || undefined,
}; };
@ -62,6 +65,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
// Reset form // Reset form
setNewTypeName(''); setNewTypeName('');
setNewTypeColor('#6366f1'); setNewTypeColor('#6366f1');
setNewTypeShape('rectangle');
setNewTypeDescription(''); setNewTypeDescription('');
setNewTypeIcon(''); setNewTypeIcon('');
}; };
@ -82,6 +86,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
setEditingId(type.id); setEditingId(type.id);
setEditLabel(type.label); setEditLabel(type.label);
setEditColor(type.color); setEditColor(type.color);
setEditShape(type.shape || 'rectangle');
setEditIcon(type.icon || ''); setEditIcon(type.icon || '');
setEditDescription(type.description || ''); setEditDescription(type.description || '');
}; };
@ -92,6 +97,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
updateNodeType(editingId, { updateNodeType(editingId, {
label: editLabel.trim(), label: editLabel.trim(),
color: editColor, color: editColor,
shape: editShape,
icon: editIcon || undefined, icon: editIcon || undefined,
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
}); });
@ -124,10 +130,12 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
<NodeTypeForm <NodeTypeForm
name={newTypeName} name={newTypeName}
color={newTypeColor} color={newTypeColor}
shape={newTypeShape}
icon={newTypeIcon} icon={newTypeIcon}
description={newTypeDescription} description={newTypeDescription}
onNameChange={setNewTypeName} onNameChange={setNewTypeName}
onColorChange={setNewTypeColor} onColorChange={setNewTypeColor}
onShapeChange={setNewTypeShape}
onIconChange={setNewTypeIcon} onIconChange={setNewTypeIcon}
onDescriptionChange={setNewTypeDescription} onDescriptionChange={setNewTypeDescription}
/> />
@ -154,10 +162,12 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
<NodeTypeForm <NodeTypeForm
name={editLabel} name={editLabel}
color={editColor} color={editColor}
shape={editShape}
icon={editIcon} icon={editIcon}
description={editDescription} description={editDescription}
onNameChange={setEditLabel} onNameChange={setEditLabel}
onColorChange={setEditColor} onColorChange={setEditColor}
onShapeChange={setEditShape}
onIconChange={setEditIcon} onIconChange={setEditIcon}
onDescriptionChange={setEditDescription} onDescriptionChange={setEditDescription}
/> />

View file

@ -1,4 +1,6 @@
import IconSelector from './IconSelector'; import IconSelector from './IconSelector';
import ShapeSelector from './ShapeSelector';
import type { NodeShape } from '../../types';
/** /**
* NodeTypeForm - Reusable form fields for creating/editing node types * NodeTypeForm - Reusable form fields for creating/editing node types
@ -6,6 +8,7 @@ import IconSelector from './IconSelector';
* Features: * Features:
* - Name input * - Name input
* - Color picker (visual + text input) * - Color picker (visual + text input)
* - Shape selector
* - Icon selector * - Icon selector
* - Description input * - Description input
*/ */
@ -13,10 +16,12 @@ import IconSelector from './IconSelector';
interface Props { interface Props {
name: string; name: string;
color: string; color: string;
shape: NodeShape;
icon: string; icon: string;
description: string; description: string;
onNameChange: (value: string) => void; onNameChange: (value: string) => void;
onColorChange: (value: string) => void; onColorChange: (value: string) => void;
onShapeChange: (value: NodeShape) => void;
onIconChange: (value: string) => void; onIconChange: (value: string) => void;
onDescriptionChange: (value: string) => void; onDescriptionChange: (value: string) => void;
} }
@ -24,10 +29,12 @@ interface Props {
const NodeTypeForm = ({ const NodeTypeForm = ({
name, name,
color, color,
shape,
icon, icon,
description, description,
onNameChange, onNameChange,
onColorChange, onColorChange,
onShapeChange,
onIconChange, onIconChange,
onDescriptionChange, onDescriptionChange,
}: Props) => { }: Props) => {
@ -67,6 +74,8 @@ const NodeTypeForm = ({
</div> </div>
</div> </div>
<ShapeSelector value={shape} onChange={onShapeChange} color={color} />
<IconSelector selectedIcon={icon} onSelect={onIconChange} /> <IconSelector selectedIcon={icon} onSelect={onIconChange} />
<div> <div>

View file

@ -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 (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Shape
</label>
<div className="grid grid-cols-3 gap-3">
{SHAPE_OPTIONS.map((option) => {
const isSelected = value === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={`
relative p-3 rounded-lg border-2 transition-all
hover:border-blue-400 hover:bg-blue-50
${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white'}
`}
title={option.description}
>
{/* Shape Preview */}
<div className="flex justify-center items-center h-12 mb-2">
<ShapePreview shape={option.id} color={color} size={40} />
</div>
{/* Shape Label */}
<div className="text-xs text-center text-gray-700 font-medium">
{option.label}
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute top-1 right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<svg
className="w-3 h-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
);
};
/**
* 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 (
<svg width={svgSize} height={svgSize * 0.7} viewBox="0 0 60 42">
<rect
x={strokeWidth}
y={strokeWidth}
width={60 - strokeWidth * 2}
height={42 - strokeWidth * 2}
fill={color}
stroke={color}
strokeWidth={strokeWidth}
rx="3"
/>
</svg>
);
case 'circle':
return (
<svg width={svgSize} height={svgSize} viewBox="0 0 50 50">
<circle
cx="25"
cy="25"
r={25 - strokeWidth}
fill={color}
stroke={color}
strokeWidth={strokeWidth}
/>
</svg>
);
case 'roundedRectangle':
return (
<svg width={svgSize} height={svgSize * 0.7} viewBox="0 0 60 42">
<rect
x={strokeWidth}
y={strokeWidth}
width={60 - strokeWidth * 2}
height={42 - strokeWidth * 2}
fill={color}
stroke={color}
strokeWidth={strokeWidth}
rx="12"
/>
</svg>
);
case 'ellipse':
return (
<svg width={svgSize * 1.2} height={svgSize * 0.7} viewBox="0 0 60 35">
<ellipse
cx="30"
cy="17.5"
rx={30 - strokeWidth}
ry={17.5 - strokeWidth}
fill={color}
stroke={color}
strokeWidth={strokeWidth}
/>
</svg>
);
case 'pill':
return (
<svg width={svgSize} height={svgSize * 0.5} viewBox="0 0 60 30">
<rect
x={strokeWidth}
y={strokeWidth}
width={60 - strokeWidth * 2}
height={30 - strokeWidth * 2}
fill={color}
stroke={color}
strokeWidth={strokeWidth}
rx="15"
/>
</svg>
);
default:
return null;
}
};
export default ShapeSelector;

View file

@ -8,6 +8,7 @@ import {
} from "../../utils/colorUtils"; } from "../../utils/colorUtils";
import { getIconComponent } from "../../utils/iconUtils"; import { getIconComponent } from "../../utils/iconUtils";
import type { ActorData } from "../../types"; import type { ActorData } from "../../types";
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
/** /**
* CustomNode - Represents an actor in the constellation graph * CustomNode - Represents an actor in the constellation graph
@ -32,6 +33,7 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type); const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
const nodeColor = nodeTypeConfig?.color || "#6b7280"; const nodeColor = nodeTypeConfig?.color || "#6b7280";
const nodeLabel = nodeTypeConfig?.label || "Unknown"; const nodeLabel = nodeTypeConfig?.label || "Unknown";
const nodeShape = nodeTypeConfig?.shape || "rectangle";
const IconComponent = getIconComponent(nodeTypeConfig?.icon); const IconComponent = getIconComponent(nodeTypeConfig?.icon);
// Determine text color based on background // Determine text color based on background
@ -86,23 +88,9 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
return ( return (
<div <div
className={` className="relative"
px-4 py-3 rounded-lg shadow-md min-w-[120px]
transition-all duration-200
${selected ? "shadow-xl" : "shadow-md"}
`}
style={{ style={{
backgroundColor: nodeColor,
borderWidth: "3px", // Keep consistent border width
borderStyle: "solid",
borderColor: borderColor,
color: textColor,
opacity: nodeOpacity, opacity: nodeOpacity,
boxShadow: selected
? `0 0 0 3px ${nodeColor}40` // Add outer glow when selected (40 = ~25% opacity)
: isHighlighted
? `0 0 0 3px ${nodeColor}80, 0 0 12px ${nodeColor}60` // Highlight glow for search matches
: undefined,
}} }}
> >
{/* Connection handles - shown only when selected or connecting */} {/* Connection handles - shown only when selected or connecting */}
@ -166,7 +154,15 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
}} }}
/> />
{/* Node content */} {/* Node content with shape renderer */}
<NodeShapeRenderer
shape={nodeShape}
color={nodeColor}
borderColor={borderColor}
textColor={textColor}
selected={selected}
isHighlighted={isHighlighted}
>
<div className="space-y-1"> <div className="space-y-1">
{/* Icon (if available) */} {/* Icon (if available) */}
{IconComponent && ( {IconComponent && (
@ -194,6 +190,7 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
{nodeLabel} {nodeLabel}
</div> </div>
</div> </div>
</NodeShapeRenderer>
</div> </div>
); );
}; };

View file

@ -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 (
<div
className={`px-4 py-3 rounded-full min-w-[120px] flex items-center justify-center transition-shadow duration-200 ${className}`}
style={{
backgroundColor: color,
borderWidth: '3px',
borderStyle: 'solid',
borderColor: borderColor,
color: textColor,
aspectRatio: '1 / 1',
boxShadow: shadowStyle,
}}
>
{children}
</div>
);
};
export default CircleShape;

View file

@ -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 (
<div
className={`px-6 py-3 min-w-[140px] min-h-[80px] flex items-center justify-center transition-shadow duration-200 ${className}`}
style={{
backgroundColor: color,
borderWidth: '3px',
borderStyle: 'solid',
borderColor: borderColor,
color: textColor,
borderRadius: '50%',
boxShadow: shadowStyle,
}}
>
{children}
</div>
);
};
export default EllipseShape;

View file

@ -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:
* <NodeShapeRenderer
* shape="circle"
* color="#3b82f6"
* borderColor="#1e40af"
* textColor="white"
* >
* <YourNodeContent />
* </NodeShapeRenderer>
*/
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 <RectangleShape {...shapeProps}>{children}</RectangleShape>;
case 'circle':
return <CircleShape {...shapeProps}>{children}</CircleShape>;
case 'roundedRectangle':
return <RoundedRectangleShape {...shapeProps}>{children}</RoundedRectangleShape>;
case 'ellipse':
return <EllipseShape {...shapeProps}>{children}</EllipseShape>;
case 'pill':
return <PillShape {...shapeProps}>{children}</PillShape>;
default:
// Fallback to rectangle for unknown shapes
return <RectangleShape {...shapeProps}>{children}</RectangleShape>;
}
};
export default NodeShapeRenderer;

View file

@ -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 (
<div
className={`px-6 py-3 min-w-[120px] flex items-center justify-center transition-shadow duration-200 ${className}`}
style={{
backgroundColor: color,
borderWidth: '3px',
borderStyle: 'solid',
borderColor: borderColor,
color: textColor,
borderRadius: '999px',
boxShadow: shadowStyle,
}}
>
{children}
</div>
);
};
export default PillShape;

View file

@ -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 (
<div
className={`px-4 py-3 rounded-lg min-w-[120px] transition-shadow duration-200 ${className}`}
style={{
backgroundColor: color,
borderWidth: '3px',
borderStyle: 'solid',
borderColor: borderColor,
color: textColor,
boxShadow: shadowStyle,
}}
>
{children}
</div>
);
};
export default RectangleShape;

View file

@ -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 (
<div
className={`px-4 py-3 min-w-[120px] transition-shadow duration-200 ${className}`}
style={{
backgroundColor: color,
borderWidth: '3px',
borderStyle: 'solid',
borderColor: borderColor,
color: textColor,
borderRadius: '24px', // More rounded than regular rectangle
boxShadow: shadowStyle,
}}
>
{children}
</div>
);
};
export default RoundedRectangleShape;

View file

@ -31,12 +31,12 @@ interface GraphStore {
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
} }
// Default node types // Default node types with semantic shape assignments
const defaultNodeTypes: NodeTypeConfig[] = [ const defaultNodeTypes: NodeTypeConfig[] = [
{ id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' }, { id: 'person', label: 'Person', color: '#3b82f6', shape: 'circle', icon: 'Person', description: 'Individual person' },
{ id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' }, { id: 'organization', label: 'Organization', color: '#10b981', shape: 'rectangle', icon: 'Business', description: 'Company or group' },
{ id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' }, { id: 'system', label: 'System', color: '#f59e0b', shape: 'roundedRectangle', icon: 'Computer', description: 'Technical system' },
{ id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' }, { id: 'concept', label: 'Concept', color: '#8b5cf6', shape: 'roundedRectangle', icon: 'Lightbulb', description: 'Abstract concept' },
]; ];
// Default edge types // Default edge types

View file

@ -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 // Deserialize graph state from a document
export function deserializeGraphState(document: ConstellationDocument): { export function deserializeGraphState(document: ConstellationDocument): {
nodes: Actor[]; nodes: Actor[];
@ -156,10 +173,13 @@ export function deserializeGraphState(document: ConstellationDocument): {
const nodes = deserializeActors(currentGraph.nodes); const nodes = deserializeActors(currentGraph.nodes);
const edges = deserializeRelations(currentGraph.edges); const edges = deserializeRelations(currentGraph.edges);
// Migrate node types to include shape property
const migratedNodeTypes = migrateNodeTypes(currentGraph.nodeTypes);
return { return {
nodes, nodes,
edges, edges,
nodeTypes: currentGraph.nodeTypes, nodeTypes: migratedNodeTypes,
edgeTypes: currentGraph.edgeTypes, edgeTypes: currentGraph.edgeTypes,
}; };
} catch (error) { } catch (error) {

View file

@ -39,10 +39,10 @@ const defaultSettings: WorkspaceSettings = {
maxOpenDocuments: 10, maxOpenDocuments: 10,
autoSaveEnabled: true, autoSaveEnabled: true,
defaultNodeTypes: [ defaultNodeTypes: [
{ id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' }, { id: 'person', label: 'Person', color: '#3b82f6', shape: 'circle', icon: 'Person', description: 'Individual person' },
{ id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' }, { id: 'organization', label: 'Organization', color: '#10b981', shape: 'rectangle', icon: 'Business', description: 'Company or group' },
{ id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' }, { id: 'system', label: 'System', color: '#f59e0b', shape: 'roundedRectangle', icon: 'Computer', description: 'Technical system' },
{ id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' }, { id: 'concept', label: 'Concept', color: '#8b5cf6', shape: 'roundedRectangle', icon: 'Lightbulb', description: 'Abstract concept' },
], ],
defaultEdgeTypes: [ defaultEdgeTypes: [
{ id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' }, { id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' },

View file

@ -23,11 +23,20 @@ export interface RelationData {
export type Relation = Edge<RelationData>; export type Relation = Edge<RelationData>;
// Node Shape Types
export type NodeShape =
| 'rectangle'
| 'circle'
| 'roundedRectangle'
| 'ellipse'
| 'pill';
// Node Type Configuration // Node Type Configuration
export interface NodeTypeConfig { export interface NodeTypeConfig {
id: string; id: string;
label: string; label: string;
color: string; color: string;
shape: NodeShape;
icon?: string; icon?: string;
description?: string; description?: string;
} }