diff --git a/src/components/Config/EditTangibleInline.tsx b/src/components/Config/EditTangibleInline.tsx index d0f3b3e..c931b6b 100644 --- a/src/components/Config/EditTangibleInline.tsx +++ b/src/components/Config/EditTangibleInline.tsx @@ -1,12 +1,15 @@ import { useState, useEffect, KeyboardEvent } from "react"; import SaveIcon from "@mui/icons-material/Save"; import TangibleForm from "./TangibleForm"; -import type { TangibleConfig, TangibleMode, LabelConfig } from "../../types"; +import type { TangibleConfig, TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types"; import type { ConstellationState } from "../../types/timeline"; +import { migrateTangibleConfig } from "../../utils/tangibleMigration"; interface Props { tangible: TangibleConfig; labels: LabelConfig[]; + nodeTypes: NodeTypeConfig[]; + edgeTypes: EdgeTypeConfig[]; states: ConstellationState[]; onSave: ( id: string, @@ -15,7 +18,7 @@ interface Props { mode: TangibleMode; description?: string; hardwareId?: string; - filterLabels?: string[]; + filters?: FilterConfig; stateId?: string; }, ) => void; @@ -25,6 +28,8 @@ interface Props { const EditTangibleInline = ({ tangible, labels, + nodeTypes, + edgeTypes, states, onSave, onCancel, @@ -33,18 +38,38 @@ const EditTangibleInline = ({ const [mode, setMode] = useState("filter"); const [description, setDescription] = useState(""); const [hardwareId, setHardwareId] = useState(""); - const [filterLabels, setFilterLabels] = useState([]); + const [filters, setFilters] = useState({ + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR' + }); const [stateId, setStateId] = useState(""); // Sync state with tangible prop useEffect(() => { if (tangible) { - setName(tangible.name); - setMode(tangible.mode); - setDescription(tangible.description || ""); - setHardwareId(tangible.hardwareId || ""); - setFilterLabels(tangible.filterLabels || []); - setStateId(tangible.stateId || ""); + // Apply migration for backward compatibility + const migratedTangible = migrateTangibleConfig(tangible); + + setName(migratedTangible.name); + setMode(migratedTangible.mode); + setDescription(migratedTangible.description || ""); + setHardwareId(migratedTangible.hardwareId || ""); + setFilters(migratedTangible.filters || { + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR' + }); + // Ensure combineMode is set (default to OR for backward compatibility) + if (migratedTangible.filters && !migratedTangible.filters.combineMode) { + setFilters({ + ...migratedTangible.filters, + combineMode: 'OR' + }); + } + setStateId(migratedTangible.stateId || ""); } }, [tangible]); @@ -52,9 +77,16 @@ const EditTangibleInline = ({ if (!name.trim()) return; // Validate mode-specific fields - if (mode === "filter" && filterLabels.length === 0) { - alert("Filter mode requires at least one label"); - return; + if (mode === "filter") { + const hasFilters = + (filters.labels && filters.labels.length > 0) || + (filters.actorTypes && filters.actorTypes.length > 0) || + (filters.relationTypes && filters.relationTypes.length > 0); + + if (!hasFilters) { + alert("Filter mode requires at least one filter (labels, actor types, or relation types)"); + return; + } } if ((mode === "state" || mode === "stateDial") && !stateId) { alert("State mode requires a state selection"); @@ -66,7 +98,7 @@ const EditTangibleInline = ({ mode, description: description.trim() || undefined, hardwareId: hardwareId.trim() || undefined, - filterLabels: mode === "filter" ? filterLabels : undefined, + filters: mode === "filter" ? filters : undefined, stateId: mode === "state" || mode === "stateDial" ? stateId : undefined, }); }; @@ -90,15 +122,17 @@ const EditTangibleInline = ({ mode={mode} description={description} hardwareId={hardwareId} - filterLabels={filterLabels} + filters={filters} stateId={stateId} labels={labels} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} states={states} onNameChange={setName} onModeChange={setMode} onDescriptionChange={setDescription} onHardwareIdChange={setHardwareId} - onFilterLabelsChange={setFilterLabels} + onFiltersChange={setFilters} onStateIdChange={setStateId} /> diff --git a/src/components/Config/QuickAddTangibleForm.tsx b/src/components/Config/QuickAddTangibleForm.tsx index 47d3206..3e46213 100644 --- a/src/components/Config/QuickAddTangibleForm.tsx +++ b/src/components/Config/QuickAddTangibleForm.tsx @@ -1,27 +1,34 @@ import { useState, useRef, KeyboardEvent } from "react"; import TangibleForm from "./TangibleForm"; -import type { TangibleMode, LabelConfig } from "../../types"; +import type { TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types"; import type { ConstellationState } from "../../types/timeline"; interface Props { labels: LabelConfig[]; + nodeTypes: NodeTypeConfig[]; + edgeTypes: EdgeTypeConfig[]; states: ConstellationState[]; onAdd: (tangible: { name: string; mode: TangibleMode; description: string; hardwareId?: string; - filterLabels?: string[]; + filters?: FilterConfig; stateId?: string; }) => void; } -const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { +const QuickAddTangibleForm = ({ labels, nodeTypes, edgeTypes, states, onAdd }: Props) => { const [name, setName] = useState(""); const [hardwareId, setHardwareId] = useState(""); const [mode, setMode] = useState("filter"); const [description, setDescription] = useState(""); - const [filterLabels, setFilterLabels] = useState([]); + const [filters, setFilters] = useState({ + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR' // Default to OR for tangibles + }); const [stateId, setStateId] = useState(""); const nameInputRef = useRef(null); @@ -33,9 +40,16 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { } // Validate mode-specific fields - if (mode === "filter" && filterLabels.length === 0) { - alert("Filter mode requires at least one label"); - return; + if (mode === "filter") { + const hasFilters = + (filters.labels && filters.labels.length > 0) || + (filters.actorTypes && filters.actorTypes.length > 0) || + (filters.relationTypes && filters.relationTypes.length > 0); + + if (!hasFilters) { + alert("Filter mode requires at least one filter (labels, actor types, or relation types)"); + return; + } } if ((mode === "state" || mode === "stateDial") && !stateId) { alert("State mode requires a state selection"); @@ -47,7 +61,7 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { mode, description, hardwareId: hardwareId.trim() || undefined, - filterLabels: mode === "filter" ? filterLabels : undefined, + filters: mode === "filter" ? filters : undefined, stateId: mode === "state" || mode === "stateDial" ? stateId : undefined, }); @@ -56,7 +70,12 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { setHardwareId(""); setMode("filter"); setDescription(""); - setFilterLabels([]); + setFilters({ + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR' + }); setStateId(""); nameInputRef.current?.focus(); @@ -72,7 +91,12 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { setHardwareId(""); setMode("filter"); setDescription(""); - setFilterLabels([]); + setFilters({ + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR' + }); setStateId(""); nameInputRef.current?.blur(); } @@ -85,15 +109,17 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { hardwareId={hardwareId} mode={mode} description={description} - filterLabels={filterLabels} + filters={filters} stateId={stateId} labels={labels} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} states={states} onNameChange={setName} onHardwareIdChange={setHardwareId} onModeChange={setMode} onDescriptionChange={setDescription} - onFilterLabelsChange={setFilterLabels} + onFiltersChange={setFilters} onStateIdChange={setStateId} /> diff --git a/src/components/Config/TangibleConfig.tsx b/src/components/Config/TangibleConfig.tsx index 0c03804..fd74aa9 100644 --- a/src/components/Config/TangibleConfig.tsx +++ b/src/components/Config/TangibleConfig.tsx @@ -15,7 +15,7 @@ interface Props { } const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Props) => { - const { tangibles, labels, addTangible, updateTangible, deleteTangible } = useGraphWithHistory(); + const { tangibles, labels, nodeTypes, edgeTypes, addTangible, updateTangible, deleteTangible } = useGraphWithHistory(); const { confirm, ConfirmDialogComponent } = useConfirm(); const { showToast } = useToastStore(); @@ -48,13 +48,21 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop mode: TangibleMode; description: string; hardwareId?: string; - filterLabels?: string[]; + filters?: import('../../types').FilterConfig; stateId?: string; }) => { // Validate mode-specific fields - if (tangible.mode === 'filter' && (!tangible.filterLabels || tangible.filterLabels.length === 0)) { - showToast('Filter mode requires at least one label', 'error'); - return; + if (tangible.mode === 'filter') { + const hasFilters = + tangible.filters && + ((tangible.filters.labels && tangible.filters.labels.length > 0) || + (tangible.filters.actorTypes && tangible.filters.actorTypes.length > 0) || + (tangible.filters.relationTypes && tangible.filters.relationTypes.length > 0)); + + if (!hasFilters) { + showToast('Filter mode requires at least one filter (labels, actor types, or relation types)', 'error'); + return; + } } if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) { showToast('State mode requires a state selection', 'error'); @@ -66,7 +74,7 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop mode: tangible.mode, description: tangible.description || undefined, hardwareId: tangible.hardwareId, - filterLabels: tangible.filterLabels, + filters: tangible.filters, stateId: tangible.stateId, }; @@ -96,7 +104,7 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop const handleSaveEdit = ( id: string, - updates: { name: string; mode: TangibleMode; description?: string; hardwareId?: string; filterLabels?: string[]; stateId?: string } + updates: { name: string; mode: TangibleMode; description?: string; hardwareId?: string; filters?: import('../../types').FilterConfig; stateId?: string } ) => { updateTangible(id, updates); setEditingTangible(null); @@ -131,6 +139,8 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop diff --git a/src/components/Config/TangibleForm.tsx b/src/components/Config/TangibleForm.tsx index 9160215..70fe927 100644 --- a/src/components/Config/TangibleForm.tsx +++ b/src/components/Config/TangibleForm.tsx @@ -1,4 +1,4 @@ -import type { TangibleMode, LabelConfig } from "../../types"; +import type { TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types"; import type { ConstellationState } from "../../types/timeline"; interface Props { @@ -6,15 +6,25 @@ interface Props { mode: TangibleMode; description: string; hardwareId: string; - filterLabels: string[]; + /** + * @deprecated Use filters instead. Kept for backward compatibility. + */ + filterLabels?: string[]; + filters: FilterConfig; stateId: string; labels: LabelConfig[]; + nodeTypes: NodeTypeConfig[]; + edgeTypes: EdgeTypeConfig[]; states: ConstellationState[]; onNameChange: (value: string) => void; onModeChange: (value: TangibleMode) => void; onDescriptionChange: (value: string) => void; onHardwareIdChange: (value: string) => void; - onFilterLabelsChange: (value: string[]) => void; + /** + * @deprecated Use onFiltersChange instead. Kept for backward compatibility. + */ + onFilterLabelsChange?: (value: string[]) => void; + onFiltersChange: (value: FilterConfig) => void; onStateIdChange: (value: string) => void; } @@ -23,45 +33,52 @@ const TangibleForm = ({ mode, description, hardwareId, - filterLabels, + filters, stateId, labels, + nodeTypes, + edgeTypes, states, onNameChange, onModeChange, onDescriptionChange, onHardwareIdChange, - onFilterLabelsChange, + onFiltersChange, onStateIdChange, }: Props) => { return (
-
- - onNameChange(e.target.value)} - placeholder="e.g., Red Block, Filter Card" - 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" - /> +
+
+ + onNameChange(e.target.value)} + placeholder="e.g., Red Block" + 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" + /> +
+ +
+ + onHardwareIdChange(e.target.value)} + placeholder="e.g., token-001" + 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" + /> +
- - onHardwareIdChange(e.target.value)} - placeholder="e.g., token-001, device-a" - 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" - /> -

- Maps this configuration to a physical token or device +

+ Hardware ID maps this configuration to a physical token or device

@@ -69,88 +86,218 @@ const TangibleForm = ({ -
- - - +
+ + +
{/* Mode-specific fields */} {mode === "filter" && ( -
- -
- {labels.length === 0 ? ( -

- No labels available -

- ) : ( - labels.map((label) => ( - - )) - )} +
+ {/* Filter Combine Mode */} +
+ +
+ + +
+
+ + {/* Filter by Labels */} +
+ +
+ {labels.length === 0 ? ( +

+ No labels available +

+ ) : ( + labels.map((label) => ( + + )) + )} +
+
+ + {/* Filter by Actor Types */} +
+ +
+ {nodeTypes.length === 0 ? ( +

+ No actor types available +

+ ) : ( + nodeTypes.map((nodeType) => ( + + )) + )} +
+
+ + {/* Filter by Relation Types */} +
+ +
+ {edgeTypes.length === 0 ? ( +

+ No relation types available +

+ ) : ( + edgeTypes.map((edgeType) => ( + + )) + )} +
)} diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index ecd24c6..1b6c1e7 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -7,11 +7,11 @@ import { useNodes, } from '@xyflow/react'; import { useGraphStore } from '../../stores/graphStore'; -import { useSearchStore } from '../../stores/searchStore'; import type { Relation } from '../../types'; import type { Group } from '../../types'; import LabelBadge from '../Common/LabelBadge'; import { getFloatingEdgeParams } from '../../utils/edgeUtils'; +import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters'; /** * CustomEdge - Represents a relation between actors in the constellation graph @@ -41,7 +41,9 @@ const CustomEdge = ({ }: EdgeProps) => { const edgeTypes = useGraphStore((state) => state.edgeTypes); const labels = useGraphStore((state) => state.labels); - const { searchText, selectedRelationTypes, selectedLabels } = useSearchStore(); + + // Get active filters based on mode (editing vs presentation) + const filters = useActiveFilters(); // Get all nodes to check if source/target are minimized groups const nodes = useNodes(); @@ -124,42 +126,20 @@ const CustomEdge = ({ // Check if this edge matches the filter criteria const isMatch = useMemo(() => { - // Check relation type filter (POSITIVE: if types selected, edge must be one of them) - const edgeType = data?.type || ''; - if (selectedRelationTypes.length > 0) { - if (!selectedRelationTypes.includes(edgeType)) { - return false; - } - } - - // Check label filter (POSITIVE: if labels selected, edge must have at least one) - if (selectedLabels.length > 0) { - const edgeLabels = data?.labels || []; - const hasSelectedLabel = edgeLabels.some((labelId) => - selectedLabels.includes(labelId) - ); - if (!hasSelectedLabel) { - return false; - } - } - - // Check search text match - if (searchText.trim()) { - const searchLower = searchText.toLowerCase(); - const label = data?.label?.toLowerCase() || ''; - const typeName = edgeTypeConfig?.label?.toLowerCase() || ''; - - return label.includes(searchLower) || typeName.includes(searchLower); - } - - return true; - }, [searchText, selectedRelationTypes, selectedLabels, data?.type, data?.label, data?.labels, edgeTypeConfig?.label]); + return edgeMatchesFilters( + data?.type || '', + data?.labels || [], + data?.label || '', + edgeTypeConfig?.label || '', + filters + ); + }, [data?.type, data?.labels, data?.label, edgeTypeConfig?.label, filters]); // Determine if filters are active const hasActiveFilters = - searchText.trim() !== '' || - selectedRelationTypes.length > 0 || - selectedLabels.length > 0; + filters.searchText.trim() !== '' || + filters.selectedRelationTypes.length > 0 || + filters.selectedLabels.length > 0; // Calculate opacity based on visibility const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index c54111c..6a06462 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -25,11 +25,11 @@ import "@xyflow/react/dist/style.css"; import { useGraphWithHistory } from "../../hooks/useGraphWithHistory"; import { useDocumentHistory } from "../../hooks/useDocumentHistory"; import { useEditorStore } from "../../stores/editorStore"; -import { useSearchStore } from "../../stores/searchStore"; import { useSettingsStore } from "../../stores/settingsStore"; import { useActiveDocument } from "../../stores/workspace/useActiveDocument"; import { useWorkspaceStore } from "../../stores/workspaceStore"; import { useCreateDocument } from "../../hooks/useCreateDocument"; +import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters"; import CustomNode from "../Nodes/CustomNode"; import GroupNode from "../Nodes/GroupNode"; import CustomEdge from "../Edges/CustomEdge"; @@ -124,13 +124,8 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG fitView, } = useReactFlow(); - // Search and filter state for auto-zoom - const { - searchText, - selectedActorTypes, - selectedRelationTypes, - selectedLabels, - } = useSearchStore(); + // Get active filters (respects presentation vs editing mode) + const filters = useActiveFilters(); // Settings for auto-zoom const { autoZoomEnabled } = useSettingsStore(); @@ -443,59 +438,30 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG if (nodes.length === 0) return; // Check if any filters are active - const hasSearchText = searchText.trim() !== ''; - const hasTypeFilters = selectedActorTypes.length > 0 || selectedRelationTypes.length > 0; - const hasLabelFilters = selectedLabels.length > 0; + const hasSearchText = filters.searchText.trim() !== ''; + const hasTypeFilters = filters.selectedActorTypes.length > 0; + const hasLabelFilters = filters.selectedLabels.length > 0; // Skip if no filters are active if (!hasSearchText && !hasTypeFilters && !hasLabelFilters) return; // Debounce to avoid excessive viewport changes while typing const timeoutId = setTimeout(() => { - const searchLower = searchText.toLowerCase().trim(); - - // Calculate matching nodes (same logic as LeftPanel and CustomNode) + // Calculate matching nodes using the centralized filter logic const matchingNodeIds = nodes .filter((node) => { const actor = node as Actor; const actorType = actor.data?.type || ''; + const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === actorType); - // Filter by actor type (POSITIVE: if types selected, node must be one of them) - if (selectedActorTypes.length > 0) { - if (!selectedActorTypes.includes(actorType)) { - return false; - } - } - - // Filter by label (POSITIVE: if labels selected, node must have at least one) - if (selectedLabels.length > 0) { - const nodeLabels = actor.data?.labels || []; - const hasSelectedLabel = nodeLabels.some((labelId) => - selectedLabels.includes(labelId) - ); - if (!hasSelectedLabel) { - return false; - } - } - - // Filter by search text - if (searchLower) { - const label = actor.data?.label?.toLowerCase() || ''; - const description = actor.data?.description?.toLowerCase() || ''; - const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === actorType); - const typeName = nodeTypeConfig?.label?.toLowerCase() || ''; - - const matches = - label.includes(searchLower) || - description.includes(searchLower) || - typeName.includes(searchLower); - - if (!matches) { - return false; - } - } - - return true; + return nodeMatchesFilters( + actorType, + actor.data?.labels || [], + actor.data?.label || '', + actor.data?.description || '', + nodeTypeConfig?.label || '', + filters + ); }) .map((node) => node.id); @@ -513,10 +479,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG return () => clearTimeout(timeoutId); }, [ - searchText, - selectedActorTypes, - selectedRelationTypes, - selectedLabels, + filters, autoZoomEnabled, nodes, nodeTypeConfigs, diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index e1a447d..049010a 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -1,7 +1,6 @@ import { memo, useMemo } from "react"; import { Handle, Position, NodeProps, useConnection } from "@xyflow/react"; import { useGraphStore } from "../../stores/graphStore"; -import { useSearchStore } from "../../stores/searchStore"; import { getContrastColor, adjustColorBrightness, @@ -10,6 +9,7 @@ import { getIconComponent } from "../../utils/iconUtils"; import type { Actor } from "../../types"; import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; import LabelBadge from "../Common/LabelBadge"; +import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters"; /** * CustomNode - Represents an actor in the constellation graph @@ -25,7 +25,9 @@ import LabelBadge from "../Common/LabelBadge"; const CustomNode = ({ data, selected }: NodeProps) => { const nodeTypes = useGraphStore((state) => state.nodeTypes); const labels = useGraphStore((state) => state.labels); - const { searchText, selectedActorTypes, selectedLabels } = useSearchStore(); + + // Get active filters based on mode (editing vs presentation) + const filters = useActiveFilters(); // Check if any connection is being made (to show handles) const connection = useConnection(); @@ -47,57 +49,30 @@ const CustomNode = ({ data, selected }: NodeProps) => { // Show handles when selected or when connecting const showHandles = selected || isConnecting; - // Check if this node matches the search and filter criteria + // Check if this node matches the filter criteria const isMatch = useMemo(() => { - // Check actor type filter (POSITIVE: if types selected, node must be one of them) - if (selectedActorTypes.length > 0) { - if (!selectedActorTypes.includes(data.type)) { - return false; - } - } - - // Check label filter (POSITIVE: if labels selected, node must have at least one) - if (selectedLabels.length > 0) { - const nodeLabels = data.labels || []; - const hasSelectedLabel = nodeLabels.some((labelId) => - selectedLabels.includes(labelId) - ); - if (!hasSelectedLabel) { - return false; - } - } - - // Check search text match - if (searchText.trim()) { - const searchLower = searchText.toLowerCase(); - const label = data.label?.toLowerCase() || ""; - const description = data.description?.toLowerCase() || ""; - const typeName = nodeLabel.toLowerCase(); - - return ( - label.includes(searchLower) || - description.includes(searchLower) || - typeName.includes(searchLower) - ); - } - - return true; + return nodeMatchesFilters( + data.type, + data.labels || [], + data.label || "", + data.description || "", + nodeLabel, + filters + ); }, [ - searchText, - selectedActorTypes, - selectedLabels, data.type, - data.label, data.labels, + data.label, data.description, nodeLabel, + filters, ]); // Determine if filters are active const hasActiveFilters = - searchText.trim() !== "" || - selectedActorTypes.length > 0 || - selectedLabels.length > 0; + filters.searchText.trim() !== "" || + filters.selectedActorTypes.length > 0 || + filters.selectedLabels.length > 0; // Calculate opacity based on match status const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; diff --git a/src/hooks/useActiveFilters.test.ts b/src/hooks/useActiveFilters.test.ts new file mode 100644 index 0000000..a90816c --- /dev/null +++ b/src/hooks/useActiveFilters.test.ts @@ -0,0 +1,699 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useActiveFilters, nodeMatchesFilters, edgeMatchesFilters } from './useActiveFilters'; +import { useSearchStore } from '../stores/searchStore'; +import { useTuioStore } from '../stores/tuioStore'; +import { useSettingsStore } from '../stores/settingsStore'; + +describe('useActiveFilters', () => { + beforeEach(() => { + // Reset all stores to initial state + useSearchStore.setState({ + searchText: '', + selectedActorTypes: [], + selectedRelationTypes: [], + selectedLabels: [], + }); + + useTuioStore.setState({ + presentationFilters: { + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR', + }, + activeTangibles: new Map(), + activeStateTangibles: [], + connectionState: { connected: false, error: null }, + websocketUrl: 'ws://localhost:3333', + protocolVersion: '1.1', + }); + + useSettingsStore.setState({ + presentationMode: false, + autoZoomEnabled: true, + fullscreenMode: false, + }); + }); + + describe('useActiveFilters hook', () => { + it('should return editing mode filters when not in presentation mode', () => { + // Set up editing mode filters + useSearchStore.setState({ + searchText: 'test search', + selectedActorTypes: ['person'], + selectedRelationTypes: ['knows'], + selectedLabels: ['urgent'], + }); + + const { result } = renderHook(() => useActiveFilters()); + + expect(result.current).toEqual({ + searchText: 'test search', + selectedLabels: ['urgent'], + selectedActorTypes: ['person'], + selectedRelationTypes: ['knows'], + combineMode: 'AND', + }); + }); + + it('should return presentation mode filters when in presentation mode', () => { + // Enable presentation mode + useSettingsStore.setState({ presentationMode: true }); + + // Set up presentation filters + useTuioStore.setState({ + presentationFilters: { + labels: ['critical'], + actorTypes: ['organization'], + relationTypes: ['employs'], + combineMode: 'OR', + }, + activeTangibles: new Map(), + activeStateTangibles: [], + connectionState: { connected: false, error: null }, + websocketUrl: 'ws://localhost:3333', + protocolVersion: '1.1', + }); + + const { result } = renderHook(() => useActiveFilters()); + + expect(result.current).toEqual({ + searchText: '', + selectedLabels: ['critical'], + selectedActorTypes: ['organization'], + selectedRelationTypes: ['employs'], + combineMode: 'OR', + }); + }); + + it('should always use AND mode for editing mode', () => { + useSettingsStore.setState({ presentationMode: false }); + + const { result } = renderHook(() => useActiveFilters()); + + expect(result.current.combineMode).toBe('AND'); + }); + + it('should not include search text in presentation mode', () => { + useSettingsStore.setState({ presentationMode: true }); + useSearchStore.setState({ searchText: 'should be ignored' }); + + const { result } = renderHook(() => useActiveFilters()); + + expect(result.current.searchText).toBe(''); + }); + }); + + describe('nodeMatchesFilters', () => { + describe('No filters active', () => { + it('should match all nodes when no filters are active', () => { + const filters = { + searchText: '', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', 'A person', 'Person', filters); + + expect(result).toBe(true); + }); + }); + + describe('Type filters', () => { + it('should match when node type is in selected types', () => { + const filters = { + searchText: '', + selectedLabels: [], + selectedActorTypes: ['person', 'organization'], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should not match when node type is not in selected types', () => { + const filters = { + searchText: '', + selectedLabels: [], + selectedActorTypes: ['organization'], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', '', 'Person', filters); + + expect(result).toBe(false); + }); + }); + + describe('Label filters', () => { + it('should match when node has at least one selected label', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent', 'critical'], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', ['urgent', 'other'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should not match when node has no selected labels', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent'], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', ['other'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(false); + }); + + it('should not match when node has no labels at all', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent'], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', '', 'Person', filters); + + expect(result).toBe(false); + }); + }); + + describe('Search text filters', () => { + it('should match when search text is in node name', () => { + const filters = { + searchText: 'john', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should match when search text is in node description', () => { + const filters = { + searchText: 'developer', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'Jane', 'A skilled developer', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should match when search text is in node type name', () => { + const filters = { + searchText: 'person', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'Jane', '', 'Person Type', filters); + + expect(result).toBe(true); + }); + + it('should be case insensitive', () => { + const filters = { + searchText: 'JOHN', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'john doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should not match when search text is not found', () => { + const filters = { + searchText: 'xyz', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', 'A person', 'Person', filters); + + expect(result).toBe(false); + }); + + it('should handle whitespace in search text', () => { + const filters = { + searchText: ' john ', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + }); + + describe('Combine mode: AND', () => { + it('should match when all filter categories match', () => { + const filters = { + searchText: 'john', + selectedLabels: ['urgent'], + selectedActorTypes: ['person'], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', ['urgent'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should not match when type matches but label does not', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent'], + selectedActorTypes: ['person'], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', ['other'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(false); + }); + + it('should not match when label matches but type does not', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent'], + selectedActorTypes: ['organization'], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', ['urgent'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(false); + }); + + it('should not match when search text does not match but others do', () => { + const filters = { + searchText: 'xyz', + selectedLabels: ['urgent'], + selectedActorTypes: ['person'], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', ['urgent'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(false); + }); + }); + + describe('Combine mode: OR', () => { + it('should match when any filter category matches', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent'], + selectedActorTypes: ['organization'], + selectedRelationTypes: [], + combineMode: 'OR' as const, + }; + + // Matches because label matches (even though type doesn't) + const result = nodeMatchesFilters('person', ['urgent'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should match when only type matches', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent'], + selectedActorTypes: ['person'], + selectedRelationTypes: [], + combineMode: 'OR' as const, + }; + + const result = nodeMatchesFilters('person', ['other'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should match when only search text matches', () => { + const filters = { + searchText: 'john', + selectedLabels: ['urgent'], + selectedActorTypes: ['organization'], + selectedRelationTypes: [], + combineMode: 'OR' as const, + }; + + const result = nodeMatchesFilters('person', ['other'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + + it('should not match when no filter categories match', () => { + const filters = { + searchText: 'xyz', + selectedLabels: ['urgent'], + selectedActorTypes: ['organization'], + selectedRelationTypes: [], + combineMode: 'OR' as const, + }; + + const result = nodeMatchesFilters('person', ['other'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(false); + }); + + it('should match when all categories match', () => { + const filters = { + searchText: 'john', + selectedLabels: ['urgent'], + selectedActorTypes: ['person'], + selectedRelationTypes: [], + combineMode: 'OR' as const, + }; + + const result = nodeMatchesFilters('person', ['urgent'], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + }); + }); + + describe('edgeMatchesFilters', () => { + describe('No filters active', () => { + it('should match all edges when no filters are active', () => { + const filters = { + searchText: '', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', [], 'custom label', 'Knows', filters); + + expect(result).toBe(true); + }); + }); + + describe('Type filters', () => { + it('should match when edge type is in selected types', () => { + const filters = { + searchText: '', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: ['knows', 'employs'], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', [], '', 'Knows', filters); + + expect(result).toBe(true); + }); + + it('should not match when edge type is not in selected types', () => { + const filters = { + searchText: '', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: ['employs'], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', [], '', 'Knows', filters); + + expect(result).toBe(false); + }); + }); + + describe('Label filters', () => { + it('should match when edge has at least one selected label', () => { + const filters = { + searchText: '', + selectedLabels: ['verified', 'important'], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', ['verified', 'other'], '', 'Knows', filters); + + expect(result).toBe(true); + }); + + it('should not match when edge has no selected labels', () => { + const filters = { + searchText: '', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', ['other'], '', 'Knows', filters); + + expect(result).toBe(false); + }); + }); + + describe('Search text filters', () => { + it('should match when search text is in edge custom label', () => { + const filters = { + searchText: 'custom', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', [], 'custom relationship', 'Knows', filters); + + expect(result).toBe(true); + }); + + it('should match when search text is in edge type name', () => { + const filters = { + searchText: 'knows', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', [], '', 'Knows About', filters); + + expect(result).toBe(true); + }); + + it('should be case insensitive', () => { + const filters = { + searchText: 'KNOWS', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', [], '', 'knows about', filters); + + expect(result).toBe(true); + }); + + it('should not match when search text is not found', () => { + const filters = { + searchText: 'xyz', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', [], '', 'Knows', filters); + + expect(result).toBe(false); + }); + }); + + describe('Combine mode: AND', () => { + it('should match when all filter categories match', () => { + const filters = { + searchText: 'custom', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: ['knows'], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', ['verified'], 'custom label', 'Knows', filters); + + expect(result).toBe(true); + }); + + it('should not match when type matches but label does not', () => { + const filters = { + searchText: '', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: ['knows'], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', ['other'], '', 'Knows', filters); + + expect(result).toBe(false); + }); + + it('should not match when label matches but type does not', () => { + const filters = { + searchText: '', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: ['employs'], + combineMode: 'AND' as const, + }; + + const result = edgeMatchesFilters('knows', ['verified'], '', 'Knows', filters); + + expect(result).toBe(false); + }); + }); + + describe('Combine mode: OR', () => { + it('should match when any filter category matches', () => { + const filters = { + searchText: '', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: ['employs'], + combineMode: 'OR' as const, + }; + + // Matches because label matches (even though type doesn't) + const result = edgeMatchesFilters('knows', ['verified'], '', 'Knows', filters); + + expect(result).toBe(true); + }); + + it('should match when only type matches', () => { + const filters = { + searchText: '', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: ['knows'], + combineMode: 'OR' as const, + }; + + const result = edgeMatchesFilters('knows', ['other'], '', 'Knows', filters); + + expect(result).toBe(true); + }); + + it('should match when only search text matches', () => { + const filters = { + searchText: 'custom', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: ['employs'], + combineMode: 'OR' as const, + }; + + const result = edgeMatchesFilters('knows', ['other'], 'custom label', 'Knows', filters); + + expect(result).toBe(true); + }); + + it('should not match when no filter categories match', () => { + const filters = { + searchText: 'xyz', + selectedLabels: ['verified'], + selectedActorTypes: [], + selectedRelationTypes: ['employs'], + combineMode: 'OR' as const, + }; + + const result = edgeMatchesFilters('knows', ['other'], '', 'Knows', filters); + + expect(result).toBe(false); + }); + }); + }); + + describe('Edge cases', () => { + it('should handle empty strings in node data', () => { + const filters = { + searchText: 'test', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('', [], '', '', '', filters); + + expect(result).toBe(false); + }); + + it('should handle undefined label arrays', () => { + const filters = { + searchText: '', + selectedLabels: ['urgent'], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John', '', 'Person', filters); + + expect(result).toBe(false); + }); + + it('should trim leading and trailing whitespace in search text', () => { + const filters = { + searchText: ' john ', + selectedLabels: [], + selectedActorTypes: [], + selectedRelationTypes: [], + combineMode: 'AND' as const, + }; + + const result = nodeMatchesFilters('person', [], 'John Doe', '', 'Person', filters); + + expect(result).toBe(true); + }); + }); +}); diff --git a/src/hooks/useActiveFilters.ts b/src/hooks/useActiveFilters.ts new file mode 100644 index 0000000..398bc04 --- /dev/null +++ b/src/hooks/useActiveFilters.ts @@ -0,0 +1,187 @@ +import { useMemo } from 'react'; +import { useSearchStore } from '../stores/searchStore'; +import { useTuioStore } from '../stores/tuioStore'; +import { useSettingsStore } from '../stores/settingsStore'; + +/** + * Hook to get the currently active filters based on mode. + * + * - In editing mode: Returns filters from searchStore + * - In presentation mode: Returns filters from tuioStore.presentationFilters + * + * This ensures that presentation mode and editing mode have separate filter states. + */ +export function useActiveFilters() { + const { presentationMode } = useSettingsStore(); + + // Editing mode filters (searchStore) + const { + searchText: editSearchText, + selectedLabels: editSelectedLabels, + selectedActorTypes: editSelectedActorTypes, + selectedRelationTypes: editSelectedRelationTypes, + } = useSearchStore(); + + // Presentation mode filters (tuioStore) + const presentationFilters = useTuioStore((state) => state.presentationFilters); + + return useMemo(() => { + if (presentationMode) { + // Use presentation filters from tangibles + return { + searchText: '', // Search text not supported in presentation mode + selectedLabels: presentationFilters.labels, + selectedActorTypes: presentationFilters.actorTypes, + selectedRelationTypes: presentationFilters.relationTypes, + combineMode: presentationFilters.combineMode, + }; + } else { + // Use editing mode filters + return { + searchText: editSearchText, + selectedLabels: editSelectedLabels, + selectedActorTypes: editSelectedActorTypes, + selectedRelationTypes: editSelectedRelationTypes, + combineMode: 'AND' as const, // Editing mode always uses AND + }; + } + }, [ + presentationMode, + editSearchText, + editSelectedLabels, + editSelectedActorTypes, + editSelectedRelationTypes, + presentationFilters, + ]); +} + +/** + * Check if a node matches the active filters. + * + * @param nodeType - The node's type ID + * @param nodeLabels - The node's label IDs + * @param nodeName - The node's name/label for text search + * @param nodeDescription - The node's description for text search + * @param nodeTypeName - The node type's display name for text search + * @param filters - The active filters from useActiveFilters() + * @returns true if the node matches the filters + */ +export function nodeMatchesFilters( + nodeType: string, + nodeLabels: string[], + nodeName: string, + nodeDescription: string, + nodeTypeName: string, + filters: ReturnType +): boolean { + const { + searchText, + selectedLabels, + selectedActorTypes, + combineMode, + } = filters; + + // Check if any filters are active + const hasTypeFilter = selectedActorTypes.length > 0; + const hasLabelFilter = selectedLabels.length > 0; + const hasSearchText = searchText.trim() !== ''; + + // If no filters active, show all nodes + if (!hasTypeFilter && !hasLabelFilter && !hasSearchText) { + return true; + } + + // Check type filter match + const typeMatches = !hasTypeFilter || selectedActorTypes.includes(nodeType); + + // Check label filter match + const labelMatches = !hasLabelFilter || nodeLabels.some((labelId) => selectedLabels.includes(labelId)); + + // Check search text match + const searchLower = searchText.toLowerCase().trim(); + const textMatches = !hasSearchText || + nodeName.toLowerCase().includes(searchLower) || + nodeDescription.toLowerCase().includes(searchLower) || + nodeTypeName.toLowerCase().includes(searchLower); + + // Apply combine mode logic + if (combineMode === 'OR') { + // OR: Show if matches ANY filter category + return ( + (hasTypeFilter && typeMatches) || + (hasLabelFilter && labelMatches) || + (hasSearchText && textMatches) + ); + } else { + // AND: Show only if matches ALL active filter categories + return ( + (!hasTypeFilter || typeMatches) && + (!hasLabelFilter || labelMatches) && + (!hasSearchText || textMatches) + ); + } +} + +/** + * Check if an edge matches the active filters. + * + * @param edgeType - The edge's type ID + * @param edgeLabels - The edge's label IDs + * @param edgeName - The edge's name/label for text search + * @param edgeTypeName - The edge type's display name for text search + * @param filters - The active filters from useActiveFilters() + * @returns true if the edge matches the filters + */ +export function edgeMatchesFilters( + edgeType: string, + edgeLabels: string[], + edgeName: string, + edgeTypeName: string, + filters: ReturnType +): boolean { + const { + searchText, + selectedLabels, + selectedRelationTypes, + combineMode, + } = filters; + + // Check if any filters are active + const hasTypeFilter = selectedRelationTypes.length > 0; + const hasLabelFilter = selectedLabels.length > 0; + const hasSearchText = searchText.trim() !== ''; + + // If no filters active, show all edges + if (!hasTypeFilter && !hasLabelFilter && !hasSearchText) { + return true; + } + + // Check type filter match + const typeMatches = !hasTypeFilter || selectedRelationTypes.includes(edgeType); + + // Check label filter match + const labelMatches = !hasLabelFilter || edgeLabels.some((labelId) => selectedLabels.includes(labelId)); + + // Check search text match + const searchLower = searchText.toLowerCase().trim(); + const textMatches = !hasSearchText || + edgeName.toLowerCase().includes(searchLower) || + edgeTypeName.toLowerCase().includes(searchLower); + + // Apply combine mode logic + if (combineMode === 'OR') { + // OR: Show if matches ANY filter category + return ( + (hasTypeFilter && typeMatches) || + (hasLabelFilter && labelMatches) || + (hasSearchText && textMatches) + ); + } else { + // AND: Show only if matches ALL active filter categories + return ( + (!hasTypeFilter || typeMatches) && + (!hasLabelFilter || labelMatches) && + (!hasSearchText || textMatches) + ); + } +} diff --git a/src/hooks/useTuioIntegration.ts b/src/hooks/useTuioIntegration.ts index 5dc5f59..b60e744 100644 --- a/src/hooks/useTuioIntegration.ts +++ b/src/hooks/useTuioIntegration.ts @@ -2,11 +2,11 @@ import { useEffect, useRef } from 'react'; import { useTuioStore } from '../stores/tuioStore'; import { useSettingsStore } from '../stores/settingsStore'; import { useGraphStore } from '../stores/graphStore'; -import { useSearchStore } from '../stores/searchStore'; import { useTimelineStore } from '../stores/timelineStore'; import { TuioClientManager } from '../lib/tuio/tuioClient'; import type { TuioTangibleInfo } from '../lib/tuio/types'; import type { TangibleConfig } from '../types'; +import { migrateTangibleConfig } from '../utils/tangibleMigration'; /** * TUIO Integration Hook @@ -29,7 +29,6 @@ export function useTuioIntegration() { if (!presentationMode) { // Disconnect if we're leaving presentation mode if (clientRef.current) { - console.log('[TUIO Integration] Presentation mode disabled, disconnecting'); clientRef.current.disconnect(); clientRef.current = null; useTuioStore.getState().clearActiveTangibles(); @@ -37,7 +36,6 @@ export function useTuioIntegration() { return; } - console.log('[TUIO Integration] Presentation mode enabled, connecting to TUIO server'); // Create TUIO client if in presentation mode const client = new TuioClientManager( @@ -46,7 +44,6 @@ export function useTuioIntegration() { onTangibleUpdate: handleTangibleUpdate, onTangibleRemove: handleTangibleRemove, onConnectionChange: (connected, error) => { - console.log('[TUIO Integration] Connection state changed:', connected, error); useTuioStore.getState().setConnectionState(connected, error); }, }, @@ -58,14 +55,13 @@ export function useTuioIntegration() { // Connect to TUIO server client .connect(websocketUrl) - .catch((error) => { - console.error('[TUIO Integration] Failed to connect to TUIO server:', error); + .catch(() => { + // Connection errors are handled by onConnectionChange callback }); // Cleanup on unmount or when presentation mode changes return () => { if (clientRef.current) { - console.log('[TUIO Integration] Cleaning up, disconnecting'); clientRef.current.disconnect(); clientRef.current = null; useTuioStore.getState().clearActiveTangibles(); @@ -78,7 +74,6 @@ export function useTuioIntegration() { * Handle tangible add event */ function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void { - console.log('[TUIO Integration] Tangible added:', hardwareId, info); // Update TUIO store useTuioStore.getState().addActiveTangible(hardwareId, info); @@ -89,15 +84,13 @@ function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void { if (!tangibleConfig) { // Unknown hardware ID - silently ignore - console.log('[TUIO Integration] No configuration found for hardware ID:', hardwareId); return; } - console.log('[TUIO Integration] Tangible configuration found:', tangibleConfig.name, 'mode:', tangibleConfig.mode); // Trigger action based on tangible mode if (tangibleConfig.mode === 'filter') { - applyFilterTangible(tangibleConfig); + applyFilterTangible(); } else if (tangibleConfig.mode === 'state') { applyStateTangible(tangibleConfig, hardwareId); } @@ -109,7 +102,6 @@ function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void { * Currently just updates position/angle in store (for future stateDial support) */ function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void { - console.log('[TUIO Integration] Tangible updated:', hardwareId, info); useTuioStore.getState().updateActiveTangible(hardwareId, info); } @@ -117,7 +109,6 @@ function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void * Handle tangible remove event */ function handleTangibleRemove(hardwareId: string): void { - console.log('[TUIO Integration] Tangible removed:', hardwareId); // Remove from TUIO store useTuioStore.getState().removeActiveTangible(hardwareId); @@ -127,71 +118,80 @@ function handleTangibleRemove(hardwareId: string): void { const tangibleConfig = tangibles.find((t) => t.hardwareId === hardwareId); if (!tangibleConfig) { - console.log('[TUIO Integration] No configuration found for removed tangible:', hardwareId); return; } - console.log('[TUIO Integration] Handling removal for configured tangible:', tangibleConfig.name); // Handle removal based on tangible mode if (tangibleConfig.mode === 'filter') { - removeFilterTangible(tangibleConfig); + removeFilterTangible(); } else if (tangibleConfig.mode === 'state' || tangibleConfig.mode === 'stateDial') { removeStateTangible(hardwareId); } } /** - * Apply filter tangible - add its labels to selected labels + * Recalculate and update presentation mode filters based on all active filter tangibles. + * This combines filters from all active tangibles (union/OR across tangibles). + * The combineMode of individual tangibles is preserved for filtering logic. */ -function applyFilterTangible(tangible: TangibleConfig): void { - if (!tangible.filterLabels || tangible.filterLabels.length === 0) { - return; - } - - const { selectedLabels, toggleSelectedLabel } = useSearchStore.getState(); - - // Add labels that aren't already selected - tangible.filterLabels.forEach((labelId) => { - if (!selectedLabels.includes(labelId)) { - toggleSelectedLabel(labelId); - } - }); -} - -/** - * Remove filter tangible - remove its labels if no other active tangible uses them - */ -function removeFilterTangible(tangible: TangibleConfig): void { - if (!tangible.filterLabels || tangible.filterLabels.length === 0) { - return; - } - - // Get all remaining active filter tangibles +function updatePresentationFilters(): void { const activeTangibles = useTuioStore.getState().activeTangibles; const allTangibles = useGraphStore.getState().tangibles; - // Build set of labels still in use by other active filter tangibles - const labelsStillActive = new Set(); + // Collect all filters from active filter tangibles + const allLabels = new Set(); + const allActorTypes = new Set(); + const allRelationTypes = new Set(); + let combinedMode: 'AND' | 'OR' = 'OR'; // Default to OR + activeTangibles.forEach((_, hwId) => { const config = allTangibles.find( (t) => t.hardwareId === hwId && t.mode === 'filter' ); - if (config && config.filterLabels) { - config.filterLabels.forEach((labelId) => labelsStillActive.add(labelId)); + if (config) { + // Apply migration to ensure we have filters + const migratedConfig = migrateTangibleConfig(config); + const filters = migratedConfig.filters; + + if (filters) { + // Collect all filter IDs (union across tangibles) + filters.labels?.forEach((id) => allLabels.add(id)); + filters.actorTypes?.forEach((id) => allActorTypes.add(id)); + filters.relationTypes?.forEach((id) => allRelationTypes.add(id)); + + // Use the combine mode from the first tangible (or could be configurable) + // For multiple tangibles, we use OR between tangibles, but preserve individual combine modes + if (filters.combineMode) { + combinedMode = filters.combineMode; + } + } } }); - // Remove labels that are no longer active - const { selectedLabels, toggleSelectedLabel } = useSearchStore.getState(); - - tangible.filterLabels.forEach((labelId) => { - if (selectedLabels.includes(labelId) && !labelsStillActive.has(labelId)) { - toggleSelectedLabel(labelId); - } + // Update presentation filters in tuioStore + useTuioStore.getState().setPresentationFilters({ + labels: Array.from(allLabels), + actorTypes: Array.from(allActorTypes), + relationTypes: Array.from(allRelationTypes), + combineMode: combinedMode, }); } +/** + * Apply filter tangible - recalculate presentation filters + */ +function applyFilterTangible(): void { + updatePresentationFilters(); +} + +/** + * Remove filter tangible - recalculate presentation filters + */ +function removeFilterTangible(): void { + updatePresentationFilters(); +} + /** * Apply state tangible - switch to its configured state */ @@ -200,7 +200,6 @@ function applyStateTangible(tangible: TangibleConfig, hardwareId: string): void return; } - console.log('[TUIO Integration] Applying state tangible:', hardwareId, 'stateId:', tangible.stateId); // Add to active state tangibles list (at the end) useTuioStore.getState().addActiveStateTangible(hardwareId); @@ -209,25 +208,21 @@ function applyStateTangible(tangible: TangibleConfig, hardwareId: string): void // Pass fromTangible=true to prevent clearing the active state tangibles list useTimelineStore.getState().switchToState(tangible.stateId, true); - console.log('[TUIO Integration] Active state tangibles:', useTuioStore.getState().activeStateTangibles); } /** * Remove state tangible - switch to next active state tangible if any */ function removeStateTangible(hardwareId: string): void { - console.log('[TUIO Integration] Removing state tangible:', hardwareId); // Remove from active state tangibles list useTuioStore.getState().removeActiveStateTangible(hardwareId); const activeStateTangibles = useTuioStore.getState().activeStateTangibles; - console.log('[TUIO Integration] Remaining active state tangibles:', activeStateTangibles); // If there are other state tangibles still active, switch to the last one if (activeStateTangibles.length > 0) { const lastActiveHwId = activeStateTangibles[activeStateTangibles.length - 1]; - console.log('[TUIO Integration] Switching to last active state tangible:', lastActiveHwId); // Find the tangible config for this hardware ID const tangibles = useGraphStore.getState().tangibles; @@ -237,7 +232,6 @@ function removeStateTangible(hardwareId: string): void { // Pass fromTangible=true to prevent clearing the active state tangibles list useTimelineStore.getState().switchToState(tangibleConfig.stateId, true); } - } else { - console.log('[TUIO Integration] No more active state tangibles, staying in current state'); } + // If no active state tangibles remain, stay in current state } diff --git a/src/lib/tuio/WebsocketTuioReceiver.ts b/src/lib/tuio/WebsocketTuioReceiver.ts index dc0c099..59e627a 100644 --- a/src/lib/tuio/WebsocketTuioReceiver.ts +++ b/src/lib/tuio/WebsocketTuioReceiver.ts @@ -22,8 +22,6 @@ export class WebsocketTuioReceiver extends TuioReceiver { constructor(host: string, port: number) { super(); - console.log(`[TUIO] Creating WebSocket receiver for ${host}:${port}`); - // Create OSC WebSocket client this.osc = new OSC({ plugin: new OSC.WebsocketClientPlugin({ @@ -34,20 +32,17 @@ export class WebsocketTuioReceiver extends TuioReceiver { // Forward all OSC messages to TUIO client this.osc.on('*', (message: OscMessage) => { - console.log('[TUIO] OSC message received:', message.address, message.args); this.onOscMessage(message); }); // Listen for WebSocket connection events this.osc.on('open', () => { - console.log('[TUIO] WebSocket connection opened'); if (this.onOpenCallback) { this.onOpenCallback(); } }); this.osc.on('close', () => { - console.log('[TUIO] WebSocket connection closed'); if (this.onCloseCallback) { this.onCloseCallback(); } @@ -55,7 +50,6 @@ export class WebsocketTuioReceiver extends TuioReceiver { this.osc.on('error', (error: unknown) => { const errorMessage = error instanceof Error ? error.message : 'WebSocket error'; - console.error('[TUIO] WebSocket error:', errorMessage); if (this.onErrorCallback) { this.onErrorCallback(errorMessage); } @@ -87,7 +81,6 @@ export class WebsocketTuioReceiver extends TuioReceiver { * Open WebSocket connection to TUIO server */ connect(): void { - console.log('[TUIO] Opening WebSocket connection...'); this.osc.open(); } @@ -95,7 +88,6 @@ export class WebsocketTuioReceiver extends TuioReceiver { * Close WebSocket connection */ disconnect(): void { - console.log('[TUIO] Closing WebSocket connection...'); this.osc.close(); } } diff --git a/src/lib/tuio/tuioClient.ts b/src/lib/tuio/tuioClient.ts index 7df7b9e..7f8fcc9 100644 --- a/src/lib/tuio/tuioClient.ts +++ b/src/lib/tuio/tuioClient.ts @@ -35,7 +35,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { async connect(url: string): Promise { return new Promise((resolve, reject) => { try { - console.log(`[TUIO] Connecting to ${url} with protocol version ${this.protocolVersion}`); // Parse WebSocket URL const wsUrl = new URL(url); @@ -49,11 +48,9 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { // Create appropriate client based on protocol version if (this.protocolVersion === '1.1') { - console.log('[TUIO] Creating TUIO 1.1 client'); this.client11 = new Tuio11Client(this.receiver); this.client20 = null; } else { - console.log('[TUIO] Creating TUIO 2.0 client'); this.client20 = new Tuio20Client(this.receiver); this.client11 = null; } @@ -61,14 +58,12 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { // Set up connection event handlers this.receiver.setOnOpen(() => { // Connection successful - console.log('[TUIO] Connection successful'); this.callbacks.onConnectionChange(true); resolve(); }); this.receiver.setOnError((error: string) => { // Connection error - console.error('TUIO connection error:', error); this.callbacks.onConnectionChange(false, error); reject(new Error(error)); }); @@ -76,7 +71,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { this.receiver.setOnClose((error?: string) => { // Connection closed if (error) { - console.error('TUIO connection closed with error:', error); this.callbacks.onConnectionChange(false, error); } else { this.callbacks.onConnectionChange(false); @@ -85,19 +79,14 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { // Add this manager as a listener if (this.client11) { - console.log('[TUIO] Adding listener to TUIO 1.1 client'); this.client11.addTuioListener(this); - console.log('[TUIO] Connecting TUIO 1.1 client'); this.client11.connect(); } else if (this.client20) { - console.log('[TUIO] Adding listener to TUIO 2.0 client'); this.client20.addTuioListener(this); - console.log('[TUIO] Connecting TUIO 2.0 client'); this.client20.connect(); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('TUIO connection error:', errorMessage); this.callbacks.onConnectionChange(false, errorMessage); reject(error); } @@ -148,16 +137,13 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 object is added (tangible placed on surface) */ addTuioObject(tuioObject: Tuio11Object): void { - console.log('[TUIO] 1.1 Object added - raw object:', tuioObject); // Validate symbolId exists if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) { - console.warn('[TUIO] 1.1 Object has no symbolId, ignoring'); return; } const info = this.extractTangibleInfo11(tuioObject); - console.log('[TUIO] 1.1 Object added - extracted info:', info); this.callbacks.onTangibleAdd(info.hardwareId, info); } @@ -165,16 +151,13 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 object is updated (position/rotation changed) */ updateTuioObject(tuioObject: Tuio11Object): void { - console.log('[TUIO] 1.1 Object updated - raw object:', tuioObject); // Validate symbolId exists if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) { - console.warn('[TUIO] 1.1 Object has no symbolId, ignoring'); return; } const info = this.extractTangibleInfo11(tuioObject); - console.log('[TUIO] 1.1 Object updated - extracted info:', info); this.callbacks.onTangibleUpdate(info.hardwareId, info); } @@ -182,16 +165,13 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 object is removed (tangible removed from surface) */ removeTuioObject(tuioObject: Tuio11Object): void { - console.log('[TUIO] 1.1 Object removed - raw object:', tuioObject); // Validate symbolId exists if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) { - console.warn('[TUIO] 1.1 Object has no symbolId, ignoring'); return; } const hardwareId = String(tuioObject.symbolId); - console.log('[TUIO] 1.1 Object removed - hardwareId:', hardwareId); this.callbacks.onTangibleRemove(hardwareId); } @@ -199,7 +179,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 cursor is added (not used for tangibles) */ addTuioCursor(): void { - console.log('[TUIO] 1.1 Cursor added (ignored)'); // Ignore cursors (touch points) } @@ -207,7 +186,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 cursor is updated (not used for tangibles) */ updateTuioCursor(): void { - console.log('[TUIO] 1.1 Cursor updated (ignored)'); // Ignore cursors } @@ -215,7 +193,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 cursor is removed (not used for tangibles) */ removeTuioCursor(): void { - console.log('[TUIO] 1.1 Cursor removed (ignored)'); // Ignore cursors } @@ -223,7 +200,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 blob is added (not used for tangibles) */ addTuioBlob(): void { - console.log('[TUIO] 1.1 Blob added (ignored)'); // Ignore blobs } @@ -231,7 +207,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 blob is updated (not used for tangibles) */ updateTuioBlob(): void { - console.log('[TUIO] 1.1 Blob updated (ignored)'); // Ignore blobs } @@ -239,7 +214,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called when a TUIO 1.1 blob is removed (not used for tangibles) */ removeTuioBlob(): void { - console.log('[TUIO] 1.1 Blob removed (ignored)'); // Ignore blobs } @@ -247,7 +221,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { * Called on TUIO 1.1 frame refresh (time sync) */ refresh(): void { - console.log('[TUIO] 1.1 Frame refresh (ignored)'); // Ignore refresh events } @@ -259,12 +232,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { tuioAdd(tuioObject: Tuio20Object): void { const token = tuioObject.token; if (!token) { - console.log('[TUIO] 2.0 Add event ignored (not a token)'); return; // Only handle tokens (tangibles), not pointers } const info = this.extractTangibleInfo(tuioObject); - console.log('[TUIO] 2.0 Token added:', info); this.callbacks.onTangibleAdd(info.hardwareId, info); } @@ -274,12 +245,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { tuioUpdate(tuioObject: Tuio20Object): void { const token = tuioObject.token; if (!token) { - console.log('[TUIO] 2.0 Update event ignored (not a token)'); return; } const info = this.extractTangibleInfo(tuioObject); - console.log('[TUIO] 2.0 Token updated:', info); this.callbacks.onTangibleUpdate(info.hardwareId, info); } @@ -289,12 +258,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener { tuioRemove(tuioObject: Tuio20Object): void { const token = tuioObject.token; if (!token) { - console.log('[TUIO] 2.0 Remove event ignored (not a token)'); return; } const hardwareId = String(token.cId); - console.log('[TUIO] 2.0 Token removed:', hardwareId); this.callbacks.onTangibleRemove(hardwareId); } diff --git a/src/stores/graphStore.ts b/src/stores/graphStore.ts index b363a39..2b47c1d 100644 --- a/src/stores/graphStore.ts +++ b/src/stores/graphStore.ts @@ -13,6 +13,7 @@ import type { GraphActions } from '../types'; import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants'; +import { migrateTangibleConfigs } from '../utils/tangibleMigration'; /** * ⚠️ IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS ⚠️ @@ -165,9 +166,26 @@ export const useGraphStore = create((set) => ({ })), deleteNodeType: (id: string) => - set((state) => ({ - nodeTypes: state.nodeTypes.filter((type) => type.id !== id), - })), + set((state) => { + // Remove node type ID from tangible filters.actorTypes arrays + const updatedTangibles = state.tangibles.map((tangible) => { + if (tangible.mode === 'filter' && tangible.filters?.actorTypes) { + return { + ...tangible, + filters: { + ...tangible.filters, + actorTypes: tangible.filters.actorTypes.filter((typeId) => typeId !== id), + }, + }; + } + return tangible; + }); + + return { + nodeTypes: state.nodeTypes.filter((type) => type.id !== id), + tangibles: updatedTangibles, + }; + }), // Edge type operations addEdgeType: (edgeType: EdgeTypeConfig) => @@ -183,9 +201,26 @@ export const useGraphStore = create((set) => ({ })), deleteEdgeType: (id: string) => - set((state) => ({ - edgeTypes: state.edgeTypes.filter((type) => type.id !== id), - })), + set((state) => { + // Remove edge type ID from tangible filters.relationTypes arrays + const updatedTangibles = state.tangibles.map((tangible) => { + if (tangible.mode === 'filter' && tangible.filters?.relationTypes) { + return { + ...tangible, + filters: { + ...tangible.filters, + relationTypes: tangible.filters.relationTypes.filter((typeId) => typeId !== id), + }, + }; + } + return tangible; + }); + + return { + edgeTypes: state.edgeTypes.filter((type) => type.id !== id), + tangibles: updatedTangibles, + }; + }), // Label operations addLabel: (label: LabelConfig) => @@ -221,13 +256,25 @@ export const useGraphStore = create((set) => ({ : edge.data, })); - // Remove label from tangible filterLabels arrays + // Remove label from tangible filterLabels arrays (old format) and filters.labels (new format) const updatedTangibles = state.tangibles.map((tangible) => { - if (tangible.mode === 'filter' && tangible.filterLabels) { - return { - ...tangible, - filterLabels: tangible.filterLabels.filter((labelId) => labelId !== id), - }; + if (tangible.mode === 'filter') { + const updates: Partial = {}; + + // Handle old format + if (tangible.filterLabels) { + updates.filterLabels = tangible.filterLabels.filter((labelId) => labelId !== id); + } + + // Handle new format + if (tangible.filters?.labels) { + updates.filters = { + ...tangible.filters, + labels: tangible.filters.labels.filter((labelId) => labelId !== id), + }; + } + + return { ...tangible, ...updates }; } return tangible; }); @@ -589,6 +636,11 @@ export const useGraphStore = create((set) => ({ return node; }); + // Apply tangible migration for backward compatibility + const migratedTangibles = data.tangibles + ? migrateTangibleConfigs(data.tangibles) + : []; + // Atomic update: all state changes happen in a single set() call set({ nodes: sanitizedNodes, @@ -597,7 +649,7 @@ export const useGraphStore = create((set) => ({ nodeTypes: data.nodeTypes, edgeTypes: data.edgeTypes, labels: data.labels || [], - tangibles: data.tangibles || [], + tangibles: migratedTangibles, }); }, })); diff --git a/src/stores/timelineStore.ts b/src/stores/timelineStore.ts index 7f07dff..8c4c857 100644 --- a/src/stores/timelineStore.ts +++ b/src/stores/timelineStore.ts @@ -260,7 +260,6 @@ export const useTimelineStore = create( // If this is a manual state switch (not from tangible), clear active state tangibles if (!fromTangible) { - console.log('[Timeline] Manual state switch detected, clearing active state tangibles'); useTuioStore.getState().clearActiveStateTangibles(); } diff --git a/src/stores/tuioStore.ts b/src/stores/tuioStore.ts index aee586f..9fb4266 100644 --- a/src/stores/tuioStore.ts +++ b/src/stores/tuioStore.ts @@ -37,6 +37,21 @@ interface TuioState { addActiveStateTangible: (hardwareId: string) => void; removeActiveStateTangible: (hardwareId: string) => void; clearActiveStateTangibles: () => void; + + // Presentation mode filters (runtime only - separate from editing mode filters) + presentationFilters: { + labels: string[]; + actorTypes: string[]; + relationTypes: string[]; + combineMode: 'AND' | 'OR'; + }; + setPresentationFilters: (filters: { + labels: string[]; + actorTypes: string[]; + relationTypes: string[]; + combineMode: 'AND' | 'OR'; + }) => void; + clearPresentationFilters: () => void; } const DEFAULT_WEBSOCKET_URL = 'ws://localhost:3333'; @@ -111,6 +126,27 @@ export const useTuioStore = create()( clearActiveStateTangibles: () => set({ activeStateTangibles: [] }), + + // Presentation mode filters + presentationFilters: { + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR', // Default to OR for presentation mode + }, + + setPresentationFilters: (filters) => + set({ presentationFilters: filters }), + + clearPresentationFilters: () => + set({ + presentationFilters: { + labels: [], + actorTypes: [], + relationTypes: [], + combineMode: 'OR', + }, + }), }), { name: 'constellation-tuio-settings', diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index aeed6f2..b812336 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -32,6 +32,7 @@ import { getCurrentGraphFromDocument } from './workspace/documentUtils'; import { Cite } from '@citation-js/core'; import type { CSLReference } from '../types/bibliography'; import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage'; +import { migrateTangibleConfigs } from '../utils/tangibleMigration'; /** * Workspace Store @@ -83,6 +84,11 @@ function initializeWorkspace(): Workspace { if (savedState.activeDocumentId) { const doc = loadDocumentFromStorage(savedState.activeDocumentId); if (doc) { + // Apply tangible migration for backward compatibility + if (doc.tangibles) { + doc.tangibles = migrateTangibleConfigs(doc.tangibles); + } + documents.set(savedState.activeDocumentId, doc); // Load timeline if it exists @@ -307,6 +313,11 @@ export const useWorkspaceStore = create((set, get) return; } + // Apply tangible migration for backward compatibility + if (doc.tangibles) { + doc.tangibles = migrateTangibleConfigs(doc.tangibles); + } + // Load timeline if it exists if (doc.timeline) { useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline); @@ -610,6 +621,11 @@ export const useWorkspaceStore = create((set, get) importedDoc.metadata.title = importedDoc.metadata.title || 'Imported Analysis'; importedDoc.metadata.updatedAt = now; + // Apply tangible migration for backward compatibility + if (importedDoc.tangibles) { + importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles); + } + const metadata: DocumentMetadata = { id: documentId, title: importedDoc.metadata.title || 'Imported Analysis', @@ -917,6 +933,11 @@ export const useWorkspaceStore = create((set, get) // Save all documents documents.forEach((doc, docId) => { + // Apply tangible migration for backward compatibility + if (doc.tangibles) { + doc.tangibles = migrateTangibleConfigs(doc.tangibles); + } + saveDocumentToStorage(docId, doc); const metadata = { @@ -1433,14 +1454,26 @@ export const useWorkspaceStore = create((set, get) // 1. Remove from document's labels doc.labels = (doc.labels || []).filter((label) => label.id !== labelId); - // 2. Remove label from tangible filterLabels arrays + // 2. Remove label from tangible filterLabels arrays (old format) and filters.labels (new format) if (doc.tangibles) { doc.tangibles = doc.tangibles.map((tangible) => { - if (tangible.mode === 'filter' && tangible.filterLabels) { - return { - ...tangible, - filterLabels: tangible.filterLabels.filter((id) => id !== labelId), - }; + if (tangible.mode === 'filter') { + const updates: Partial = {}; + + // Handle old format + if (tangible.filterLabels) { + updates.filterLabels = tangible.filterLabels.filter((id) => id !== labelId); + } + + // Handle new format + if (tangible.filters?.labels) { + updates.filters = { + ...tangible.filters, + labels: tangible.filters.labels.filter((id) => id !== labelId), + }; + } + + return { ...tangible, ...updates }; } return tangible; }); @@ -1590,9 +1623,20 @@ export const useWorkspaceStore = create((set, get) } // Validate mode-specific fields - if (tangible.mode === 'filter' && (!tangible.filterLabels || tangible.filterLabels.length === 0)) { - useToastStore.getState().showToast('Filter mode requires at least one label', 'error'); - return; + if (tangible.mode === 'filter') { + // Check new format first + const hasNewFilters = tangible.filters && ( + (tangible.filters.labels && tangible.filters.labels.length > 0) || + (tangible.filters.actorTypes && tangible.filters.actorTypes.length > 0) || + (tangible.filters.relationTypes && tangible.filters.relationTypes.length > 0) + ); + // Check old format for backward compatibility + const hasOldFilters = tangible.filterLabels && tangible.filterLabels.length > 0; + + if (!hasNewFilters && !hasOldFilters) { + useToastStore.getState().showToast('Filter mode requires at least one filter (labels, actor types, or relation types)', 'error'); + return; + } } if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) { useToastStore.getState().showToast('State mode requires a state selection', 'error'); @@ -1654,9 +1698,20 @@ export const useWorkspaceStore = create((set, get) } // Validate mode-specific fields if mode is being updated - if (updates.mode === 'filter' && (!updates.filterLabels || updates.filterLabels.length === 0)) { - useToastStore.getState().showToast('Filter mode requires at least one label', 'error'); - return; + if (updates.mode === 'filter') { + // Check new format first + const hasNewFilters = updates.filters && ( + (updates.filters.labels && updates.filters.labels.length > 0) || + (updates.filters.actorTypes && updates.filters.actorTypes.length > 0) || + (updates.filters.relationTypes && updates.filters.relationTypes.length > 0) + ); + // Check old format for backward compatibility + const hasOldFilters = updates.filterLabels && updates.filterLabels.length > 0; + + if (!hasNewFilters && !hasOldFilters) { + useToastStore.getState().showToast('Filter mode requires at least one filter (labels, actor types, or relation types)', 'error'); + return; + } } if ((updates.mode === 'state' || updates.mode === 'stateDial') && !updates.stateId) { useToastStore.getState().showToast('State mode requires a state selection', 'error'); diff --git a/src/types/index.ts b/src/types/index.ts index a0fa9fd..a5cd9c6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,13 +69,26 @@ export interface LabelConfig { // Tangible Configuration export type TangibleMode = 'filter' | 'state' | 'stateDial'; +export type FilterCombineMode = 'AND' | 'OR'; + +export interface FilterConfig { + labels?: string[]; // Array of LabelConfig IDs + actorTypes?: string[]; // Array of NodeTypeConfig IDs + relationTypes?: string[]; // Array of EdgeTypeConfig IDs + combineMode?: FilterCombineMode; // How to combine filter categories (default: 'OR' for tangibles, 'AND' for editing) +} + export interface TangibleConfig { id: string; // Internal unique identifier (auto-generated from name) name: string; mode: TangibleMode; description?: string; hardwareId?: string; // Hardware token/device ID (editable, must be unique if present) - filterLabels?: string[]; // For filter mode: array of LabelConfig IDs + /** + * @deprecated Use filters instead. This field is kept for backward compatibility. + */ + filterLabels?: string[]; // For filter mode: array of LabelConfig IDs (deprecated, use filters.labels) + filters?: FilterConfig; // For filter mode: filter configuration for labels, actor types, and relation types stateId?: string; // For state/stateDial mode: ConstellationState ID } diff --git a/src/utils/__tests__/tangibleMigration.test.ts b/src/utils/__tests__/tangibleMigration.test.ts new file mode 100644 index 0000000..7771c75 --- /dev/null +++ b/src/utils/__tests__/tangibleMigration.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { migrateTangibleConfig, migrateTangibleConfigs } from '../tangibleMigration'; +import type { TangibleConfig } from '../../types'; + +describe('tangibleMigration', () => { + describe('migrateTangibleConfig', () => { + it('should migrate old filterLabels to new filters.labels format', () => { + const oldFormat: TangibleConfig = { + id: 'test-1', + name: 'Test Tangible', + mode: 'filter', + filterLabels: ['label-1', 'label-2'], + }; + + const result = migrateTangibleConfig(oldFormat); + + expect(result.filters).toEqual({ + labels: ['label-1', 'label-2'], + }); + // Original filterLabels should still be present for compatibility + expect(result.filterLabels).toEqual(['label-1', 'label-2']); + }); + + it('should leave tangibles with filters unchanged', () => { + const newFormat: TangibleConfig = { + id: 'test-2', + name: 'Test Tangible', + mode: 'filter', + filters: { + labels: ['label-1'], + actorTypes: ['type-1'], + relationTypes: ['rel-1'], + }, + }; + + const result = migrateTangibleConfig(newFormat); + + expect(result).toEqual(newFormat); + }); + + it('should handle tangibles with no filters', () => { + const noFilters: TangibleConfig = { + id: 'test-3', + name: 'Test Tangible', + mode: 'state', + stateId: 'state-1', + }; + + const result = migrateTangibleConfig(noFilters); + + expect(result).toEqual(noFilters); + }); + + it('should handle tangibles with empty filterLabels', () => { + const emptyFilters: TangibleConfig = { + id: 'test-4', + name: 'Test Tangible', + mode: 'filter', + filterLabels: [], + }; + + const result = migrateTangibleConfig(emptyFilters); + + expect(result).toEqual(emptyFilters); + }); + + it('should handle tangibles with all three filter types', () => { + const allFilters: TangibleConfig = { + id: 'test-5', + name: 'Test Tangible', + mode: 'filter', + filters: { + labels: ['label-1', 'label-2'], + actorTypes: ['type-1', 'type-2'], + relationTypes: ['rel-1', 'rel-2'], + }, + }; + + const result = migrateTangibleConfig(allFilters); + + expect(result).toEqual(allFilters); + }); + + it('should migrate only if filters is not present', () => { + const withBoth: TangibleConfig = { + id: 'test-6', + name: 'Test Tangible', + mode: 'filter', + filterLabels: ['label-1', 'label-2'], + filters: { + labels: ['label-3'], + }, + }; + + const result = migrateTangibleConfig(withBoth); + + // Should use existing filters, not migrate from filterLabels + expect(result.filters).toEqual({ + labels: ['label-3'], + }); + }); + }); + + describe('migrateTangibleConfigs', () => { + it('should migrate an array of tangibles', () => { + const tangibles: TangibleConfig[] = [ + { + id: 'test-1', + name: 'Old Format', + mode: 'filter', + filterLabels: ['label-1'], + }, + { + id: 'test-2', + name: 'New Format', + mode: 'filter', + filters: { + labels: ['label-2'], + }, + }, + { + id: 'test-3', + name: 'State Mode', + mode: 'state', + stateId: 'state-1', + }, + ]; + + const result = migrateTangibleConfigs(tangibles); + + expect(result).toHaveLength(3); + expect(result[0].filters).toEqual({ labels: ['label-1'] }); + expect(result[1].filters).toEqual({ labels: ['label-2'] }); + expect(result[2]).toEqual(tangibles[2]); + }); + + it('should handle empty array', () => { + const result = migrateTangibleConfigs([]); + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/utils/tangibleMigration.ts b/src/utils/tangibleMigration.ts new file mode 100644 index 0000000..4842ddf --- /dev/null +++ b/src/utils/tangibleMigration.ts @@ -0,0 +1,41 @@ +import { TangibleConfig, FilterConfig } from '../types'; + +/** + * Migrates a tangible configuration from the old filterLabels format to the new filters format. + * This function ensures backward compatibility with existing configurations. + * + * @param tangible - The tangible configuration to migrate + * @returns The migrated tangible configuration + */ +export function migrateTangibleConfig(tangible: TangibleConfig): TangibleConfig { + // If tangible already has filters, return as-is (already new format) + if (tangible.filters) { + return tangible; + } + + // If tangible has filterLabels (old format), convert to new format + if (tangible.filterLabels && tangible.filterLabels.length > 0) { + const filters: FilterConfig = { + labels: tangible.filterLabels, + }; + + // Return migrated tangible (keep filterLabels for compatibility during transition) + return { + ...tangible, + filters, + }; + } + + // Otherwise return unchanged + return tangible; +} + +/** + * Migrates an array of tangible configurations. + * + * @param tangibles - Array of tangible configurations to migrate + * @returns Array of migrated tangible configurations + */ +export function migrateTangibleConfigs(tangibles: TangibleConfig[]): TangibleConfig[] { + return tangibles.map(migrateTangibleConfig); +} diff --git a/src/utils/tangibleValidation.ts b/src/utils/tangibleValidation.ts new file mode 100644 index 0000000..d87d6c7 --- /dev/null +++ b/src/utils/tangibleValidation.ts @@ -0,0 +1,66 @@ +import { TangibleConfig } from '../types'; + +/** + * Validates a tangible configuration. + * For filter mode: requires at least one filter (labels, actorTypes, or relationTypes) + * For state/stateDial mode: requires stateId + * Supports both old (filterLabels) and new (filters) formats. + * + * @param tangible - The tangible configuration to validate + * @returns true if valid, false otherwise + */ +export function validateTangibleConfig(tangible: TangibleConfig): boolean { + if (tangible.mode === 'filter') { + // Check new format first + if (tangible.filters) { + const hasLabels = !!(tangible.filters.labels && tangible.filters.labels.length > 0); + const hasActorTypes = !!(tangible.filters.actorTypes && tangible.filters.actorTypes.length > 0); + const hasRelationTypes = !!(tangible.filters.relationTypes && tangible.filters.relationTypes.length > 0); + + return hasLabels || hasActorTypes || hasRelationTypes; + } + + // Fallback to old format for backward compatibility + if (tangible.filterLabels && tangible.filterLabels.length > 0) { + return true; + } + + return false; + } + + if (tangible.mode === 'state' || tangible.mode === 'stateDial') { + return !!tangible.stateId; + } + + return false; +} + +/** + * Gets validation error message for a tangible configuration. + * + * @param tangible - The tangible configuration to validate + * @returns Error message if invalid, null if valid + */ +export function getTangibleValidationError(tangible: TangibleConfig): string | null { + if (tangible.mode === 'filter') { + const hasNewFilters = tangible.filters && ( + (tangible.filters.labels && tangible.filters.labels.length > 0) || + (tangible.filters.actorTypes && tangible.filters.actorTypes.length > 0) || + (tangible.filters.relationTypes && tangible.filters.relationTypes.length > 0) + ); + + const hasOldFilters = tangible.filterLabels && tangible.filterLabels.length > 0; + + if (!hasNewFilters && !hasOldFilters) { + return 'At least one filter must be selected (labels, actor types, or relation types)'; + } + } + + if (tangible.mode === 'state' || tangible.mode === 'stateDial') { + if (!tangible.stateId) { + return 'A constellation state must be selected'; + } + } + + return null; +}