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:
Jan-Henrik Bruhn 2025-10-17 10:40:00 +02:00
parent cfd7a0b76f
commit d98acf963b
30 changed files with 1890 additions and 202 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -8,6 +8,7 @@ import {
import { useGraphStore } from '../../stores/graphStore'; import { useGraphStore } from '../../stores/graphStore';
import { useSearchStore } from '../../stores/searchStore'; import { useSearchStore } from '../../stores/searchStore';
import type { RelationData } from '../../types'; import type { RelationData } from '../../types';
import LabelBadge from '../Common/LabelBadge';
/** /**
* CustomEdge - Represents a relation between actors in the constellation graph * CustomEdge - Represents a relation between actors in the constellation graph
@ -33,7 +34,8 @@ const CustomEdge = ({
selected, selected,
}: EdgeProps<RelationData>) => { }: EdgeProps<RelationData>) => {
const edgeTypes = useGraphStore((state) => state.edgeTypes); 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 // Calculate the bezier path
const [edgePath, labelX, labelY] = getBezierPath({ const [edgePath, labelX, labelY] = getBezierPath({
@ -65,12 +67,24 @@ const CustomEdge = ({
// Check if this edge matches the filter criteria // Check if this edge matches the filter criteria
const isMatch = useMemo(() => { 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 edgeType = data?.type || '';
const isTypeVisible = visibleRelationTypes[edgeType] !== false; if (selectedRelationTypes.length > 0) {
if (!isTypeVisible) { if (!selectedRelationTypes.includes(edgeType)) {
return false; 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 // Check search text match
if (searchText.trim()) { if (searchText.trim()) {
@ -82,12 +96,13 @@ const CustomEdge = ({
} }
return true; 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 // Determine if filters are active
const hasActiveFilters = const hasActiveFilters =
searchText.trim() !== '' || searchText.trim() !== '' ||
Object.values(visibleRelationTypes).some(v => v === false); selectedRelationTypes.length > 0 ||
selectedLabels.length > 0;
// Calculate opacity based on visibility // Calculate opacity based on visibility
const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
@ -151,8 +166,8 @@ const CustomEdge = ({
markerStart={markerStart} markerStart={markerStart}
/> />
{/* Edge label - show custom or type default */} {/* Edge label - show custom or type default, plus labels */}
{displayLabel && ( {(displayLabel || (data?.labels && data.labels.length > 0)) && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
style={{ 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" 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> </div>
</EdgeLabelRenderer> </EdgeLabelRenderer>
)} )}

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import PropertyPanel from '../Common/PropertyPanel'; import PropertyPanel from '../Common/PropertyPanel';
import LabelSelector from '../Common/LabelSelector';
import type { Relation } from '../../types'; import type { Relation } from '../../types';
/** /**
@ -22,6 +23,7 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
const { edgeTypes, updateEdge, deleteEdge } = useGraphWithHistory(); const { edgeTypes, updateEdge, deleteEdge } = useGraphWithHistory();
const [relationType, setRelationType] = useState(''); const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState(''); const [relationLabel, setRelationLabel] = useState('');
const [relationLabels, setRelationLabels] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
if (selectedEdge && selectedEdge.data) { 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 typeLabel = edgeTypes.find((et) => et.id === selectedEdge.data?.type)?.label;
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel; const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || ''); setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
setRelationLabels(selectedEdge.data.labels || []);
} }
}, [selectedEdge, edgeTypes]); }, [selectedEdge, edgeTypes]);
@ -39,6 +42,7 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
type: relationType, type: relationType,
// Only set label if user provided a custom one (not empty) // Only set label if user provided a custom one (not empty)
label: relationLabel.trim() || undefined, label: relationLabel.trim() || undefined,
labels: relationLabels.length > 0 ? relationLabels : undefined,
}); });
onClose(); onClose();
}; };
@ -121,6 +125,18 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
</p> </p>
</div> </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 */} {/* Connection Info */}
{selectedEdge && ( {selectedEdge && (
<div className="pt-3 border-t border-gray-200"> <div className="pt-3 border-t border-gray-200">

View file

@ -107,8 +107,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
// Search and filter state for auto-zoom // Search and filter state for auto-zoom
const { const {
searchText, searchText,
visibleActorTypes, selectedActorTypes,
visibleRelationTypes, selectedRelationTypes,
selectedLabels,
} = useSearchStore(); } = useSearchStore();
// Settings for auto-zoom // Settings for auto-zoom
@ -260,12 +261,11 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
// Check if any filters are active // Check if any filters are active
const hasSearchText = searchText.trim() !== ''; const hasSearchText = searchText.trim() !== '';
const hasTypeFilters = const hasTypeFilters = selectedActorTypes.length > 0 || selectedRelationTypes.length > 0;
Object.values(visibleActorTypes).some(v => v === false) || const hasLabelFilters = selectedLabels.length > 0;
Object.values(visibleRelationTypes).some(v => v === false);
// Skip if no filters are active // Skip if no filters are active
if (!hasSearchText && !hasTypeFilters) return; if (!hasSearchText && !hasTypeFilters && !hasLabelFilters) return;
// Debounce to avoid excessive viewport changes while typing // Debounce to avoid excessive viewport changes while typing
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@ -277,11 +277,23 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
const actor = node as Actor; const actor = node as Actor;
const actorType = actor.data?.type || ''; const actorType = actor.data?.type || '';
// Filter by actor type visibility // Filter by actor type (POSITIVE: if types selected, node must be one of them)
const isTypeVisible = visibleActorTypes[actorType] !== false; if (selectedActorTypes.length > 0) {
if (!isTypeVisible) { if (!selectedActorTypes.includes(actorType)) {
return false; 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 // Filter by search text
if (searchLower) { if (searchLower) {
@ -319,8 +331,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [ }, [
searchText, searchText,
visibleActorTypes, selectedActorTypes,
visibleRelationTypes, selectedRelationTypes,
selectedLabels,
autoZoomEnabled, autoZoomEnabled,
nodes, nodes,
nodeTypeConfigs, nodeTypeConfigs,
@ -631,12 +644,16 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
[nodeTypeConfigs, addNodeWithHistory], [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(() => { useEffect(() => {
if (onAddNodeRequest) { if (onAddNodeRequest) {
onAddNodeRequest(handleAddNode); onAddNodeRequest((...args) => handleAddNodeRef.current(...args));
} }
}, [onAddNodeRequest, handleAddNode]);
}, [onAddNodeRequest]); // Only run when onAddNodeRequest changes
// Provide export callback to parent // Provide export callback to parent
const handleExport = useCallback( const handleExport = useCallback(
@ -650,11 +667,15 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
[exportPNG, exportSVG] [exportPNG, exportSVG]
); );
const handleExportRef = useRef(handleExport);
handleExportRef.current = handleExport;
useEffect(() => { useEffect(() => {
if (onExportRequest) { if (onExportRequest) {
onExportRequest(handleExport); onExportRequest((...args) => handleExportRef.current(...args));
} }
}, [onExportRequest, handleExport]);
}, [onExportRequest]); // Only run when onExportRequest changes
// Add new actor at context menu position // Add new actor at context menu position
const handleAddActorFromContextMenu = useCallback( const handleAddActorFromContextMenu = useCallback(

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import PropertyPanel from '../Common/PropertyPanel'; import PropertyPanel from '../Common/PropertyPanel';
import LabelSelector from '../Common/LabelSelector';
import type { Actor } from '../../types'; import type { Actor } from '../../types';
/** /**
@ -24,6 +25,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
const [actorType, setActorType] = useState(''); const [actorType, setActorType] = useState('');
const [actorLabel, setActorLabel] = useState(''); const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState(''); const [actorDescription, setActorDescription] = useState('');
const [actorLabels, setActorLabels] = useState<string[]>([]);
const labelInputRef = useRef<HTMLInputElement>(null); const labelInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@ -31,6 +33,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
setActorType(selectedNode.data?.type || ''); setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || ''); setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || ''); setActorDescription(selectedNode.data?.description || '');
setActorLabels(selectedNode.data?.labels || []);
// Focus and select the label input when panel opens // Focus and select the label input when panel opens
setTimeout(() => { setTimeout(() => {
@ -49,6 +52,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
type: actorType, type: actorType,
label: actorLabel, label: actorLabel,
description: actorDescription || undefined, description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined,
}, },
}); });
onClose(); onClose();
@ -130,6 +134,18 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
/> />
</div> </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 */} {/* Node Info */}
{selectedNode && ( {selectedNode && (
<div className="pt-3 border-t border-gray-200"> <div className="pt-3 border-t border-gray-200">

View file

@ -6,6 +6,7 @@ import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import DocumentManager from '../Workspace/DocumentManager'; import DocumentManager from '../Workspace/DocumentManager';
import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import LabelConfigModal from '../Config/LabelConfig';
import InputDialog from '../Common/InputDialog'; import InputDialog from '../Common/InputDialog';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import { useShortcutLabels } from '../../hooks/useShortcutLabels'; import { useShortcutLabels } from '../../hooks/useShortcutLabels';
@ -32,6 +33,7 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
const [showDocumentManager, setShowDocumentManager] = useState(false); const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showNodeConfig, setShowNodeConfig] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false);
const [showEdgeConfig, setShowEdgeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false);
const [showLabelConfig, setShowLabelConfig] = useState(false);
const [showNewDocDialog, setShowNewDocDialog] = useState(false); const [showNewDocDialog, setShowNewDocDialog] = useState(false);
const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false); const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -173,6 +175,11 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
closeMenu(); closeMenu();
}, [closeMenu]); }, [closeMenu]);
const handleConfigureLabels = useCallback(() => {
setShowLabelConfig(true);
closeMenu();
}, [closeMenu]);
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
undo(); undo();
closeMenu(); closeMenu();
@ -373,6 +380,12 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
> >
Configure Relation Types Configure Relation Types
</button> </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" /> <hr className="my-1 border-gray-200" />
@ -465,6 +478,10 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
isOpen={showEdgeConfig} isOpen={showEdgeConfig}
onClose={() => setShowEdgeConfig(false)} onClose={() => setShowEdgeConfig(false)}
/> />
<LabelConfigModal
isOpen={showLabelConfig}
onClose={() => setShowLabelConfig(false)}
/>
{/* Input Dialogs */} {/* Input Dialogs */}
<InputDialog <InputDialog

View file

@ -9,6 +9,7 @@ import {
import { getIconComponent } from "../../utils/iconUtils"; import { getIconComponent } from "../../utils/iconUtils";
import type { ActorData } from "../../types"; import type { ActorData } from "../../types";
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
import LabelBadge from "../Common/LabelBadge";
/** /**
* CustomNode - Represents an actor in the constellation graph * CustomNode - Represents an actor in the constellation graph
@ -23,7 +24,8 @@ import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
*/ */
const CustomNode = ({ data, selected }: NodeProps<ActorData>) => { const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
const nodeTypes = useGraphStore((state) => state.nodeTypes); 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) // Check if any connection is being made (to show handles)
const connectionNodeId = useStore((state) => state.connectionNodeId); const connectionNodeId = useStore((state) => state.connectionNodeId);
@ -47,11 +49,23 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
// Check if this node matches the search and filter criteria // Check if this node matches the search and filter criteria
const isMatch = useMemo(() => { const isMatch = useMemo(() => {
// Check type visibility // Check actor type filter (POSITIVE: if types selected, node must be one of them)
const isTypeVisible = visibleActorTypes[data.type] !== false; if (selectedActorTypes.length > 0) {
if (!isTypeVisible) { if (!selectedActorTypes.includes(data.type)) {
return false; 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 // Check search text match
if (searchText.trim()) { if (searchText.trim()) {
@ -70,9 +84,11 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
return true; return true;
}, [ }, [
searchText, searchText,
visibleActorTypes, selectedActorTypes,
selectedLabels,
data.type, data.type,
data.label, data.label,
data.labels,
data.description, data.description,
nodeLabel, nodeLabel,
]); ]);
@ -80,7 +96,8 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
// Determine if filters are active // Determine if filters are active
const hasActiveFilters = const hasActiveFilters =
searchText.trim() !== "" || searchText.trim() !== "" ||
Object.values(visibleActorTypes).some((v) => v === false); selectedActorTypes.length > 0 ||
selectedLabels.length > 0;
// Calculate opacity based on match status // Calculate opacity based on match status
const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
@ -189,6 +206,25 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
> >
{nodeLabel} {nodeLabel}
</div> </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> </div>
</NodeShapeRenderer> </NodeShapeRenderer>
</div> </div>

View file

@ -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 { IconButton, Tooltip, Checkbox } from '@mui/material';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
@ -19,6 +19,7 @@ import { getIconComponent } from '../../utils/iconUtils';
import { getContrastColor } from '../../utils/colorUtils'; import { getContrastColor } from '../../utils/colorUtils';
import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import LabelBadge from '../Common/LabelBadge';
import type { Actor } from '../../types'; import type { Actor } from '../../types';
/** /**
@ -52,7 +53,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
expandLeftPanel, expandLeftPanel,
} = usePanelStore(); } = usePanelStore();
const { nodeTypes, edgeTypes, addNode, nodes, edges } = useGraphWithHistory(); const { nodeTypes, edgeTypes, labels, addNode, nodes, edges } = useGraphWithHistory();
const { selectedRelationType, setSelectedRelationType } = useEditorStore(); const { selectedRelationType, setSelectedRelationType } = useEditorStore();
const [showNodeConfig, setShowNodeConfig] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false);
const [showEdgeConfig, setShowEdgeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false);
@ -84,10 +85,12 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
const { const {
searchText, searchText,
setSearchText, setSearchText,
visibleActorTypes, selectedActorTypes,
setActorTypeVisible, toggleSelectedActorType,
visibleRelationTypes, selectedRelationTypes,
setRelationTypeVisible, toggleSelectedRelationType,
selectedLabels,
toggleSelectedLabel,
clearFilters, clearFilters,
hasActiveFilters, hasActiveFilters,
} = useSearchStore(); } = useSearchStore();
@ -95,22 +98,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
// Settings // Settings
const { autoZoomEnabled, setAutoZoomEnabled } = useSettingsStore(); const { autoZoomEnabled, setAutoZoomEnabled } = useSettingsStore();
// Initialize filter state when node/edge types change // No need to initialize filter state - all filters are positive (empty = show all)
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]);
// Calculate matching nodes based on search and filters // Calculate matching nodes based on search and filters
const matchingNodes = useMemo(() => { const matchingNodes = useMemo(() => {
@ -120,11 +108,23 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
const actor = node as Actor; const actor = node as Actor;
const actorType = actor.data?.type || ''; const actorType = actor.data?.type || '';
// Filter by actor type visibility // Filter by actor type (POSITIVE: if types selected, node must be one of them)
const isTypeVisible = visibleActorTypes[actorType] !== false; if (selectedActorTypes.length > 0) {
if (!isTypeVisible) { if (!selectedActorTypes.includes(actorType)) {
return false; 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 // Filter by search text
if (searchLower) { if (searchLower) {
@ -145,7 +145,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
return true; return true;
}); });
}, [nodes, searchText, visibleActorTypes, nodeTypes]); }, [nodes, searchText, selectedActorTypes, selectedLabels, nodeTypes]);
// Calculate matching edges based on search and filters // Calculate matching edges based on search and filters
const matchingEdges = useMemo(() => { const matchingEdges = useMemo(() => {
@ -154,11 +154,23 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
return edges.filter((edge) => { return edges.filter((edge) => {
const edgeType = edge.data?.type || ''; const edgeType = edge.data?.type || '';
// Filter by edge type visibility // Filter by relation type (POSITIVE: if types selected, edge must be one of them)
const isTypeVisible = visibleRelationTypes[edgeType] !== false; if (selectedRelationTypes.length > 0) {
if (!isTypeVisible) { if (!selectedRelationTypes.includes(edgeType)) {
return false; 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 // Filter by search text
if (searchLower) { if (searchLower) {
@ -177,7 +189,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
return true; return true;
}); });
}, [edges, searchText, visibleRelationTypes, edgeTypes]); }, [edges, searchText, selectedRelationTypes, selectedLabels, edgeTypes]);
const handleAddNode = useCallback( const handleAddNode = useCallback(
@ -464,10 +476,13 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-2"> <label className="block text-xs font-medium text-gray-600 mb-2">
Filter by Actor Type Filter by Actor Type
{selectedActorTypes.length > 0 && (
<span className="ml-1 text-blue-600">({selectedActorTypes.length} selected)</span>
)}
</label> </label>
<div className="space-y-1.5"> <div className="space-y-1.5">
{nodeTypes.map((nodeType) => { {nodeTypes.map((nodeType) => {
const isVisible = visibleActorTypes[nodeType.id] !== false; const isSelected = selectedActorTypes.includes(nodeType.id);
const IconComponent = getIconComponent(nodeType.icon); const IconComponent = getIconComponent(nodeType.icon);
return ( 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" className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
> >
<Checkbox <Checkbox
checked={isVisible} checked={isSelected}
onChange={() => setActorTypeVisible(nodeType.id, !isVisible)} onChange={() => toggleSelectedActorType(nodeType.id)}
size="small" size="small"
sx={{ padding: '2px' }} sx={{ padding: '2px' }}
/> />
@ -501,16 +516,29 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
); );
})} })}
</div> </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> </div>
{/* Filter by Relation */} {/* Filter by Relation */}
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-2"> <label className="block text-xs font-medium text-gray-600 mb-2">
Filter by Relation Filter by Relation
{selectedRelationTypes.length > 0 && (
<span className="ml-1 text-blue-600">({selectedRelationTypes.length} selected)</span>
)}
</label> </label>
<div className="space-y-1.5"> <div className="space-y-1.5">
{edgeTypes.map((edgeType) => { {edgeTypes.map((edgeType) => {
const isVisible = visibleRelationTypes[edgeType.id] !== false; const isSelected = selectedRelationTypes.includes(edgeType.id);
return ( return (
<label <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" className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
> >
<Checkbox <Checkbox
checked={isVisible} checked={isSelected}
onChange={() => setRelationTypeVisible(edgeType.id, !isVisible)} onChange={() => toggleSelectedRelationType(edgeType.id)}
size="small" size="small"
sx={{ padding: '2px' }} sx={{ padding: '2px' }}
/> />
@ -540,8 +568,66 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
); );
})} })}
</div> </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> </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 */} {/* Results Summary */}
<div className="pt-2 border-t border-gray-100"> <div className="pt-2 border-t border-gray-100">
<div className="text-xs text-gray-600 space-y-1"> <div className="text-xs text-gray-600 space-y-1">

View file

@ -16,6 +16,7 @@ import GraphMetrics from '../Common/GraphMetrics';
import ConnectionDisplay from '../Common/ConnectionDisplay'; import ConnectionDisplay from '../Common/ConnectionDisplay';
import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
import type { Actor, Relation, EdgeDirectionality } from '../../types'; import type { Actor, Relation, EdgeDirectionality } from '../../types';
/** /**
@ -72,11 +73,13 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
const [actorType, setActorType] = useState(''); const [actorType, setActorType] = useState('');
const [actorLabel, setActorLabel] = useState(''); const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState(''); const [actorDescription, setActorDescription] = useState('');
const [actorLabels, setActorLabels] = useState<string[]>([]);
const labelInputRef = useRef<HTMLInputElement>(null); const labelInputRef = useRef<HTMLInputElement>(null);
// Edge property states // Edge property states
const [relationType, setRelationType] = useState(''); const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState(''); const [relationLabel, setRelationLabel] = useState('');
const [relationLabels, setRelationLabels] = useState<string[]>([]);
const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed'); const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed');
// Track if user has made changes // Track if user has made changes
@ -97,6 +100,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
setActorType(selectedNode.data?.type || ''); setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || ''); setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || ''); setActorDescription(selectedNode.data?.description || '');
setActorLabels(selectedNode.data?.labels || []);
setHasNodeChanges(false); setHasNodeChanges(false);
// Focus and select the label input when node is selected // 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 typeLabel = edgeTypes.find((et) => et.id === selectedEdge.data?.type)?.label;
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel; const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || ''); setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
setRelationLabels(selectedEdge.data.labels || []);
const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type); const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type);
setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed'); setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
setHasEdgeChanges(false); setHasEdgeChanges(false);
@ -130,10 +135,11 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
type: actorType, type: actorType,
label: actorLabel, label: actorLabel,
description: actorDescription || undefined, description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined,
}, },
}); });
setHasNodeChanges(false); setHasNodeChanges(false);
}, [selectedNode, actorType, actorLabel, actorDescription, hasNodeChanges, updateNode]); }, [selectedNode, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]);
// Live update edge properties (debounced) // Live update edge properties (debounced)
const updateEdgeProperties = useCallback(() => { const updateEdgeProperties = useCallback(() => {
@ -142,9 +148,10 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
type: relationType, type: relationType,
label: relationLabel.trim() || undefined, label: relationLabel.trim() || undefined,
directionality: relationDirectionality, directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined,
}); });
setHasEdgeChanges(false); setHasEdgeChanges(false);
}, [selectedEdge, relationType, relationLabel, relationDirectionality, hasEdgeChanges, updateEdge]); }, [selectedEdge, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]);
// Debounce live updates // Debounce live updates
useEffect(() => { useEffect(() => {
@ -341,6 +348,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
type: newType, type: newType,
label: actorLabel, label: actorLabel,
description: actorDescription || undefined, description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined,
}, },
}); });
} }
@ -401,6 +409,21 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
/> />
</div> </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 */} {/* Connections */}
<div className="pt-3 border-t border-gray-200"> <div className="pt-3 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-700 mb-2"> <h3 className="text-xs font-semibold text-gray-700 mb-2">
@ -553,6 +576,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
type: newType, type: newType,
label: relationLabel.trim() || undefined, label: relationLabel.trim() || undefined,
directionality: relationDirectionality, directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined,
}); });
} }
}} }}
@ -587,6 +611,21 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
</p> </p>
</div> </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 */} {/* Directionality */}
<div className="pt-3 border-t border-gray-200"> <div className="pt-3 border-t border-gray-200">
<label className="block text-xs font-medium text-gray-700 mb-2"> <label className="block text-xs font-medium text-gray-700 mb-2">
@ -603,6 +642,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
type: relationType, type: relationType,
label: relationLabel.trim() || undefined, label: relationLabel.trim() || undefined,
directionality: newValue, directionality: newValue,
labels: relationLabels.length > 0 ? relationLabels : undefined,
}); });
} }
}} }}

View file

@ -23,6 +23,7 @@ export function useDocumentHistory() {
const setNodeTypes = useGraphStore((state) => state.setNodeTypes); const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes); const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
const setLabels = useGraphStore((state) => state.setLabels);
const historyStore = useHistoryStore(); const historyStore = useHistoryStore();
@ -63,7 +64,7 @@ export function useDocumentHistory() {
} }
// Create a snapshot of the complete document state // 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 = { const snapshot: DocumentSnapshot = {
timeline: { timeline: {
states: new Map(timeline.states), // Clone the Map states: new Map(timeline.states), // Clone the Map
@ -72,6 +73,7 @@ export function useDocumentHistory() {
}, },
nodeTypes: activeDoc.nodeTypes, nodeTypes: activeDoc.nodeTypes,
edgeTypes: activeDoc.edgeTypes, edgeTypes: activeDoc.edgeTypes,
labels: activeDoc.labels || [],
}; };
// Push to history // Push to history
@ -109,7 +111,7 @@ export function useDocumentHistory() {
return; return;
} }
// NOTE: Read types from the document, not from graphStore // NOTE: Read types and labels from the document, not from graphStore
const currentSnapshot: DocumentSnapshot = { const currentSnapshot: DocumentSnapshot = {
timeline: { timeline: {
states: new Map(timeline.states), states: new Map(timeline.states),
@ -118,21 +120,24 @@ export function useDocumentHistory() {
}, },
nodeTypes: activeDoc.nodeTypes, nodeTypes: activeDoc.nodeTypes,
edgeTypes: activeDoc.edgeTypes, edgeTypes: activeDoc.edgeTypes,
labels: activeDoc.labels || [],
}; };
const restoredState = historyStore.undo(activeDocumentId, currentSnapshot); const restoredState = historyStore.undo(activeDocumentId, currentSnapshot);
if (restoredState) { if (restoredState) {
// Restore complete document state (timeline + types) // Restore complete document state (timeline + types + labels)
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline); 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.nodeTypes = restoredState.nodeTypes;
activeDoc.edgeTypes = restoredState.edgeTypes; activeDoc.edgeTypes = restoredState.edgeTypes;
activeDoc.labels = restoredState.labels || [];
// Sync to graph store // Sync to graph store
setNodeTypes(restoredState.nodeTypes); setNodeTypes(restoredState.nodeTypes);
setEdgeTypes(restoredState.edgeTypes); setEdgeTypes(restoredState.edgeTypes);
setLabels(restoredState.labels || []);
// Load the current state's graph from the restored timeline // Load the current state's graph from the restored timeline
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId); const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
@ -152,7 +157,7 @@ export function useDocumentHistory() {
saveDocument(activeDocumentId); saveDocument(activeDocumentId);
}, 1000); }, 1000);
} }
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, markDocumentDirty]); }, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, setLabels, markDocumentDirty]);
/** /**
* Redo the last undone action for the active document * Redo the last undone action for the active document
@ -179,7 +184,7 @@ export function useDocumentHistory() {
return; return;
} }
// NOTE: Read types from the document, not from graphStore // NOTE: Read types and labels from the document, not from graphStore
const currentSnapshot: DocumentSnapshot = { const currentSnapshot: DocumentSnapshot = {
timeline: { timeline: {
states: new Map(timeline.states), states: new Map(timeline.states),
@ -188,21 +193,24 @@ export function useDocumentHistory() {
}, },
nodeTypes: activeDoc.nodeTypes, nodeTypes: activeDoc.nodeTypes,
edgeTypes: activeDoc.edgeTypes, edgeTypes: activeDoc.edgeTypes,
labels: activeDoc.labels || [],
}; };
const restoredState = historyStore.redo(activeDocumentId, currentSnapshot); const restoredState = historyStore.redo(activeDocumentId, currentSnapshot);
if (restoredState) { if (restoredState) {
// Restore complete document state (timeline + types) // Restore complete document state (timeline + types + labels)
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline); 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.nodeTypes = restoredState.nodeTypes;
activeDoc.edgeTypes = restoredState.edgeTypes; activeDoc.edgeTypes = restoredState.edgeTypes;
activeDoc.labels = restoredState.labels || [];
// Sync to graph store // Sync to graph store
setNodeTypes(restoredState.nodeTypes); setNodeTypes(restoredState.nodeTypes);
setEdgeTypes(restoredState.edgeTypes); setEdgeTypes(restoredState.edgeTypes);
setLabels(restoredState.labels || []);
// Load the current state's graph from the restored timeline // Load the current state's graph from the restored timeline
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId); const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
@ -222,7 +230,7 @@ export function useDocumentHistory() {
saveDocument(activeDocumentId); saveDocument(activeDocumentId);
}, 1000); }, 1000);
} }
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, markDocumentDirty]); }, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, setLabels, markDocumentDirty]);
/** /**
* Check if undo is available for the active document * Check if undo is available for the active document

View file

@ -2,7 +2,7 @@ import { useCallback, useRef, useEffect } from 'react';
import { useGraphStore } from '../stores/graphStore'; import { useGraphStore } from '../stores/graphStore';
import { useWorkspaceStore } from '../stores/workspaceStore'; import { useWorkspaceStore } from '../stores/workspaceStore';
import { useDocumentHistory } from './useDocumentHistory'; 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 * useGraphWithHistory Hook
@ -20,11 +20,12 @@ import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } fr
* - Node operations: addNode, updateNode, deleteNode * - Node operations: addNode, updateNode, deleteNode
* - Edge operations: addEdge, updateEdge, deleteEdge * - Edge operations: addEdge, updateEdge, deleteEdge
* - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType * - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType
* - Label operations: addLabel, updateLabel, deleteLabel
* - Utility: clearGraph * - Utility: clearGraph
* *
* Read-only pass-through operations (no history): * Read-only pass-through operations (no history):
* - setNodes, setEdges (used for bulk updates during undo/redo/document loading) * - setNodes, setEdges, setLabels (used for bulk updates during undo/redo/document loading)
* - nodes, edges, nodeTypes, edgeTypes (state access) * - nodes, edges, nodeTypes, edgeTypes, labels (state access)
* - loadGraphState * - loadGraphState
* *
* Usage: * Usage:
@ -46,6 +47,9 @@ export function useGraphWithHistory() {
const addEdgeTypeToDocument = useWorkspaceStore((state) => state.addEdgeTypeToDocument); const addEdgeTypeToDocument = useWorkspaceStore((state) => state.addEdgeTypeToDocument);
const updateEdgeTypeInDocument = useWorkspaceStore((state) => state.updateEdgeTypeInDocument); const updateEdgeTypeInDocument = useWorkspaceStore((state) => state.updateEdgeTypeInDocument);
const deleteEdgeTypeFromDocument = useWorkspaceStore((state) => state.deleteEdgeTypeFromDocument); 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(); const { pushToHistory } = useDocumentHistory();
// Track if we're currently restoring from history to prevent recursive history pushes // Track if we're currently restoring from history to prevent recursive history pushes
@ -285,6 +289,55 @@ export function useGraphWithHistory() {
[graphStore, pushToHistory] [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 { return {
// Wrapped operations with history // Wrapped operations with history
addNode, addNode,
@ -299,6 +352,9 @@ export function useGraphWithHistory() {
addEdgeType, addEdgeType,
updateEdgeType, updateEdgeType,
deleteEdgeType, deleteEdgeType,
addLabel,
updateLabel,
deleteLabel,
clearGraph, clearGraph,
// Pass through read-only operations // Pass through read-only operations
@ -306,10 +362,12 @@ export function useGraphWithHistory() {
edges: graphStore.edges, edges: graphStore.edges,
nodeTypes: graphStore.nodeTypes, nodeTypes: graphStore.nodeTypes,
edgeTypes: graphStore.edgeTypes, edgeTypes: graphStore.edgeTypes,
labels: graphStore.labels,
setNodes: graphStore.setNodes, setNodes: graphStore.setNodes,
setEdges: graphStore.setEdges, setEdges: graphStore.setEdges,
setNodeTypes: graphStore.setNodeTypes, setNodeTypes: graphStore.setNodeTypes,
setEdgeTypes: graphStore.setEdgeTypes, setEdgeTypes: graphStore.setEdgeTypes,
setLabels: graphStore.setLabels,
loadGraphState: graphStore.loadGraphState, loadGraphState: graphStore.loadGraphState,
// NOTE: exportToFile and importFromFile have been removed // NOTE: exportToFile and importFromFile have been removed

View file

@ -5,6 +5,7 @@ import type {
Relation, Relation,
NodeTypeConfig, NodeTypeConfig,
EdgeTypeConfig, EdgeTypeConfig,
LabelConfig,
RelationData, RelationData,
GraphActions GraphActions
} from '../types'; } from '../types';
@ -29,6 +30,7 @@ interface GraphStore {
edges: Relation[]; edges: Relation[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[];
} }
// Default node types with semantic shape assignments // Default node types with semantic shape assignments
@ -57,6 +59,7 @@ const loadInitialState = (): GraphStore => {
edges: savedState.edges, edges: savedState.edges,
nodeTypes: savedState.nodeTypes, nodeTypes: savedState.nodeTypes,
edgeTypes: savedState.edgeTypes, edgeTypes: savedState.edgeTypes,
labels: savedState.labels || [],
}; };
} }
@ -65,6 +68,7 @@ const loadInitialState = (): GraphStore => {
edges: [], edges: [],
nodeTypes: defaultNodeTypes, nodeTypes: defaultNodeTypes,
edgeTypes: defaultEdgeTypes, edgeTypes: defaultEdgeTypes,
labels: [],
}; };
}; };
@ -75,6 +79,7 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
edges: initialState.edges, edges: initialState.edges,
nodeTypes: initialState.nodeTypes, nodeTypes: initialState.nodeTypes,
edgeTypes: initialState.edgeTypes, edgeTypes: initialState.edgeTypes,
labels: initialState.labels,
// Node operations // Node operations
addNode: (node: Actor) => addNode: (node: Actor) =>
@ -83,17 +88,32 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
})), })),
updateNode: (id: string, updates: Partial<Actor>) => updateNode: (id: string, updates: Partial<Actor>) =>
set((state) => ({ 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) => nodes: state.nodes.map((node) =>
node.id === id node.id === id
? { ? {
...node, ...node,
...updates, ...updates,
data: updates.data ? { ...node.data, ...updates.data } : node.data, data: validatedData ? { ...node.data, ...validatedData } : node.data,
} }
: node : node
), ),
})), };
}),
deleteNode: (id: string) => deleteNode: (id: string) =>
set((state) => ({ set((state) => ({
@ -110,13 +130,28 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
})), })),
updateEdge: (id: string, data: Partial<RelationData>) => updateEdge: (id: string, data: Partial<RelationData>) =>
set((state) => ({ 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) => edges: state.edges.map((edge) =>
edge.id === id edge.id === id
? { ...edge, data: { ...edge.data, ...data } as RelationData } ? { ...edge, data: { ...edge.data, ...validatedData } as RelationData }
: edge : edge
), ),
})), };
}),
deleteEdge: (id: string) => deleteEdge: (id: string) =>
set((state) => ({ set((state) => ({
@ -159,6 +194,47 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
edgeTypes: state.edgeTypes.filter((type) => type.id !== id), 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 // Utility operations
clearGraph: () => clearGraph: () =>
set({ set({
@ -186,6 +262,11 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
edgeTypes, edgeTypes,
}), }),
setLabels: (labels: LabelConfig[]) =>
set({
labels,
}),
// NOTE: exportToFile and importFromFile have been removed // NOTE: exportToFile and importFromFile have been removed
// Import/export is now handled by the workspace-level system // Import/export is now handled by the workspace-level system
// See: workspaceStore.importDocumentFromFile() and workspaceStore.exportDocument() // See: workspaceStore.importDocumentFromFile() and workspaceStore.exportDocument()
@ -196,5 +277,6 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
edges: data.edges, edges: data.edges,
nodeTypes: data.nodeTypes, nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes, edgeTypes: data.edgeTypes,
labels: data.labels || [],
}), }),
})); }));

View file

@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { NodeTypeConfig, EdgeTypeConfig } from "../types"; import type { NodeTypeConfig, EdgeTypeConfig, LabelConfig } from "../types";
import type { ConstellationState, StateId } from "../types/timeline"; import type { ConstellationState, StateId } from "../types/timeline";
/** /**
@ -25,6 +25,8 @@ export interface DocumentSnapshot {
// Global types (shared across all timeline states) // Global types (shared across all timeline states)
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
// Labels (shared across all timeline states)
labels: LabelConfig[];
} }
export interface HistoryAction { export interface HistoryAction {

View file

@ -20,6 +20,7 @@ interface PanelState {
history: boolean; history: boolean;
addActors: boolean; addActors: boolean;
relations: boolean; relations: boolean;
labels: boolean;
layout: boolean; layout: boolean;
view: boolean; view: boolean;
search: boolean; search: boolean;
@ -73,6 +74,7 @@ export const usePanelStore = create<PanelState>()(
history: true, history: true,
addActors: true, addActors: true,
relations: true, relations: true,
labels: false,
layout: false, layout: false,
view: false, view: false,
search: false, search: false,

View file

@ -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 type { ConstellationDocument, SerializedActor, SerializedRelation } from './types';
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants'; import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
@ -117,9 +117,10 @@ export function getCurrentGraphFromDocument(document: ConstellationDocument): {
edges: SerializedRelation[]; edges: SerializedRelation[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[];
} | null { } | null {
try { try {
const { timeline, nodeTypes, edgeTypes } = document; const { timeline, nodeTypes, edgeTypes, labels } = document;
const currentState = timeline.states[timeline.currentStateId]; const currentState = timeline.states[timeline.currentStateId];
if (!currentState || !currentState.graph) { if (!currentState || !currentState.graph) {
@ -127,12 +128,13 @@ export function getCurrentGraphFromDocument(document: ConstellationDocument): {
return null; return null;
} }
// Combine state graph with document types // Combine state graph with document types and labels
return { return {
nodes: currentState.graph.nodes, nodes: currentState.graph.nodes,
edges: currentState.graph.edges, edges: currentState.graph.edges,
nodeTypes, nodeTypes,
edgeTypes, edgeTypes,
labels: labels || [], // Default to empty array for backward compatibility
}; };
} catch (error) { } catch (error) {
console.error('Failed to get current graph from document:', error); console.error('Failed to get current graph from document:', error);
@ -163,6 +165,7 @@ export function deserializeGraphState(document: ConstellationDocument): {
edges: Relation[]; edges: Relation[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[];
} | null { } | null {
try { try {
const currentGraph = getCurrentGraphFromDocument(document); const currentGraph = getCurrentGraphFromDocument(document);
@ -181,6 +184,7 @@ export function deserializeGraphState(document: ConstellationDocument): {
edges, edges,
nodeTypes: migratedNodeTypes, nodeTypes: migratedNodeTypes,
edgeTypes: currentGraph.edgeTypes, edgeTypes: currentGraph.edgeTypes,
labels: currentGraph.labels || [], // Default to empty array for backward compatibility
}; };
} catch (error) { } catch (error) {
console.error('Failed to deserialize graph state:', error); console.error('Failed to deserialize graph state:', error);
@ -194,6 +198,7 @@ export function loadGraphState(): {
edges: Relation[]; edges: Relation[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[];
} | null { } | null {
const document = loadDocument(); const document = loadDocument();

View file

@ -1,5 +1,5 @@
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types'; 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'; import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
/** /**
@ -41,6 +41,7 @@ export function createDocument(
edges: SerializedRelation[], edges: SerializedRelation[],
nodeTypes: NodeTypeConfig[], nodeTypes: NodeTypeConfig[],
edgeTypes: EdgeTypeConfig[], edgeTypes: EdgeTypeConfig[],
labels?: LabelConfig[],
existingDocument?: ConstellationDocument existingDocument?: ConstellationDocument
): ConstellationDocument { ): ConstellationDocument {
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -59,7 +60,7 @@ export function createDocument(
updatedAt: now, 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 { return {
metadata: { metadata: {
version: SCHEMA_VERSION, version: SCHEMA_VERSION,
@ -70,6 +71,7 @@ export function createDocument(
}, },
nodeTypes, nodeTypes,
edgeTypes, edgeTypes,
labels: labels || [],
timeline: { timeline: {
states: { states: {
[rootStateId]: initialState, [rootStateId]: initialState,

View file

@ -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'; import type { ConstellationState } from '../../types/timeline';
/** /**
@ -42,6 +42,8 @@ export interface ConstellationDocument {
// Global node and edge types for the entire document // Global node and edge types for the entire document
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
// Global labels for the entire document (optional for backward compatibility)
labels?: LabelConfig[];
// Timeline with multiple states - every document has this // Timeline with multiple states - every document has this
// The graph is stored within each state (nodes and edges only, not types) // The graph is stored within each state (nodes and edges only, not types)
timeline: { timeline: {

View file

@ -5,8 +5,10 @@ import { create } from 'zustand';
* *
* Features: * Features:
* - Search text for filtering both actors (by label, description, or type) and relations (by label or type) * - 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) * - POSITIVE FILTERS (empty = show all, selected = show only selected):
* - Filter by relation types (show/hide specific edge types) * - Filter by actor types
* - Filter by relation types
* - Filter by labels
* - Results tracking * - Results tracking
*/ */
@ -15,17 +17,20 @@ interface SearchStore {
searchText: string; searchText: string;
setSearchText: (text: string) => void; setSearchText: (text: string) => void;
// Filter visibility by actor types (nodeTypeId -> visible) // POSITIVE actor type filter: selected type IDs to show (empty = show all)
visibleActorTypes: Record<string, boolean>; selectedActorTypes: string[];
setActorTypeVisible: (typeId: string, visible: boolean) => void; toggleSelectedActorType: (typeId: string) => void;
toggleActorType: (typeId: string) => void; clearSelectedActorTypes: () => void;
setAllActorTypesVisible: (visible: boolean) => void;
// Filter visibility by relation types (edgeTypeId -> visible) // POSITIVE relation type filter: selected type IDs to show (empty = show all)
visibleRelationTypes: Record<string, boolean>; selectedRelationTypes: string[];
setRelationTypeVisible: (typeId: string, visible: boolean) => void; toggleSelectedRelationType: (typeId: string) => void;
toggleRelationType: (typeId: string) => void; clearSelectedRelationTypes: () => void;
setAllRelationTypesVisible: (visible: boolean) => void;
// POSITIVE label filter: selected label IDs to show (empty = show all)
selectedLabels: string[];
toggleSelectedLabel: (labelId: string) => void;
clearSelectedLabels: () => void;
// Clear all filters // Clear all filters
clearFilters: () => void; clearFilters: () => void;
@ -36,81 +41,58 @@ interface SearchStore {
export const useSearchStore = create<SearchStore>((set, get) => ({ export const useSearchStore = create<SearchStore>((set, get) => ({
searchText: '', searchText: '',
visibleActorTypes: {}, selectedActorTypes: [],
visibleRelationTypes: {}, selectedRelationTypes: [],
selectedLabels: [],
setSearchText: (text: string) => setSearchText: (text: string) =>
set({ searchText: text }), set({ searchText: text }),
setActorTypeVisible: (typeId: string, visible: boolean) => toggleSelectedActorType: (typeId: string) =>
set((state) => ({
visibleActorTypes: {
...state.visibleActorTypes,
[typeId]: visible,
},
})),
toggleActorType: (typeId: string) =>
set((state) => ({
visibleActorTypes: {
...state.visibleActorTypes,
[typeId]: !state.visibleActorTypes[typeId],
},
})),
setAllActorTypesVisible: (visible: boolean) =>
set((state) => { set((state) => {
const updated: Record<string, boolean> = {}; const isSelected = state.selectedActorTypes.includes(typeId);
Object.keys(state.visibleActorTypes).forEach((typeId) => { return {
updated[typeId] = visible; selectedActorTypes: isSelected
}); ? state.selectedActorTypes.filter((id) => id !== typeId)
return { visibleActorTypes: updated }; : [...state.selectedActorTypes, typeId],
};
}), }),
setRelationTypeVisible: (typeId: string, visible: boolean) => clearSelectedActorTypes: () =>
set((state) => ({ set({ selectedActorTypes: [] }),
visibleRelationTypes: {
...state.visibleRelationTypes,
[typeId]: visible,
},
})),
toggleRelationType: (typeId: string) => toggleSelectedRelationType: (typeId: string) =>
set((state) => ({
visibleRelationTypes: {
...state.visibleRelationTypes,
[typeId]: !state.visibleRelationTypes[typeId],
},
})),
setAllRelationTypesVisible: (visible: boolean) =>
set((state) => { set((state) => {
const updated: Record<string, boolean> = {}; const isSelected = state.selectedRelationTypes.includes(typeId);
Object.keys(state.visibleRelationTypes).forEach((typeId) => { return {
updated[typeId] = visible; selectedRelationTypes: isSelected
}); ? state.selectedRelationTypes.filter((id) => id !== typeId)
return { visibleRelationTypes: updated }; : [...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: () => clearFilters: () =>
set((state) => { set({
// 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: '', searchText: '',
visibleActorTypes: resetActorTypes, selectedActorTypes: [],
visibleRelationTypes: resetRelationTypes, selectedRelationTypes: [],
}; selectedLabels: [],
}), }),
hasActiveFilters: () => { hasActiveFilters: () => {
@ -121,19 +103,18 @@ export const useSearchStore = create<SearchStore>((set, get) => ({
return true; return true;
} }
// Check if any actor type is hidden // Check if any actor types are selected (positive filter)
const hasHiddenActorType = Object.values(state.visibleActorTypes).some( if (state.selectedActorTypes.length > 0) {
(visible) => !visible
);
if (hasHiddenActorType) {
return true; return true;
} }
// Check if any relation type is hidden // Check if any relation types are selected (positive filter)
const hasHiddenRelationType = Object.values(state.visibleRelationTypes).some( if (state.selectedRelationTypes.length > 0) {
(visible) => !visible return true;
); }
if (hasHiddenRelationType) {
// Check if any labels are selected (positive filter)
if (state.selectedLabels.length > 0) {
return true; return true;
} }

View file

@ -53,6 +53,7 @@ function pushDocumentHistory(documentId: string, description: string) {
}, },
nodeTypes: graphStore.nodeTypes, nodeTypes: graphStore.nodeTypes,
edgeTypes: graphStore.edgeTypes, edgeTypes: graphStore.edgeTypes,
labels: graphStore.labels,
}; };
historyStore.pushAction(documentId, { historyStore.pushAction(documentId, {

View file

@ -1,5 +1,5 @@
import type { ConstellationDocument } from '../persistence/types'; import type { ConstellationDocument } from '../persistence/types';
import type { NodeTypeConfig, EdgeTypeConfig } from '../../types'; import type { NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
/** /**
* Workspace Types * Workspace Types
@ -95,6 +95,11 @@ export interface WorkspaceActions {
updateEdgeTypeInDocument: (documentId: string, typeId: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void; updateEdgeTypeInDocument: (documentId: string, typeId: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void;
deleteEdgeTypeFromDocument: (documentId: string, typeId: string) => 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 // Viewport operations
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void; saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void;
getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined; getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined;

View file

@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
import { useWorkspaceStore } from '../workspaceStore'; import { useWorkspaceStore } from '../workspaceStore';
import { useGraphStore } from '../graphStore'; import { useGraphStore } from '../graphStore';
import { useTimelineStore } from '../timelineStore'; 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'; import { getCurrentGraphFromDocument } from '../persistence/loader';
/** /**
@ -29,10 +29,12 @@ export function useActiveDocument() {
const setEdges = useGraphStore((state) => state.setEdges); const setEdges = useGraphStore((state) => state.setEdges);
const setNodeTypes = useGraphStore((state) => state.setNodeTypes); const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes); const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
const setLabels = useGraphStore((state) => state.setLabels);
const graphNodes = useGraphStore((state) => state.nodes); const graphNodes = useGraphStore((state) => state.nodes);
const graphEdges = useGraphStore((state) => state.edges); const graphEdges = useGraphStore((state) => state.edges);
const graphNodeTypes = useGraphStore((state) => state.nodeTypes); const graphNodeTypes = useGraphStore((state) => state.nodeTypes);
const graphEdgeTypes = useGraphStore((state) => state.edgeTypes); const graphEdgeTypes = useGraphStore((state) => state.edgeTypes);
const graphLabels = useGraphStore((state) => state.labels);
// Track unload timers for inactive documents // Track unload timers for inactive documents
const unloadTimersRef = useRef<Map<string, number>>(new Map()); const unloadTimersRef = useRef<Map<string, number>>(new Map());
@ -48,12 +50,14 @@ export function useActiveDocument() {
edges: Relation[]; edges: Relation[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[];
}>({ }>({
documentId: null, documentId: null,
nodes: [], nodes: [],
edges: [], edges: [],
nodeTypes: [], nodeTypes: [],
edgeTypes: [], edgeTypes: [],
labels: [],
}); });
// Load active document into graphStore when it changes // Load active document into graphStore when it changes
@ -76,6 +80,7 @@ export function useActiveDocument() {
setEdges(currentGraph.edges as never[]); setEdges(currentGraph.edges as never[]);
setNodeTypes(currentGraph.nodeTypes as never[]); setNodeTypes(currentGraph.nodeTypes as never[]);
setEdgeTypes(currentGraph.edgeTypes as never[]); setEdgeTypes(currentGraph.edgeTypes as never[]);
setLabels(activeDocument.labels || []);
// Update the last synced state to match what we just loaded // Update the last synced state to match what we just loaded
lastSyncedStateRef.current = { lastSyncedStateRef.current = {
@ -84,6 +89,7 @@ export function useActiveDocument() {
edges: currentGraph.edges as Relation[], edges: currentGraph.edges as Relation[],
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[], nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[], edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],
labels: activeDocument.labels || [],
}; };
// Clear loading flag after a brief delay to allow state to settle // Clear loading flag after a brief delay to allow state to settle
@ -100,6 +106,7 @@ export function useActiveDocument() {
setNodes([]); setNodes([]);
setEdges([]); setEdges([]);
setLabels([]);
// Note: We keep nodeTypes and edgeTypes so they're available for new documents // Note: We keep nodeTypes and edgeTypes so they're available for new documents
// Clear the last synced state // Clear the last synced state
@ -109,6 +116,7 @@ export function useActiveDocument() {
edges: [], edges: [],
nodeTypes: [], nodeTypes: [],
edgeTypes: [], edgeTypes: [],
labels: [],
}; };
// Clear loading flag after a brief delay // Clear loading flag after a brief delay
@ -116,7 +124,7 @@ export function useActiveDocument() {
isLoadingRef.current = false; isLoadingRef.current = false;
}, 100); }, 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) // Save graphStore changes back to workspace (debounced via workspace)
useEffect(() => { useEffect(() => {
@ -153,13 +161,14 @@ export function useActiveDocument() {
console.log(`Document ${activeDocumentId} has changes, marking as dirty`); console.log(`Document ${activeDocumentId} has changes, marking as dirty`);
markDocumentDirty(activeDocumentId); 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 = { lastSyncedStateRef.current = {
documentId: activeDocumentId, documentId: activeDocumentId,
nodes: graphNodes as Actor[], nodes: graphNodes as Actor[],
edges: graphEdges as Relation[], edges: graphEdges as Relation[],
nodeTypes: graphNodeTypes as NodeTypeConfig[], nodeTypes: graphNodeTypes as NodeTypeConfig[],
edgeTypes: graphEdgeTypes as EdgeTypeConfig[], edgeTypes: graphEdgeTypes as EdgeTypeConfig[],
labels: graphLabels as LabelConfig[],
}; };
// Update the timeline's current state with the new graph data (nodes and edges only) // 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); 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 // Memory management: Unload inactive documents after timeout
useEffect(() => { useEffect(() => {

View file

@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { ConstellationDocument } from './persistence/types'; import type { ConstellationDocument } from './persistence/types';
import type { Workspace, WorkspaceActions, DocumentMetadata, WorkspaceSettings } from './workspace/types'; import type { Workspace, WorkspaceActions, DocumentMetadata, WorkspaceSettings } from './workspace/types';
import type { Actor, Relation } from '../types';
import { createDocument as createDocumentHelper } from './persistence/saver'; import { createDocument as createDocumentHelper } from './persistence/saver';
import { selectFileForImport, exportDocumentToFile } from './persistence/fileIO'; import { selectFileForImport, exportDocumentToFile } from './persistence/fileIO';
import { import {
@ -142,6 +143,7 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
); );
newDoc.metadata.documentId = documentId; newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title; newDoc.metadata.title = title;
newDoc.labels = []; // Initialize with empty labels
const metadata: DocumentMetadata = { const metadata: DocumentMetadata = {
id: documentId, id: documentId,
@ -220,6 +222,7 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
); );
newDoc.metadata.documentId = documentId; newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title; newDoc.metadata.title = title;
newDoc.labels = sourceDoc.labels || []; // Copy labels from source document
const metadata: DocumentMetadata = { const metadata: DocumentMetadata = {
id: documentId, id: documentId,
@ -1030,4 +1033,134 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
useGraphStore.getState().setEdgeTypes(doc.edgeTypes); 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);
}
},
})); }));

View file

@ -5,6 +5,7 @@ export interface ActorData {
label: string; label: string;
type: string; type: string;
description?: string; description?: string;
labels?: string[]; // Array of LabelConfig IDs
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@ -18,6 +19,7 @@ export interface RelationData {
type: string; type: string;
directionality?: EdgeDirectionality; directionality?: EdgeDirectionality;
strength?: number; strength?: number;
labels?: string[]; // Array of LabelConfig IDs
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@ -51,12 +53,24 @@ export interface EdgeTypeConfig {
defaultDirectionality?: EdgeDirectionality; 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 // Graph State
export interface GraphState { export interface GraphState {
nodes: Actor[]; nodes: Actor[];
edges: Relation[]; edges: Relation[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[];
} }
// Editor Settings // Editor Settings
@ -82,14 +96,18 @@ export interface GraphActions {
addEdgeType: (edgeType: EdgeTypeConfig) => void; addEdgeType: (edgeType: EdgeTypeConfig) => void;
updateEdgeType: (id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void; updateEdgeType: (id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void;
deleteEdgeType: (id: string) => void; deleteEdgeType: (id: string) => void;
addLabel: (label: LabelConfig) => void;
updateLabel: (id: string, updates: Partial<Omit<LabelConfig, 'id'>>) => void;
deleteLabel: (id: string) => void;
clearGraph: () => void; clearGraph: () => void;
setNodes: (nodes: Actor[]) => void; setNodes: (nodes: Actor[]) => void;
setEdges: (edges: Relation[]) => void; setEdges: (edges: Relation[]) => void;
setNodeTypes: (nodeTypes: NodeTypeConfig[]) => void; setNodeTypes: (nodeTypes: NodeTypeConfig[]) => void;
setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) => void; setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) => void;
setLabels: (labels: LabelConfig[]) => void;
// NOTE: exportToFile and importFromFile have been removed // NOTE: exportToFile and importFromFile have been removed
// Import/export is now handled by the workspace-level system (workspaceStore) // 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 { export interface EditorActions {