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 [newTypeColor, setNewTypeColor] = useState('#6366f1');
|
||||
const [newTypeShape, setNewTypeShape] = useState<import('../../types').NodeShape>('rectangle');
|
||||
const [newTypeDescription, setNewTypeDescription] = useState('');
|
||||
const [newTypeIcon, setNewTypeIcon] = useState('');
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
|||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editLabel, setEditLabel] = useState('');
|
||||
const [editColor, setEditColor] = useState('');
|
||||
const [editShape, setEditShape] = useState<import('../../types').NodeShape>('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) => {
|
|||
<NodeTypeForm
|
||||
name={newTypeName}
|
||||
color={newTypeColor}
|
||||
shape={newTypeShape}
|
||||
icon={newTypeIcon}
|
||||
description={newTypeDescription}
|
||||
onNameChange={setNewTypeName}
|
||||
onColorChange={setNewTypeColor}
|
||||
onShapeChange={setNewTypeShape}
|
||||
onIconChange={setNewTypeIcon}
|
||||
onDescriptionChange={setNewTypeDescription}
|
||||
/>
|
||||
|
|
@ -154,10 +162,12 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
|||
<NodeTypeForm
|
||||
name={editLabel}
|
||||
color={editColor}
|
||||
shape={editShape}
|
||||
icon={editIcon}
|
||||
description={editDescription}
|
||||
onNameChange={setEditLabel}
|
||||
onColorChange={setEditColor}
|
||||
onShapeChange={setEditShape}
|
||||
onIconChange={setEditIcon}
|
||||
onDescriptionChange={setEditDescription}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ShapeSelector value={shape} onChange={onShapeChange} color={color} />
|
||||
|
||||
<IconSelector selectedIcon={icon} onSelect={onIconChange} />
|
||||
|
||||
<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";
|
||||
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<ActorData>) => {
|
|||
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<ActorData>) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg shadow-md min-w-[120px]
|
||||
transition-all duration-200
|
||||
${selected ? "shadow-xl" : "shadow-md"}
|
||||
`}
|
||||
className="relative"
|
||||
style={{
|
||||
backgroundColor: nodeColor,
|
||||
borderWidth: "3px", // Keep consistent border width
|
||||
borderStyle: "solid",
|
||||
borderColor: borderColor,
|
||||
color: textColor,
|
||||
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 */}
|
||||
|
|
@ -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">
|
||||
{/* Icon (if available) */}
|
||||
{IconComponent && (
|
||||
|
|
@ -194,6 +190,7 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
|||
{nodeLabel}
|
||||
</div>
|
||||
</div>
|
||||
</NodeShapeRenderer>
|
||||
</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[];
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -23,11 +23,20 @@ export interface RelationData {
|
|||
|
||||
export type Relation = Edge<RelationData>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue