mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
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:
parent
084a3bb486
commit
e0784ff3d8
14 changed files with 633 additions and 48 deletions
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
196
src/components/Config/ShapeSelector.tsx
Normal file
196
src/components/Config/ShapeSelector.tsx
Normal 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;
|
||||||
|
|
@ -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,34 +154,43 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Node content */}
|
{/* Node content with shape renderer */}
|
||||||
<div className="space-y-1">
|
<NodeShapeRenderer
|
||||||
{/* Icon (if available) */}
|
shape={nodeShape}
|
||||||
{IconComponent && (
|
color={nodeColor}
|
||||||
|
borderColor={borderColor}
|
||||||
|
textColor={textColor}
|
||||||
|
selected={selected}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{/* Icon (if available) */}
|
||||||
|
{IconComponent && (
|
||||||
|
<div
|
||||||
|
className="flex justify-center mb-1"
|
||||||
|
style={{ color: textColor, fontSize: "2rem" }}
|
||||||
|
>
|
||||||
|
<IconComponent />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main label */}
|
||||||
<div
|
<div
|
||||||
className="flex justify-center mb-1"
|
className="text-base font-bold text-center break-words leading-tight"
|
||||||
style={{ color: textColor, fontSize: "2rem" }}
|
style={{ color: textColor }}
|
||||||
>
|
>
|
||||||
<IconComponent />
|
{data.label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main label */}
|
{/* Type as subtle subtitle */}
|
||||||
<div
|
<div
|
||||||
className="text-base font-bold text-center break-words leading-tight"
|
className="text-xs text-center opacity-70 font-medium leading-tight"
|
||||||
style={{ color: textColor }}
|
style={{ color: textColor }}
|
||||||
>
|
>
|
||||||
{data.label}
|
{nodeLabel}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</NodeShapeRenderer>
|
||||||
{/* Type as subtle subtitle */}
|
|
||||||
<div
|
|
||||||
className="text-xs text-center opacity-70 font-medium leading-tight"
|
|
||||||
style={{ color: textColor }}
|
|
||||||
>
|
|
||||||
{nodeLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
53
src/components/Nodes/Shapes/CircleShape.tsx
Normal file
53
src/components/Nodes/Shapes/CircleShape.tsx
Normal 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;
|
||||||
53
src/components/Nodes/Shapes/EllipseShape.tsx
Normal file
53
src/components/Nodes/Shapes/EllipseShape.tsx
Normal 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;
|
||||||
80
src/components/Nodes/Shapes/NodeShapeRenderer.tsx
Normal file
80
src/components/Nodes/Shapes/NodeShapeRenderer.tsx
Normal 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;
|
||||||
53
src/components/Nodes/Shapes/PillShape.tsx
Normal file
53
src/components/Nodes/Shapes/PillShape.tsx
Normal 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;
|
||||||
52
src/components/Nodes/Shapes/RectangleShape.tsx
Normal file
52
src/components/Nodes/Shapes/RectangleShape.tsx
Normal 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;
|
||||||
53
src/components/Nodes/Shapes/RoundedRectangleShape.tsx
Normal file
53
src/components/Nodes/Shapes/RoundedRectangleShape.tsx
Normal 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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue