mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
refactor: extract RightPanel into separate specialized components
Improves code organization and maintainability by separating panel implementations into focused, single-responsibility components. Changes: - Extract NodeEditorPanel for actor property editing - Extract EdgeEditorPanel for relation property editing - Extract GraphAnalysisPanel for graph metrics display - Simplify RightPanel to act as routing/layout component (745→114 lines) - Remove old unused Editor/NodePropertiesPanel.tsx - Remove old unused Editor/EdgePropertiesPanel.tsx All existing functionality preserved including live updates, debouncing, modals, and connection displays. Each panel now self-contained with its own state management and logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d98acf963b
commit
61a13383dc
6 changed files with 658 additions and 961 deletions
|
|
@ -1,155 +0,0 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
|
||||||
import PropertyPanel from '../Common/PropertyPanel';
|
|
||||||
import LabelSelector from '../Common/LabelSelector';
|
|
||||||
import type { Relation } from '../../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EdgePropertiesPanel - Side panel for editing edge/relation properties
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Change relation type
|
|
||||||
* - Edit relation label
|
|
||||||
* - Delete relation
|
|
||||||
* - Visual preview of line style
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
selectedEdge: Relation | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
|
|
||||||
const { edgeTypes, updateEdge, deleteEdge } = useGraphWithHistory();
|
|
||||||
const [relationType, setRelationType] = useState('');
|
|
||||||
const [relationLabel, setRelationLabel] = useState('');
|
|
||||||
const [relationLabels, setRelationLabels] = useState<string[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedEdge && selectedEdge.data) {
|
|
||||||
setRelationType(selectedEdge.data.type || '');
|
|
||||||
// Only show custom label if it exists and differs from type label
|
|
||||||
const typeLabel = edgeTypes.find((et) => et.id === selectedEdge.data?.type)?.label;
|
|
||||||
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
|
|
||||||
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
|
|
||||||
setRelationLabels(selectedEdge.data.labels || []);
|
|
||||||
}
|
|
||||||
}, [selectedEdge, edgeTypes]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!selectedEdge) return;
|
|
||||||
updateEdge(selectedEdge.id, {
|
|
||||||
type: relationType,
|
|
||||||
// Only set label if user provided a custom one (not empty)
|
|
||||||
label: relationLabel.trim() || undefined,
|
|
||||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (!selectedEdge) return;
|
|
||||||
deleteEdge(selectedEdge.id);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedEdgeTypeConfig = edgeTypes.find((et) => et.id === relationType);
|
|
||||||
|
|
||||||
const renderStylePreview = () => {
|
|
||||||
if (!selectedEdgeTypeConfig) return null;
|
|
||||||
|
|
||||||
const strokeDasharray = {
|
|
||||||
solid: '0',
|
|
||||||
dashed: '8,4',
|
|
||||||
dotted: '2,4',
|
|
||||||
}[selectedEdgeTypeConfig.style || 'solid'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="20" className="mt-2">
|
|
||||||
<line
|
|
||||||
x1="0"
|
|
||||||
y1="10"
|
|
||||||
x2="100%"
|
|
||||||
y2="10"
|
|
||||||
stroke={selectedEdgeTypeConfig.color}
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeDasharray={strokeDasharray}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PropertyPanel
|
|
||||||
isOpen={!!selectedEdge}
|
|
||||||
title="Relation Properties"
|
|
||||||
onClose={onClose}
|
|
||||||
onSave={handleSave}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
deleteConfirmMessage="Are you sure you want to delete this relation?"
|
|
||||||
deleteButtonLabel="Delete"
|
|
||||||
>
|
|
||||||
{/* Relation Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Relation Type
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={relationType}
|
|
||||||
onChange={(e) => setRelationType(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
{edgeTypes.map((edgeType) => (
|
|
||||||
<option key={edgeType.id} value={edgeType.id}>
|
|
||||||
{edgeType.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{renderStylePreview()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Label */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Custom Label (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={relationLabel}
|
|
||||||
onChange={(e) => setRelationLabel(e.target.value)}
|
|
||||||
placeholder={selectedEdgeTypeConfig?.label || 'Enter label'}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Leave empty to use default type label
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Labels (optional)
|
|
||||||
</label>
|
|
||||||
<LabelSelector
|
|
||||||
value={relationLabels}
|
|
||||||
onChange={setRelationLabels}
|
|
||||||
scope="relations"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Info */}
|
|
||||||
{selectedEdge && (
|
|
||||||
<div className="pt-3 border-t border-gray-200">
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
<span className="font-medium">From:</span> {selectedEdge.source}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
<span className="font-medium">To:</span> {selectedEdge.target}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PropertyPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EdgePropertiesPanel;
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
|
||||||
import PropertyPanel from '../Common/PropertyPanel';
|
|
||||||
import LabelSelector from '../Common/LabelSelector';
|
|
||||||
import type { Actor } from '../../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NodePropertiesPanel - Side panel for editing node/actor properties
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Change actor type
|
|
||||||
* - Edit actor label
|
|
||||||
* - Edit description
|
|
||||||
* - Delete actor
|
|
||||||
* - Visual color preview
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
selectedNode: Actor | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
|
|
||||||
const { nodeTypes, updateNode, deleteNode } = useGraphWithHistory();
|
|
||||||
const [actorType, setActorType] = useState('');
|
|
||||||
const [actorLabel, setActorLabel] = useState('');
|
|
||||||
const [actorDescription, setActorDescription] = useState('');
|
|
||||||
const [actorLabels, setActorLabels] = useState<string[]>([]);
|
|
||||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedNode) {
|
|
||||||
setActorType(selectedNode.data?.type || '');
|
|
||||||
setActorLabel(selectedNode.data?.label || '');
|
|
||||||
setActorDescription(selectedNode.data?.description || '');
|
|
||||||
setActorLabels(selectedNode.data?.labels || []);
|
|
||||||
|
|
||||||
// Focus and select the label input when panel opens
|
|
||||||
setTimeout(() => {
|
|
||||||
if (labelInputRef.current) {
|
|
||||||
labelInputRef.current.focus();
|
|
||||||
labelInputRef.current.select();
|
|
||||||
}
|
|
||||||
}, 100); // Small delay to ensure panel animation completes
|
|
||||||
}
|
|
||||||
}, [selectedNode]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
updateNode(selectedNode.id, {
|
|
||||||
data: {
|
|
||||||
type: actorType,
|
|
||||||
label: actorLabel,
|
|
||||||
description: actorDescription || undefined,
|
|
||||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
deleteNode(selectedNode.id);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedNodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PropertyPanel
|
|
||||||
isOpen={!!selectedNode}
|
|
||||||
title="Actor Properties"
|
|
||||||
onClose={onClose}
|
|
||||||
onSave={handleSave}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
deleteConfirmMessage="Are you sure you want to delete this actor? All connected relations will also be deleted."
|
|
||||||
deleteButtonLabel="Delete"
|
|
||||||
>
|
|
||||||
{/* Actor Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Actor Type
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={actorType}
|
|
||||||
onChange={(e) => setActorType(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
{nodeTypes.map((nodeType) => (
|
|
||||||
<option key={nodeType.id} value={nodeType.id}>
|
|
||||||
{nodeType.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selectedNodeTypeConfig && (
|
|
||||||
<div
|
|
||||||
className="mt-2 h-8 rounded border-2 flex items-center justify-center text-xs font-medium text-white"
|
|
||||||
style={{
|
|
||||||
backgroundColor: selectedNodeTypeConfig.color,
|
|
||||||
borderColor: selectedNodeTypeConfig.color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Color Preview
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actor Label */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Label *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={labelInputRef}
|
|
||||||
type="text"
|
|
||||||
value={actorLabel}
|
|
||||||
onChange={(e) => setActorLabel(e.target.value)}
|
|
||||||
placeholder="Enter actor name"
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Description (optional)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={actorDescription}
|
|
||||||
onChange={(e) => setActorDescription(e.target.value)}
|
|
||||||
placeholder="Add a description"
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Labels (optional)
|
|
||||||
</label>
|
|
||||||
<LabelSelector
|
|
||||||
value={actorLabels}
|
|
||||||
onChange={setActorLabels}
|
|
||||||
scope="actors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Node Info */}
|
|
||||||
{selectedNode && (
|
|
||||||
<div className="pt-3 border-t border-gray-200">
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
<span className="font-medium">Node ID:</span> {selectedNode.id}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
<span className="font-medium">Position:</span> ({Math.round(selectedNode.position.x)}, {Math.round(selectedNode.position.y)})
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PropertyPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NodePropertiesPanel;
|
|
||||||
333
src/components/Panels/EdgeEditorPanel.tsx
Normal file
333
src/components/Panels/EdgeEditorPanel.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { IconButton, Tooltip, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
import SyncAltIcon from '@mui/icons-material/SyncAlt';
|
||||||
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
|
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
import ConnectionDisplay from '../Common/ConnectionDisplay';
|
||||||
|
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||||
|
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
|
||||||
|
import type { Relation, EdgeDirectionality } from '../../types';
|
||||||
|
|
||||||
|
interface EdgeEditorPanelProps {
|
||||||
|
selectedEdge: Relation;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
|
||||||
|
const { nodes, edges, nodeTypes, edgeTypes, updateEdge, deleteEdge, setEdges } = useGraphWithHistory();
|
||||||
|
const { pushToHistory } = useDocumentHistory();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||||
|
|
||||||
|
// Edge property states
|
||||||
|
const [relationType, setRelationType] = useState('');
|
||||||
|
const [relationLabel, setRelationLabel] = useState('');
|
||||||
|
const [relationLabels, setRelationLabels] = useState<string[]>([]);
|
||||||
|
const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed');
|
||||||
|
|
||||||
|
// Track if user has made changes
|
||||||
|
const [hasEdgeChanges, setHasEdgeChanges] = useState(false);
|
||||||
|
|
||||||
|
// Relation type modal state
|
||||||
|
const [showRelationTypeModal, setShowRelationTypeModal] = useState(false);
|
||||||
|
const [editingRelationTypeId, setEditingRelationTypeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Update state when selected edge changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEdge.data) {
|
||||||
|
setRelationType(selectedEdge.data.type || '');
|
||||||
|
const typeLabel = edgeTypes.find((et) => et.id === selectedEdge.data?.type)?.label;
|
||||||
|
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
|
||||||
|
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
|
||||||
|
setRelationLabels(selectedEdge.data.labels || []);
|
||||||
|
const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type);
|
||||||
|
setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
|
||||||
|
setHasEdgeChanges(false);
|
||||||
|
}
|
||||||
|
}, [selectedEdge, edgeTypes]);
|
||||||
|
|
||||||
|
// Live update edge properties (debounced)
|
||||||
|
const updateEdgeProperties = useCallback(() => {
|
||||||
|
if (!hasEdgeChanges) return;
|
||||||
|
updateEdge(selectedEdge.id, {
|
||||||
|
type: relationType,
|
||||||
|
label: relationLabel.trim() || undefined,
|
||||||
|
directionality: relationDirectionality,
|
||||||
|
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||||
|
});
|
||||||
|
setHasEdgeChanges(false);
|
||||||
|
}, [selectedEdge.id, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]);
|
||||||
|
|
||||||
|
// Debounce live updates
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (hasEdgeChanges) {
|
||||||
|
updateEdgeProperties();
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [hasEdgeChanges, updateEdgeProperties]);
|
||||||
|
|
||||||
|
// Handle edge deletion
|
||||||
|
const handleDeleteEdge = async () => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Delete Relation',
|
||||||
|
message: 'Are you sure you want to delete this relation?',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
severity: 'danger',
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
deleteEdge(selectedEdge.id);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle reverse direction
|
||||||
|
const handleReverseDirection = () => {
|
||||||
|
// Push to history BEFORE mutation
|
||||||
|
pushToHistory('Reverse Relation Direction');
|
||||||
|
|
||||||
|
// Update the edges array with the reversed edge
|
||||||
|
const updatedEdges = edges.map(edge => {
|
||||||
|
if (edge.id === selectedEdge.id) {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
source: edge.target,
|
||||||
|
target: edge.source,
|
||||||
|
sourceHandle: edge.targetHandle,
|
||||||
|
targetHandle: edge.sourceHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return edge;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply the update (setEdges is a pass-through without history tracking)
|
||||||
|
setEdges(updatedEdges);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit relation type
|
||||||
|
const handleEditRelationType = () => {
|
||||||
|
if (!relationType) return;
|
||||||
|
setEditingRelationTypeId(relationType);
|
||||||
|
setShowRelationTypeModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle close relation type modal
|
||||||
|
const handleCloseRelationTypeModal = () => {
|
||||||
|
setShowRelationTypeModal(false);
|
||||||
|
setEditingRelationTypeId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the current edge data from the store (to reflect live updates)
|
||||||
|
const currentEdge = edges.find(e => e.id === selectedEdge.id) || selectedEdge;
|
||||||
|
const selectedEdgeTypeConfig = edgeTypes.find((et) => et.id === relationType);
|
||||||
|
|
||||||
|
const renderStylePreview = () => {
|
||||||
|
if (!selectedEdgeTypeConfig) return null;
|
||||||
|
|
||||||
|
const strokeDasharray = {
|
||||||
|
solid: '0',
|
||||||
|
dashed: '8,4',
|
||||||
|
dotted: '2,4',
|
||||||
|
}[selectedEdgeTypeConfig.style || 'solid'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="20" className="mt-2">
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="10"
|
||||||
|
x2="100%"
|
||||||
|
y2="10"
|
||||||
|
stroke={selectedEdgeTypeConfig.color}
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray={strokeDasharray}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
|
||||||
|
{/* Relation Type */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-700">
|
||||||
|
Relation Type
|
||||||
|
</label>
|
||||||
|
<Tooltip title="Edit Relation Type">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleEditRelationType}
|
||||||
|
sx={{ padding: '2px' }}
|
||||||
|
>
|
||||||
|
<EditIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={relationType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newType = e.target.value;
|
||||||
|
setRelationType(newType);
|
||||||
|
// Apply relation type change instantly (no debounce)
|
||||||
|
updateEdge(selectedEdge.id, {
|
||||||
|
type: newType,
|
||||||
|
label: relationLabel.trim() || undefined,
|
||||||
|
directionality: relationDirectionality,
|
||||||
|
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{edgeTypes.map((edgeType) => (
|
||||||
|
<option key={edgeType.id} value={edgeType.id}>
|
||||||
|
{edgeType.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{renderStylePreview()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Label */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Custom Label (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={relationLabel}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRelationLabel(e.target.value);
|
||||||
|
setHasEdgeChanges(true);
|
||||||
|
}}
|
||||||
|
placeholder={selectedEdgeTypeConfig?.label || 'Enter label'}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Leave empty to use default type label
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Labels (optional)
|
||||||
|
</label>
|
||||||
|
<AutocompleteLabelSelector
|
||||||
|
value={relationLabels}
|
||||||
|
onChange={(newLabels) => {
|
||||||
|
setRelationLabels(newLabels);
|
||||||
|
setHasEdgeChanges(true);
|
||||||
|
}}
|
||||||
|
scope="relations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Directionality */}
|
||||||
|
<div className="pt-3 border-t border-gray-200">
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-2">
|
||||||
|
Directionality
|
||||||
|
</label>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={relationDirectionality}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
if (newValue !== null) {
|
||||||
|
setRelationDirectionality(newValue);
|
||||||
|
// Apply directionality change instantly (no debounce)
|
||||||
|
updateEdge(selectedEdge.id, {
|
||||||
|
type: relationType,
|
||||||
|
label: relationLabel.trim() || undefined,
|
||||||
|
directionality: newValue,
|
||||||
|
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
aria-label="relationship directionality"
|
||||||
|
>
|
||||||
|
<ToggleButton value="directed" aria-label="directed relationship">
|
||||||
|
<Tooltip title="Directed (one-way)">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<ArrowForwardIcon fontSize="small" />
|
||||||
|
<span className="text-xs">Directed</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="bidirectional" aria-label="bidirectional relationship">
|
||||||
|
<Tooltip title="Bidirectional (two-way)">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<SyncAltIcon fontSize="small" />
|
||||||
|
<span className="text-xs">Both</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="undirected" aria-label="undirected relationship">
|
||||||
|
<Tooltip title="Undirected (no direction)">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<RemoveIcon fontSize="small" />
|
||||||
|
<span className="text-xs">None</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Info with Reverse Direction */}
|
||||||
|
<div className="pt-3 border-t border-gray-200">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-xs font-medium text-gray-700">
|
||||||
|
Connection
|
||||||
|
</label>
|
||||||
|
{relationDirectionality === 'directed' && (
|
||||||
|
<Tooltip title="Reverse Direction">
|
||||||
|
<IconButton size="small" onClick={handleReverseDirection}>
|
||||||
|
<SwapHorizIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ConnectionDisplay
|
||||||
|
sourceNode={nodes.find(n => n.id === currentEdge.source)}
|
||||||
|
targetNode={nodes.find(n => n.id === currentEdge.target)}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
directionality={relationDirectionality}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with actions */}
|
||||||
|
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50">
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteEdge}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
<span>Delete Relation</span>
|
||||||
|
</button>
|
||||||
|
{hasEdgeChanges && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 text-center italic">
|
||||||
|
Saving changes...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
<EdgeTypeConfigModal
|
||||||
|
isOpen={showRelationTypeModal}
|
||||||
|
onClose={handleCloseRelationTypeModal}
|
||||||
|
initialEditingTypeId={editingRelationTypeId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EdgeEditorPanel;
|
||||||
13
src/components/Panels/GraphAnalysisPanel.tsx
Normal file
13
src/components/Panels/GraphAnalysisPanel.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import GraphMetrics from '../Common/GraphMetrics';
|
||||||
|
import type { Actor, Relation } from '../../types';
|
||||||
|
|
||||||
|
interface GraphAnalysisPanelProps {
|
||||||
|
nodes: Actor[];
|
||||||
|
edges: Relation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GraphAnalysisPanel = ({ nodes, edges }: GraphAnalysisPanelProps) => {
|
||||||
|
return <GraphMetrics nodes={nodes} edges={edges} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GraphAnalysisPanel;
|
||||||
302
src/components/Panels/NodeEditorPanel.tsx
Normal file
302
src/components/Panels/NodeEditorPanel.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
import ConnectionDisplay from '../Common/ConnectionDisplay';
|
||||||
|
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||||
|
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
|
||||||
|
import type { Actor } from '../../types';
|
||||||
|
|
||||||
|
interface NodeEditorPanelProps {
|
||||||
|
selectedNode: Actor;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
|
||||||
|
const { nodes, edges, nodeTypes, edgeTypes, updateNode, deleteNode } = useGraphWithHistory();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||||
|
|
||||||
|
// Node property states
|
||||||
|
const [actorType, setActorType] = useState('');
|
||||||
|
const [actorLabel, setActorLabel] = useState('');
|
||||||
|
const [actorDescription, setActorDescription] = useState('');
|
||||||
|
const [actorLabels, setActorLabels] = useState<string[]>([]);
|
||||||
|
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Track if user has made changes
|
||||||
|
const [hasNodeChanges, setHasNodeChanges] = useState(false);
|
||||||
|
|
||||||
|
// Actor type modal state
|
||||||
|
const [showActorTypeModal, setShowActorTypeModal] = useState(false);
|
||||||
|
const [editingActorTypeId, setEditingActorTypeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Update state when selected node changes
|
||||||
|
useEffect(() => {
|
||||||
|
setActorType(selectedNode.data?.type || '');
|
||||||
|
setActorLabel(selectedNode.data?.label || '');
|
||||||
|
setActorDescription(selectedNode.data?.description || '');
|
||||||
|
setActorLabels(selectedNode.data?.labels || []);
|
||||||
|
setHasNodeChanges(false);
|
||||||
|
|
||||||
|
// Focus and select the label input when node is selected
|
||||||
|
setTimeout(() => {
|
||||||
|
if (labelInputRef.current) {
|
||||||
|
labelInputRef.current.focus();
|
||||||
|
labelInputRef.current.select();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, [selectedNode]);
|
||||||
|
|
||||||
|
// Live update node properties (debounced)
|
||||||
|
const updateNodeProperties = useCallback(() => {
|
||||||
|
if (!hasNodeChanges) return;
|
||||||
|
updateNode(selectedNode.id, {
|
||||||
|
data: {
|
||||||
|
type: actorType,
|
||||||
|
label: actorLabel,
|
||||||
|
description: actorDescription || undefined,
|
||||||
|
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setHasNodeChanges(false);
|
||||||
|
}, [selectedNode.id, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]);
|
||||||
|
|
||||||
|
// Debounce live updates
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (hasNodeChanges) {
|
||||||
|
updateNodeProperties();
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [hasNodeChanges, updateNodeProperties]);
|
||||||
|
|
||||||
|
// Handle node deletion
|
||||||
|
const handleDeleteNode = async () => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Delete Actor',
|
||||||
|
message: 'Are you sure you want to delete this actor? All connected relations will also be deleted.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
severity: 'danger',
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
deleteNode(selectedNode.id);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit actor type
|
||||||
|
const handleEditActorType = () => {
|
||||||
|
if (!actorType) return;
|
||||||
|
setEditingActorTypeId(actorType);
|
||||||
|
setShowActorTypeModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle close actor type modal
|
||||||
|
const handleCloseActorTypeModal = () => {
|
||||||
|
setShowActorTypeModal(false);
|
||||||
|
setEditingActorTypeId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get connections for selected node
|
||||||
|
const getNodeConnections = () => {
|
||||||
|
return edges.filter(
|
||||||
|
(edge) => edge.source === selectedNode.id || edge.target === selectedNode.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connections = getNodeConnections();
|
||||||
|
const selectedNodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
|
||||||
|
{/* Actor Type */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-700">
|
||||||
|
Actor Type
|
||||||
|
</label>
|
||||||
|
<Tooltip title="Edit Actor Type">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleEditActorType}
|
||||||
|
sx={{ padding: '2px' }}
|
||||||
|
>
|
||||||
|
<EditIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={actorType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newType = e.target.value;
|
||||||
|
setActorType(newType);
|
||||||
|
// Apply actor type change instantly (no debounce)
|
||||||
|
updateNode(selectedNode.id, {
|
||||||
|
data: {
|
||||||
|
type: newType,
|
||||||
|
label: actorLabel,
|
||||||
|
description: actorDescription || undefined,
|
||||||
|
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{nodeTypes.map((nodeType) => (
|
||||||
|
<option key={nodeType.id} value={nodeType.id}>
|
||||||
|
{nodeType.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedNodeTypeConfig && (
|
||||||
|
<div
|
||||||
|
className="mt-2 h-8 rounded border-2 flex items-center justify-center text-xs font-medium text-white"
|
||||||
|
style={{
|
||||||
|
backgroundColor: selectedNodeTypeConfig.color,
|
||||||
|
borderColor: selectedNodeTypeConfig.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedNodeTypeConfig.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actor Label */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Label *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={labelInputRef}
|
||||||
|
type="text"
|
||||||
|
value={actorLabel}
|
||||||
|
onChange={(e) => {
|
||||||
|
setActorLabel(e.target.value);
|
||||||
|
setHasNodeChanges(true);
|
||||||
|
}}
|
||||||
|
placeholder="Enter actor name"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={actorDescription}
|
||||||
|
onChange={(e) => {
|
||||||
|
setActorDescription(e.target.value);
|
||||||
|
setHasNodeChanges(true);
|
||||||
|
}}
|
||||||
|
placeholder="Add a description"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Labels (optional)
|
||||||
|
</label>
|
||||||
|
<AutocompleteLabelSelector
|
||||||
|
value={actorLabels}
|
||||||
|
onChange={(newLabels) => {
|
||||||
|
setActorLabels(newLabels);
|
||||||
|
setHasNodeChanges(true);
|
||||||
|
}}
|
||||||
|
scope="actors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connections */}
|
||||||
|
<div className="pt-3 border-t border-gray-200">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-700 mb-2">
|
||||||
|
Connections ({connections.length})
|
||||||
|
</h3>
|
||||||
|
{connections.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500 italic">No connections</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{connections.map((edge) => {
|
||||||
|
const edgeConfig = edgeTypes.find((et) => et.id === edge.data?.type);
|
||||||
|
const sourceNode = nodes.find(n => n.id === edge.source);
|
||||||
|
const targetNode = nodes.find(n => n.id === edge.target);
|
||||||
|
const edgeDirectionality = edge.data?.directionality || edgeConfig?.defaultDirectionality || 'directed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={edge.id} className="space-y-1">
|
||||||
|
{/* Edge Type Badge */}
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: edgeConfig?.color || '#6b7280' }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{edgeConfig?.label || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
{edge.data?.label && edge.data.label !== edgeConfig?.label && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
({edge.data.label})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Connection Display */}
|
||||||
|
<ConnectionDisplay
|
||||||
|
sourceNode={sourceNode}
|
||||||
|
targetNode={targetNode}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
directionality={edgeDirectionality}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Info */}
|
||||||
|
<div className="pt-3 border-t border-gray-200">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
<span className="font-medium">Node ID:</span> {selectedNode.id}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
<span className="font-medium">Position:</span> ({Math.round(selectedNode.position.x)}, {Math.round(selectedNode.position.y)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with actions */}
|
||||||
|
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50">
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteNode}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
<span>Delete Actor</span>
|
||||||
|
</button>
|
||||||
|
{hasNodeChanges && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 text-center italic">
|
||||||
|
Saving changes...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
<NodeTypeConfigModal
|
||||||
|
isOpen={showActorTypeModal}
|
||||||
|
onClose={handleCloseActorTypeModal}
|
||||||
|
initialEditingTypeId={editingActorTypeId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NodeEditorPanel;
|
||||||
|
|
@ -1,23 +1,12 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
import { IconButton, Tooltip, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
|
||||||
import SyncAltIcon from '@mui/icons-material/SyncAlt';
|
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
import { usePanelStore } from '../../stores/panelStore';
|
import { usePanelStore } from '../../stores/panelStore';
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
|
import NodeEditorPanel from './NodeEditorPanel';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import EdgeEditorPanel from './EdgeEditorPanel';
|
||||||
import GraphMetrics from '../Common/GraphMetrics';
|
import GraphAnalysisPanel from './GraphAnalysisPanel';
|
||||||
import ConnectionDisplay from '../Common/ConnectionDisplay';
|
import type { Actor, Relation } from '../../types';
|
||||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
|
||||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
|
||||||
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
|
|
||||||
import type { Actor, Relation, EdgeDirectionality } from '../../types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RightPanel - Context-aware properties panel on the right side
|
* RightPanel - Context-aware properties panel on the right side
|
||||||
|
|
@ -57,7 +46,6 @@ const PanelHeader = ({ title, onCollapse }: PanelHeaderProps) => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
rightPanelCollapsed,
|
rightPanelCollapsed,
|
||||||
rightPanelWidth,
|
rightPanelWidth,
|
||||||
|
|
@ -65,199 +53,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
||||||
expandRightPanel,
|
expandRightPanel,
|
||||||
} = usePanelStore();
|
} = usePanelStore();
|
||||||
|
|
||||||
const { nodes, edges, nodeTypes, edgeTypes, updateNode, updateEdge, deleteNode, deleteEdge, setEdges } = useGraphWithHistory();
|
const { nodes, edges } = useGraphWithHistory();
|
||||||
const { pushToHistory } = useDocumentHistory();
|
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
|
||||||
|
|
||||||
// Node property states
|
|
||||||
const [actorType, setActorType] = useState('');
|
|
||||||
const [actorLabel, setActorLabel] = useState('');
|
|
||||||
const [actorDescription, setActorDescription] = useState('');
|
|
||||||
const [actorLabels, setActorLabels] = useState<string[]>([]);
|
|
||||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Edge property states
|
|
||||||
const [relationType, setRelationType] = useState('');
|
|
||||||
const [relationLabel, setRelationLabel] = useState('');
|
|
||||||
const [relationLabels, setRelationLabels] = useState<string[]>([]);
|
|
||||||
const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed');
|
|
||||||
|
|
||||||
// Track if user has made changes
|
|
||||||
const [hasNodeChanges, setHasNodeChanges] = useState(false);
|
|
||||||
const [hasEdgeChanges, setHasEdgeChanges] = useState(false);
|
|
||||||
|
|
||||||
// Actor type modal state
|
|
||||||
const [showActorTypeModal, setShowActorTypeModal] = useState(false);
|
|
||||||
const [editingActorTypeId, setEditingActorTypeId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Relation type modal state
|
|
||||||
const [showRelationTypeModal, setShowRelationTypeModal] = useState(false);
|
|
||||||
const [editingRelationTypeId, setEditingRelationTypeId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Update state when selected node changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedNode) {
|
|
||||||
setActorType(selectedNode.data?.type || '');
|
|
||||||
setActorLabel(selectedNode.data?.label || '');
|
|
||||||
setActorDescription(selectedNode.data?.description || '');
|
|
||||||
setActorLabels(selectedNode.data?.labels || []);
|
|
||||||
setHasNodeChanges(false);
|
|
||||||
|
|
||||||
// Focus and select the label input when node is selected
|
|
||||||
setTimeout(() => {
|
|
||||||
if (labelInputRef.current && !rightPanelCollapsed) {
|
|
||||||
labelInputRef.current.focus();
|
|
||||||
labelInputRef.current.select();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, [selectedNode, rightPanelCollapsed]);
|
|
||||||
|
|
||||||
// Update state when selected edge changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedEdge && selectedEdge.data) {
|
|
||||||
setRelationType(selectedEdge.data.type || '');
|
|
||||||
const typeLabel = edgeTypes.find((et) => et.id === selectedEdge.data?.type)?.label;
|
|
||||||
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
|
|
||||||
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
|
|
||||||
setRelationLabels(selectedEdge.data.labels || []);
|
|
||||||
const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type);
|
|
||||||
setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
|
|
||||||
setHasEdgeChanges(false);
|
|
||||||
}
|
|
||||||
}, [selectedEdge, edgeTypes]);
|
|
||||||
|
|
||||||
// Live update node properties (debounced)
|
|
||||||
const updateNodeProperties = useCallback(() => {
|
|
||||||
if (!selectedNode || !hasNodeChanges) return;
|
|
||||||
updateNode(selectedNode.id, {
|
|
||||||
data: {
|
|
||||||
type: actorType,
|
|
||||||
label: actorLabel,
|
|
||||||
description: actorDescription || undefined,
|
|
||||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setHasNodeChanges(false);
|
|
||||||
}, [selectedNode, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]);
|
|
||||||
|
|
||||||
// Live update edge properties (debounced)
|
|
||||||
const updateEdgeProperties = useCallback(() => {
|
|
||||||
if (!selectedEdge || !hasEdgeChanges) return;
|
|
||||||
updateEdge(selectedEdge.id, {
|
|
||||||
type: relationType,
|
|
||||||
label: relationLabel.trim() || undefined,
|
|
||||||
directionality: relationDirectionality,
|
|
||||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
|
||||||
});
|
|
||||||
setHasEdgeChanges(false);
|
|
||||||
}, [selectedEdge, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]);
|
|
||||||
|
|
||||||
// Debounce live updates
|
|
||||||
useEffect(() => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (hasNodeChanges) {
|
|
||||||
updateNodeProperties();
|
|
||||||
}
|
|
||||||
if (hasEdgeChanges) {
|
|
||||||
updateEdgeProperties();
|
|
||||||
}
|
|
||||||
}, 500); // 500ms debounce
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [hasNodeChanges, hasEdgeChanges, updateNodeProperties, updateEdgeProperties]);
|
|
||||||
|
|
||||||
// Handle node deletion
|
|
||||||
const handleDeleteNode = async () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: 'Delete Actor',
|
|
||||||
message: 'Are you sure you want to delete this actor? All connected relations will also be deleted.',
|
|
||||||
confirmLabel: 'Delete',
|
|
||||||
severity: 'danger',
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
deleteNode(selectedNode.id);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edge deletion
|
|
||||||
const handleDeleteEdge = async () => {
|
|
||||||
if (!selectedEdge) return;
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: 'Delete Relation',
|
|
||||||
message: 'Are you sure you want to delete this relation?',
|
|
||||||
confirmLabel: 'Delete',
|
|
||||||
severity: 'danger',
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
deleteEdge(selectedEdge.id);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle reverse direction
|
|
||||||
const handleReverseDirection = () => {
|
|
||||||
if (!selectedEdge) return;
|
|
||||||
|
|
||||||
// Push to history BEFORE mutation
|
|
||||||
pushToHistory('Reverse Relation Direction');
|
|
||||||
|
|
||||||
// Update the edges array with the reversed edge
|
|
||||||
const updatedEdges = edges.map(edge => {
|
|
||||||
if (edge.id === selectedEdge.id) {
|
|
||||||
return {
|
|
||||||
...edge,
|
|
||||||
source: edge.target,
|
|
||||||
target: edge.source,
|
|
||||||
sourceHandle: edge.targetHandle,
|
|
||||||
targetHandle: edge.sourceHandle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return edge;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply the update (setEdges is a pass-through without history tracking)
|
|
||||||
setEdges(updatedEdges);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit actor type
|
|
||||||
const handleEditActorType = () => {
|
|
||||||
if (!actorType) return;
|
|
||||||
setEditingActorTypeId(actorType);
|
|
||||||
setShowActorTypeModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle close actor type modal
|
|
||||||
const handleCloseActorTypeModal = () => {
|
|
||||||
setShowActorTypeModal(false);
|
|
||||||
setEditingActorTypeId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit relation type
|
|
||||||
const handleEditRelationType = () => {
|
|
||||||
if (!relationType) return;
|
|
||||||
setEditingRelationTypeId(relationType);
|
|
||||||
setShowRelationTypeModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle close relation type modal
|
|
||||||
const handleCloseRelationTypeModal = () => {
|
|
||||||
setShowRelationTypeModal(false);
|
|
||||||
setEditingRelationTypeId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get connections for selected node
|
|
||||||
const getNodeConnections = () => {
|
|
||||||
if (!selectedNode) return [];
|
|
||||||
return edges.filter(
|
|
||||||
(edge) => edge.source === selectedNode.id || edge.target === selectedNode.id
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedNodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
|
|
||||||
const selectedEdgeTypeConfig = edgeTypes.find((et) => et.id === relationType);
|
|
||||||
|
|
||||||
// Collapsed view
|
// Collapsed view
|
||||||
if (rightPanelCollapsed) {
|
if (rightPanelCollapsed) {
|
||||||
|
|
@ -268,17 +64,6 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
||||||
<ChevronLeftIcon fontSize="small" />
|
<ChevronLeftIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{ConfirmDialogComponent}
|
|
||||||
<NodeTypeConfigModal
|
|
||||||
isOpen={showActorTypeModal}
|
|
||||||
onClose={handleCloseActorTypeModal}
|
|
||||||
initialEditingTypeId={editingActorTypeId}
|
|
||||||
/>
|
|
||||||
<EdgeTypeConfigModal
|
|
||||||
isOpen={showRelationTypeModal}
|
|
||||||
onClose={handleCloseRelationTypeModal}
|
|
||||||
initialEditingTypeId={editingRelationTypeId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -291,455 +76,38 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
||||||
style={{ width: `${rightPanelWidth}px` }}
|
style={{ width: `${rightPanelWidth}px` }}
|
||||||
>
|
>
|
||||||
<PanelHeader title="Graph Analysis" onCollapse={collapseRightPanel} />
|
<PanelHeader title="Graph Analysis" onCollapse={collapseRightPanel} />
|
||||||
<GraphMetrics nodes={nodes} edges={edges} />
|
<GraphAnalysisPanel nodes={nodes} edges={edges} />
|
||||||
{ConfirmDialogComponent}
|
|
||||||
<NodeTypeConfigModal
|
|
||||||
isOpen={showActorTypeModal}
|
|
||||||
onClose={handleCloseActorTypeModal}
|
|
||||||
initialEditingTypeId={editingActorTypeId}
|
|
||||||
/>
|
|
||||||
<EdgeTypeConfigModal
|
|
||||||
isOpen={showRelationTypeModal}
|
|
||||||
onClose={handleCloseRelationTypeModal}
|
|
||||||
initialEditingTypeId={editingRelationTypeId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node properties view
|
// Node properties view
|
||||||
if (selectedNode) {
|
if (selectedNode) {
|
||||||
const connections = getNodeConnections();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full bg-white border-l border-gray-200 flex flex-col"
|
className="h-full bg-white border-l border-gray-200 flex flex-col"
|
||||||
style={{ width: `${rightPanelWidth}px` }}
|
style={{ width: `${rightPanelWidth}px` }}
|
||||||
>
|
>
|
||||||
<PanelHeader title="Actor Properties" onCollapse={collapseRightPanel} />
|
<PanelHeader title="Actor Properties" onCollapse={collapseRightPanel} />
|
||||||
|
<NodeEditorPanel selectedNode={selectedNode} onClose={onClose} />
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
|
|
||||||
{/* Actor Type */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<label className="block text-xs font-medium text-gray-700">
|
|
||||||
Actor Type
|
|
||||||
</label>
|
|
||||||
<Tooltip title="Edit Actor Type">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={handleEditActorType}
|
|
||||||
sx={{ padding: '2px' }}
|
|
||||||
>
|
|
||||||
<EditIcon sx={{ fontSize: 14 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={actorType}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newType = e.target.value;
|
|
||||||
setActorType(newType);
|
|
||||||
// Apply actor type change instantly (no debounce)
|
|
||||||
if (selectedNode) {
|
|
||||||
updateNode(selectedNode.id, {
|
|
||||||
data: {
|
|
||||||
type: newType,
|
|
||||||
label: actorLabel,
|
|
||||||
description: actorDescription || undefined,
|
|
||||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
{nodeTypes.map((nodeType) => (
|
|
||||||
<option key={nodeType.id} value={nodeType.id}>
|
|
||||||
{nodeType.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selectedNodeTypeConfig && (
|
|
||||||
<div
|
|
||||||
className="mt-2 h-8 rounded border-2 flex items-center justify-center text-xs font-medium text-white"
|
|
||||||
style={{
|
|
||||||
backgroundColor: selectedNodeTypeConfig.color,
|
|
||||||
borderColor: selectedNodeTypeConfig.color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedNodeTypeConfig.label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actor Label */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Label *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={labelInputRef}
|
|
||||||
type="text"
|
|
||||||
value={actorLabel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setActorLabel(e.target.value);
|
|
||||||
setHasNodeChanges(true);
|
|
||||||
}}
|
|
||||||
placeholder="Enter actor name"
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Description (optional)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={actorDescription}
|
|
||||||
onChange={(e) => {
|
|
||||||
setActorDescription(e.target.value);
|
|
||||||
setHasNodeChanges(true);
|
|
||||||
}}
|
|
||||||
placeholder="Add a description"
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Labels (optional)
|
|
||||||
</label>
|
|
||||||
<AutocompleteLabelSelector
|
|
||||||
value={actorLabels}
|
|
||||||
onChange={(newLabels) => {
|
|
||||||
setActorLabels(newLabels);
|
|
||||||
setHasNodeChanges(true);
|
|
||||||
}}
|
|
||||||
scope="actors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connections */}
|
|
||||||
<div className="pt-3 border-t border-gray-200">
|
|
||||||
<h3 className="text-xs font-semibold text-gray-700 mb-2">
|
|
||||||
Connections ({connections.length})
|
|
||||||
</h3>
|
|
||||||
{connections.length === 0 ? (
|
|
||||||
<p className="text-xs text-gray-500 italic">No connections</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{connections.map((edge) => {
|
|
||||||
const edgeConfig = edgeTypes.find((et) => et.id === edge.data?.type);
|
|
||||||
const sourceNode = nodes.find(n => n.id === edge.source);
|
|
||||||
const targetNode = nodes.find(n => n.id === edge.target);
|
|
||||||
const edgeDirectionality = edge.data?.directionality || edgeConfig?.defaultDirectionality || 'directed';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={edge.id} className="space-y-1">
|
|
||||||
{/* Edge Type Badge */}
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span
|
|
||||||
className="inline-block w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: edgeConfig?.color || '#6b7280' }}
|
|
||||||
/>
|
|
||||||
<span className="text-xs font-medium text-gray-700">
|
|
||||||
{edgeConfig?.label || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
{edge.data?.label && edge.data.label !== edgeConfig?.label && (
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
({edge.data.label})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Connection Display */}
|
|
||||||
<ConnectionDisplay
|
|
||||||
sourceNode={sourceNode}
|
|
||||||
targetNode={targetNode}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
directionality={edgeDirectionality}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Node Info */}
|
|
||||||
<div className="pt-3 border-t border-gray-200">
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
<span className="font-medium">Node ID:</span> {selectedNode.id}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
<span className="font-medium">Position:</span> ({Math.round(selectedNode.position.x)}, {Math.round(selectedNode.position.y)})
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with actions */}
|
|
||||||
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50">
|
|
||||||
<button
|
|
||||||
onClick={handleDeleteNode}
|
|
||||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
<span>Delete Actor</span>
|
|
||||||
</button>
|
|
||||||
{hasNodeChanges && (
|
|
||||||
<p className="text-xs text-gray-500 mt-2 text-center italic">
|
|
||||||
Saving changes...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{ConfirmDialogComponent}
|
|
||||||
<NodeTypeConfigModal
|
|
||||||
isOpen={showActorTypeModal}
|
|
||||||
onClose={handleCloseActorTypeModal}
|
|
||||||
initialEditingTypeId={editingActorTypeId}
|
|
||||||
/>
|
|
||||||
<EdgeTypeConfigModal
|
|
||||||
isOpen={showRelationTypeModal}
|
|
||||||
onClose={handleCloseRelationTypeModal}
|
|
||||||
initialEditingTypeId={editingRelationTypeId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge properties view
|
// Edge properties view
|
||||||
if (selectedEdge) {
|
if (selectedEdge) {
|
||||||
// Get the current edge data from the store (to reflect live updates)
|
|
||||||
const currentEdge = edges.find(e => e.id === selectedEdge.id) || selectedEdge;
|
|
||||||
|
|
||||||
const renderStylePreview = () => {
|
|
||||||
if (!selectedEdgeTypeConfig) return null;
|
|
||||||
|
|
||||||
const strokeDasharray = {
|
|
||||||
solid: '0',
|
|
||||||
dashed: '8,4',
|
|
||||||
dotted: '2,4',
|
|
||||||
}[selectedEdgeTypeConfig.style || 'solid'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="20" className="mt-2">
|
|
||||||
<line
|
|
||||||
x1="0"
|
|
||||||
y1="10"
|
|
||||||
x2="100%"
|
|
||||||
y2="10"
|
|
||||||
stroke={selectedEdgeTypeConfig.color}
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeDasharray={strokeDasharray}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full bg-white border-l border-gray-200 flex flex-col"
|
className="h-full bg-white border-l border-gray-200 flex flex-col"
|
||||||
style={{ width: `${rightPanelWidth}px` }}
|
style={{ width: `${rightPanelWidth}px` }}
|
||||||
>
|
>
|
||||||
<PanelHeader title="Relation Properties" onCollapse={collapseRightPanel} />
|
<PanelHeader title="Relation Properties" onCollapse={collapseRightPanel} />
|
||||||
|
<EdgeEditorPanel selectedEdge={selectedEdge} onClose={onClose} />
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
|
|
||||||
{/* Relation Type */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<label className="block text-xs font-medium text-gray-700">
|
|
||||||
Relation Type
|
|
||||||
</label>
|
|
||||||
<Tooltip title="Edit Relation Type">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={handleEditRelationType}
|
|
||||||
sx={{ padding: '2px' }}
|
|
||||||
>
|
|
||||||
<EditIcon sx={{ fontSize: 14 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={relationType}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newType = e.target.value;
|
|
||||||
setRelationType(newType);
|
|
||||||
// Apply relation type change instantly (no debounce)
|
|
||||||
if (selectedEdge) {
|
|
||||||
updateEdge(selectedEdge.id, {
|
|
||||||
type: newType,
|
|
||||||
label: relationLabel.trim() || undefined,
|
|
||||||
directionality: relationDirectionality,
|
|
||||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
{edgeTypes.map((edgeType) => (
|
|
||||||
<option key={edgeType.id} value={edgeType.id}>
|
|
||||||
{edgeType.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{renderStylePreview()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Label */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Custom Label (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={relationLabel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRelationLabel(e.target.value);
|
|
||||||
setHasEdgeChanges(true);
|
|
||||||
}}
|
|
||||||
placeholder={selectedEdgeTypeConfig?.label || 'Enter label'}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Leave empty to use default type label
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Labels (optional)
|
|
||||||
</label>
|
|
||||||
<AutocompleteLabelSelector
|
|
||||||
value={relationLabels}
|
|
||||||
onChange={(newLabels) => {
|
|
||||||
setRelationLabels(newLabels);
|
|
||||||
setHasEdgeChanges(true);
|
|
||||||
}}
|
|
||||||
scope="relations"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Directionality */}
|
|
||||||
<div className="pt-3 border-t border-gray-200">
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-2">
|
|
||||||
Directionality
|
|
||||||
</label>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={relationDirectionality}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, newValue) => {
|
|
||||||
if (newValue !== null && selectedEdge) {
|
|
||||||
setRelationDirectionality(newValue);
|
|
||||||
// Apply directionality change instantly (no debounce)
|
|
||||||
updateEdge(selectedEdge.id, {
|
|
||||||
type: relationType,
|
|
||||||
label: relationLabel.trim() || undefined,
|
|
||||||
directionality: newValue,
|
|
||||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
fullWidth
|
|
||||||
aria-label="relationship directionality"
|
|
||||||
>
|
|
||||||
<ToggleButton value="directed" aria-label="directed relationship">
|
|
||||||
<Tooltip title="Directed (one-way)">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<ArrowForwardIcon fontSize="small" />
|
|
||||||
<span className="text-xs">Directed</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="bidirectional" aria-label="bidirectional relationship">
|
|
||||||
<Tooltip title="Bidirectional (two-way)">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<SyncAltIcon fontSize="small" />
|
|
||||||
<span className="text-xs">Both</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="undirected" aria-label="undirected relationship">
|
|
||||||
<Tooltip title="Undirected (no direction)">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<RemoveIcon fontSize="small" />
|
|
||||||
<span className="text-xs">None</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Info with Reverse Direction */}
|
|
||||||
<div className="pt-3 border-t border-gray-200">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<label className="text-xs font-medium text-gray-700">
|
|
||||||
Connection
|
|
||||||
</label>
|
|
||||||
{relationDirectionality === 'directed' && (
|
|
||||||
<Tooltip title="Reverse Direction">
|
|
||||||
<IconButton size="small" onClick={handleReverseDirection}>
|
|
||||||
<SwapHorizIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ConnectionDisplay
|
|
||||||
sourceNode={nodes.find(n => n.id === currentEdge.source)}
|
|
||||||
targetNode={nodes.find(n => n.id === currentEdge.target)}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
directionality={relationDirectionality}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with actions */}
|
|
||||||
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50">
|
|
||||||
<button
|
|
||||||
onClick={handleDeleteEdge}
|
|
||||||
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
<span>Delete Relation</span>
|
|
||||||
</button>
|
|
||||||
{hasEdgeChanges && (
|
|
||||||
<p className="text-xs text-gray-500 mt-2 text-center italic">
|
|
||||||
Saving changes...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{ConfirmDialogComponent}
|
|
||||||
<EdgeTypeConfigModal
|
|
||||||
isOpen={showRelationTypeModal}
|
|
||||||
onClose={handleCloseRelationTypeModal}
|
|
||||||
initialEditingTypeId={editingRelationTypeId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<>
|
|
||||||
{ConfirmDialogComponent}
|
|
||||||
<NodeTypeConfigModal
|
|
||||||
isOpen={showActorTypeModal}
|
|
||||||
onClose={handleCloseActorTypeModal}
|
|
||||||
initialEditingTypeId={editingActorTypeId}
|
|
||||||
/>
|
|
||||||
<EdgeTypeConfigModal
|
|
||||||
isOpen={showRelationTypeModal}
|
|
||||||
onClose={handleCloseRelationTypeModal}
|
|
||||||
initialEditingTypeId={editingRelationTypeId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RightPanel;
|
export default RightPanel;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue