mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
feat: implement label system and redesign filtering with positive filters
Implements a comprehensive label system and completely redesigns all filtering (labels, actor types, relation types) to use intuitive positive filtering where empty selection shows all items. Label System Features: - Create, edit, delete labels with names, colors, and scope (actors/relations/both) - Inline editing with click-to-edit UI for quick modifications - Quick-add label forms in config modals - Autocomplete label selector with inline label creation - Label badges rendered on nodes and edges (no overflow limits) - Full undo/redo support for label operations - Label validation and cleanup when labels are deleted - Labels stored per-document in workspace system Filter System Redesign: - Changed from negative to positive filtering for all filter types - Empty selection = show all items (intuitive default) - Selected items = show only those items (positive filter) - Consistent behavior across actor types, relation types, and labels - Clear visual feedback with selection counts and helper text - Auto-zoom viewport adjustment works for all filter types including labels Label Cleanup & Validation: - When label deleted, automatically removed from all nodes/edges across all timeline states - Label references validated during node/edge updates - Unknown label IDs filtered out to maintain data integrity UI Improvements: - All labels rendered without overflow limits (removed +N more indicators) - Filter checkboxes start unchecked (select to filter, not hide) - Helper text explains current filter state - Selection counts displayed in filter section headers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cfd7a0b76f
commit
d98acf963b
30 changed files with 1890 additions and 202 deletions
258
src/components/Common/AutocompleteLabelSelector.tsx
Normal file
258
src/components/Common/AutocompleteLabelSelector.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useGraphStore } from '../../stores/graphStore';
|
||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||
import LabelBadge from './LabelBadge';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
/**
|
||||
* AutocompleteLabelSelector - Text input with autocomplete dropdown for label selection
|
||||
*
|
||||
* Features:
|
||||
* - Type to search/filter available labels
|
||||
* - Quick create new label with typed name and random color
|
||||
* - Multi-select with badge display
|
||||
* - Keyboard navigation (arrow keys, enter, escape)
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
value: string[]; // Selected label IDs
|
||||
onChange: (labelIds: string[]) => void;
|
||||
scope: 'actors' | 'relations';
|
||||
}
|
||||
|
||||
// Generate random pastel color for new labels
|
||||
const generateRandomColor = () => {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
return `hsl(${hue}, 70%, 65%)`;
|
||||
};
|
||||
|
||||
const AutocompleteLabelSelector = ({ value, onChange, scope }: Props) => {
|
||||
const labels = useGraphStore((state) => state.labels);
|
||||
const { addLabel } = useGraphWithHistory();
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter labels by scope and search text
|
||||
const availableLabels = labels.filter(
|
||||
(label) => (label.appliesTo === scope || label.appliesTo === 'both') && !value.includes(label.id)
|
||||
);
|
||||
|
||||
const filteredLabels = inputValue.trim()
|
||||
? availableLabels.filter((label) =>
|
||||
label.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
: availableLabels;
|
||||
|
||||
// Check if input matches an existing label exactly
|
||||
const exactMatch = filteredLabels.find(
|
||||
(label) => label.name.toLowerCase() === inputValue.trim().toLowerCase()
|
||||
);
|
||||
|
||||
// Show "Create new" option if there's input text and no exact match
|
||||
const showCreateOption = inputValue.trim() && !exactMatch;
|
||||
|
||||
// Combined options: filtered labels + create option
|
||||
const totalOptions = filteredLabels.length + (showCreateOption ? 1 : 0);
|
||||
|
||||
// Get selected label configs
|
||||
const selectedLabels = value
|
||||
.map((id) => labels.find((l) => l.id === id))
|
||||
.filter((label): label is NonNullable<typeof label> => label !== undefined);
|
||||
|
||||
// Handle selecting an existing label
|
||||
const handleSelectLabel = (labelId: string) => {
|
||||
if (!value.includes(labelId)) {
|
||||
onChange([...value, labelId]);
|
||||
}
|
||||
setInputValue('');
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(0);
|
||||
};
|
||||
|
||||
// Handle creating a new label
|
||||
const handleCreateLabel = () => {
|
||||
const name = inputValue.trim();
|
||||
if (!name) return;
|
||||
|
||||
// Generate label ID from name (same logic as LabelConfig)
|
||||
const id = name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
// Check if ID already exists
|
||||
if (labels.some((l) => l.id === id)) {
|
||||
// If label already exists, just select it
|
||||
onChange([...value, id]);
|
||||
setInputValue('');
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new label (default to 'both' so it can be used anywhere)
|
||||
const newLabel = {
|
||||
id,
|
||||
name,
|
||||
color: generateRandomColor(),
|
||||
appliesTo: 'both' as const,
|
||||
};
|
||||
|
||||
addLabel(newLabel);
|
||||
onChange([...value, id]);
|
||||
setInputValue('');
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(0);
|
||||
};
|
||||
|
||||
// Handle removing a selected label
|
||||
const handleRemoveLabel = (labelId: string) => {
|
||||
onChange(value.filter((id) => id !== labelId));
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen && e.key !== 'Escape') {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev + 1) % totalOptions);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (totalOptions > 0) {
|
||||
if (showCreateOption && highlightedIndex === filteredLabels.length) {
|
||||
handleCreateLabel();
|
||||
} else if (highlightedIndex < filteredLabels.length) {
|
||||
handleSelectLabel(filteredLabels[highlightedIndex].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setInputValue('');
|
||||
setHighlightedIndex(0);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Reset highlighted index when filtered options change
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(0);
|
||||
}, [inputValue]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Selected labels */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{selectedLabels.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="flex items-center gap-1 group"
|
||||
>
|
||||
<LabelBadge
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
size="sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveLabel(label.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity -ml-1"
|
||||
title="Remove label"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 14 }} className="text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type to search or create labels..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (filteredLabels.length > 0 || showCreateOption) && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
{/* Existing labels */}
|
||||
{filteredLabels.map((label, index) => (
|
||||
<button
|
||||
key={label.id}
|
||||
onClick={() => handleSelectLabel(label.id)}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||
index === highlightedIndex ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
>
|
||||
<LabelBadge
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
size="sm"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Create new label option */}
|
||||
{showCreateOption && (
|
||||
<button
|
||||
onClick={handleCreateLabel}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-blue-50 flex items-center gap-2 border-t border-gray-200 ${
|
||||
highlightedIndex === filteredLabels.length ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<AddIcon fontSize="small" className="text-blue-600" />
|
||||
<span className="text-blue-600 font-medium">
|
||||
Create "{inputValue.trim()}"
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutocompleteLabelSelector;
|
||||
42
src/components/Common/LabelBadge.tsx
Normal file
42
src/components/Common/LabelBadge.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { getContrastColor } from '../../utils/colorUtils';
|
||||
|
||||
/**
|
||||
* LabelBadge - Displays a single label as a colored badge
|
||||
*
|
||||
* Features:
|
||||
* - Pill-shaped design
|
||||
* - Auto-contrast text color
|
||||
* - Truncation with ellipsis
|
||||
* - Tooltip on hover (via title attribute)
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
color: string;
|
||||
maxWidth?: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const LabelBadge = ({ name, color, maxWidth = '120px', size = 'sm' }: Props) => {
|
||||
const textColor = getContrastColor(color);
|
||||
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'text-xs px-2 py-0.5'
|
||||
: 'text-sm px-2.5 py-1';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full font-medium whitespace-nowrap overflow-hidden text-ellipsis ${sizeClasses}`}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
color: textColor,
|
||||
maxWidth,
|
||||
}}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelBadge;
|
||||
113
src/components/Common/LabelSelector.tsx
Normal file
113
src/components/Common/LabelSelector.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useGraphStore } from '../../stores/graphStore';
|
||||
import LabelBadge from './LabelBadge';
|
||||
|
||||
/**
|
||||
* LabelSelector - Multi-select dropdown for assigning labels to actors or relations
|
||||
*
|
||||
* Features:
|
||||
* - Shows available labels filtered by scope (actors/relations/both)
|
||||
* - Displays selected labels as badges with remove button
|
||||
* - Checkbox interface for adding/removing labels
|
||||
* - Empty state when no labels are configured
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
value: string[]; // Array of selected label IDs
|
||||
onChange: (labelIds: string[]) => void;
|
||||
scope: 'actors' | 'relations'; // Filter labels by scope
|
||||
}
|
||||
|
||||
const LabelSelector = ({ value, onChange, scope }: Props) => {
|
||||
const labels = useGraphStore((state) => state.labels);
|
||||
|
||||
// Filter labels by scope
|
||||
const availableLabels = useMemo(() => {
|
||||
return labels.filter(
|
||||
(label) => label.appliesTo === scope || label.appliesTo === 'both'
|
||||
);
|
||||
}, [labels, scope]);
|
||||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return availableLabels.filter((label) => value.includes(label.id));
|
||||
}, [availableLabels, value]);
|
||||
|
||||
const handleToggle = (labelId: string) => {
|
||||
if (value.includes(labelId)) {
|
||||
onChange(value.filter((id) => id !== labelId));
|
||||
} else {
|
||||
onChange([...value, labelId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (labelId: string) => {
|
||||
onChange(value.filter((id) => id !== labelId));
|
||||
};
|
||||
|
||||
if (availableLabels.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-500 italic">
|
||||
No labels configured. Configure labels in Edit → Configure Labels.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Selected labels display */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 p-2 bg-gray-50 rounded border border-gray-200">
|
||||
{selectedLabels.map((label) => (
|
||||
<div key={label.id} className="flex items-center gap-1">
|
||||
<LabelBadge name={label.name} color={label.color} size="sm" />
|
||||
<button
|
||||
onClick={() => handleRemove(label.id)}
|
||||
className="text-gray-500 hover:text-red-600 text-xs font-bold leading-none"
|
||||
title={`Remove ${label.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label selection checkboxes */}
|
||||
<div className="border border-gray-300 rounded max-h-48 overflow-y-auto">
|
||||
{availableLabels.length === 0 ? (
|
||||
<div className="p-3 text-sm text-gray-500 italic">
|
||||
No {scope} labels available
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{availableLabels.map((label) => (
|
||||
<label
|
||||
key={label.id}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(label.id)}
|
||||
onChange={() => handleToggle(label.id)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<LabelBadge
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
size="sm"
|
||||
/>
|
||||
{label.description && (
|
||||
<span className="text-xs text-gray-500 ml-auto">
|
||||
{label.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelSelector;
|
||||
115
src/components/Config/EditLabelInline.tsx
Normal file
115
src/components/Config/EditLabelInline.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useState, useEffect, KeyboardEvent } from 'react';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import LabelForm from './LabelForm';
|
||||
import type { LabelConfig, LabelScope } from '../../types';
|
||||
|
||||
/**
|
||||
* EditLabelInline - Inline edit view that replaces the right column
|
||||
*
|
||||
* Features:
|
||||
* - Replaces management list in right column when editing
|
||||
* - Reuses LabelForm
|
||||
* - Save/Cancel actions
|
||||
* - Keyboard accessible (Cmd/Ctrl+Enter to save, Escape to cancel)
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
label: LabelConfig;
|
||||
onSave: (
|
||||
id: string,
|
||||
updates: {
|
||||
name: string;
|
||||
color: string;
|
||||
appliesTo: LabelScope;
|
||||
description?: string;
|
||||
}
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const EditLabelInline = ({ label, onSave, onCancel }: Props) => {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#6366f1');
|
||||
const [appliesTo, setAppliesTo] = useState<LabelScope>('both');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Sync state with label prop
|
||||
useEffect(() => {
|
||||
if (label) {
|
||||
setName(label.name);
|
||||
setColor(label.color);
|
||||
setAppliesTo(label.appliesTo);
|
||||
setDescription(label.description || '');
|
||||
}
|
||||
}, [label]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(label.id, {
|
||||
name: name.trim(),
|
||||
color,
|
||||
appliesTo,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full" onKeyDown={handleKeyDown}>
|
||||
{/* Form Fields */}
|
||||
<div className="flex-1 mb-6">
|
||||
<LabelForm
|
||||
name={name}
|
||||
color={color}
|
||||
appliesTo={appliesTo}
|
||||
description={description}
|
||||
onNameChange={setName}
|
||||
onColorChange={setColor}
|
||||
onAppliesToChange={setAppliesTo}
|
||||
onDescriptionChange={setDescription}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-6 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 px-6 py-3 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<SaveIcon fontSize="small" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
|
||||
{navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter
|
||||
</kbd>{' '}
|
||||
to save, <kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">Esc</kbd> to cancel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLabelInline;
|
||||
234
src/components/Config/LabelConfig.tsx
Normal file
234
src/components/Config/LabelConfig.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import QuickAddLabelForm from './QuickAddLabelForm';
|
||||
import LabelManagementList from './LabelManagementList';
|
||||
import EditLabelInline from './EditLabelInline';
|
||||
import type { LabelConfig as LabelConfigType, LabelScope } from '../../types';
|
||||
|
||||
/**
|
||||
* LabelConfig - Modal for managing labels
|
||||
*
|
||||
* Features:
|
||||
* - Two-column layout: quick add (left) + management/edit (right)
|
||||
* - Inline editing replaces right column
|
||||
* - Usage count calculation
|
||||
* - Toast notifications for actions
|
||||
* - Full keyboard accessibility
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialEditingLabelId?: string | null;
|
||||
}
|
||||
|
||||
const LabelConfigModal = ({ isOpen, onClose, initialEditingLabelId }: Props) => {
|
||||
const { labels, nodes, edges, addLabel, updateLabel, deleteLabel } = useGraphWithHistory();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||
const { showToast } = useToastStore();
|
||||
|
||||
const [editingLabel, setEditingLabel] = useState<LabelConfigType | null>(null);
|
||||
|
||||
// Calculate usage counts for each label
|
||||
const usageCounts = useMemo(() => {
|
||||
const counts: Record<string, { actors: number; relations: number }> = {};
|
||||
|
||||
// Initialize counts
|
||||
labels.forEach((label) => {
|
||||
counts[label.id] = { actors: 0, relations: 0 };
|
||||
});
|
||||
|
||||
// Count actor usage
|
||||
nodes.forEach((node) => {
|
||||
node.data.labels?.forEach((labelId) => {
|
||||
if (counts[labelId]) {
|
||||
counts[labelId].actors++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Count relation usage
|
||||
edges.forEach((edge) => {
|
||||
edge.data?.labels?.forEach((labelId) => {
|
||||
if (counts[labelId]) {
|
||||
counts[labelId].relations++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [labels, nodes, edges]);
|
||||
|
||||
// Set editing label when initialEditingLabelId changes
|
||||
useEffect(() => {
|
||||
if (initialEditingLabelId && isOpen) {
|
||||
const labelToEdit = labels.find((l) => l.id === initialEditingLabelId);
|
||||
if (labelToEdit) {
|
||||
setEditingLabel(labelToEdit);
|
||||
}
|
||||
} else if (!isOpen) {
|
||||
// Clear editing label when modal closes
|
||||
setEditingLabel(null);
|
||||
}
|
||||
}, [initialEditingLabelId, isOpen, labels]);
|
||||
|
||||
const handleAddLabel = (label: {
|
||||
name: string;
|
||||
color: string;
|
||||
appliesTo: LabelScope;
|
||||
description: string;
|
||||
}) => {
|
||||
const id = label.name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
// Check if ID already exists (case-insensitive)
|
||||
if (labels.some((l) => l.id === id)) {
|
||||
showToast('A label with this name already exists', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newLabel: LabelConfigType = {
|
||||
id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
appliesTo: label.appliesTo,
|
||||
description: label.description || undefined,
|
||||
};
|
||||
|
||||
addLabel(newLabel);
|
||||
showToast(`Label "${label.name}" created`, 'success');
|
||||
};
|
||||
|
||||
const handleDeleteLabel = async (id: string) => {
|
||||
const label = labels.find((l) => l.id === id);
|
||||
const usage = usageCounts[id] || { actors: 0, relations: 0 };
|
||||
const totalUsage = usage.actors + usage.relations;
|
||||
|
||||
let message = 'Are you sure you want to delete this label?';
|
||||
if (totalUsage > 0) {
|
||||
message = `This label is used by ${totalUsage} item${totalUsage !== 1 ? 's' : ''}. Deleting it will remove it from all actors and relations. This action cannot be undone.`;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Label',
|
||||
message,
|
||||
confirmLabel: 'Delete',
|
||||
severity: 'danger',
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
deleteLabel(id);
|
||||
showToast(`Label "${label?.name}" deleted`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditLabel = (label: LabelConfigType) => {
|
||||
setEditingLabel(label);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (
|
||||
id: string,
|
||||
updates: { name: string; color: string; appliesTo: LabelScope; description?: string }
|
||||
) => {
|
||||
updateLabel(id, updates);
|
||||
setEditingLabel(null);
|
||||
showToast(`Label "${updates.name}" updated`, 'success');
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingLabel(null);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Modal */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Configure Labels</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Create and manage labels to categorize actors and relations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content - Two-Column or Full-Width Edit */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{editingLabel ? (
|
||||
/* Full-Width Edit Mode */
|
||||
<div className="w-full p-6 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<EditLabelInline
|
||||
label={editingLabel}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Left Column - Quick Add (60%) */}
|
||||
<div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">
|
||||
Quick Add Label
|
||||
</h3>
|
||||
<QuickAddLabelForm onAdd={handleAddLabel} />
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-1">About Labels</h4>
|
||||
<ul className="text-xs text-blue-800 space-y-1">
|
||||
<li>• Labels help you categorize and filter actors and relations</li>
|
||||
<li>• Apply multiple labels to any item for flexible organization</li>
|
||||
<li>• Labels can apply to actors only, relations only, or both</li>
|
||||
<li>• Use the filter panel to show/hide items by label</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Management (40%) */}
|
||||
<div className="w-2/5 p-6 overflow-y-auto bg-gray-50">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
Labels ({labels.length})
|
||||
</h3>
|
||||
</div>
|
||||
<LabelManagementList
|
||||
labels={labels}
|
||||
usageCounts={usageCounts}
|
||||
onEdit={handleEditLabel}
|
||||
onDelete={handleDeleteLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Hidden when editing */}
|
||||
{!editingLabel && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{ConfirmDialogComponent}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelConfigModal;
|
||||
127
src/components/Config/LabelForm.tsx
Normal file
127
src/components/Config/LabelForm.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { LabelScope } from '../../types';
|
||||
|
||||
/**
|
||||
* LabelForm - Reusable form fields for creating/editing labels
|
||||
*
|
||||
* Features:
|
||||
* - Name input
|
||||
* - Color picker (visual + text input)
|
||||
* - Scope selector (actors/relations/both)
|
||||
* - Description input
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
color: string;
|
||||
appliesTo: LabelScope;
|
||||
description: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onColorChange: (value: string) => void;
|
||||
onAppliesToChange: (value: LabelScope) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const LabelForm = ({
|
||||
name,
|
||||
color,
|
||||
appliesTo,
|
||||
description,
|
||||
onNameChange,
|
||||
onColorChange,
|
||||
onAppliesToChange,
|
||||
onDescriptionChange,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="e.g., Team, Lead, Important"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Color *
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
className="h-10 w-20 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
placeholder="#6366f1"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Applies To *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="appliesTo"
|
||||
value="actors"
|
||||
checked={appliesTo === 'actors'}
|
||||
onChange={(e) => onAppliesToChange(e.target.value as LabelScope)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Actors only</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="appliesTo"
|
||||
value="relations"
|
||||
checked={appliesTo === 'relations'}
|
||||
onChange={(e) => onAppliesToChange(e.target.value as LabelScope)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Relations only</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="appliesTo"
|
||||
value="both"
|
||||
checked={appliesTo === 'both'}
|
||||
onChange={(e) => onAppliesToChange(e.target.value as LabelScope)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Both actors and relations</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Description (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="Brief description of this label"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelForm;
|
||||
128
src/components/Config/LabelManagementList.tsx
Normal file
128
src/components/Config/LabelManagementList.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import type { LabelConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* LabelManagementList - Compact list view for managing existing labels
|
||||
*
|
||||
* Features:
|
||||
* - White background cards with click to edit
|
||||
* - Color badge + name + scope + usage count
|
||||
* - Delete button
|
||||
* - Keyboard accessible
|
||||
* - ARIA compliant
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
labels: LabelConfig[];
|
||||
usageCounts: Record<string, { actors: number; relations: number }>;
|
||||
onEdit: (label: LabelConfig) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const LabelManagementList = ({ labels, usageCounts, onEdit, onDelete }: Props) => {
|
||||
if (labels.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-sm">No labels yet.</p>
|
||||
<p className="text-xs mt-1">Add your first label above.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getScopeLabel = (appliesTo: string) => {
|
||||
switch (appliesTo) {
|
||||
case 'actors':
|
||||
return 'Actors';
|
||||
case 'relations':
|
||||
return 'Relations';
|
||||
case 'both':
|
||||
return 'Both';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getUsageText = (labelId: string) => {
|
||||
const usage = usageCounts[labelId] || { actors: 0, relations: 0 };
|
||||
const total = usage.actors + usage.relations;
|
||||
|
||||
if (total === 0) return 'Not used';
|
||||
|
||||
const parts = [];
|
||||
if (usage.actors > 0) parts.push(`${usage.actors} actor${usage.actors !== 1 ? 's' : ''}`);
|
||||
if (usage.relations > 0) parts.push(`${usage.relations} relation${usage.relations !== 1 ? 's' : ''}`);
|
||||
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group bg-white border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer"
|
||||
onClick={() => onEdit(label)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEdit(label);
|
||||
}
|
||||
}}
|
||||
aria-label={`Edit ${label.name}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Label Info */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Color Badge */}
|
||||
<div
|
||||
className="w-10 h-10 rounded flex-shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Name, Scope & Usage */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{label.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-gray-500">
|
||||
{getScopeLabel(label.appliesTo)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">•</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{getUsageText(label.id)}
|
||||
</span>
|
||||
</div>
|
||||
{label.description && (
|
||||
<div className="text-xs text-gray-400 truncate mt-0.5">
|
||||
{label.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(label.id);
|
||||
}}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
aria-label={`Delete ${label.name}`}
|
||||
title="Delete"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelManagementList;
|
||||
95
src/components/Config/QuickAddLabelForm.tsx
Normal file
95
src/components/Config/QuickAddLabelForm.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useState, useRef, KeyboardEvent } from 'react';
|
||||
import LabelForm from './LabelForm';
|
||||
import type { LabelScope } from '../../types';
|
||||
|
||||
/**
|
||||
* QuickAddLabelForm - Streamlined form for quickly adding new labels
|
||||
*
|
||||
* Features:
|
||||
* - Quick add with name, color, and scope
|
||||
* - Keyboard accessible (Enter to submit, Escape to cancel)
|
||||
* - Focus management
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
onAdd: (label: {
|
||||
name: string;
|
||||
color: string;
|
||||
appliesTo: LabelScope;
|
||||
description: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const QuickAddLabelForm = ({ onAdd }: Props) => {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#6366f1');
|
||||
const [appliesTo, setAppliesTo] = useState<LabelScope>('both');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) {
|
||||
nameInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd({ name: name.trim(), color, appliesTo, description });
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
setColor('#6366f1');
|
||||
setAppliesTo('both');
|
||||
setDescription('');
|
||||
|
||||
// Focus back to name input for quick subsequent additions
|
||||
nameInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Reset form
|
||||
setName('');
|
||||
setColor('#6366f1');
|
||||
setAppliesTo('both');
|
||||
setDescription('');
|
||||
nameInputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3" onKeyDown={handleKeyDown}>
|
||||
<LabelForm
|
||||
name={name}
|
||||
color={color}
|
||||
appliesTo={appliesTo}
|
||||
description={description}
|
||||
onNameChange={setName}
|
||||
onColorChange={setColor}
|
||||
onAppliesToChange={setAppliesTo}
|
||||
onDescriptionChange={setDescription}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
aria-label="Add label"
|
||||
>
|
||||
Add Label
|
||||
</button>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
{name && (
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
Press Enter to add, Escape to cancel
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddLabelForm;
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { useGraphStore } from '../../stores/graphStore';
|
||||
import { useSearchStore } from '../../stores/searchStore';
|
||||
import type { RelationData } from '../../types';
|
||||
import LabelBadge from '../Common/LabelBadge';
|
||||
|
||||
/**
|
||||
* CustomEdge - Represents a relation between actors in the constellation graph
|
||||
|
|
@ -33,7 +34,8 @@ const CustomEdge = ({
|
|||
selected,
|
||||
}: EdgeProps<RelationData>) => {
|
||||
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
||||
const { searchText, visibleRelationTypes } = useSearchStore();
|
||||
const labels = useGraphStore((state) => state.labels);
|
||||
const { searchText, selectedRelationTypes, selectedLabels } = useSearchStore();
|
||||
|
||||
// Calculate the bezier path
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
|
|
@ -65,11 +67,23 @@ const CustomEdge = ({
|
|||
|
||||
// Check if this edge matches the filter criteria
|
||||
const isMatch = useMemo(() => {
|
||||
// Check type visibility
|
||||
// Check relation type filter (POSITIVE: if types selected, edge must be one of them)
|
||||
const edgeType = data?.type || '';
|
||||
const isTypeVisible = visibleRelationTypes[edgeType] !== false;
|
||||
if (!isTypeVisible) {
|
||||
return false;
|
||||
if (selectedRelationTypes.length > 0) {
|
||||
if (!selectedRelationTypes.includes(edgeType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check label filter (POSITIVE: if labels selected, edge must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const edgeLabels = data?.labels || [];
|
||||
const hasSelectedLabel = edgeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check search text match
|
||||
|
|
@ -82,12 +96,13 @@ const CustomEdge = ({
|
|||
}
|
||||
|
||||
return true;
|
||||
}, [searchText, visibleRelationTypes, data?.type, data?.label, edgeTypeConfig?.label]);
|
||||
}, [searchText, selectedRelationTypes, selectedLabels, data?.type, data?.label, data?.labels, edgeTypeConfig?.label]);
|
||||
|
||||
// Determine if filters are active
|
||||
const hasActiveFilters =
|
||||
searchText.trim() !== '' ||
|
||||
Object.values(visibleRelationTypes).some(v => v === false);
|
||||
selectedRelationTypes.length > 0 ||
|
||||
selectedLabels.length > 0;
|
||||
|
||||
// Calculate opacity based on visibility
|
||||
const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
|
||||
|
|
@ -151,8 +166,8 @@ const CustomEdge = ({
|
|||
markerStart={markerStart}
|
||||
/>
|
||||
|
||||
{/* Edge label - show custom or type default */}
|
||||
{displayLabel && (
|
||||
{/* Edge label - show custom or type default, plus labels */}
|
||||
{(displayLabel || (data?.labels && data.labels.length > 0)) && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -163,7 +178,28 @@ const CustomEdge = ({
|
|||
}}
|
||||
className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm"
|
||||
>
|
||||
<div style={{ color: edgeColor }}>{displayLabel}</div>
|
||||
{displayLabel && (
|
||||
<div style={{ color: edgeColor }} className="mb-1">
|
||||
{displayLabel}
|
||||
</div>
|
||||
)}
|
||||
{data?.labels && data.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-center">
|
||||
{data.labels.map((labelId) => {
|
||||
const labelConfig = labels.find((l) => l.id === labelId);
|
||||
if (!labelConfig) return null;
|
||||
return (
|
||||
<LabelBadge
|
||||
key={labelId}
|
||||
name={labelConfig.name}
|
||||
color={labelConfig.color}
|
||||
maxWidth="80px"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
|
|
@ -22,6 +23,7 @@ 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) {
|
||||
|
|
@ -30,6 +32,7 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
|
|||
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]);
|
||||
|
||||
|
|
@ -39,6 +42,7 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
|
|||
type: relationType,
|
||||
// Only set label if user provided a custom one (not empty)
|
||||
label: relationLabel.trim() || undefined,
|
||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
|
@ -121,6 +125,18 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
|
|||
</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">
|
||||
|
|
|
|||
|
|
@ -107,8 +107,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
// Search and filter state for auto-zoom
|
||||
const {
|
||||
searchText,
|
||||
visibleActorTypes,
|
||||
visibleRelationTypes,
|
||||
selectedActorTypes,
|
||||
selectedRelationTypes,
|
||||
selectedLabels,
|
||||
} = useSearchStore();
|
||||
|
||||
// Settings for auto-zoom
|
||||
|
|
@ -260,12 +261,11 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
|
||||
// Check if any filters are active
|
||||
const hasSearchText = searchText.trim() !== '';
|
||||
const hasTypeFilters =
|
||||
Object.values(visibleActorTypes).some(v => v === false) ||
|
||||
Object.values(visibleRelationTypes).some(v => v === false);
|
||||
const hasTypeFilters = selectedActorTypes.length > 0 || selectedRelationTypes.length > 0;
|
||||
const hasLabelFilters = selectedLabels.length > 0;
|
||||
|
||||
// Skip if no filters are active
|
||||
if (!hasSearchText && !hasTypeFilters) return;
|
||||
if (!hasSearchText && !hasTypeFilters && !hasLabelFilters) return;
|
||||
|
||||
// Debounce to avoid excessive viewport changes while typing
|
||||
const timeoutId = setTimeout(() => {
|
||||
|
|
@ -277,10 +277,22 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
const actor = node as Actor;
|
||||
const actorType = actor.data?.type || '';
|
||||
|
||||
// Filter by actor type visibility
|
||||
const isTypeVisible = visibleActorTypes[actorType] !== false;
|
||||
if (!isTypeVisible) {
|
||||
return false;
|
||||
// Filter by actor type (POSITIVE: if types selected, node must be one of them)
|
||||
if (selectedActorTypes.length > 0) {
|
||||
if (!selectedActorTypes.includes(actorType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by label (POSITIVE: if labels selected, node must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const nodeLabels = actor.data?.labels || [];
|
||||
const hasSelectedLabel = nodeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
|
|
@ -319,8 +331,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
searchText,
|
||||
visibleActorTypes,
|
||||
visibleRelationTypes,
|
||||
selectedActorTypes,
|
||||
selectedRelationTypes,
|
||||
selectedLabels,
|
||||
autoZoomEnabled,
|
||||
nodes,
|
||||
nodeTypeConfigs,
|
||||
|
|
@ -631,12 +644,16 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
[nodeTypeConfigs, addNodeWithHistory],
|
||||
);
|
||||
|
||||
// Call the onAddNodeRequest callback if provided
|
||||
// Store callbacks in refs and call parent callbacks only once on mount
|
||||
const handleAddNodeRef = useRef(handleAddNode);
|
||||
handleAddNodeRef.current = handleAddNode;
|
||||
|
||||
useEffect(() => {
|
||||
if (onAddNodeRequest) {
|
||||
onAddNodeRequest(handleAddNode);
|
||||
onAddNodeRequest((...args) => handleAddNodeRef.current(...args));
|
||||
}
|
||||
}, [onAddNodeRequest, handleAddNode]);
|
||||
|
||||
}, [onAddNodeRequest]); // Only run when onAddNodeRequest changes
|
||||
|
||||
// Provide export callback to parent
|
||||
const handleExport = useCallback(
|
||||
|
|
@ -650,11 +667,15 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
[exportPNG, exportSVG]
|
||||
);
|
||||
|
||||
const handleExportRef = useRef(handleExport);
|
||||
handleExportRef.current = handleExport;
|
||||
|
||||
useEffect(() => {
|
||||
if (onExportRequest) {
|
||||
onExportRequest(handleExport);
|
||||
onExportRequest((...args) => handleExportRef.current(...args));
|
||||
}
|
||||
}, [onExportRequest, handleExport]);
|
||||
|
||||
}, [onExportRequest]); // Only run when onExportRequest changes
|
||||
|
||||
// Add new actor at context menu position
|
||||
const handleAddActorFromContextMenu = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
|
|
@ -24,6 +25,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
|
|||
const [actorType, setActorType] = useState('');
|
||||
const [actorLabel, setActorLabel] = useState('');
|
||||
const [actorDescription, setActorDescription] = useState('');
|
||||
const [actorLabels, setActorLabels] = useState<string[]>([]);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -31,6 +33,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
|
|||
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(() => {
|
||||
|
|
@ -49,6 +52,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
|
|||
type: actorType,
|
||||
label: actorLabel,
|
||||
description: actorDescription || undefined,
|
||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
|
|
@ -130,6 +134,18 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
|
|||
/>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useDocumentHistory } from '../../hooks/useDocumentHistory';
|
|||
import DocumentManager from '../Workspace/DocumentManager';
|
||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||
import LabelConfigModal from '../Config/LabelConfig';
|
||||
import InputDialog from '../Common/InputDialog';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useShortcutLabels } from '../../hooks/useShortcutLabels';
|
||||
|
|
@ -32,6 +33,7 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
const [showDocumentManager, setShowDocumentManager] = useState(false);
|
||||
const [showNodeConfig, setShowNodeConfig] = useState(false);
|
||||
const [showEdgeConfig, setShowEdgeConfig] = useState(false);
|
||||
const [showLabelConfig, setShowLabelConfig] = useState(false);
|
||||
const [showNewDocDialog, setShowNewDocDialog] = useState(false);
|
||||
const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -173,6 +175,11 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
closeMenu();
|
||||
}, [closeMenu]);
|
||||
|
||||
const handleConfigureLabels = useCallback(() => {
|
||||
setShowLabelConfig(true);
|
||||
closeMenu();
|
||||
}, [closeMenu]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
undo();
|
||||
closeMenu();
|
||||
|
|
@ -373,6 +380,12 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
>
|
||||
Configure Relation Types
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfigureLabels}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Configure Labels
|
||||
</button>
|
||||
|
||||
<hr className="my-1 border-gray-200" />
|
||||
|
||||
|
|
@ -465,6 +478,10 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
isOpen={showEdgeConfig}
|
||||
onClose={() => setShowEdgeConfig(false)}
|
||||
/>
|
||||
<LabelConfigModal
|
||||
isOpen={showLabelConfig}
|
||||
onClose={() => setShowLabelConfig(false)}
|
||||
/>
|
||||
|
||||
{/* Input Dialogs */}
|
||||
<InputDialog
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { getIconComponent } from "../../utils/iconUtils";
|
||||
import type { ActorData } from "../../types";
|
||||
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
|
||||
import LabelBadge from "../Common/LabelBadge";
|
||||
|
||||
/**
|
||||
* CustomNode - Represents an actor in the constellation graph
|
||||
|
|
@ -23,7 +24,8 @@ import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
|
|||
*/
|
||||
const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
||||
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
||||
const { searchText, visibleActorTypes } = useSearchStore();
|
||||
const labels = useGraphStore((state) => state.labels);
|
||||
const { searchText, selectedActorTypes, selectedLabels } = useSearchStore();
|
||||
|
||||
// Check if any connection is being made (to show handles)
|
||||
const connectionNodeId = useStore((state) => state.connectionNodeId);
|
||||
|
|
@ -47,10 +49,22 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
|||
|
||||
// Check if this node matches the search and filter criteria
|
||||
const isMatch = useMemo(() => {
|
||||
// Check type visibility
|
||||
const isTypeVisible = visibleActorTypes[data.type] !== false;
|
||||
if (!isTypeVisible) {
|
||||
return false;
|
||||
// Check actor type filter (POSITIVE: if types selected, node must be one of them)
|
||||
if (selectedActorTypes.length > 0) {
|
||||
if (!selectedActorTypes.includes(data.type)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check label filter (POSITIVE: if labels selected, node must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const nodeLabels = data.labels || [];
|
||||
const hasSelectedLabel = nodeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check search text match
|
||||
|
|
@ -70,9 +84,11 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
|||
return true;
|
||||
}, [
|
||||
searchText,
|
||||
visibleActorTypes,
|
||||
selectedActorTypes,
|
||||
selectedLabels,
|
||||
data.type,
|
||||
data.label,
|
||||
data.labels,
|
||||
data.description,
|
||||
nodeLabel,
|
||||
]);
|
||||
|
|
@ -80,7 +96,8 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
|||
// Determine if filters are active
|
||||
const hasActiveFilters =
|
||||
searchText.trim() !== "" ||
|
||||
Object.values(visibleActorTypes).some((v) => v === false);
|
||||
selectedActorTypes.length > 0 ||
|
||||
selectedLabels.length > 0;
|
||||
|
||||
// Calculate opacity based on match status
|
||||
const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
|
||||
|
|
@ -189,6 +206,25 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
|||
>
|
||||
{nodeLabel}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{data.labels && data.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-center mt-2">
|
||||
{data.labels.map((labelId) => {
|
||||
const labelConfig = labels.find((l) => l.id === labelId);
|
||||
if (!labelConfig) return null;
|
||||
return (
|
||||
<LabelBadge
|
||||
key={labelId}
|
||||
name={labelConfig.name}
|
||||
color={labelConfig.color}
|
||||
maxWidth="80px"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeShapeRenderer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState, useMemo, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
import { useCallback, useState, useMemo, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
import { IconButton, Tooltip, Checkbox } from '@mui/material';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
|
|
@ -19,6 +19,7 @@ import { getIconComponent } from '../../utils/iconUtils';
|
|||
import { getContrastColor } from '../../utils/colorUtils';
|
||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||
import LabelBadge from '../Common/LabelBadge';
|
||||
import type { Actor } from '../../types';
|
||||
|
||||
/**
|
||||
|
|
@ -52,7 +53,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
expandLeftPanel,
|
||||
} = usePanelStore();
|
||||
|
||||
const { nodeTypes, edgeTypes, addNode, nodes, edges } = useGraphWithHistory();
|
||||
const { nodeTypes, edgeTypes, labels, addNode, nodes, edges } = useGraphWithHistory();
|
||||
const { selectedRelationType, setSelectedRelationType } = useEditorStore();
|
||||
const [showNodeConfig, setShowNodeConfig] = useState(false);
|
||||
const [showEdgeConfig, setShowEdgeConfig] = useState(false);
|
||||
|
|
@ -84,10 +85,12 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
visibleActorTypes,
|
||||
setActorTypeVisible,
|
||||
visibleRelationTypes,
|
||||
setRelationTypeVisible,
|
||||
selectedActorTypes,
|
||||
toggleSelectedActorType,
|
||||
selectedRelationTypes,
|
||||
toggleSelectedRelationType,
|
||||
selectedLabels,
|
||||
toggleSelectedLabel,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
} = useSearchStore();
|
||||
|
|
@ -95,22 +98,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
// Settings
|
||||
const { autoZoomEnabled, setAutoZoomEnabled } = useSettingsStore();
|
||||
|
||||
// Initialize filter state when node/edge types change
|
||||
useEffect(() => {
|
||||
nodeTypes.forEach((nodeType) => {
|
||||
if (!(nodeType.id in visibleActorTypes)) {
|
||||
setActorTypeVisible(nodeType.id, true);
|
||||
}
|
||||
});
|
||||
}, [nodeTypes, visibleActorTypes, setActorTypeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
edgeTypes.forEach((edgeType) => {
|
||||
if (!(edgeType.id in visibleRelationTypes)) {
|
||||
setRelationTypeVisible(edgeType.id, true);
|
||||
}
|
||||
});
|
||||
}, [edgeTypes, visibleRelationTypes, setRelationTypeVisible]);
|
||||
// No need to initialize filter state - all filters are positive (empty = show all)
|
||||
|
||||
// Calculate matching nodes based on search and filters
|
||||
const matchingNodes = useMemo(() => {
|
||||
|
|
@ -120,10 +108,22 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
const actor = node as Actor;
|
||||
const actorType = actor.data?.type || '';
|
||||
|
||||
// Filter by actor type visibility
|
||||
const isTypeVisible = visibleActorTypes[actorType] !== false;
|
||||
if (!isTypeVisible) {
|
||||
return false;
|
||||
// Filter by actor type (POSITIVE: if types selected, node must be one of them)
|
||||
if (selectedActorTypes.length > 0) {
|
||||
if (!selectedActorTypes.includes(actorType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by label (POSITIVE: if labels selected, node must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const nodeLabels = actor.data?.labels || [];
|
||||
const hasSelectedLabel = nodeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
|
|
@ -145,7 +145,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
|
||||
return true;
|
||||
});
|
||||
}, [nodes, searchText, visibleActorTypes, nodeTypes]);
|
||||
}, [nodes, searchText, selectedActorTypes, selectedLabels, nodeTypes]);
|
||||
|
||||
// Calculate matching edges based on search and filters
|
||||
const matchingEdges = useMemo(() => {
|
||||
|
|
@ -154,10 +154,22 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
return edges.filter((edge) => {
|
||||
const edgeType = edge.data?.type || '';
|
||||
|
||||
// Filter by edge type visibility
|
||||
const isTypeVisible = visibleRelationTypes[edgeType] !== false;
|
||||
if (!isTypeVisible) {
|
||||
return false;
|
||||
// Filter by relation type (POSITIVE: if types selected, edge must be one of them)
|
||||
if (selectedRelationTypes.length > 0) {
|
||||
if (!selectedRelationTypes.includes(edgeType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by label (POSITIVE: if labels selected, edge must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const edgeLabels = edge.data?.labels || [];
|
||||
const hasSelectedLabel = edgeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
|
|
@ -177,7 +189,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
|
||||
return true;
|
||||
});
|
||||
}, [edges, searchText, visibleRelationTypes, edgeTypes]);
|
||||
}, [edges, searchText, selectedRelationTypes, selectedLabels, edgeTypes]);
|
||||
|
||||
|
||||
const handleAddNode = useCallback(
|
||||
|
|
@ -464,10 +476,13 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
Filter by Actor Type
|
||||
{selectedActorTypes.length > 0 && (
|
||||
<span className="ml-1 text-blue-600">({selectedActorTypes.length} selected)</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{nodeTypes.map((nodeType) => {
|
||||
const isVisible = visibleActorTypes[nodeType.id] !== false;
|
||||
const isSelected = selectedActorTypes.includes(nodeType.id);
|
||||
const IconComponent = getIconComponent(nodeType.icon);
|
||||
|
||||
return (
|
||||
|
|
@ -476,8 +491,8 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onChange={() => setActorTypeVisible(nodeType.id, !isVisible)}
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelectedActorType(nodeType.id)}
|
||||
size="small"
|
||||
sx={{ padding: '2px' }}
|
||||
/>
|
||||
|
|
@ -501,16 +516,29 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedActorTypes.length === 0 && (
|
||||
<p className="text-xs text-gray-500 italic mt-2">
|
||||
No types selected - showing all actors
|
||||
</p>
|
||||
)}
|
||||
{selectedActorTypes.length > 0 && (
|
||||
<p className="text-xs text-gray-500 italic mt-2">
|
||||
Showing only selected actor types
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter by Relation */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
Filter by Relation
|
||||
{selectedRelationTypes.length > 0 && (
|
||||
<span className="ml-1 text-blue-600">({selectedRelationTypes.length} selected)</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{edgeTypes.map((edgeType) => {
|
||||
const isVisible = visibleRelationTypes[edgeType.id] !== false;
|
||||
const isSelected = selectedRelationTypes.includes(edgeType.id);
|
||||
|
||||
return (
|
||||
<label
|
||||
|
|
@ -518,8 +546,8 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onChange={() => setRelationTypeVisible(edgeType.id, !isVisible)}
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelectedRelationType(edgeType.id)}
|
||||
size="small"
|
||||
sx={{ padding: '2px' }}
|
||||
/>
|
||||
|
|
@ -540,8 +568,66 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedRelationTypes.length === 0 && (
|
||||
<p className="text-xs text-gray-500 italic mt-2">
|
||||
No types selected - showing all relations
|
||||
</p>
|
||||
)}
|
||||
{selectedRelationTypes.length > 0 && (
|
||||
<p className="text-xs text-gray-500 italic mt-2">
|
||||
Showing only selected relation types
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter by Label */}
|
||||
{labels.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
Filter by Label
|
||||
{selectedLabels.length > 0 && (
|
||||
<span className="ml-1 text-blue-600">({selectedLabels.length} selected)</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{labels.map((label) => {
|
||||
const isSelected = selectedLabels.includes(label.id);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={label.id}
|
||||
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelectedLabel(label.id)}
|
||||
size="small"
|
||||
sx={{ padding: '2px' }}
|
||||
/>
|
||||
<div className="flex items-center flex-1">
|
||||
<LabelBadge
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedLabels.length === 0 && (
|
||||
<p className="text-xs text-gray-500 italic mt-2">
|
||||
No labels selected - showing all items
|
||||
</p>
|
||||
)}
|
||||
{selectedLabels.length > 0 && (
|
||||
<p className="text-xs text-gray-500 italic mt-2">
|
||||
Showing only items with selected labels
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import GraphMetrics from '../Common/GraphMetrics';
|
|||
import ConnectionDisplay from '../Common/ConnectionDisplay';
|
||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
|
||||
import type { Actor, Relation, EdgeDirectionality } from '../../types';
|
||||
|
||||
/**
|
||||
|
|
@ -72,11 +73,13 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
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
|
||||
|
|
@ -97,6 +100,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
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
|
||||
|
|
@ -116,6 +120,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
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);
|
||||
|
|
@ -130,10 +135,11 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
type: actorType,
|
||||
label: actorLabel,
|
||||
description: actorDescription || undefined,
|
||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
||||
},
|
||||
});
|
||||
setHasNodeChanges(false);
|
||||
}, [selectedNode, actorType, actorLabel, actorDescription, hasNodeChanges, updateNode]);
|
||||
}, [selectedNode, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]);
|
||||
|
||||
// Live update edge properties (debounced)
|
||||
const updateEdgeProperties = useCallback(() => {
|
||||
|
|
@ -142,9 +148,10 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
type: relationType,
|
||||
label: relationLabel.trim() || undefined,
|
||||
directionality: relationDirectionality,
|
||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||
});
|
||||
setHasEdgeChanges(false);
|
||||
}, [selectedEdge, relationType, relationLabel, relationDirectionality, hasEdgeChanges, updateEdge]);
|
||||
}, [selectedEdge, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]);
|
||||
|
||||
// Debounce live updates
|
||||
useEffect(() => {
|
||||
|
|
@ -341,6 +348,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
type: newType,
|
||||
label: actorLabel,
|
||||
description: actorDescription || undefined,
|
||||
labels: actorLabels.length > 0 ? actorLabels : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -401,6 +409,21 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
/>
|
||||
</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">
|
||||
|
|
@ -553,6 +576,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
type: newType,
|
||||
label: relationLabel.trim() || undefined,
|
||||
directionality: relationDirectionality,
|
||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
@ -587,6 +611,21 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
</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">
|
||||
|
|
@ -603,6 +642,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
|
|||
type: relationType,
|
||||
label: relationLabel.trim() || undefined,
|
||||
directionality: newValue,
|
||||
labels: relationLabels.length > 0 ? relationLabels : undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export function useDocumentHistory() {
|
|||
|
||||
const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
|
||||
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
|
||||
const setLabels = useGraphStore((state) => state.setLabels);
|
||||
|
||||
const historyStore = useHistoryStore();
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ export function useDocumentHistory() {
|
|||
}
|
||||
|
||||
// Create a snapshot of the complete document state
|
||||
// NOTE: Read types from the document, not from graphStore
|
||||
// NOTE: Read types and labels from the document, not from graphStore
|
||||
const snapshot: DocumentSnapshot = {
|
||||
timeline: {
|
||||
states: new Map(timeline.states), // Clone the Map
|
||||
|
|
@ -72,6 +73,7 @@ export function useDocumentHistory() {
|
|||
},
|
||||
nodeTypes: activeDoc.nodeTypes,
|
||||
edgeTypes: activeDoc.edgeTypes,
|
||||
labels: activeDoc.labels || [],
|
||||
};
|
||||
|
||||
// Push to history
|
||||
|
|
@ -109,7 +111,7 @@ export function useDocumentHistory() {
|
|||
return;
|
||||
}
|
||||
|
||||
// NOTE: Read types from the document, not from graphStore
|
||||
// NOTE: Read types and labels from the document, not from graphStore
|
||||
const currentSnapshot: DocumentSnapshot = {
|
||||
timeline: {
|
||||
states: new Map(timeline.states),
|
||||
|
|
@ -118,21 +120,24 @@ export function useDocumentHistory() {
|
|||
},
|
||||
nodeTypes: activeDoc.nodeTypes,
|
||||
edgeTypes: activeDoc.edgeTypes,
|
||||
labels: activeDoc.labels || [],
|
||||
};
|
||||
|
||||
const restoredState = historyStore.undo(activeDocumentId, currentSnapshot);
|
||||
if (restoredState) {
|
||||
|
||||
// Restore complete document state (timeline + types)
|
||||
// Restore complete document state (timeline + types + labels)
|
||||
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
|
||||
|
||||
// Update document's types (which will sync to graphStore via workspaceStore)
|
||||
// Update document's types and labels (which will sync to graphStore via workspaceStore)
|
||||
activeDoc.nodeTypes = restoredState.nodeTypes;
|
||||
activeDoc.edgeTypes = restoredState.edgeTypes;
|
||||
activeDoc.labels = restoredState.labels || [];
|
||||
|
||||
// Sync to graph store
|
||||
setNodeTypes(restoredState.nodeTypes);
|
||||
setEdgeTypes(restoredState.edgeTypes);
|
||||
setLabels(restoredState.labels || []);
|
||||
|
||||
// Load the current state's graph from the restored timeline
|
||||
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
|
||||
|
|
@ -152,7 +157,7 @@ export function useDocumentHistory() {
|
|||
saveDocument(activeDocumentId);
|
||||
}, 1000);
|
||||
}
|
||||
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, markDocumentDirty]);
|
||||
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, setLabels, markDocumentDirty]);
|
||||
|
||||
/**
|
||||
* Redo the last undone action for the active document
|
||||
|
|
@ -179,7 +184,7 @@ export function useDocumentHistory() {
|
|||
return;
|
||||
}
|
||||
|
||||
// NOTE: Read types from the document, not from graphStore
|
||||
// NOTE: Read types and labels from the document, not from graphStore
|
||||
const currentSnapshot: DocumentSnapshot = {
|
||||
timeline: {
|
||||
states: new Map(timeline.states),
|
||||
|
|
@ -188,21 +193,24 @@ export function useDocumentHistory() {
|
|||
},
|
||||
nodeTypes: activeDoc.nodeTypes,
|
||||
edgeTypes: activeDoc.edgeTypes,
|
||||
labels: activeDoc.labels || [],
|
||||
};
|
||||
|
||||
const restoredState = historyStore.redo(activeDocumentId, currentSnapshot);
|
||||
if (restoredState) {
|
||||
|
||||
// Restore complete document state (timeline + types)
|
||||
// Restore complete document state (timeline + types + labels)
|
||||
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
|
||||
|
||||
// Update document's types (which will sync to graphStore via workspaceStore)
|
||||
// Update document's types and labels (which will sync to graphStore via workspaceStore)
|
||||
activeDoc.nodeTypes = restoredState.nodeTypes;
|
||||
activeDoc.edgeTypes = restoredState.edgeTypes;
|
||||
activeDoc.labels = restoredState.labels || [];
|
||||
|
||||
// Sync to graph store
|
||||
setNodeTypes(restoredState.nodeTypes);
|
||||
setEdgeTypes(restoredState.edgeTypes);
|
||||
setLabels(restoredState.labels || []);
|
||||
|
||||
// Load the current state's graph from the restored timeline
|
||||
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
|
||||
|
|
@ -222,7 +230,7 @@ export function useDocumentHistory() {
|
|||
saveDocument(activeDocumentId);
|
||||
}, 1000);
|
||||
}
|
||||
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, markDocumentDirty]);
|
||||
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, setLabels, markDocumentDirty]);
|
||||
|
||||
/**
|
||||
* Check if undo is available for the active document
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useRef, useEffect } from 'react';
|
|||
import { useGraphStore } from '../stores/graphStore';
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore';
|
||||
import { useDocumentHistory } from './useDocumentHistory';
|
||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } from '../types';
|
||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig, RelationData } from '../types';
|
||||
|
||||
/**
|
||||
* useGraphWithHistory Hook
|
||||
|
|
@ -20,11 +20,12 @@ import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } fr
|
|||
* - Node operations: addNode, updateNode, deleteNode
|
||||
* - Edge operations: addEdge, updateEdge, deleteEdge
|
||||
* - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType
|
||||
* - Label operations: addLabel, updateLabel, deleteLabel
|
||||
* - Utility: clearGraph
|
||||
*
|
||||
* Read-only pass-through operations (no history):
|
||||
* - setNodes, setEdges (used for bulk updates during undo/redo/document loading)
|
||||
* - nodes, edges, nodeTypes, edgeTypes (state access)
|
||||
* - setNodes, setEdges, setLabels (used for bulk updates during undo/redo/document loading)
|
||||
* - nodes, edges, nodeTypes, edgeTypes, labels (state access)
|
||||
* - loadGraphState
|
||||
*
|
||||
* Usage:
|
||||
|
|
@ -46,6 +47,9 @@ export function useGraphWithHistory() {
|
|||
const addEdgeTypeToDocument = useWorkspaceStore((state) => state.addEdgeTypeToDocument);
|
||||
const updateEdgeTypeInDocument = useWorkspaceStore((state) => state.updateEdgeTypeInDocument);
|
||||
const deleteEdgeTypeFromDocument = useWorkspaceStore((state) => state.deleteEdgeTypeFromDocument);
|
||||
const addLabelToDocument = useWorkspaceStore((state) => state.addLabelToDocument);
|
||||
const updateLabelInDocument = useWorkspaceStore((state) => state.updateLabelInDocument);
|
||||
const deleteLabelFromDocument = useWorkspaceStore((state) => state.deleteLabelFromDocument);
|
||||
const { pushToHistory } = useDocumentHistory();
|
||||
|
||||
// Track if we're currently restoring from history to prevent recursive history pushes
|
||||
|
|
@ -285,6 +289,55 @@ export function useGraphWithHistory() {
|
|||
[graphStore, pushToHistory]
|
||||
);
|
||||
|
||||
const addLabel = useCallback(
|
||||
(label: LabelConfig) => {
|
||||
if (!activeDocumentId) {
|
||||
console.warn('No active document');
|
||||
return;
|
||||
}
|
||||
if (isRestoringRef.current) {
|
||||
graphStore.addLabel(label);
|
||||
return;
|
||||
}
|
||||
pushToHistory(`Add Label: ${label.name}`); // Synchronous push BEFORE mutation
|
||||
addLabelToDocument(activeDocumentId, label);
|
||||
},
|
||||
[activeDocumentId, graphStore, pushToHistory, addLabelToDocument]
|
||||
);
|
||||
|
||||
const updateLabel = useCallback(
|
||||
(id: string, updates: Partial<Omit<LabelConfig, 'id'>>) => {
|
||||
if (!activeDocumentId) {
|
||||
console.warn('No active document');
|
||||
return;
|
||||
}
|
||||
if (isRestoringRef.current) {
|
||||
graphStore.updateLabel(id, updates);
|
||||
return;
|
||||
}
|
||||
pushToHistory('Update Label'); // Synchronous push BEFORE mutation
|
||||
updateLabelInDocument(activeDocumentId, id, updates);
|
||||
},
|
||||
[activeDocumentId, graphStore, pushToHistory, updateLabelInDocument]
|
||||
);
|
||||
|
||||
const deleteLabel = useCallback(
|
||||
(id: string) => {
|
||||
if (!activeDocumentId) {
|
||||
console.warn('No active document');
|
||||
return;
|
||||
}
|
||||
if (isRestoringRef.current) {
|
||||
graphStore.deleteLabel(id);
|
||||
return;
|
||||
}
|
||||
const label = graphStore.labels.find((l) => l.id === id);
|
||||
pushToHistory(`Delete Label: ${label?.name || id}`); // Synchronous push BEFORE mutation
|
||||
deleteLabelFromDocument(activeDocumentId, id);
|
||||
},
|
||||
[activeDocumentId, graphStore, pushToHistory, deleteLabelFromDocument]
|
||||
);
|
||||
|
||||
return {
|
||||
// Wrapped operations with history
|
||||
addNode,
|
||||
|
|
@ -299,6 +352,9 @@ export function useGraphWithHistory() {
|
|||
addEdgeType,
|
||||
updateEdgeType,
|
||||
deleteEdgeType,
|
||||
addLabel,
|
||||
updateLabel,
|
||||
deleteLabel,
|
||||
clearGraph,
|
||||
|
||||
// Pass through read-only operations
|
||||
|
|
@ -306,10 +362,12 @@ export function useGraphWithHistory() {
|
|||
edges: graphStore.edges,
|
||||
nodeTypes: graphStore.nodeTypes,
|
||||
edgeTypes: graphStore.edgeTypes,
|
||||
labels: graphStore.labels,
|
||||
setNodes: graphStore.setNodes,
|
||||
setEdges: graphStore.setEdges,
|
||||
setNodeTypes: graphStore.setNodeTypes,
|
||||
setEdgeTypes: graphStore.setEdgeTypes,
|
||||
setLabels: graphStore.setLabels,
|
||||
loadGraphState: graphStore.loadGraphState,
|
||||
|
||||
// NOTE: exportToFile and importFromFile have been removed
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
Relation,
|
||||
NodeTypeConfig,
|
||||
EdgeTypeConfig,
|
||||
LabelConfig,
|
||||
RelationData,
|
||||
GraphActions
|
||||
} from '../types';
|
||||
|
|
@ -29,6 +30,7 @@ interface GraphStore {
|
|||
edges: Relation[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
labels: LabelConfig[];
|
||||
}
|
||||
|
||||
// Default node types with semantic shape assignments
|
||||
|
|
@ -57,6 +59,7 @@ const loadInitialState = (): GraphStore => {
|
|||
edges: savedState.edges,
|
||||
nodeTypes: savedState.nodeTypes,
|
||||
edgeTypes: savedState.edgeTypes,
|
||||
labels: savedState.labels || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +68,7 @@ const loadInitialState = (): GraphStore => {
|
|||
edges: [],
|
||||
nodeTypes: defaultNodeTypes,
|
||||
edgeTypes: defaultEdgeTypes,
|
||||
labels: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -75,6 +79,7 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
edges: initialState.edges,
|
||||
nodeTypes: initialState.nodeTypes,
|
||||
edgeTypes: initialState.edgeTypes,
|
||||
labels: initialState.labels,
|
||||
|
||||
// Node operations
|
||||
addNode: (node: Actor) =>
|
||||
|
|
@ -83,17 +88,32 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
})),
|
||||
|
||||
updateNode: (id: string, updates: Partial<Actor>) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
...updates,
|
||||
data: updates.data ? { ...node.data, ...updates.data } : node.data,
|
||||
}
|
||||
: node
|
||||
),
|
||||
})),
|
||||
set((state) => {
|
||||
// Validate and filter labels if present
|
||||
let validatedData = updates.data;
|
||||
if (updates.data?.labels) {
|
||||
const validLabelIds = new Set(state.labels.map((l) => l.id));
|
||||
const filteredLabels = updates.data.labels.filter((labelId) =>
|
||||
validLabelIds.has(labelId)
|
||||
);
|
||||
validatedData = {
|
||||
...updates.data,
|
||||
labels: filteredLabels.length > 0 ? filteredLabels : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
...updates,
|
||||
data: validatedData ? { ...node.data, ...validatedData } : node.data,
|
||||
}
|
||||
: node
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
deleteNode: (id: string) =>
|
||||
set((state) => ({
|
||||
|
|
@ -110,13 +130,28 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
})),
|
||||
|
||||
updateEdge: (id: string, data: Partial<RelationData>) =>
|
||||
set((state) => ({
|
||||
edges: state.edges.map((edge) =>
|
||||
edge.id === id
|
||||
? { ...edge, data: { ...edge.data, ...data } as RelationData }
|
||||
: edge
|
||||
),
|
||||
})),
|
||||
set((state) => {
|
||||
// Validate and filter labels if present
|
||||
let validatedData = data;
|
||||
if (data.labels) {
|
||||
const validLabelIds = new Set(state.labels.map((l) => l.id));
|
||||
const filteredLabels = data.labels.filter((labelId) =>
|
||||
validLabelIds.has(labelId)
|
||||
);
|
||||
validatedData = {
|
||||
...data,
|
||||
labels: filteredLabels.length > 0 ? filteredLabels : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
edges: state.edges.map((edge) =>
|
||||
edge.id === id
|
||||
? { ...edge, data: { ...edge.data, ...validatedData } as RelationData }
|
||||
: edge
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
deleteEdge: (id: string) =>
|
||||
set((state) => ({
|
||||
|
|
@ -159,6 +194,47 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
edgeTypes: state.edgeTypes.filter((type) => type.id !== id),
|
||||
})),
|
||||
|
||||
// Label operations
|
||||
addLabel: (label: LabelConfig) =>
|
||||
set((state) => ({
|
||||
labels: [...state.labels, label],
|
||||
})),
|
||||
|
||||
updateLabel: (id: string, updates: Partial<Omit<LabelConfig, 'id'>>) =>
|
||||
set((state) => ({
|
||||
labels: state.labels.map((label) =>
|
||||
label.id === id ? { ...label, ...updates } : label
|
||||
),
|
||||
})),
|
||||
|
||||
deleteLabel: (id: string) =>
|
||||
set((state) => {
|
||||
// Remove label from all nodes and edges
|
||||
const updatedNodes = state.nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
labels: node.data.labels?.filter((labelId) => labelId !== id),
|
||||
},
|
||||
}));
|
||||
|
||||
const updatedEdges = state.edges.map((edge) => ({
|
||||
...edge,
|
||||
data: edge.data
|
||||
? {
|
||||
...edge.data,
|
||||
labels: edge.data.labels?.filter((labelId) => labelId !== id),
|
||||
}
|
||||
: edge.data,
|
||||
}));
|
||||
|
||||
return {
|
||||
labels: state.labels.filter((label) => label.id !== id),
|
||||
nodes: updatedNodes,
|
||||
edges: updatedEdges,
|
||||
};
|
||||
}),
|
||||
|
||||
// Utility operations
|
||||
clearGraph: () =>
|
||||
set({
|
||||
|
|
@ -186,6 +262,11 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
edgeTypes,
|
||||
}),
|
||||
|
||||
setLabels: (labels: LabelConfig[]) =>
|
||||
set({
|
||||
labels,
|
||||
}),
|
||||
|
||||
// NOTE: exportToFile and importFromFile have been removed
|
||||
// Import/export is now handled by the workspace-level system
|
||||
// See: workspaceStore.importDocumentFromFile() and workspaceStore.exportDocument()
|
||||
|
|
@ -196,5 +277,6 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
edges: data.edges,
|
||||
nodeTypes: data.nodeTypes,
|
||||
edgeTypes: data.edgeTypes,
|
||||
labels: data.labels || [],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { create } from "zustand";
|
||||
import type { NodeTypeConfig, EdgeTypeConfig } from "../types";
|
||||
import type { NodeTypeConfig, EdgeTypeConfig, LabelConfig } from "../types";
|
||||
import type { ConstellationState, StateId } from "../types/timeline";
|
||||
|
||||
/**
|
||||
|
|
@ -25,6 +25,8 @@ export interface DocumentSnapshot {
|
|||
// Global types (shared across all timeline states)
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
// Labels (shared across all timeline states)
|
||||
labels: LabelConfig[];
|
||||
}
|
||||
|
||||
export interface HistoryAction {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface PanelState {
|
|||
history: boolean;
|
||||
addActors: boolean;
|
||||
relations: boolean;
|
||||
labels: boolean;
|
||||
layout: boolean;
|
||||
view: boolean;
|
||||
search: boolean;
|
||||
|
|
@ -73,6 +74,7 @@ export const usePanelStore = create<PanelState>()(
|
|||
history: true,
|
||||
addActors: true,
|
||||
relations: true,
|
||||
labels: false,
|
||||
layout: false,
|
||||
view: false,
|
||||
search: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
|
||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
|
||||
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types';
|
||||
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
|
||||
|
||||
|
|
@ -117,9 +117,10 @@ export function getCurrentGraphFromDocument(document: ConstellationDocument): {
|
|||
edges: SerializedRelation[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
labels: LabelConfig[];
|
||||
} | null {
|
||||
try {
|
||||
const { timeline, nodeTypes, edgeTypes } = document;
|
||||
const { timeline, nodeTypes, edgeTypes, labels } = document;
|
||||
const currentState = timeline.states[timeline.currentStateId];
|
||||
|
||||
if (!currentState || !currentState.graph) {
|
||||
|
|
@ -127,12 +128,13 @@ export function getCurrentGraphFromDocument(document: ConstellationDocument): {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Combine state graph with document types
|
||||
// Combine state graph with document types and labels
|
||||
return {
|
||||
nodes: currentState.graph.nodes,
|
||||
edges: currentState.graph.edges,
|
||||
nodeTypes,
|
||||
edgeTypes,
|
||||
labels: labels || [], // Default to empty array for backward compatibility
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get current graph from document:', error);
|
||||
|
|
@ -163,6 +165,7 @@ export function deserializeGraphState(document: ConstellationDocument): {
|
|||
edges: Relation[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
labels: LabelConfig[];
|
||||
} | null {
|
||||
try {
|
||||
const currentGraph = getCurrentGraphFromDocument(document);
|
||||
|
|
@ -181,6 +184,7 @@ export function deserializeGraphState(document: ConstellationDocument): {
|
|||
edges,
|
||||
nodeTypes: migratedNodeTypes,
|
||||
edgeTypes: currentGraph.edgeTypes,
|
||||
labels: currentGraph.labels || [], // Default to empty array for backward compatibility
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to deserialize graph state:', error);
|
||||
|
|
@ -194,6 +198,7 @@ export function loadGraphState(): {
|
|||
edges: Relation[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
labels: LabelConfig[];
|
||||
} | null {
|
||||
const document = loadDocument();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types';
|
||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
|
||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
|
||||
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
|
||||
|
||||
/**
|
||||
|
|
@ -41,6 +41,7 @@ export function createDocument(
|
|||
edges: SerializedRelation[],
|
||||
nodeTypes: NodeTypeConfig[],
|
||||
edgeTypes: EdgeTypeConfig[],
|
||||
labels?: LabelConfig[],
|
||||
existingDocument?: ConstellationDocument
|
||||
): ConstellationDocument {
|
||||
const now = new Date().toISOString();
|
||||
|
|
@ -59,7 +60,7 @@ export function createDocument(
|
|||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Create document with global types and timeline containing the initial state
|
||||
// Create document with global types, labels, and timeline containing the initial state
|
||||
return {
|
||||
metadata: {
|
||||
version: SCHEMA_VERSION,
|
||||
|
|
@ -70,6 +71,7 @@ export function createDocument(
|
|||
},
|
||||
nodeTypes,
|
||||
edgeTypes,
|
||||
labels: labels || [],
|
||||
timeline: {
|
||||
states: {
|
||||
[rootStateId]: initialState,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ActorData, RelationData, NodeTypeConfig, EdgeTypeConfig } from '../../types';
|
||||
import type { ActorData, RelationData, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
|
||||
import type { ConstellationState } from '../../types/timeline';
|
||||
|
||||
/**
|
||||
|
|
@ -42,6 +42,8 @@ export interface ConstellationDocument {
|
|||
// Global node and edge types for the entire document
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
// Global labels for the entire document (optional for backward compatibility)
|
||||
labels?: LabelConfig[];
|
||||
// Timeline with multiple states - every document has this
|
||||
// The graph is stored within each state (nodes and edges only, not types)
|
||||
timeline: {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { create } from 'zustand';
|
|||
*
|
||||
* Features:
|
||||
* - Search text for filtering both actors (by label, description, or type) and relations (by label or type)
|
||||
* - Filter by actor types (show/hide specific node types)
|
||||
* - Filter by relation types (show/hide specific edge types)
|
||||
* - POSITIVE FILTERS (empty = show all, selected = show only selected):
|
||||
* - Filter by actor types
|
||||
* - Filter by relation types
|
||||
* - Filter by labels
|
||||
* - Results tracking
|
||||
*/
|
||||
|
||||
|
|
@ -15,17 +17,20 @@ interface SearchStore {
|
|||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
|
||||
// Filter visibility by actor types (nodeTypeId -> visible)
|
||||
visibleActorTypes: Record<string, boolean>;
|
||||
setActorTypeVisible: (typeId: string, visible: boolean) => void;
|
||||
toggleActorType: (typeId: string) => void;
|
||||
setAllActorTypesVisible: (visible: boolean) => void;
|
||||
// POSITIVE actor type filter: selected type IDs to show (empty = show all)
|
||||
selectedActorTypes: string[];
|
||||
toggleSelectedActorType: (typeId: string) => void;
|
||||
clearSelectedActorTypes: () => void;
|
||||
|
||||
// Filter visibility by relation types (edgeTypeId -> visible)
|
||||
visibleRelationTypes: Record<string, boolean>;
|
||||
setRelationTypeVisible: (typeId: string, visible: boolean) => void;
|
||||
toggleRelationType: (typeId: string) => void;
|
||||
setAllRelationTypesVisible: (visible: boolean) => void;
|
||||
// POSITIVE relation type filter: selected type IDs to show (empty = show all)
|
||||
selectedRelationTypes: string[];
|
||||
toggleSelectedRelationType: (typeId: string) => void;
|
||||
clearSelectedRelationTypes: () => void;
|
||||
|
||||
// POSITIVE label filter: selected label IDs to show (empty = show all)
|
||||
selectedLabels: string[];
|
||||
toggleSelectedLabel: (labelId: string) => void;
|
||||
clearSelectedLabels: () => void;
|
||||
|
||||
// Clear all filters
|
||||
clearFilters: () => void;
|
||||
|
|
@ -36,81 +41,58 @@ interface SearchStore {
|
|||
|
||||
export const useSearchStore = create<SearchStore>((set, get) => ({
|
||||
searchText: '',
|
||||
visibleActorTypes: {},
|
||||
visibleRelationTypes: {},
|
||||
selectedActorTypes: [],
|
||||
selectedRelationTypes: [],
|
||||
selectedLabels: [],
|
||||
|
||||
setSearchText: (text: string) =>
|
||||
set({ searchText: text }),
|
||||
|
||||
setActorTypeVisible: (typeId: string, visible: boolean) =>
|
||||
set((state) => ({
|
||||
visibleActorTypes: {
|
||||
...state.visibleActorTypes,
|
||||
[typeId]: visible,
|
||||
},
|
||||
})),
|
||||
|
||||
toggleActorType: (typeId: string) =>
|
||||
set((state) => ({
|
||||
visibleActorTypes: {
|
||||
...state.visibleActorTypes,
|
||||
[typeId]: !state.visibleActorTypes[typeId],
|
||||
},
|
||||
})),
|
||||
|
||||
setAllActorTypesVisible: (visible: boolean) =>
|
||||
toggleSelectedActorType: (typeId: string) =>
|
||||
set((state) => {
|
||||
const updated: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleActorTypes).forEach((typeId) => {
|
||||
updated[typeId] = visible;
|
||||
});
|
||||
return { visibleActorTypes: updated };
|
||||
const isSelected = state.selectedActorTypes.includes(typeId);
|
||||
return {
|
||||
selectedActorTypes: isSelected
|
||||
? state.selectedActorTypes.filter((id) => id !== typeId)
|
||||
: [...state.selectedActorTypes, typeId],
|
||||
};
|
||||
}),
|
||||
|
||||
setRelationTypeVisible: (typeId: string, visible: boolean) =>
|
||||
set((state) => ({
|
||||
visibleRelationTypes: {
|
||||
...state.visibleRelationTypes,
|
||||
[typeId]: visible,
|
||||
},
|
||||
})),
|
||||
clearSelectedActorTypes: () =>
|
||||
set({ selectedActorTypes: [] }),
|
||||
|
||||
toggleRelationType: (typeId: string) =>
|
||||
set((state) => ({
|
||||
visibleRelationTypes: {
|
||||
...state.visibleRelationTypes,
|
||||
[typeId]: !state.visibleRelationTypes[typeId],
|
||||
},
|
||||
})),
|
||||
|
||||
setAllRelationTypesVisible: (visible: boolean) =>
|
||||
toggleSelectedRelationType: (typeId: string) =>
|
||||
set((state) => {
|
||||
const updated: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleRelationTypes).forEach((typeId) => {
|
||||
updated[typeId] = visible;
|
||||
});
|
||||
return { visibleRelationTypes: updated };
|
||||
const isSelected = state.selectedRelationTypes.includes(typeId);
|
||||
return {
|
||||
selectedRelationTypes: isSelected
|
||||
? state.selectedRelationTypes.filter((id) => id !== typeId)
|
||||
: [...state.selectedRelationTypes, typeId],
|
||||
};
|
||||
}),
|
||||
|
||||
clearSelectedRelationTypes: () =>
|
||||
set({ selectedRelationTypes: [] }),
|
||||
|
||||
toggleSelectedLabel: (labelId: string) =>
|
||||
set((state) => {
|
||||
const isSelected = state.selectedLabels.includes(labelId);
|
||||
return {
|
||||
selectedLabels: isSelected
|
||||
? state.selectedLabels.filter((id) => id !== labelId)
|
||||
: [...state.selectedLabels, labelId],
|
||||
};
|
||||
}),
|
||||
|
||||
clearSelectedLabels: () =>
|
||||
set({ selectedLabels: [] }),
|
||||
|
||||
clearFilters: () =>
|
||||
set((state) => {
|
||||
// Reset all actor types to visible
|
||||
const resetActorTypes: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleActorTypes).forEach((typeId) => {
|
||||
resetActorTypes[typeId] = true;
|
||||
});
|
||||
|
||||
// Reset all relation types to visible
|
||||
const resetRelationTypes: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleRelationTypes).forEach((typeId) => {
|
||||
resetRelationTypes[typeId] = true;
|
||||
});
|
||||
|
||||
return {
|
||||
searchText: '',
|
||||
visibleActorTypes: resetActorTypes,
|
||||
visibleRelationTypes: resetRelationTypes,
|
||||
};
|
||||
set({
|
||||
searchText: '',
|
||||
selectedActorTypes: [],
|
||||
selectedRelationTypes: [],
|
||||
selectedLabels: [],
|
||||
}),
|
||||
|
||||
hasActiveFilters: () => {
|
||||
|
|
@ -121,19 +103,18 @@ export const useSearchStore = create<SearchStore>((set, get) => ({
|
|||
return true;
|
||||
}
|
||||
|
||||
// Check if any actor type is hidden
|
||||
const hasHiddenActorType = Object.values(state.visibleActorTypes).some(
|
||||
(visible) => !visible
|
||||
);
|
||||
if (hasHiddenActorType) {
|
||||
// Check if any actor types are selected (positive filter)
|
||||
if (state.selectedActorTypes.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any relation type is hidden
|
||||
const hasHiddenRelationType = Object.values(state.visibleRelationTypes).some(
|
||||
(visible) => !visible
|
||||
);
|
||||
if (hasHiddenRelationType) {
|
||||
// Check if any relation types are selected (positive filter)
|
||||
if (state.selectedRelationTypes.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any labels are selected (positive filter)
|
||||
if (state.selectedLabels.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function pushDocumentHistory(documentId: string, description: string) {
|
|||
},
|
||||
nodeTypes: graphStore.nodeTypes,
|
||||
edgeTypes: graphStore.edgeTypes,
|
||||
labels: graphStore.labels,
|
||||
};
|
||||
|
||||
historyStore.pushAction(documentId, {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ConstellationDocument } from '../persistence/types';
|
||||
import type { NodeTypeConfig, EdgeTypeConfig } from '../../types';
|
||||
import type { NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* Workspace Types
|
||||
|
|
@ -95,6 +95,11 @@ export interface WorkspaceActions {
|
|||
updateEdgeTypeInDocument: (documentId: string, typeId: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void;
|
||||
deleteEdgeTypeFromDocument: (documentId: string, typeId: string) => void;
|
||||
|
||||
// Label management (document-level)
|
||||
addLabelToDocument: (documentId: string, label: LabelConfig) => void;
|
||||
updateLabelInDocument: (documentId: string, labelId: string, updates: Partial<Omit<LabelConfig, 'id'>>) => void;
|
||||
deleteLabelFromDocument: (documentId: string, labelId: string) => void;
|
||||
|
||||
// Viewport operations
|
||||
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void;
|
||||
getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||
import { useWorkspaceStore } from '../workspaceStore';
|
||||
import { useGraphStore } from '../graphStore';
|
||||
import { useTimelineStore } from '../timelineStore';
|
||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
|
||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
|
||||
import { getCurrentGraphFromDocument } from '../persistence/loader';
|
||||
|
||||
/**
|
||||
|
|
@ -29,10 +29,12 @@ export function useActiveDocument() {
|
|||
const setEdges = useGraphStore((state) => state.setEdges);
|
||||
const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
|
||||
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
|
||||
const setLabels = useGraphStore((state) => state.setLabels);
|
||||
const graphNodes = useGraphStore((state) => state.nodes);
|
||||
const graphEdges = useGraphStore((state) => state.edges);
|
||||
const graphNodeTypes = useGraphStore((state) => state.nodeTypes);
|
||||
const graphEdgeTypes = useGraphStore((state) => state.edgeTypes);
|
||||
const graphLabels = useGraphStore((state) => state.labels);
|
||||
|
||||
// Track unload timers for inactive documents
|
||||
const unloadTimersRef = useRef<Map<string, number>>(new Map());
|
||||
|
|
@ -48,12 +50,14 @@ export function useActiveDocument() {
|
|||
edges: Relation[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
labels: LabelConfig[];
|
||||
}>({
|
||||
documentId: null,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
nodeTypes: [],
|
||||
edgeTypes: [],
|
||||
labels: [],
|
||||
});
|
||||
|
||||
// Load active document into graphStore when it changes
|
||||
|
|
@ -76,6 +80,7 @@ export function useActiveDocument() {
|
|||
setEdges(currentGraph.edges as never[]);
|
||||
setNodeTypes(currentGraph.nodeTypes as never[]);
|
||||
setEdgeTypes(currentGraph.edgeTypes as never[]);
|
||||
setLabels(activeDocument.labels || []);
|
||||
|
||||
// Update the last synced state to match what we just loaded
|
||||
lastSyncedStateRef.current = {
|
||||
|
|
@ -84,6 +89,7 @@ export function useActiveDocument() {
|
|||
edges: currentGraph.edges as Relation[],
|
||||
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
|
||||
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],
|
||||
labels: activeDocument.labels || [],
|
||||
};
|
||||
|
||||
// Clear loading flag after a brief delay to allow state to settle
|
||||
|
|
@ -100,6 +106,7 @@ export function useActiveDocument() {
|
|||
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setLabels([]);
|
||||
// Note: We keep nodeTypes and edgeTypes so they're available for new documents
|
||||
|
||||
// Clear the last synced state
|
||||
|
|
@ -109,6 +116,7 @@ export function useActiveDocument() {
|
|||
edges: [],
|
||||
nodeTypes: [],
|
||||
edgeTypes: [],
|
||||
labels: [],
|
||||
};
|
||||
|
||||
// Clear loading flag after a brief delay
|
||||
|
|
@ -116,7 +124,7 @@ export function useActiveDocument() {
|
|||
isLoadingRef.current = false;
|
||||
}, 100);
|
||||
}
|
||||
}, [activeDocumentId, activeDocument, documents, setNodes, setEdges, setNodeTypes, setEdgeTypes]);
|
||||
}, [activeDocumentId, activeDocument, documents, setNodes, setEdges, setNodeTypes, setEdgeTypes, setLabels]);
|
||||
|
||||
// Save graphStore changes back to workspace (debounced via workspace)
|
||||
useEffect(() => {
|
||||
|
|
@ -153,13 +161,14 @@ export function useActiveDocument() {
|
|||
console.log(`Document ${activeDocumentId} has changes, marking as dirty`);
|
||||
markDocumentDirty(activeDocumentId);
|
||||
|
||||
// Update the last synced state (keep types for reference, but don't track them for changes)
|
||||
// Update the last synced state (keep types and labels for reference, but don't track them for changes)
|
||||
lastSyncedStateRef.current = {
|
||||
documentId: activeDocumentId,
|
||||
nodes: graphNodes as Actor[],
|
||||
edges: graphEdges as Relation[],
|
||||
nodeTypes: graphNodeTypes as NodeTypeConfig[],
|
||||
edgeTypes: graphEdgeTypes as EdgeTypeConfig[],
|
||||
labels: graphLabels as LabelConfig[],
|
||||
};
|
||||
|
||||
// Update the timeline's current state with the new graph data (nodes and edges only)
|
||||
|
|
@ -175,7 +184,7 @@ export function useActiveDocument() {
|
|||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [graphNodes, graphEdges, graphNodeTypes, graphEdgeTypes, activeDocumentId, activeDocument, documents, markDocumentDirty, saveDocument]);
|
||||
}, [graphNodes, graphEdges, graphNodeTypes, graphEdgeTypes, graphLabels, activeDocumentId, activeDocument, documents, markDocumentDirty, saveDocument]);
|
||||
|
||||
// Memory management: Unload inactive documents after timeout
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { create } from 'zustand';
|
||||
import type { ConstellationDocument } from './persistence/types';
|
||||
import type { Workspace, WorkspaceActions, DocumentMetadata, WorkspaceSettings } from './workspace/types';
|
||||
import type { Actor, Relation } from '../types';
|
||||
import { createDocument as createDocumentHelper } from './persistence/saver';
|
||||
import { selectFileForImport, exportDocumentToFile } from './persistence/fileIO';
|
||||
import {
|
||||
|
|
@ -142,6 +143,7 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
);
|
||||
newDoc.metadata.documentId = documentId;
|
||||
newDoc.metadata.title = title;
|
||||
newDoc.labels = []; // Initialize with empty labels
|
||||
|
||||
const metadata: DocumentMetadata = {
|
||||
id: documentId,
|
||||
|
|
@ -220,6 +222,7 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
);
|
||||
newDoc.metadata.documentId = documentId;
|
||||
newDoc.metadata.title = title;
|
||||
newDoc.labels = sourceDoc.labels || []; // Copy labels from source document
|
||||
|
||||
const metadata: DocumentMetadata = {
|
||||
id: documentId,
|
||||
|
|
@ -1030,4 +1033,134 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
|
||||
}
|
||||
},
|
||||
|
||||
// Label management - document-level operations
|
||||
addLabelToDocument: (documentId: string, label) => {
|
||||
const state = get();
|
||||
const doc = state.documents.get(documentId);
|
||||
|
||||
if (!doc) {
|
||||
console.error(`Document ${documentId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize labels array if it doesn't exist (backward compatibility)
|
||||
if (!doc.labels) {
|
||||
doc.labels = [];
|
||||
}
|
||||
|
||||
// Add to document's labels
|
||||
doc.labels = [...doc.labels, label];
|
||||
|
||||
// Save document
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
|
||||
updateLabelInDocument: (documentId: string, labelId: string, updates) => {
|
||||
const state = get();
|
||||
const doc = state.documents.get(documentId);
|
||||
|
||||
if (!doc) {
|
||||
console.error(`Document ${documentId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize labels array if it doesn't exist (backward compatibility)
|
||||
if (!doc.labels) {
|
||||
doc.labels = [];
|
||||
}
|
||||
|
||||
// Update in document's labels
|
||||
doc.labels = doc.labels.map((label) =>
|
||||
label.id === labelId ? { ...label, ...updates } : label
|
||||
);
|
||||
|
||||
// Save document
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
|
||||
deleteLabelFromDocument: (documentId: string, labelId: string) => {
|
||||
const state = get();
|
||||
const doc = state.documents.get(documentId);
|
||||
|
||||
if (!doc) {
|
||||
console.error(`Document ${documentId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize labels array if it doesn't exist (backward compatibility)
|
||||
if (!doc.labels) {
|
||||
doc.labels = [];
|
||||
}
|
||||
|
||||
// Remove from document's labels
|
||||
doc.labels = doc.labels.filter((label) => label.id !== labelId);
|
||||
|
||||
// Remove label from all nodes and edges in all timeline states
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const timeline = timelineStore.timelines.get(documentId);
|
||||
|
||||
if (timeline) {
|
||||
let hasChanges = false;
|
||||
|
||||
// Iterate through all timeline states and clean up label references
|
||||
timeline.states.forEach((constellationState) => {
|
||||
// Clean up nodes
|
||||
constellationState.graph.nodes.forEach((node) => {
|
||||
const nodeData = node.data as { labels?: string[] };
|
||||
if (nodeData?.labels && nodeData.labels.includes(labelId)) {
|
||||
nodeData.labels = nodeData.labels.filter((id: string) => id !== labelId);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up edges
|
||||
constellationState.graph.edges.forEach((edge) => {
|
||||
const edgeData = edge.data as { labels?: string[] };
|
||||
if (edgeData?.labels && edgeData.labels.includes(labelId)) {
|
||||
edgeData.labels = edgeData.labels.filter((id: string) => id !== labelId);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If this is the active document and changes were made, sync to graphStore
|
||||
if (hasChanges && documentId === state.activeDocumentId) {
|
||||
const currentState = timeline.states.get(timeline.currentStateId);
|
||||
if (currentState) {
|
||||
useGraphStore.setState({
|
||||
nodes: currentState.graph.nodes as Actor[],
|
||||
edges: currentState.graph.edges as Relation[],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save document
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface ActorData {
|
|||
label: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
labels?: string[]; // Array of LabelConfig IDs
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export interface RelationData {
|
|||
type: string;
|
||||
directionality?: EdgeDirectionality;
|
||||
strength?: number;
|
||||
labels?: string[]; // Array of LabelConfig IDs
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
@ -51,12 +53,24 @@ export interface EdgeTypeConfig {
|
|||
defaultDirectionality?: EdgeDirectionality;
|
||||
}
|
||||
|
||||
// Label Configuration
|
||||
export type LabelScope = 'actors' | 'relations' | 'both';
|
||||
|
||||
export interface LabelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
appliesTo: LabelScope;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Graph State
|
||||
export interface GraphState {
|
||||
nodes: Actor[];
|
||||
edges: Relation[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
labels: LabelConfig[];
|
||||
}
|
||||
|
||||
// Editor Settings
|
||||
|
|
@ -82,14 +96,18 @@ export interface GraphActions {
|
|||
addEdgeType: (edgeType: EdgeTypeConfig) => void;
|
||||
updateEdgeType: (id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void;
|
||||
deleteEdgeType: (id: string) => void;
|
||||
addLabel: (label: LabelConfig) => void;
|
||||
updateLabel: (id: string, updates: Partial<Omit<LabelConfig, 'id'>>) => void;
|
||||
deleteLabel: (id: string) => void;
|
||||
clearGraph: () => void;
|
||||
setNodes: (nodes: Actor[]) => void;
|
||||
setEdges: (edges: Relation[]) => void;
|
||||
setNodeTypes: (nodeTypes: NodeTypeConfig[]) => void;
|
||||
setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) => void;
|
||||
setLabels: (labels: LabelConfig[]) => void;
|
||||
// NOTE: exportToFile and importFromFile have been removed
|
||||
// Import/export is now handled by the workspace-level system (workspaceStore)
|
||||
loadGraphState: (data: { nodes: Actor[]; edges: Relation[]; nodeTypes: NodeTypeConfig[]; edgeTypes: EdgeTypeConfig[] }) => void;
|
||||
loadGraphState: (data: { nodes: Actor[]; edges: Relation[]; nodeTypes: NodeTypeConfig[]; edgeTypes: EdgeTypeConfig[]; labels?: LabelConfig[] }) => void;
|
||||
}
|
||||
|
||||
export interface EditorActions {
|
||||
|
|
|
|||
Loading…
Reference in a new issue