From d98acf963b95369ec33506b3641b438e455978f9 Mon Sep 17 00:00:00 2001
From: Jan-Henrik Bruhn
Date: Fri, 17 Oct 2025 10:40:00 +0200
Subject: [PATCH] feat: implement label system and redesign filtering with
positive filters
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../Common/AutocompleteLabelSelector.tsx | 258 ++++++++++++++++++
src/components/Common/LabelBadge.tsx | 42 +++
src/components/Common/LabelSelector.tsx | 113 ++++++++
src/components/Config/EditLabelInline.tsx | 115 ++++++++
src/components/Config/LabelConfig.tsx | 234 ++++++++++++++++
src/components/Config/LabelForm.tsx | 127 +++++++++
src/components/Config/LabelManagementList.tsx | 128 +++++++++
src/components/Config/QuickAddLabelForm.tsx | 95 +++++++
src/components/Edges/CustomEdge.tsx | 56 +++-
src/components/Editor/EdgePropertiesPanel.tsx | 16 ++
src/components/Editor/GraphEditor.tsx | 55 ++--
src/components/Editor/NodePropertiesPanel.tsx | 16 ++
src/components/Menu/MenuBar.tsx | 17 ++
src/components/Nodes/CustomNode.tsx | 50 +++-
src/components/Panels/LeftPanel.tsx | 162 ++++++++---
src/components/Panels/RightPanel.tsx | 44 ++-
src/hooks/useDocumentHistory.ts | 26 +-
src/hooks/useGraphWithHistory.ts | 64 ++++-
src/stores/graphStore.ts | 118 ++++++--
src/stores/historyStore.ts | 4 +-
src/stores/panelStore.ts | 2 +
src/stores/persistence/loader.ts | 11 +-
src/stores/persistence/saver.ts | 6 +-
src/stores/persistence/types.ts | 4 +-
src/stores/searchStore.ts | 151 +++++-----
src/stores/timelineStore.ts | 1 +
src/stores/workspace/types.ts | 7 +-
src/stores/workspace/useActiveDocument.ts | 17 +-
src/stores/workspaceStore.ts | 133 +++++++++
src/types/index.ts | 20 +-
30 files changed, 1890 insertions(+), 202 deletions(-)
create mode 100644 src/components/Common/AutocompleteLabelSelector.tsx
create mode 100644 src/components/Common/LabelBadge.tsx
create mode 100644 src/components/Common/LabelSelector.tsx
create mode 100644 src/components/Config/EditLabelInline.tsx
create mode 100644 src/components/Config/LabelConfig.tsx
create mode 100644 src/components/Config/LabelForm.tsx
create mode 100644 src/components/Config/LabelManagementList.tsx
create mode 100644 src/components/Config/QuickAddLabelForm.tsx
diff --git a/src/components/Common/AutocompleteLabelSelector.tsx b/src/components/Common/AutocompleteLabelSelector.tsx
new file mode 100644
index 0000000..f1e25c6
--- /dev/null
+++ b/src/components/Common/AutocompleteLabelSelector.tsx
@@ -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(null);
+ const dropdownRef = useRef(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 => 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 (
+
+ {/* Selected labels */}
+ {selectedLabels.length > 0 && (
+
+ {selectedLabels.map((label) => (
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Input field */}
+
{
+ 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) && (
+
+ {/* Existing labels */}
+ {filteredLabels.map((label, index) => (
+
+ ))}
+
+ {/* Create new label option */}
+ {showCreateOption && (
+
+ )}
+
+ )}
+
+ );
+};
+
+export default AutocompleteLabelSelector;
diff --git a/src/components/Common/LabelBadge.tsx b/src/components/Common/LabelBadge.tsx
new file mode 100644
index 0000000..332b860
--- /dev/null
+++ b/src/components/Common/LabelBadge.tsx
@@ -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 (
+
+ {name}
+
+ );
+};
+
+export default LabelBadge;
diff --git a/src/components/Common/LabelSelector.tsx b/src/components/Common/LabelSelector.tsx
new file mode 100644
index 0000000..3b441ec
--- /dev/null
+++ b/src/components/Common/LabelSelector.tsx
@@ -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 (
+
+ No labels configured. Configure labels in Edit → Configure Labels.
+
+ );
+ }
+
+ return (
+
+ {/* Selected labels display */}
+ {selectedLabels.length > 0 && (
+
+ {selectedLabels.map((label) => (
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Label selection checkboxes */}
+
+ {availableLabels.length === 0 ? (
+
+ No {scope} labels available
+
+ ) : (
+
+ {availableLabels.map((label) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default LabelSelector;
diff --git a/src/components/Config/EditLabelInline.tsx b/src/components/Config/EditLabelInline.tsx
new file mode 100644
index 0000000..cd7ae5b
--- /dev/null
+++ b/src/components/Config/EditLabelInline.tsx
@@ -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('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 (
+
+ {/* Form Fields */}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ {/* Keyboard Shortcut Hint */}
+
+
+ {navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter
+ {' '}
+ to save, Esc to cancel
+
+
+
+ );
+};
+
+export default EditLabelInline;
diff --git a/src/components/Config/LabelConfig.tsx b/src/components/Config/LabelConfig.tsx
new file mode 100644
index 0000000..21abe46
--- /dev/null
+++ b/src/components/Config/LabelConfig.tsx
@@ -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(null);
+
+ // Calculate usage counts for each label
+ const usageCounts = useMemo(() => {
+ const counts: Record = {};
+
+ // 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 */}
+
+
+ {/* Header */}
+
+
Configure Labels
+
+ Create and manage labels to categorize actors and relations
+
+
+
+ {/* Content - Two-Column or Full-Width Edit */}
+
+ {editingLabel ? (
+ /* Full-Width Edit Mode */
+
+ ) : (
+ <>
+ {/* Left Column - Quick Add (60%) */}
+
+
+
+ Quick Add Label
+
+
+
+
+ {/* Helper Text */}
+
+
About Labels
+
+ - • Labels help you categorize and filter actors and relations
+ - • Apply multiple labels to any item for flexible organization
+ - • Labels can apply to actors only, relations only, or both
+ - • Use the filter panel to show/hide items by label
+
+
+
+
+ {/* Right Column - Management (40%) */}
+
+
+
+
+ Labels ({labels.length})
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Footer - Hidden when editing */}
+ {!editingLabel && (
+
+
+
+ )}
+
+
+
+ {/* Confirmation Dialog */}
+ {ConfirmDialogComponent}
+ >
+ );
+};
+
+export default LabelConfigModal;
diff --git a/src/components/Config/LabelForm.tsx b/src/components/Config/LabelForm.tsx
new file mode 100644
index 0000000..db374d5
--- /dev/null
+++ b/src/components/Config/LabelForm.tsx
@@ -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 (
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+ );
+};
+
+export default LabelForm;
diff --git a/src/components/Config/LabelManagementList.tsx b/src/components/Config/LabelManagementList.tsx
new file mode 100644
index 0000000..d5e0074
--- /dev/null
+++ b/src/components/Config/LabelManagementList.tsx
@@ -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;
+ onEdit: (label: LabelConfig) => void;
+ onDelete: (id: string) => void;
+}
+
+const LabelManagementList = ({ labels, usageCounts, onEdit, onDelete }: Props) => {
+ if (labels.length === 0) {
+ return (
+
+
No labels yet.
+
Add your first label above.
+
+ );
+ }
+
+ 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 (
+
+ {labels.map((label) => (
+
onEdit(label)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onEdit(label);
+ }
+ }}
+ aria-label={`Edit ${label.name}`}
+ >
+
+ {/* Label Info */}
+
+ {/* Color Badge */}
+
+
+ {/* Name, Scope & Usage */}
+
+
+ {label.name}
+
+
+
+ {getScopeLabel(label.appliesTo)}
+
+ •
+
+ {getUsageText(label.id)}
+
+
+ {label.description && (
+
+ {label.description}
+
+ )}
+
+
+
+ {/* Actions */}
+
+
+
+
+
+ ))}
+
+ );
+};
+
+export default LabelManagementList;
diff --git a/src/components/Config/QuickAddLabelForm.tsx b/src/components/Config/QuickAddLabelForm.tsx
new file mode 100644
index 0000000..f96fbeb
--- /dev/null
+++ b/src/components/Config/QuickAddLabelForm.tsx
@@ -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('both');
+ const [description, setDescription] = useState('');
+
+ const nameInputRef = useRef(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 (
+
+
+
+
+
+ {/* Keyboard Shortcuts Hint */}
+ {name && (
+
+ Press Enter to add, Escape to cancel
+
+ )}
+
+ );
+};
+
+export default QuickAddLabelForm;
diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx
index 8990801..3cd5627 100644
--- a/src/components/Edges/CustomEdge.tsx
+++ b/src/components/Edges/CustomEdge.tsx
@@ -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) => {
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)) && (
-
{displayLabel}
+ {displayLabel && (
+
+ {displayLabel}
+
+ )}
+ {data?.labels && data.labels.length > 0 && (
+
+ {data.labels.map((labelId) => {
+ const labelConfig = labels.find((l) => l.id === labelId);
+ if (!labelConfig) return null;
+ return (
+
+ );
+ })}
+
+ )}
)}
diff --git a/src/components/Editor/EdgePropertiesPanel.tsx b/src/components/Editor/EdgePropertiesPanel.tsx
index 1a7eb0a..618d033 100644
--- a/src/components/Editor/EdgePropertiesPanel.tsx
+++ b/src/components/Editor/EdgePropertiesPanel.tsx
@@ -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([]);
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) => {
+ {/* Labels */}
+
+
+
+
+
{/* Connection Info */}
{selectedEdge && (
diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx
index 8cbde57..1f3aedf 100644
--- a/src/components/Editor/GraphEditor.tsx
+++ b/src/components/Editor/GraphEditor.tsx
@@ -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(
diff --git a/src/components/Editor/NodePropertiesPanel.tsx b/src/components/Editor/NodePropertiesPanel.tsx
index 3400905..319e549 100644
--- a/src/components/Editor/NodePropertiesPanel.tsx
+++ b/src/components/Editor/NodePropertiesPanel.tsx
@@ -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([]);
const labelInputRef = useRef(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) => {
/>
+ {/* Labels */}
+
+
+
+
+
{/* Node Info */}
{selectedNode && (
diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx
index 565e693..2684b76 100644
--- a/src/components/Menu/MenuBar.tsx
+++ b/src/components/Menu/MenuBar.tsx
@@ -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
= ({ 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(null);
@@ -173,6 +175,11 @@ const MenuBar: React.FC = ({ 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 = ({ onOpenHelp, onFitView, onExport }) =>
>
Configure Relation Types
+
@@ -465,6 +478,10 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) =>
isOpen={showEdgeConfig}
onClose={() => setShowEdgeConfig(false)}
/>
+ setShowLabelConfig(false)}
+ />
{/* Input Dialogs */}
) => {
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) => {
// 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) => {
return true;
}, [
searchText,
- visibleActorTypes,
+ selectedActorTypes,
+ selectedLabels,
data.type,
data.label,
+ data.labels,
data.description,
nodeLabel,
]);
@@ -80,7 +96,8 @@ const CustomNode = ({ data, selected }: NodeProps) => {
// 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) => {
>
{nodeLabel}
+
+ {/* Labels */}
+ {data.labels && data.labels.length > 0 && (
+
+ {data.labels.map((labelId) => {
+ const labelConfig = labels.find((l) => l.id === labelId);
+ if (!labelConfig) return null;
+ return (
+
+ );
+ })}
+
+ )}
diff --git a/src/components/Panels/LeftPanel.tsx b/src/components/Panels/LeftPanel.tsx
index 1c2d996..301cf54 100644
--- a/src/components/Panels/LeftPanel.tsx
+++ b/src/components/Panels/LeftPanel.tsx
@@ -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(({ 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(({ 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(({ 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(({ 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(({ 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(({ 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(({ onDeselectAll, onA
return true;
});
- }, [edges, searchText, visibleRelationTypes, edgeTypes]);
+ }, [edges, searchText, selectedRelationTypes, selectedLabels, edgeTypes]);
const handleAddNode = useCallback(
@@ -464,10 +476,13 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA
{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(({ onDeselectAll, onA
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
>
setActorTypeVisible(nodeType.id, !isVisible)}
+ checked={isSelected}
+ onChange={() => toggleSelectedActorType(nodeType.id)}
size="small"
sx={{ padding: '2px' }}
/>
@@ -501,16 +516,29 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA
);
})}
+ {selectedActorTypes.length === 0 && (
+
+ No types selected - showing all actors
+
+ )}
+ {selectedActorTypes.length > 0 && (
+
+ Showing only selected actor types
+
+ )}
{/* Filter by Relation */}
{edgeTypes.map((edgeType) => {
- const isVisible = visibleRelationTypes[edgeType.id] !== false;
+ const isSelected = selectedRelationTypes.includes(edgeType.id);
return (
+ {selectedRelationTypes.length === 0 && (
+
+ No types selected - showing all relations
+
+ )}
+ {selectedRelationTypes.length > 0 && (
+
+ Showing only selected relation types
+
+ )}
+ {/* Filter by Label */}
+ {labels.length > 0 && (
+
+
+
+ {labels.map((label) => {
+ const isSelected = selectedLabels.includes(label.id);
+
+ return (
+
+ );
+ })}
+
+ {selectedLabels.length === 0 && (
+
+ No labels selected - showing all items
+
+ )}
+ {selectedLabels.length > 0 && (
+
+ Showing only items with selected labels
+
+ )}
+
+ )}
+
{/* Results Summary */}
diff --git a/src/components/Panels/RightPanel.tsx b/src/components/Panels/RightPanel.tsx
index a1bb0d6..c8c2e23 100644
--- a/src/components/Panels/RightPanel.tsx
+++ b/src/components/Panels/RightPanel.tsx
@@ -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([]);
const labelInputRef = useRef(null);
// Edge property states
const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState('');
+ const [relationLabels, setRelationLabels] = useState([]);
const [relationDirectionality, setRelationDirectionality] = useState('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) => {
/>
+ {/* Labels */}
+
+
+
{
+ setActorLabels(newLabels);
+ setHasNodeChanges(true);
+ }}
+ scope="actors"
+ />
+
+
{/* Connections */}
@@ -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) => {
+ {/* Labels */}
+
+
+
{
+ setRelationLabels(newLabels);
+ setHasEdgeChanges(true);
+ }}
+ scope="relations"
+ />
+
+
{/* Directionality */}