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 { useState, useEffect, KeyboardEvent } from "react";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import TangibleForm from "./TangibleForm"; 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 type { ConstellationState } from "../../types/timeline";
import { migrateTangibleConfig } from "../../utils/tangibleMigration";
interface Props { interface Props {
tangible: TangibleConfig; tangible: TangibleConfig;
labels: LabelConfig[]; labels: LabelConfig[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
states: ConstellationState[]; states: ConstellationState[];
onSave: ( onSave: (
id: string, id: string,
@ -15,7 +18,7 @@ interface Props {
mode: TangibleMode; mode: TangibleMode;
description?: string; description?: string;
hardwareId?: string; hardwareId?: string;
filterLabels?: string[]; filters?: FilterConfig;
stateId?: string; stateId?: string;
}, },
) => void; ) => void;
@ -25,6 +28,8 @@ interface Props {
const EditTangibleInline = ({ const EditTangibleInline = ({
tangible, tangible,
labels, labels,
nodeTypes,
edgeTypes,
states, states,
onSave, onSave,
onCancel, onCancel,
@ -33,18 +38,38 @@ const EditTangibleInline = ({
const [mode, setMode] = useState<TangibleMode>("filter"); const [mode, setMode] = useState<TangibleMode>("filter");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [hardwareId, setHardwareId] = useState(""); const [hardwareId, setHardwareId] = useState("");
const [filterLabels, setFilterLabels] = useState<string[]>([]); const [filters, setFilters] = useState<FilterConfig>({
labels: [],
actorTypes: [],
relationTypes: [],
combineMode: 'OR'
});
const [stateId, setStateId] = useState(""); const [stateId, setStateId] = useState("");
// Sync state with tangible prop // Sync state with tangible prop
useEffect(() => { useEffect(() => {
if (tangible) { if (tangible) {
setName(tangible.name); // Apply migration for backward compatibility
setMode(tangible.mode); const migratedTangible = migrateTangibleConfig(tangible);
setDescription(tangible.description || "");
setHardwareId(tangible.hardwareId || ""); setName(migratedTangible.name);
setFilterLabels(tangible.filterLabels || []); setMode(migratedTangible.mode);
setStateId(tangible.stateId || ""); 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]); }, [tangible]);
@ -52,10 +77,17 @@ const EditTangibleInline = ({
if (!name.trim()) return; if (!name.trim()) return;
// Validate mode-specific fields // Validate mode-specific fields
if (mode === "filter" && filterLabels.length === 0) { if (mode === "filter") {
alert("Filter mode requires at least one label"); 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; return;
} }
}
if ((mode === "state" || mode === "stateDial") && !stateId) { if ((mode === "state" || mode === "stateDial") && !stateId) {
alert("State mode requires a state selection"); alert("State mode requires a state selection");
return; return;
@ -66,7 +98,7 @@ const EditTangibleInline = ({
mode, mode,
description: description.trim() || undefined, description: description.trim() || undefined,
hardwareId: hardwareId.trim() || undefined, hardwareId: hardwareId.trim() || undefined,
filterLabels: mode === "filter" ? filterLabels : undefined, filters: mode === "filter" ? filters : undefined,
stateId: mode === "state" || mode === "stateDial" ? stateId : undefined, stateId: mode === "state" || mode === "stateDial" ? stateId : undefined,
}); });
}; };
@ -90,15 +122,17 @@ const EditTangibleInline = ({
mode={mode} mode={mode}
description={description} description={description}
hardwareId={hardwareId} hardwareId={hardwareId}
filterLabels={filterLabels} filters={filters}
stateId={stateId} stateId={stateId}
labels={labels} labels={labels}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={states} states={states}
onNameChange={setName} onNameChange={setName}
onModeChange={setMode} onModeChange={setMode}
onDescriptionChange={setDescription} onDescriptionChange={setDescription}
onHardwareIdChange={setHardwareId} onHardwareIdChange={setHardwareId}
onFilterLabelsChange={setFilterLabels} onFiltersChange={setFilters}
onStateIdChange={setStateId} onStateIdChange={setStateId}
/> />
</div> </div>

View file

@ -1,27 +1,34 @@
import { useState, useRef, KeyboardEvent } from "react"; import { useState, useRef, KeyboardEvent } from "react";
import TangibleForm from "./TangibleForm"; 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"; import type { ConstellationState } from "../../types/timeline";
interface Props { interface Props {
labels: LabelConfig[]; labels: LabelConfig[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
states: ConstellationState[]; states: ConstellationState[];
onAdd: (tangible: { onAdd: (tangible: {
name: string; name: string;
mode: TangibleMode; mode: TangibleMode;
description: string; description: string;
hardwareId?: string; hardwareId?: string;
filterLabels?: string[]; filters?: FilterConfig;
stateId?: string; stateId?: string;
}) => void; }) => void;
} }
const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { const QuickAddTangibleForm = ({ labels, nodeTypes, edgeTypes, states, onAdd }: Props) => {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [hardwareId, setHardwareId] = useState(""); const [hardwareId, setHardwareId] = useState("");
const [mode, setMode] = useState<TangibleMode>("filter"); const [mode, setMode] = useState<TangibleMode>("filter");
const [description, setDescription] = useState(""); 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 [stateId, setStateId] = useState("");
const nameInputRef = useRef<HTMLInputElement>(null); const nameInputRef = useRef<HTMLInputElement>(null);
@ -33,10 +40,17 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
} }
// Validate mode-specific fields // Validate mode-specific fields
if (mode === "filter" && filterLabels.length === 0) { if (mode === "filter") {
alert("Filter mode requires at least one label"); 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; return;
} }
}
if ((mode === "state" || mode === "stateDial") && !stateId) { if ((mode === "state" || mode === "stateDial") && !stateId) {
alert("State mode requires a state selection"); alert("State mode requires a state selection");
return; return;
@ -47,7 +61,7 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
mode, mode,
description, description,
hardwareId: hardwareId.trim() || undefined, hardwareId: hardwareId.trim() || undefined,
filterLabels: mode === "filter" ? filterLabels : undefined, filters: mode === "filter" ? filters : undefined,
stateId: mode === "state" || mode === "stateDial" ? stateId : undefined, stateId: mode === "state" || mode === "stateDial" ? stateId : undefined,
}); });
@ -56,7 +70,12 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
setHardwareId(""); setHardwareId("");
setMode("filter"); setMode("filter");
setDescription(""); setDescription("");
setFilterLabels([]); setFilters({
labels: [],
actorTypes: [],
relationTypes: [],
combineMode: 'OR'
});
setStateId(""); setStateId("");
nameInputRef.current?.focus(); nameInputRef.current?.focus();
@ -72,7 +91,12 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
setHardwareId(""); setHardwareId("");
setMode("filter"); setMode("filter");
setDescription(""); setDescription("");
setFilterLabels([]); setFilters({
labels: [],
actorTypes: [],
relationTypes: [],
combineMode: 'OR'
});
setStateId(""); setStateId("");
nameInputRef.current?.blur(); nameInputRef.current?.blur();
} }
@ -85,15 +109,17 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
hardwareId={hardwareId} hardwareId={hardwareId}
mode={mode} mode={mode}
description={description} description={description}
filterLabels={filterLabels} filters={filters}
stateId={stateId} stateId={stateId}
labels={labels} labels={labels}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={states} states={states}
onNameChange={setName} onNameChange={setName}
onHardwareIdChange={setHardwareId} onHardwareIdChange={setHardwareId}
onModeChange={setMode} onModeChange={setMode}
onDescriptionChange={setDescription} onDescriptionChange={setDescription}
onFilterLabelsChange={setFilterLabels} onFiltersChange={setFilters}
onStateIdChange={setStateId} onStateIdChange={setStateId}
/> />

View file

@ -15,7 +15,7 @@ interface Props {
} }
const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: 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 { confirm, ConfirmDialogComponent } = useConfirm();
const { showToast } = useToastStore(); const { showToast } = useToastStore();
@ -48,14 +48,22 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
mode: TangibleMode; mode: TangibleMode;
description: string; description: string;
hardwareId?: string; hardwareId?: string;
filterLabels?: string[]; filters?: import('../../types').FilterConfig;
stateId?: string; stateId?: string;
}) => { }) => {
// Validate mode-specific fields // Validate mode-specific fields
if (tangible.mode === 'filter' && (!tangible.filterLabels || tangible.filterLabels.length === 0)) { if (tangible.mode === 'filter') {
showToast('Filter mode requires at least one label', 'error'); 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; return;
} }
}
if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) { if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) {
showToast('State mode requires a state selection', 'error'); showToast('State mode requires a state selection', 'error');
return; return;
@ -66,7 +74,7 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
mode: tangible.mode, mode: tangible.mode,
description: tangible.description || undefined, description: tangible.description || undefined,
hardwareId: tangible.hardwareId, hardwareId: tangible.hardwareId,
filterLabels: tangible.filterLabels, filters: tangible.filters,
stateId: tangible.stateId, stateId: tangible.stateId,
}; };
@ -96,7 +104,7 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
const handleSaveEdit = ( const handleSaveEdit = (
id: string, 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); updateTangible(id, updates);
setEditingTangible(null); setEditingTangible(null);
@ -131,6 +139,8 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
<EditTangibleInline <EditTangibleInline
tangible={editingTangible} tangible={editingTangible}
labels={labels} labels={labels}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={availableStates} states={availableStates}
onSave={handleSaveEdit} onSave={handleSaveEdit}
onCancel={handleCancelEdit} onCancel={handleCancelEdit}
@ -147,6 +157,8 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
</h3> </h3>
<QuickAddTangibleForm <QuickAddTangibleForm
labels={labels} labels={labels}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={availableStates} states={availableStates}
onAdd={handleAddTangible} 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"; import type { ConstellationState } from "../../types/timeline";
interface Props { interface Props {
@ -6,15 +6,25 @@ interface Props {
mode: TangibleMode; mode: TangibleMode;
description: string; description: string;
hardwareId: string; hardwareId: string;
filterLabels: string[]; /**
* @deprecated Use filters instead. Kept for backward compatibility.
*/
filterLabels?: string[];
filters: FilterConfig;
stateId: string; stateId: string;
labels: LabelConfig[]; labels: LabelConfig[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
states: ConstellationState[]; states: ConstellationState[];
onNameChange: (value: string) => void; onNameChange: (value: string) => void;
onModeChange: (value: TangibleMode) => void; onModeChange: (value: TangibleMode) => void;
onDescriptionChange: (value: string) => void; onDescriptionChange: (value: string) => void;
onHardwareIdChange: (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; onStateIdChange: (value: string) => void;
} }
@ -23,19 +33,22 @@ const TangibleForm = ({
mode, mode,
description, description,
hardwareId, hardwareId,
filterLabels, filters,
stateId, stateId,
labels, labels,
nodeTypes,
edgeTypes,
states, states,
onNameChange, onNameChange,
onModeChange, onModeChange,
onDescriptionChange, onDescriptionChange,
onHardwareIdChange, onHardwareIdChange,
onFilterLabelsChange, onFiltersChange,
onStateIdChange, onStateIdChange,
}: Props) => { }: Props) => {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1"> <label className="block text-xs font-medium text-gray-700 mb-1">
Name * Name *
@ -44,7 +57,7 @@ const TangibleForm = ({
type="text" type="text"
value={name} value={name}
onChange={(e) => onNameChange(e.target.value)} 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" className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
@ -57,11 +70,15 @@ const TangibleForm = ({
type="text" type="text"
value={hardwareId} value={hardwareId}
onChange={(e) => onHardwareIdChange(e.target.value)} 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" 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"> </div>
Maps this configuration to a physical token or device </div>
<div>
<p className="text-xs text-gray-500">
Hardware ID maps this configuration to a physical token or device
</p> </p>
</div> </div>
@ -69,56 +86,88 @@ const TangibleForm = ({
<label className="block text-xs font-medium text-gray-700 mb-1"> <label className="block text-xs font-medium text-gray-700 mb-1">
Mode * Mode *
</label> </label>
<div className="space-y-2"> <div className="flex gap-2">
<label className="flex items-center cursor-pointer"> <button
<input type="button"
type="radio" onClick={() => onModeChange("filter")}
name="mode" className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
value="filter" mode === "filter"
checked={mode === "filter"} ? "bg-blue-500 text-white border-blue-600"
onChange={(e) => onModeChange(e.target.value as TangibleMode)} : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
className="mr-2" }`}
/> >
<span className="text-sm text-gray-700"> <div className="font-medium">Filter</div>
Filter mode (activate label filters) <div className="text-xs opacity-80">Apply filters</div>
</span> </button>
</label> <button
<label className="flex items-center cursor-pointer"> type="button"
<input onClick={() => onModeChange("state")}
type="radio" className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
name="mode" mode === "state"
value="state" ? "bg-blue-500 text-white border-blue-600"
checked={mode === "state"} : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
onChange={(e) => onModeChange(e.target.value as TangibleMode)} }`}
className="mr-2" >
/> <div className="font-medium">State</div>
<span className="text-sm text-gray-700"> <div className="text-xs opacity-80">Timeline state</div>
State mode (switch to timeline state) </button>
</span> <button
</label> type="button"
<label className="flex items-center cursor-pointer"> onClick={() => onModeChange("stateDial")}
<input className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
type="radio" mode === "stateDial"
name="mode" ? "bg-blue-500 text-white border-blue-600"
value="stateDial" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
checked={mode === "stateDial"} }`}
onChange={(e) => onModeChange(e.target.value as TangibleMode)} >
className="mr-2" <div className="font-medium">State Dial</div>
/> <div className="text-xs opacity-80">Deferred</div>
<span className="text-sm text-gray-700"> </button>
State dial mode (clock-like, deferred)
</span>
</label>
</div> </div>
</div> </div>
{/* Mode-specific fields */} {/* Mode-specific fields */}
{mode === "filter" && ( {mode === "filter" && (
<div className="space-y-3">
{/* Filter Combine Mode */}
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1"> <label className="block text-xs font-medium text-gray-700 mb-1">
Filter Labels * (select one or more) Combine Mode
</label> </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 ? ( {labels.length === 0 ? (
<p className="text-xs text-gray-500 italic"> <p className="text-xs text-gray-500 italic">
No labels available No labels available
@ -131,14 +180,19 @@ const TangibleForm = ({
> >
<input <input
type="checkbox" type="checkbox"
checked={filterLabels.includes(label.id)} checked={filters.labels?.includes(label.id) || false}
onChange={(e) => { onChange={(e) => {
const currentLabels = filters.labels || [];
if (e.target.checked) { if (e.target.checked) {
onFilterLabelsChange([...filterLabels, label.id]); onFiltersChange({
...filters,
labels: [...currentLabels, label.id],
});
} else { } else {
onFilterLabelsChange( onFiltersChange({
filterLabels.filter((id) => id !== label.id), ...filters,
); labels: currentLabels.filter((id) => id !== label.id),
});
} }
}} }}
className="mr-2" className="mr-2"
@ -153,6 +207,99 @@ const TangibleForm = ({
)} )}
</div> </div>
</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") && ( {(mode === "state" || mode === "stateDial") && (

View file

@ -7,11 +7,11 @@ import {
useNodes, useNodes,
} from '@xyflow/react'; } from '@xyflow/react';
import { useGraphStore } from '../../stores/graphStore'; import { useGraphStore } from '../../stores/graphStore';
import { useSearchStore } from '../../stores/searchStore';
import type { Relation } from '../../types'; import type { Relation } from '../../types';
import type { Group } from '../../types'; import type { Group } from '../../types';
import LabelBadge from '../Common/LabelBadge'; import LabelBadge from '../Common/LabelBadge';
import { getFloatingEdgeParams } from '../../utils/edgeUtils'; import { getFloatingEdgeParams } from '../../utils/edgeUtils';
import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters';
/** /**
* CustomEdge - Represents a relation between actors in the constellation graph * CustomEdge - Represents a relation between actors in the constellation graph
@ -41,7 +41,9 @@ const CustomEdge = ({
}: EdgeProps<Relation>) => { }: EdgeProps<Relation>) => {
const edgeTypes = useGraphStore((state) => state.edgeTypes); const edgeTypes = useGraphStore((state) => state.edgeTypes);
const labels = useGraphStore((state) => state.labels); 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 // Get all nodes to check if source/target are minimized groups
const nodes = useNodes(); const nodes = useNodes();
@ -124,42 +126,20 @@ const CustomEdge = ({
// Check if this edge matches the filter criteria // Check if this edge matches the filter criteria
const isMatch = useMemo(() => { const isMatch = useMemo(() => {
// Check relation type filter (POSITIVE: if types selected, edge must be one of them) return edgeMatchesFilters(
const edgeType = data?.type || ''; data?.type || '',
if (selectedRelationTypes.length > 0) { data?.labels || [],
if (!selectedRelationTypes.includes(edgeType)) { data?.label || '',
return false; edgeTypeConfig?.label || '',
} filters
}
// 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) { }, [data?.type, data?.labels, data?.label, edgeTypeConfig?.label, filters]);
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]);
// Determine if filters are active // Determine if filters are active
const hasActiveFilters = const hasActiveFilters =
searchText.trim() !== '' || filters.searchText.trim() !== '' ||
selectedRelationTypes.length > 0 || filters.selectedRelationTypes.length > 0 ||
selectedLabels.length > 0; filters.selectedLabels.length > 0;
// Calculate opacity based on visibility // Calculate opacity based on visibility
const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;

View file

@ -25,11 +25,11 @@ import "@xyflow/react/dist/style.css";
import { useGraphWithHistory } from "../../hooks/useGraphWithHistory"; import { useGraphWithHistory } from "../../hooks/useGraphWithHistory";
import { useDocumentHistory } from "../../hooks/useDocumentHistory"; import { useDocumentHistory } from "../../hooks/useDocumentHistory";
import { useEditorStore } from "../../stores/editorStore"; import { useEditorStore } from "../../stores/editorStore";
import { useSearchStore } from "../../stores/searchStore";
import { useSettingsStore } from "../../stores/settingsStore"; import { useSettingsStore } from "../../stores/settingsStore";
import { useActiveDocument } from "../../stores/workspace/useActiveDocument"; import { useActiveDocument } from "../../stores/workspace/useActiveDocument";
import { useWorkspaceStore } from "../../stores/workspaceStore"; import { useWorkspaceStore } from "../../stores/workspaceStore";
import { useCreateDocument } from "../../hooks/useCreateDocument"; import { useCreateDocument } from "../../hooks/useCreateDocument";
import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters";
import CustomNode from "../Nodes/CustomNode"; import CustomNode from "../Nodes/CustomNode";
import GroupNode from "../Nodes/GroupNode"; import GroupNode from "../Nodes/GroupNode";
import CustomEdge from "../Edges/CustomEdge"; import CustomEdge from "../Edges/CustomEdge";
@ -124,13 +124,8 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
fitView, fitView,
} = useReactFlow(); } = useReactFlow();
// Search and filter state for auto-zoom // Get active filters (respects presentation vs editing mode)
const { const filters = useActiveFilters();
searchText,
selectedActorTypes,
selectedRelationTypes,
selectedLabels,
} = useSearchStore();
// Settings for auto-zoom // Settings for auto-zoom
const { autoZoomEnabled } = useSettingsStore(); const { autoZoomEnabled } = useSettingsStore();
@ -443,59 +438,30 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
if (nodes.length === 0) return; if (nodes.length === 0) return;
// Check if any filters are active // Check if any filters are active
const hasSearchText = searchText.trim() !== ''; const hasSearchText = filters.searchText.trim() !== '';
const hasTypeFilters = selectedActorTypes.length > 0 || selectedRelationTypes.length > 0; const hasTypeFilters = filters.selectedActorTypes.length > 0;
const hasLabelFilters = selectedLabels.length > 0; const hasLabelFilters = filters.selectedLabels.length > 0;
// Skip if no filters are active // Skip if no filters are active
if (!hasSearchText && !hasTypeFilters && !hasLabelFilters) return; if (!hasSearchText && !hasTypeFilters && !hasLabelFilters) return;
// Debounce to avoid excessive viewport changes while typing // Debounce to avoid excessive viewport changes while typing
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
const searchLower = searchText.toLowerCase().trim(); // Calculate matching nodes using the centralized filter logic
// Calculate matching nodes (same logic as LeftPanel and CustomNode)
const matchingNodeIds = nodes const matchingNodeIds = nodes
.filter((node) => { .filter((node) => {
const actor = node as Actor; const actor = node as Actor;
const actorType = actor.data?.type || ''; 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 nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === actorType);
const typeName = nodeTypeConfig?.label?.toLowerCase() || '';
const matches = return nodeMatchesFilters(
label.includes(searchLower) || actorType,
description.includes(searchLower) || actor.data?.labels || [],
typeName.includes(searchLower); actor.data?.label || '',
actor.data?.description || '',
if (!matches) { nodeTypeConfig?.label || '',
return false; filters
} );
}
return true;
}) })
.map((node) => node.id); .map((node) => node.id);
@ -513,10 +479,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [ }, [
searchText, filters,
selectedActorTypes,
selectedRelationTypes,
selectedLabels,
autoZoomEnabled, autoZoomEnabled,
nodes, nodes,
nodeTypeConfigs, nodeTypeConfigs,

View file

@ -1,7 +1,6 @@
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { Handle, Position, NodeProps, useConnection } from "@xyflow/react"; import { Handle, Position, NodeProps, useConnection } from "@xyflow/react";
import { useGraphStore } from "../../stores/graphStore"; import { useGraphStore } from "../../stores/graphStore";
import { useSearchStore } from "../../stores/searchStore";
import { import {
getContrastColor, getContrastColor,
adjustColorBrightness, adjustColorBrightness,
@ -10,6 +9,7 @@ import { getIconComponent } from "../../utils/iconUtils";
import type { Actor } from "../../types"; import type { Actor } from "../../types";
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
import LabelBadge from "../Common/LabelBadge"; import LabelBadge from "../Common/LabelBadge";
import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters";
/** /**
* CustomNode - Represents an actor in the constellation graph * CustomNode - Represents an actor in the constellation graph
@ -25,7 +25,9 @@ import LabelBadge from "../Common/LabelBadge";
const CustomNode = ({ data, selected }: NodeProps<Actor>) => { const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
const nodeTypes = useGraphStore((state) => state.nodeTypes); const nodeTypes = useGraphStore((state) => state.nodeTypes);
const labels = useGraphStore((state) => state.labels); 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) // Check if any connection is being made (to show handles)
const connection = useConnection(); const connection = useConnection();
@ -47,57 +49,30 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
// Show handles when selected or when connecting // Show handles when selected or when connecting
const showHandles = selected || isConnecting; 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(() => { const isMatch = useMemo(() => {
// Check actor type filter (POSITIVE: if types selected, node must be one of them) return nodeMatchesFilters(
if (selectedActorTypes.length > 0) { data.type,
if (!selectedActorTypes.includes(data.type)) { data.labels || [],
return false; data.label || "",
} data.description || "",
} nodeLabel,
filters
// 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,
data.type, data.type,
data.label,
data.labels, data.labels,
data.label,
data.description, data.description,
nodeLabel, nodeLabel,
filters,
]); ]);
// Determine if filters are active // Determine if filters are active
const hasActiveFilters = const hasActiveFilters =
searchText.trim() !== "" || filters.searchText.trim() !== "" ||
selectedActorTypes.length > 0 || filters.selectedActorTypes.length > 0 ||
selectedLabels.length > 0; filters.selectedLabels.length > 0;
// Calculate opacity based on match status // Calculate opacity based on match status
const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;

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 { useTuioStore } from '../stores/tuioStore';
import { useSettingsStore } from '../stores/settingsStore'; import { useSettingsStore } from '../stores/settingsStore';
import { useGraphStore } from '../stores/graphStore'; import { useGraphStore } from '../stores/graphStore';
import { useSearchStore } from '../stores/searchStore';
import { useTimelineStore } from '../stores/timelineStore'; import { useTimelineStore } from '../stores/timelineStore';
import { TuioClientManager } from '../lib/tuio/tuioClient'; import { TuioClientManager } from '../lib/tuio/tuioClient';
import type { TuioTangibleInfo } from '../lib/tuio/types'; import type { TuioTangibleInfo } from '../lib/tuio/types';
import type { TangibleConfig } from '../types'; import type { TangibleConfig } from '../types';
import { migrateTangibleConfig } from '../utils/tangibleMigration';
/** /**
* TUIO Integration Hook * TUIO Integration Hook
@ -29,7 +29,6 @@ export function useTuioIntegration() {
if (!presentationMode) { if (!presentationMode) {
// Disconnect if we're leaving presentation mode // Disconnect if we're leaving presentation mode
if (clientRef.current) { if (clientRef.current) {
console.log('[TUIO Integration] Presentation mode disabled, disconnecting');
clientRef.current.disconnect(); clientRef.current.disconnect();
clientRef.current = null; clientRef.current = null;
useTuioStore.getState().clearActiveTangibles(); useTuioStore.getState().clearActiveTangibles();
@ -37,7 +36,6 @@ export function useTuioIntegration() {
return; return;
} }
console.log('[TUIO Integration] Presentation mode enabled, connecting to TUIO server');
// Create TUIO client if in presentation mode // Create TUIO client if in presentation mode
const client = new TuioClientManager( const client = new TuioClientManager(
@ -46,7 +44,6 @@ export function useTuioIntegration() {
onTangibleUpdate: handleTangibleUpdate, onTangibleUpdate: handleTangibleUpdate,
onTangibleRemove: handleTangibleRemove, onTangibleRemove: handleTangibleRemove,
onConnectionChange: (connected, error) => { onConnectionChange: (connected, error) => {
console.log('[TUIO Integration] Connection state changed:', connected, error);
useTuioStore.getState().setConnectionState(connected, error); useTuioStore.getState().setConnectionState(connected, error);
}, },
}, },
@ -58,14 +55,13 @@ export function useTuioIntegration() {
// Connect to TUIO server // Connect to TUIO server
client client
.connect(websocketUrl) .connect(websocketUrl)
.catch((error) => { .catch(() => {
console.error('[TUIO Integration] Failed to connect to TUIO server:', error); // Connection errors are handled by onConnectionChange callback
}); });
// Cleanup on unmount or when presentation mode changes // Cleanup on unmount or when presentation mode changes
return () => { return () => {
if (clientRef.current) { if (clientRef.current) {
console.log('[TUIO Integration] Cleaning up, disconnecting');
clientRef.current.disconnect(); clientRef.current.disconnect();
clientRef.current = null; clientRef.current = null;
useTuioStore.getState().clearActiveTangibles(); useTuioStore.getState().clearActiveTangibles();
@ -78,7 +74,6 @@ export function useTuioIntegration() {
* Handle tangible add event * Handle tangible add event
*/ */
function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void { function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void {
console.log('[TUIO Integration] Tangible added:', hardwareId, info);
// Update TUIO store // Update TUIO store
useTuioStore.getState().addActiveTangible(hardwareId, info); useTuioStore.getState().addActiveTangible(hardwareId, info);
@ -89,15 +84,13 @@ function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void {
if (!tangibleConfig) { if (!tangibleConfig) {
// Unknown hardware ID - silently ignore // Unknown hardware ID - silently ignore
console.log('[TUIO Integration] No configuration found for hardware ID:', hardwareId);
return; return;
} }
console.log('[TUIO Integration] Tangible configuration found:', tangibleConfig.name, 'mode:', tangibleConfig.mode);
// Trigger action based on tangible mode // Trigger action based on tangible mode
if (tangibleConfig.mode === 'filter') { if (tangibleConfig.mode === 'filter') {
applyFilterTangible(tangibleConfig); applyFilterTangible();
} else if (tangibleConfig.mode === 'state') { } else if (tangibleConfig.mode === 'state') {
applyStateTangible(tangibleConfig, hardwareId); 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) * Currently just updates position/angle in store (for future stateDial support)
*/ */
function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void { function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void {
console.log('[TUIO Integration] Tangible updated:', hardwareId, info);
useTuioStore.getState().updateActiveTangible(hardwareId, info); useTuioStore.getState().updateActiveTangible(hardwareId, info);
} }
@ -117,7 +109,6 @@ function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void
* Handle tangible remove event * Handle tangible remove event
*/ */
function handleTangibleRemove(hardwareId: string): void { function handleTangibleRemove(hardwareId: string): void {
console.log('[TUIO Integration] Tangible removed:', hardwareId);
// Remove from TUIO store // Remove from TUIO store
useTuioStore.getState().removeActiveTangible(hardwareId); useTuioStore.getState().removeActiveTangible(hardwareId);
@ -127,71 +118,80 @@ function handleTangibleRemove(hardwareId: string): void {
const tangibleConfig = tangibles.find((t) => t.hardwareId === hardwareId); const tangibleConfig = tangibles.find((t) => t.hardwareId === hardwareId);
if (!tangibleConfig) { if (!tangibleConfig) {
console.log('[TUIO Integration] No configuration found for removed tangible:', hardwareId);
return; return;
} }
console.log('[TUIO Integration] Handling removal for configured tangible:', tangibleConfig.name);
// Handle removal based on tangible mode // Handle removal based on tangible mode
if (tangibleConfig.mode === 'filter') { if (tangibleConfig.mode === 'filter') {
removeFilterTangible(tangibleConfig); removeFilterTangible();
} else if (tangibleConfig.mode === 'state' || tangibleConfig.mode === 'stateDial') { } else if (tangibleConfig.mode === 'state' || tangibleConfig.mode === 'stateDial') {
removeStateTangible(hardwareId); 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 { function updatePresentationFilters(): 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
const activeTangibles = useTuioStore.getState().activeTangibles; const activeTangibles = useTuioStore.getState().activeTangibles;
const allTangibles = useGraphStore.getState().tangibles; const allTangibles = useGraphStore.getState().tangibles;
// Build set of labels still in use by other active filter tangibles // Collect all filters from active filter tangibles
const labelsStillActive = new Set<string>(); 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) => { activeTangibles.forEach((_, hwId) => {
const config = allTangibles.find( const config = allTangibles.find(
(t) => t.hardwareId === hwId && t.mode === 'filter' (t) => t.hardwareId === hwId && t.mode === 'filter'
); );
if (config && config.filterLabels) { if (config) {
config.filterLabels.forEach((labelId) => labelsStillActive.add(labelId)); // 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 // Update presentation filters in tuioStore
const { selectedLabels, toggleSelectedLabel } = useSearchStore.getState(); useTuioStore.getState().setPresentationFilters({
labels: Array.from(allLabels),
tangible.filterLabels.forEach((labelId) => { actorTypes: Array.from(allActorTypes),
if (selectedLabels.includes(labelId) && !labelsStillActive.has(labelId)) { relationTypes: Array.from(allRelationTypes),
toggleSelectedLabel(labelId); 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 * Apply state tangible - switch to its configured state
*/ */
@ -200,7 +200,6 @@ function applyStateTangible(tangible: TangibleConfig, hardwareId: string): void
return; return;
} }
console.log('[TUIO Integration] Applying state tangible:', hardwareId, 'stateId:', tangible.stateId);
// Add to active state tangibles list (at the end) // Add to active state tangibles list (at the end)
useTuioStore.getState().addActiveStateTangible(hardwareId); 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 // Pass fromTangible=true to prevent clearing the active state tangibles list
useTimelineStore.getState().switchToState(tangible.stateId, true); 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 * Remove state tangible - switch to next active state tangible if any
*/ */
function removeStateTangible(hardwareId: string): void { function removeStateTangible(hardwareId: string): void {
console.log('[TUIO Integration] Removing state tangible:', hardwareId);
// Remove from active state tangibles list // Remove from active state tangibles list
useTuioStore.getState().removeActiveStateTangible(hardwareId); useTuioStore.getState().removeActiveStateTangible(hardwareId);
const activeStateTangibles = useTuioStore.getState().activeStateTangibles; 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 there are other state tangibles still active, switch to the last one
if (activeStateTangibles.length > 0) { if (activeStateTangibles.length > 0) {
const lastActiveHwId = activeStateTangibles[activeStateTangibles.length - 1]; 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 // Find the tangible config for this hardware ID
const tangibles = useGraphStore.getState().tangibles; 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 // Pass fromTangible=true to prevent clearing the active state tangibles list
useTimelineStore.getState().switchToState(tangibleConfig.stateId, true); 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) { constructor(host: string, port: number) {
super(); super();
console.log(`[TUIO] Creating WebSocket receiver for ${host}:${port}`);
// Create OSC WebSocket client // Create OSC WebSocket client
this.osc = new OSC({ this.osc = new OSC({
plugin: new OSC.WebsocketClientPlugin({ plugin: new OSC.WebsocketClientPlugin({
@ -34,20 +32,17 @@ export class WebsocketTuioReceiver extends TuioReceiver {
// Forward all OSC messages to TUIO client // Forward all OSC messages to TUIO client
this.osc.on('*', (message: OscMessage) => { this.osc.on('*', (message: OscMessage) => {
console.log('[TUIO] OSC message received:', message.address, message.args);
this.onOscMessage(message); this.onOscMessage(message);
}); });
// Listen for WebSocket connection events // Listen for WebSocket connection events
this.osc.on('open', () => { this.osc.on('open', () => {
console.log('[TUIO] WebSocket connection opened');
if (this.onOpenCallback) { if (this.onOpenCallback) {
this.onOpenCallback(); this.onOpenCallback();
} }
}); });
this.osc.on('close', () => { this.osc.on('close', () => {
console.log('[TUIO] WebSocket connection closed');
if (this.onCloseCallback) { if (this.onCloseCallback) {
this.onCloseCallback(); this.onCloseCallback();
} }
@ -55,7 +50,6 @@ export class WebsocketTuioReceiver extends TuioReceiver {
this.osc.on('error', (error: unknown) => { this.osc.on('error', (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : 'WebSocket error'; const errorMessage = error instanceof Error ? error.message : 'WebSocket error';
console.error('[TUIO] WebSocket error:', errorMessage);
if (this.onErrorCallback) { if (this.onErrorCallback) {
this.onErrorCallback(errorMessage); this.onErrorCallback(errorMessage);
} }
@ -87,7 +81,6 @@ export class WebsocketTuioReceiver extends TuioReceiver {
* Open WebSocket connection to TUIO server * Open WebSocket connection to TUIO server
*/ */
connect(): void { connect(): void {
console.log('[TUIO] Opening WebSocket connection...');
this.osc.open(); this.osc.open();
} }
@ -95,7 +88,6 @@ export class WebsocketTuioReceiver extends TuioReceiver {
* Close WebSocket connection * Close WebSocket connection
*/ */
disconnect(): void { disconnect(): void {
console.log('[TUIO] Closing WebSocket connection...');
this.osc.close(); this.osc.close();
} }
} }

View file

@ -35,7 +35,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
async connect(url: string): Promise<void> { async connect(url: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
console.log(`[TUIO] Connecting to ${url} with protocol version ${this.protocolVersion}`);
// Parse WebSocket URL // Parse WebSocket URL
const wsUrl = new URL(url); const wsUrl = new URL(url);
@ -49,11 +48,9 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
// Create appropriate client based on protocol version // Create appropriate client based on protocol version
if (this.protocolVersion === '1.1') { if (this.protocolVersion === '1.1') {
console.log('[TUIO] Creating TUIO 1.1 client');
this.client11 = new Tuio11Client(this.receiver); this.client11 = new Tuio11Client(this.receiver);
this.client20 = null; this.client20 = null;
} else { } else {
console.log('[TUIO] Creating TUIO 2.0 client');
this.client20 = new Tuio20Client(this.receiver); this.client20 = new Tuio20Client(this.receiver);
this.client11 = null; this.client11 = null;
} }
@ -61,14 +58,12 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
// Set up connection event handlers // Set up connection event handlers
this.receiver.setOnOpen(() => { this.receiver.setOnOpen(() => {
// Connection successful // Connection successful
console.log('[TUIO] Connection successful');
this.callbacks.onConnectionChange(true); this.callbacks.onConnectionChange(true);
resolve(); resolve();
}); });
this.receiver.setOnError((error: string) => { this.receiver.setOnError((error: string) => {
// Connection error // Connection error
console.error('TUIO connection error:', error);
this.callbacks.onConnectionChange(false, error); this.callbacks.onConnectionChange(false, error);
reject(new Error(error)); reject(new Error(error));
}); });
@ -76,7 +71,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
this.receiver.setOnClose((error?: string) => { this.receiver.setOnClose((error?: string) => {
// Connection closed // Connection closed
if (error) { if (error) {
console.error('TUIO connection closed with error:', error);
this.callbacks.onConnectionChange(false, error); this.callbacks.onConnectionChange(false, error);
} else { } else {
this.callbacks.onConnectionChange(false); this.callbacks.onConnectionChange(false);
@ -85,19 +79,14 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
// Add this manager as a listener // Add this manager as a listener
if (this.client11) { if (this.client11) {
console.log('[TUIO] Adding listener to TUIO 1.1 client');
this.client11.addTuioListener(this); this.client11.addTuioListener(this);
console.log('[TUIO] Connecting TUIO 1.1 client');
this.client11.connect(); this.client11.connect();
} else if (this.client20) { } else if (this.client20) {
console.log('[TUIO] Adding listener to TUIO 2.0 client');
this.client20.addTuioListener(this); this.client20.addTuioListener(this);
console.log('[TUIO] Connecting TUIO 2.0 client');
this.client20.connect(); this.client20.connect();
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('TUIO connection error:', errorMessage);
this.callbacks.onConnectionChange(false, errorMessage); this.callbacks.onConnectionChange(false, errorMessage);
reject(error); 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) * Called when a TUIO 1.1 object is added (tangible placed on surface)
*/ */
addTuioObject(tuioObject: Tuio11Object): void { addTuioObject(tuioObject: Tuio11Object): void {
console.log('[TUIO] 1.1 Object added - raw object:', tuioObject);
// Validate symbolId exists // Validate symbolId exists
if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) { if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) {
console.warn('[TUIO] 1.1 Object has no symbolId, ignoring');
return; return;
} }
const info = this.extractTangibleInfo11(tuioObject); const info = this.extractTangibleInfo11(tuioObject);
console.log('[TUIO] 1.1 Object added - extracted info:', info);
this.callbacks.onTangibleAdd(info.hardwareId, 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) * Called when a TUIO 1.1 object is updated (position/rotation changed)
*/ */
updateTuioObject(tuioObject: Tuio11Object): void { updateTuioObject(tuioObject: Tuio11Object): void {
console.log('[TUIO] 1.1 Object updated - raw object:', tuioObject);
// Validate symbolId exists // Validate symbolId exists
if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) { if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) {
console.warn('[TUIO] 1.1 Object has no symbolId, ignoring');
return; return;
} }
const info = this.extractTangibleInfo11(tuioObject); const info = this.extractTangibleInfo11(tuioObject);
console.log('[TUIO] 1.1 Object updated - extracted info:', info);
this.callbacks.onTangibleUpdate(info.hardwareId, 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) * Called when a TUIO 1.1 object is removed (tangible removed from surface)
*/ */
removeTuioObject(tuioObject: Tuio11Object): void { removeTuioObject(tuioObject: Tuio11Object): void {
console.log('[TUIO] 1.1 Object removed - raw object:', tuioObject);
// Validate symbolId exists // Validate symbolId exists
if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) { if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) {
console.warn('[TUIO] 1.1 Object has no symbolId, ignoring');
return; return;
} }
const hardwareId = String(tuioObject.symbolId); const hardwareId = String(tuioObject.symbolId);
console.log('[TUIO] 1.1 Object removed - hardwareId:', hardwareId);
this.callbacks.onTangibleRemove(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) * Called when a TUIO 1.1 cursor is added (not used for tangibles)
*/ */
addTuioCursor(): void { addTuioCursor(): void {
console.log('[TUIO] 1.1 Cursor added (ignored)');
// Ignore cursors (touch points) // 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) * Called when a TUIO 1.1 cursor is updated (not used for tangibles)
*/ */
updateTuioCursor(): void { updateTuioCursor(): void {
console.log('[TUIO] 1.1 Cursor updated (ignored)');
// Ignore cursors // 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) * Called when a TUIO 1.1 cursor is removed (not used for tangibles)
*/ */
removeTuioCursor(): void { removeTuioCursor(): void {
console.log('[TUIO] 1.1 Cursor removed (ignored)');
// Ignore cursors // 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) * Called when a TUIO 1.1 blob is added (not used for tangibles)
*/ */
addTuioBlob(): void { addTuioBlob(): void {
console.log('[TUIO] 1.1 Blob added (ignored)');
// Ignore blobs // 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) * Called when a TUIO 1.1 blob is updated (not used for tangibles)
*/ */
updateTuioBlob(): void { updateTuioBlob(): void {
console.log('[TUIO] 1.1 Blob updated (ignored)');
// Ignore blobs // 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) * Called when a TUIO 1.1 blob is removed (not used for tangibles)
*/ */
removeTuioBlob(): void { removeTuioBlob(): void {
console.log('[TUIO] 1.1 Blob removed (ignored)');
// Ignore blobs // Ignore blobs
} }
@ -247,7 +221,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
* Called on TUIO 1.1 frame refresh (time sync) * Called on TUIO 1.1 frame refresh (time sync)
*/ */
refresh(): void { refresh(): void {
console.log('[TUIO] 1.1 Frame refresh (ignored)');
// Ignore refresh events // Ignore refresh events
} }
@ -259,12 +232,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
tuioAdd(tuioObject: Tuio20Object): void { tuioAdd(tuioObject: Tuio20Object): void {
const token = tuioObject.token; const token = tuioObject.token;
if (!token) { if (!token) {
console.log('[TUIO] 2.0 Add event ignored (not a token)');
return; // Only handle tokens (tangibles), not pointers return; // Only handle tokens (tangibles), not pointers
} }
const info = this.extractTangibleInfo(tuioObject); const info = this.extractTangibleInfo(tuioObject);
console.log('[TUIO] 2.0 Token added:', info);
this.callbacks.onTangibleAdd(info.hardwareId, info); this.callbacks.onTangibleAdd(info.hardwareId, info);
} }
@ -274,12 +245,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
tuioUpdate(tuioObject: Tuio20Object): void { tuioUpdate(tuioObject: Tuio20Object): void {
const token = tuioObject.token; const token = tuioObject.token;
if (!token) { if (!token) {
console.log('[TUIO] 2.0 Update event ignored (not a token)');
return; return;
} }
const info = this.extractTangibleInfo(tuioObject); const info = this.extractTangibleInfo(tuioObject);
console.log('[TUIO] 2.0 Token updated:', info);
this.callbacks.onTangibleUpdate(info.hardwareId, info); this.callbacks.onTangibleUpdate(info.hardwareId, info);
} }
@ -289,12 +258,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
tuioRemove(tuioObject: Tuio20Object): void { tuioRemove(tuioObject: Tuio20Object): void {
const token = tuioObject.token; const token = tuioObject.token;
if (!token) { if (!token) {
console.log('[TUIO] 2.0 Remove event ignored (not a token)');
return; return;
} }
const hardwareId = String(token.cId); const hardwareId = String(token.cId);
console.log('[TUIO] 2.0 Token removed:', hardwareId);
this.callbacks.onTangibleRemove(hardwareId); this.callbacks.onTangibleRemove(hardwareId);
} }

View file

@ -13,6 +13,7 @@ import type {
GraphActions GraphActions
} from '../types'; } from '../types';
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants'; import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
/** /**
* IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS * IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS
@ -165,9 +166,26 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
})), })),
deleteNodeType: (id: string) => 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), nodeTypes: state.nodeTypes.filter((type) => type.id !== id),
})), tangibles: updatedTangibles,
};
}),
// Edge type operations // Edge type operations
addEdgeType: (edgeType: EdgeTypeConfig) => addEdgeType: (edgeType: EdgeTypeConfig) =>
@ -183,9 +201,26 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
})), })),
deleteEdgeType: (id: string) => 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), edgeTypes: state.edgeTypes.filter((type) => type.id !== id),
})), tangibles: updatedTangibles,
};
}),
// Label operations // Label operations
addLabel: (label: LabelConfig) => addLabel: (label: LabelConfig) =>
@ -221,14 +256,26 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
: edge.data, : 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) => { const updatedTangibles = state.tangibles.map((tangible) => {
if (tangible.mode === 'filter' && tangible.filterLabels) { if (tangible.mode === 'filter') {
return { const updates: Partial<typeof tangible> = {};
...tangible,
filterLabels: tangible.filterLabels.filter((labelId) => labelId !== id), // 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; return tangible;
}); });
@ -589,6 +636,11 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
return node; 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 // Atomic update: all state changes happen in a single set() call
set({ set({
nodes: sanitizedNodes, nodes: sanitizedNodes,
@ -597,7 +649,7 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
nodeTypes: data.nodeTypes, nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes, edgeTypes: data.edgeTypes,
labels: data.labels || [], 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 this is a manual state switch (not from tangible), clear active state tangibles
if (!fromTangible) { if (!fromTangible) {
console.log('[Timeline] Manual state switch detected, clearing active state tangibles');
useTuioStore.getState().clearActiveStateTangibles(); useTuioStore.getState().clearActiveStateTangibles();
} }

View file

@ -37,6 +37,21 @@ interface TuioState {
addActiveStateTangible: (hardwareId: string) => void; addActiveStateTangible: (hardwareId: string) => void;
removeActiveStateTangible: (hardwareId: string) => void; removeActiveStateTangible: (hardwareId: string) => void;
clearActiveStateTangibles: () => 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'; const DEFAULT_WEBSOCKET_URL = 'ws://localhost:3333';
@ -111,6 +126,27 @@ export const useTuioStore = create<TuioState>()(
clearActiveStateTangibles: () => clearActiveStateTangibles: () =>
set({ activeStateTangibles: [] }), 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', name: 'constellation-tuio-settings',

View file

@ -32,6 +32,7 @@ import { getCurrentGraphFromDocument } from './workspace/documentUtils';
import { Cite } from '@citation-js/core'; import { Cite } from '@citation-js/core';
import type { CSLReference } from '../types/bibliography'; import type { CSLReference } from '../types/bibliography';
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage'; import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
/** /**
* Workspace Store * Workspace Store
@ -83,6 +84,11 @@ function initializeWorkspace(): Workspace {
if (savedState.activeDocumentId) { if (savedState.activeDocumentId) {
const doc = loadDocumentFromStorage(savedState.activeDocumentId); const doc = loadDocumentFromStorage(savedState.activeDocumentId);
if (doc) { if (doc) {
// Apply tangible migration for backward compatibility
if (doc.tangibles) {
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
}
documents.set(savedState.activeDocumentId, doc); documents.set(savedState.activeDocumentId, doc);
// Load timeline if it exists // Load timeline if it exists
@ -307,6 +313,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
return; return;
} }
// Apply tangible migration for backward compatibility
if (doc.tangibles) {
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
}
// Load timeline if it exists // Load timeline if it exists
if (doc.timeline) { if (doc.timeline) {
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as 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.title = importedDoc.metadata.title || 'Imported Analysis';
importedDoc.metadata.updatedAt = now; importedDoc.metadata.updatedAt = now;
// Apply tangible migration for backward compatibility
if (importedDoc.tangibles) {
importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles);
}
const metadata: DocumentMetadata = { const metadata: DocumentMetadata = {
id: documentId, id: documentId,
title: importedDoc.metadata.title || 'Imported Analysis', title: importedDoc.metadata.title || 'Imported Analysis',
@ -917,6 +933,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
// Save all documents // Save all documents
documents.forEach((doc, docId) => { documents.forEach((doc, docId) => {
// Apply tangible migration for backward compatibility
if (doc.tangibles) {
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
}
saveDocumentToStorage(docId, doc); saveDocumentToStorage(docId, doc);
const metadata = { const metadata = {
@ -1433,15 +1454,27 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
// 1. Remove from document's labels // 1. Remove from document's labels
doc.labels = (doc.labels || []).filter((label) => label.id !== labelId); 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) { if (doc.tangibles) {
doc.tangibles = doc.tangibles.map((tangible) => { doc.tangibles = doc.tangibles.map((tangible) => {
if (tangible.mode === 'filter' && tangible.filterLabels) { if (tangible.mode === 'filter') {
return { const updates: Partial<typeof tangible> = {};
...tangible,
filterLabels: tangible.filterLabels.filter((id) => id !== labelId), // 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; return tangible;
}); });
} }
@ -1590,10 +1623,21 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
} }
// Validate mode-specific fields // Validate mode-specific fields
if (tangible.mode === 'filter' && (!tangible.filterLabels || tangible.filterLabels.length === 0)) { if (tangible.mode === 'filter') {
useToastStore.getState().showToast('Filter mode requires at least one label', 'error'); // 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; return;
} }
}
if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) { if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) {
useToastStore.getState().showToast('State mode requires a state selection', 'error'); useToastStore.getState().showToast('State mode requires a state selection', 'error');
return; return;
@ -1654,10 +1698,21 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
} }
// Validate mode-specific fields if mode is being updated // Validate mode-specific fields if mode is being updated
if (updates.mode === 'filter' && (!updates.filterLabels || updates.filterLabels.length === 0)) { if (updates.mode === 'filter') {
useToastStore.getState().showToast('Filter mode requires at least one label', 'error'); // 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; return;
} }
}
if ((updates.mode === 'state' || updates.mode === 'stateDial') && !updates.stateId) { if ((updates.mode === 'state' || updates.mode === 'stateDial') && !updates.stateId) {
useToastStore.getState().showToast('State mode requires a state selection', 'error'); useToastStore.getState().showToast('State mode requires a state selection', 'error');
return; return;

View file

@ -69,13 +69,26 @@ export interface LabelConfig {
// Tangible Configuration // Tangible Configuration
export type TangibleMode = 'filter' | 'state' | 'stateDial'; 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 { export interface TangibleConfig {
id: string; // Internal unique identifier (auto-generated from name) id: string; // Internal unique identifier (auto-generated from name)
name: string; name: string;
mode: TangibleMode; mode: TangibleMode;
description?: string; description?: string;
hardwareId?: string; // Hardware token/device ID (editable, must be unique if present) 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 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;
}