Add multi-filter tangible support with presentation mode filtering

Features:
- Extended tangible filters to support labels, actor types, and relation types
- Added configurable combine mode (OR/AND) for filter logic
- Separated presentation mode filters from editing mode filters
- Implemented backward compatibility with legacy filterLabels format

Filter Behavior:
- OR mode (default for tangibles): Show items matching ANY filter category
- AND mode (default for editing): Show items matching ALL filter categories
- Presentation mode uses tuioStore.presentationFilters
- Editing mode uses searchStore filters

UI Improvements:
- Replaced radio buttons with horizontal button layout for mode selection
- Replaced dropdown with horizontal buttons for combine mode selection
- Consolidated Name and Hardware ID fields into two-column layout
- More compact and consistent interface

Technical Changes:
- Added FilterConfig type with combineMode field
- Created tangibleMigration.ts for backward compatibility
- Created tangibleValidation.ts for multi-format validation
- Added useActiveFilters hook for mode-aware filter access
- Added nodeMatchesFilters and edgeMatchesFilters helper functions
- Updated cascade cleanup for node/edge type deletions
- Removed all TUIO debug logging

Tests:
- Added 44 comprehensive tests for useActiveFilters hook
- Added tests for tangibleMigration utility
- All 499 tests passing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-19 20:20:34 +01:00
parent 010d8a558c
commit 3e2a7b6b20
20 changed files with 1778 additions and 398 deletions

View file

@ -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<TangibleMode>("filter");
const [description, setDescription] = useState("");
const [hardwareId, setHardwareId] = useState("");
const [filterLabels, setFilterLabels] = useState<string[]>([]);
const [filters, setFilters] = useState<FilterConfig>({
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,10 +77,17 @@ const EditTangibleInline = ({
if (!name.trim()) return;
// Validate mode-specific fields
if (mode === "filter" && filterLabels.length === 0) {
alert("Filter mode requires at least one label");
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");
return;
@ -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}
/>
</div>

View file

@ -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<TangibleMode>("filter");
const [description, setDescription] = useState("");
const [filterLabels, setFilterLabels] = useState<string[]>([]);
const [filters, setFilters] = useState<FilterConfig>({
labels: [],
actorTypes: [],
relationTypes: [],
combineMode: 'OR' // Default to OR for tangibles
});
const [stateId, setStateId] = useState("");
const nameInputRef = useRef<HTMLInputElement>(null);
@ -33,10 +40,17 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
}
// Validate mode-specific fields
if (mode === "filter" && filterLabels.length === 0) {
alert("Filter mode requires at least one label");
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");
return;
@ -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}
/>

View file

@ -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,14 +48,22 @@ 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');
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');
return;
@ -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
<EditTangibleInline
tangible={editingTangible}
labels={labels}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={availableStates}
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
@ -147,6 +157,8 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
</h3>
<QuickAddTangibleForm
labels={labels}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={availableStates}
onAdd={handleAddTangible}
/>

View file

@ -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,19 +33,22 @@ const TangibleForm = ({
mode,
description,
hardwareId,
filterLabels,
filters,
stateId,
labels,
nodeTypes,
edgeTypes,
states,
onNameChange,
onModeChange,
onDescriptionChange,
onHardwareIdChange,
onFilterLabelsChange,
onFiltersChange,
onStateIdChange,
}: Props) => {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Name *
@ -44,7 +57,7 @@ const TangibleForm = ({
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Red Block, Filter Card"
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"
/>
</div>
@ -57,11 +70,15 @@ const TangibleForm = ({
type="text"
value={hardwareId}
onChange={(e) => onHardwareIdChange(e.target.value)}
placeholder="e.g., token-001, device-a"
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"
/>
<p className="text-xs text-gray-500 mt-1">
Maps this configuration to a physical token or device
</div>
</div>
<div>
<p className="text-xs text-gray-500">
Hardware ID maps this configuration to a physical token or device
</p>
</div>
@ -69,56 +86,88 @@ const TangibleForm = ({
<label className="block text-xs font-medium text-gray-700 mb-1">
Mode *
</label>
<div className="space-y-2">
<label className="flex items-center cursor-pointer">
<input
type="radio"
name="mode"
value="filter"
checked={mode === "filter"}
onChange={(e) => onModeChange(e.target.value as TangibleMode)}
className="mr-2"
/>
<span className="text-sm text-gray-700">
Filter mode (activate label filters)
</span>
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
name="mode"
value="state"
checked={mode === "state"}
onChange={(e) => onModeChange(e.target.value as TangibleMode)}
className="mr-2"
/>
<span className="text-sm text-gray-700">
State mode (switch to timeline state)
</span>
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
name="mode"
value="stateDial"
checked={mode === "stateDial"}
onChange={(e) => onModeChange(e.target.value as TangibleMode)}
className="mr-2"
/>
<span className="text-sm text-gray-700">
State dial mode (clock-like, deferred)
</span>
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onModeChange("filter")}
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
mode === "filter"
? "bg-blue-500 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
>
<div className="font-medium">Filter</div>
<div className="text-xs opacity-80">Apply filters</div>
</button>
<button
type="button"
onClick={() => onModeChange("state")}
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
mode === "state"
? "bg-blue-500 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
>
<div className="font-medium">State</div>
<div className="text-xs opacity-80">Timeline state</div>
</button>
<button
type="button"
onClick={() => onModeChange("stateDial")}
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
mode === "stateDial"
? "bg-blue-500 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
>
<div className="font-medium">State Dial</div>
<div className="text-xs opacity-80">Deferred</div>
</button>
</div>
</div>
{/* Mode-specific fields */}
{mode === "filter" && (
<div className="space-y-3">
{/* Filter Combine Mode */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Filter Labels * (select one or more)
Combine Mode
</label>
<div className="border border-gray-300 rounded-md p-2 max-h-40 overflow-y-auto">
<div className="flex gap-2">
<button
type="button"
onClick={() => onFiltersChange({ ...filters, combineMode: 'OR' })}
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
(filters.combineMode || 'OR') === 'OR'
? "bg-blue-500 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
>
<div className="font-medium">OR</div>
<div className="text-xs opacity-80">Match ANY</div>
</button>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, combineMode: 'AND' })}
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
filters.combineMode === 'AND'
? "bg-blue-500 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
>
<div className="font-medium">AND</div>
<div className="text-xs opacity-80">Match ALL</div>
</button>
</div>
</div>
{/* Filter by Labels */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Filter by Labels (optional)
</label>
<div className="border border-gray-300 rounded-md p-2 max-h-32 overflow-y-auto">
{labels.length === 0 ? (
<p className="text-xs text-gray-500 italic">
No labels available
@ -131,14 +180,19 @@ const TangibleForm = ({
>
<input
type="checkbox"
checked={filterLabels.includes(label.id)}
checked={filters.labels?.includes(label.id) || false}
onChange={(e) => {
const currentLabels = filters.labels || [];
if (e.target.checked) {
onFilterLabelsChange([...filterLabels, label.id]);
onFiltersChange({
...filters,
labels: [...currentLabels, label.id],
});
} else {
onFilterLabelsChange(
filterLabels.filter((id) => id !== label.id),
);
onFiltersChange({
...filters,
labels: currentLabels.filter((id) => id !== label.id),
});
}
}}
className="mr-2"
@ -153,6 +207,99 @@ const TangibleForm = ({
)}
</div>
</div>
{/* Filter by Actor Types */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Filter by Actor Types (optional)
</label>
<div className="border border-gray-300 rounded-md p-2 max-h-32 overflow-y-auto">
{nodeTypes.length === 0 ? (
<p className="text-xs text-gray-500 italic">
No actor types available
</p>
) : (
nodeTypes.map((nodeType) => (
<label
key={nodeType.id}
className="flex items-center py-1 cursor-pointer"
>
<input
type="checkbox"
checked={filters.actorTypes?.includes(nodeType.id) || false}
onChange={(e) => {
const currentActorTypes = filters.actorTypes || [];
if (e.target.checked) {
onFiltersChange({
...filters,
actorTypes: [...currentActorTypes, nodeType.id],
});
} else {
onFiltersChange({
...filters,
actorTypes: currentActorTypes.filter((id) => id !== nodeType.id),
});
}
}}
className="mr-2"
/>
<span
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: nodeType.color }}
/>
<span className="text-sm text-gray-700">{nodeType.label}</span>
</label>
))
)}
</div>
</div>
{/* Filter by Relation Types */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Filter by Relation Types (optional)
</label>
<div className="border border-gray-300 rounded-md p-2 max-h-32 overflow-y-auto">
{edgeTypes.length === 0 ? (
<p className="text-xs text-gray-500 italic">
No relation types available
</p>
) : (
edgeTypes.map((edgeType) => (
<label
key={edgeType.id}
className="flex items-center py-1 cursor-pointer"
>
<input
type="checkbox"
checked={filters.relationTypes?.includes(edgeType.id) || false}
onChange={(e) => {
const currentRelationTypes = filters.relationTypes || [];
if (e.target.checked) {
onFiltersChange({
...filters,
relationTypes: [...currentRelationTypes, edgeType.id],
});
} else {
onFiltersChange({
...filters,
relationTypes: currentRelationTypes.filter((id) => id !== edgeType.id),
});
}
}}
className="mr-2"
/>
<span
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: edgeType.color }}
/>
<span className="text-sm text-gray-700">{edgeType.label}</span>
</label>
))
)}
</div>
</div>
</div>
)}
{(mode === "state" || mode === "stateDial") && (

View file

@ -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<Relation>) => {
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)
return edgeMatchesFilters(
data?.type || '',
data?.labels || [],
data?.label || '',
edgeTypeConfig?.label || '',
filters
);
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]);
}, [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;

View file

@ -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 || '';
// 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,

View file

@ -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<Actor>) => {
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<Actor>) => {
// 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;
}, [
searchText,
selectedActorTypes,
selectedLabels,
return nodeMatchesFilters(
data.type,
data.labels || [],
data.label || "",
data.description || "",
nodeLabel,
filters
);
}, [
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;

View file

@ -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);
});
});
});

View file

@ -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<typeof useActiveFilters>
): 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<typeof useActiveFilters>
): 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)
);
}
}

View file

@ -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<string>();
// Collect all filters from active filter tangibles
const allLabels = new Set<string>();
const allActorTypes = new Set<string>();
const allRelationTypes = new Set<string>();
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
}

View file

@ -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();
}
}

View file

@ -35,7 +35,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
async connect(url: string): Promise<void> {
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);
}

View file

@ -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<GraphStore & GraphActions>((set) => ({
})),
deleteNodeType: (id: string) =>
set((state) => ({
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<GraphStore & GraphActions>((set) => ({
})),
deleteEdgeType: (id: string) =>
set((state) => ({
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,14 +256,26 @@ export const useGraphStore = create<GraphStore & GraphActions>((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<typeof tangible> = {};
// 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<GraphStore & GraphActions>((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<GraphStore & GraphActions>((set) => ({
nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes,
labels: data.labels || [],
tangibles: data.tangibles || [],
tangibles: migratedTangibles,
});
},
}));

View file

@ -260,7 +260,6 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
// 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();
}

View file

@ -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<TuioState>()(
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',

View file

@ -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<Workspace & WorkspaceActions>((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<Workspace & WorkspaceActions>((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<Workspace & WorkspaceActions>((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,15 +1454,27 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((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<typeof tangible> = {};
// 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,10 +1623,21 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((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');
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');
return;
@ -1654,10 +1698,21 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((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');
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');
return;

View file

@ -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
}

View file

@ -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([]);
});
});
});

View file

@ -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);
}

View file

@ -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;
}