mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
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:
parent
010d8a558c
commit
3e2a7b6b20
20 changed files with 1778 additions and 398 deletions
|
|
@ -1,12 +1,15 @@
|
|||
import { useState, useEffect, KeyboardEvent } from "react";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import TangibleForm from "./TangibleForm";
|
||||
import type { TangibleConfig, TangibleMode, LabelConfig } from "../../types";
|
||||
import type { TangibleConfig, TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types";
|
||||
import type { ConstellationState } from "../../types/timeline";
|
||||
import { migrateTangibleConfig } from "../../utils/tangibleMigration";
|
||||
|
||||
interface Props {
|
||||
tangible: TangibleConfig;
|
||||
labels: LabelConfig[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
states: ConstellationState[];
|
||||
onSave: (
|
||||
id: string,
|
||||
|
|
@ -15,7 +18,7 @@ interface Props {
|
|||
mode: TangibleMode;
|
||||
description?: string;
|
||||
hardwareId?: string;
|
||||
filterLabels?: string[];
|
||||
filters?: FilterConfig;
|
||||
stateId?: string;
|
||||
},
|
||||
) => void;
|
||||
|
|
@ -25,6 +28,8 @@ interface Props {
|
|||
const EditTangibleInline = ({
|
||||
tangible,
|
||||
labels,
|
||||
nodeTypes,
|
||||
edgeTypes,
|
||||
states,
|
||||
onSave,
|
||||
onCancel,
|
||||
|
|
@ -33,18 +38,38 @@ const EditTangibleInline = ({
|
|||
const [mode, setMode] = useState<TangibleMode>("filter");
|
||||
const [description, setDescription] = useState("");
|
||||
const [hardwareId, setHardwareId] = useState("");
|
||||
const [filterLabels, setFilterLabels] = useState<string[]>([]);
|
||||
const [filters, setFilters] = useState<FilterConfig>({
|
||||
labels: [],
|
||||
actorTypes: [],
|
||||
relationTypes: [],
|
||||
combineMode: 'OR'
|
||||
});
|
||||
const [stateId, setStateId] = useState("");
|
||||
|
||||
// Sync state with tangible prop
|
||||
useEffect(() => {
|
||||
if (tangible) {
|
||||
setName(tangible.name);
|
||||
setMode(tangible.mode);
|
||||
setDescription(tangible.description || "");
|
||||
setHardwareId(tangible.hardwareId || "");
|
||||
setFilterLabels(tangible.filterLabels || []);
|
||||
setStateId(tangible.stateId || "");
|
||||
// Apply migration for backward compatibility
|
||||
const migratedTangible = migrateTangibleConfig(tangible);
|
||||
|
||||
setName(migratedTangible.name);
|
||||
setMode(migratedTangible.mode);
|
||||
setDescription(migratedTangible.description || "");
|
||||
setHardwareId(migratedTangible.hardwareId || "");
|
||||
setFilters(migratedTangible.filters || {
|
||||
labels: [],
|
||||
actorTypes: [],
|
||||
relationTypes: [],
|
||||
combineMode: 'OR'
|
||||
});
|
||||
// Ensure combineMode is set (default to OR for backward compatibility)
|
||||
if (migratedTangible.filters && !migratedTangible.filters.combineMode) {
|
||||
setFilters({
|
||||
...migratedTangible.filters,
|
||||
combineMode: 'OR'
|
||||
});
|
||||
}
|
||||
setStateId(migratedTangible.stateId || "");
|
||||
}
|
||||
}, [tangible]);
|
||||
|
||||
|
|
@ -52,9 +77,16 @@ const EditTangibleInline = ({
|
|||
if (!name.trim()) return;
|
||||
|
||||
// Validate mode-specific fields
|
||||
if (mode === "filter" && filterLabels.length === 0) {
|
||||
alert("Filter mode requires at least one label");
|
||||
return;
|
||||
if (mode === "filter") {
|
||||
const hasFilters =
|
||||
(filters.labels && filters.labels.length > 0) ||
|
||||
(filters.actorTypes && filters.actorTypes.length > 0) ||
|
||||
(filters.relationTypes && filters.relationTypes.length > 0);
|
||||
|
||||
if (!hasFilters) {
|
||||
alert("Filter mode requires at least one filter (labels, actor types, or relation types)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((mode === "state" || mode === "stateDial") && !stateId) {
|
||||
alert("State mode requires a state selection");
|
||||
|
|
@ -66,7 +98,7 @@ const EditTangibleInline = ({
|
|||
mode,
|
||||
description: description.trim() || undefined,
|
||||
hardwareId: hardwareId.trim() || undefined,
|
||||
filterLabels: mode === "filter" ? filterLabels : undefined,
|
||||
filters: mode === "filter" ? filters : undefined,
|
||||
stateId: mode === "state" || mode === "stateDial" ? stateId : undefined,
|
||||
});
|
||||
};
|
||||
|
|
@ -90,15 +122,17 @@ const EditTangibleInline = ({
|
|||
mode={mode}
|
||||
description={description}
|
||||
hardwareId={hardwareId}
|
||||
filterLabels={filterLabels}
|
||||
filters={filters}
|
||||
stateId={stateId}
|
||||
labels={labels}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
states={states}
|
||||
onNameChange={setName}
|
||||
onModeChange={setMode}
|
||||
onDescriptionChange={setDescription}
|
||||
onHardwareIdChange={setHardwareId}
|
||||
onFilterLabelsChange={setFilterLabels}
|
||||
onFiltersChange={setFilters}
|
||||
onStateIdChange={setStateId}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,34 @@
|
|||
import { useState, useRef, KeyboardEvent } from "react";
|
||||
import TangibleForm from "./TangibleForm";
|
||||
import type { TangibleMode, LabelConfig } from "../../types";
|
||||
import type { TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types";
|
||||
import type { ConstellationState } from "../../types/timeline";
|
||||
|
||||
interface Props {
|
||||
labels: LabelConfig[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
states: ConstellationState[];
|
||||
onAdd: (tangible: {
|
||||
name: string;
|
||||
mode: TangibleMode;
|
||||
description: string;
|
||||
hardwareId?: string;
|
||||
filterLabels?: string[];
|
||||
filters?: FilterConfig;
|
||||
stateId?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
|
||||
const QuickAddTangibleForm = ({ labels, nodeTypes, edgeTypes, states, onAdd }: Props) => {
|
||||
const [name, setName] = useState("");
|
||||
const [hardwareId, setHardwareId] = useState("");
|
||||
const [mode, setMode] = useState<TangibleMode>("filter");
|
||||
const [description, setDescription] = useState("");
|
||||
const [filterLabels, setFilterLabels] = useState<string[]>([]);
|
||||
const [filters, setFilters] = useState<FilterConfig>({
|
||||
labels: [],
|
||||
actorTypes: [],
|
||||
relationTypes: [],
|
||||
combineMode: 'OR' // Default to OR for tangibles
|
||||
});
|
||||
const [stateId, setStateId] = useState("");
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -33,9 +40,16 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
|
|||
}
|
||||
|
||||
// Validate mode-specific fields
|
||||
if (mode === "filter" && filterLabels.length === 0) {
|
||||
alert("Filter mode requires at least one label");
|
||||
return;
|
||||
if (mode === "filter") {
|
||||
const hasFilters =
|
||||
(filters.labels && filters.labels.length > 0) ||
|
||||
(filters.actorTypes && filters.actorTypes.length > 0) ||
|
||||
(filters.relationTypes && filters.relationTypes.length > 0);
|
||||
|
||||
if (!hasFilters) {
|
||||
alert("Filter mode requires at least one filter (labels, actor types, or relation types)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((mode === "state" || mode === "stateDial") && !stateId) {
|
||||
alert("State mode requires a state selection");
|
||||
|
|
@ -47,7 +61,7 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
|
|||
mode,
|
||||
description,
|
||||
hardwareId: hardwareId.trim() || undefined,
|
||||
filterLabels: mode === "filter" ? filterLabels : undefined,
|
||||
filters: mode === "filter" ? filters : undefined,
|
||||
stateId: mode === "state" || mode === "stateDial" ? stateId : undefined,
|
||||
});
|
||||
|
||||
|
|
@ -56,7 +70,12 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
|
|||
setHardwareId("");
|
||||
setMode("filter");
|
||||
setDescription("");
|
||||
setFilterLabels([]);
|
||||
setFilters({
|
||||
labels: [],
|
||||
actorTypes: [],
|
||||
relationTypes: [],
|
||||
combineMode: 'OR'
|
||||
});
|
||||
setStateId("");
|
||||
|
||||
nameInputRef.current?.focus();
|
||||
|
|
@ -72,7 +91,12 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
|
|||
setHardwareId("");
|
||||
setMode("filter");
|
||||
setDescription("");
|
||||
setFilterLabels([]);
|
||||
setFilters({
|
||||
labels: [],
|
||||
actorTypes: [],
|
||||
relationTypes: [],
|
||||
combineMode: 'OR'
|
||||
});
|
||||
setStateId("");
|
||||
nameInputRef.current?.blur();
|
||||
}
|
||||
|
|
@ -85,15 +109,17 @@ const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => {
|
|||
hardwareId={hardwareId}
|
||||
mode={mode}
|
||||
description={description}
|
||||
filterLabels={filterLabels}
|
||||
filters={filters}
|
||||
stateId={stateId}
|
||||
labels={labels}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
states={states}
|
||||
onNameChange={setName}
|
||||
onHardwareIdChange={setHardwareId}
|
||||
onModeChange={setMode}
|
||||
onDescriptionChange={setDescription}
|
||||
onFilterLabelsChange={setFilterLabels}
|
||||
onFiltersChange={setFilters}
|
||||
onStateIdChange={setStateId}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ interface Props {
|
|||
}
|
||||
|
||||
const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Props) => {
|
||||
const { tangibles, labels, addTangible, updateTangible, deleteTangible } = useGraphWithHistory();
|
||||
const { tangibles, labels, nodeTypes, edgeTypes, addTangible, updateTangible, deleteTangible } = useGraphWithHistory();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||
const { showToast } = useToastStore();
|
||||
|
||||
|
|
@ -48,13 +48,21 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
|
|||
mode: TangibleMode;
|
||||
description: string;
|
||||
hardwareId?: string;
|
||||
filterLabels?: string[];
|
||||
filters?: import('../../types').FilterConfig;
|
||||
stateId?: string;
|
||||
}) => {
|
||||
// Validate mode-specific fields
|
||||
if (tangible.mode === 'filter' && (!tangible.filterLabels || tangible.filterLabels.length === 0)) {
|
||||
showToast('Filter mode requires at least one label', 'error');
|
||||
return;
|
||||
if (tangible.mode === 'filter') {
|
||||
const hasFilters =
|
||||
tangible.filters &&
|
||||
((tangible.filters.labels && tangible.filters.labels.length > 0) ||
|
||||
(tangible.filters.actorTypes && tangible.filters.actorTypes.length > 0) ||
|
||||
(tangible.filters.relationTypes && tangible.filters.relationTypes.length > 0));
|
||||
|
||||
if (!hasFilters) {
|
||||
showToast('Filter mode requires at least one filter (labels, actor types, or relation types)', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) {
|
||||
showToast('State mode requires a state selection', 'error');
|
||||
|
|
@ -66,7 +74,7 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
|
|||
mode: tangible.mode,
|
||||
description: tangible.description || undefined,
|
||||
hardwareId: tangible.hardwareId,
|
||||
filterLabels: tangible.filterLabels,
|
||||
filters: tangible.filters,
|
||||
stateId: tangible.stateId,
|
||||
};
|
||||
|
||||
|
|
@ -96,7 +104,7 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
|
|||
|
||||
const handleSaveEdit = (
|
||||
id: string,
|
||||
updates: { name: string; mode: TangibleMode; description?: string; hardwareId?: string; filterLabels?: string[]; stateId?: string }
|
||||
updates: { name: string; mode: TangibleMode; description?: string; hardwareId?: string; filters?: import('../../types').FilterConfig; stateId?: string }
|
||||
) => {
|
||||
updateTangible(id, updates);
|
||||
setEditingTangible(null);
|
||||
|
|
@ -131,6 +139,8 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
|
|||
<EditTangibleInline
|
||||
tangible={editingTangible}
|
||||
labels={labels}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
states={availableStates}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
|
|
@ -147,6 +157,8 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
|
|||
</h3>
|
||||
<QuickAddTangibleForm
|
||||
labels={labels}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
states={availableStates}
|
||||
onAdd={handleAddTangible}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TangibleMode, LabelConfig } from "../../types";
|
||||
import type { TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types";
|
||||
import type { ConstellationState } from "../../types/timeline";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -6,15 +6,25 @@ interface Props {
|
|||
mode: TangibleMode;
|
||||
description: string;
|
||||
hardwareId: string;
|
||||
filterLabels: string[];
|
||||
/**
|
||||
* @deprecated Use filters instead. Kept for backward compatibility.
|
||||
*/
|
||||
filterLabels?: string[];
|
||||
filters: FilterConfig;
|
||||
stateId: string;
|
||||
labels: LabelConfig[];
|
||||
nodeTypes: NodeTypeConfig[];
|
||||
edgeTypes: EdgeTypeConfig[];
|
||||
states: ConstellationState[];
|
||||
onNameChange: (value: string) => void;
|
||||
onModeChange: (value: TangibleMode) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onHardwareIdChange: (value: string) => void;
|
||||
onFilterLabelsChange: (value: string[]) => void;
|
||||
/**
|
||||
* @deprecated Use onFiltersChange instead. Kept for backward compatibility.
|
||||
*/
|
||||
onFilterLabelsChange?: (value: string[]) => void;
|
||||
onFiltersChange: (value: FilterConfig) => void;
|
||||
onStateIdChange: (value: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -23,45 +33,52 @@ const TangibleForm = ({
|
|||
mode,
|
||||
description,
|
||||
hardwareId,
|
||||
filterLabels,
|
||||
filters,
|
||||
stateId,
|
||||
labels,
|
||||
nodeTypes,
|
||||
edgeTypes,
|
||||
states,
|
||||
onNameChange,
|
||||
onModeChange,
|
||||
onDescriptionChange,
|
||||
onHardwareIdChange,
|
||||
onFilterLabelsChange,
|
||||
onFiltersChange,
|
||||
onStateIdChange,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="e.g., Red Block, Filter Card"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="e.g., Red Block"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Hardware ID (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hardwareId}
|
||||
onChange={(e) => onHardwareIdChange(e.target.value)}
|
||||
placeholder="e.g., token-001"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Hardware ID (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hardwareId}
|
||||
onChange={(e) => onHardwareIdChange(e.target.value)}
|
||||
placeholder="e.g., token-001, device-a"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maps this configuration to a physical token or device
|
||||
<p className="text-xs text-gray-500">
|
||||
Hardware ID maps this configuration to a physical token or device
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -69,88 +86,218 @@ const TangibleForm = ({
|
|||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Mode *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="filter"
|
||||
checked={mode === "filter"}
|
||||
onChange={(e) => onModeChange(e.target.value as TangibleMode)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Filter mode (activate label filters)
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="state"
|
||||
checked={mode === "state"}
|
||||
onChange={(e) => onModeChange(e.target.value as TangibleMode)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
State mode (switch to timeline state)
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="stateDial"
|
||||
checked={mode === "stateDial"}
|
||||
onChange={(e) => onModeChange(e.target.value as TangibleMode)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
State dial mode (clock-like, deferred)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onModeChange("filter")}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
|
||||
mode === "filter"
|
||||
? "bg-blue-500 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">Filter</div>
|
||||
<div className="text-xs opacity-80">Apply filters</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onModeChange("state")}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
|
||||
mode === "state"
|
||||
? "bg-blue-500 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">State</div>
|
||||
<div className="text-xs opacity-80">Timeline state</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onModeChange("stateDial")}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
|
||||
mode === "stateDial"
|
||||
? "bg-blue-500 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">State Dial</div>
|
||||
<div className="text-xs opacity-80">Deferred</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode-specific fields */}
|
||||
{mode === "filter" && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Filter Labels * (select one or more)
|
||||
</label>
|
||||
<div className="border border-gray-300 rounded-md p-2 max-h-40 overflow-y-auto">
|
||||
{labels.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
No labels available
|
||||
</p>
|
||||
) : (
|
||||
labels.map((label) => (
|
||||
<label
|
||||
key={label.id}
|
||||
className="flex items-center py-1 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filterLabels.includes(label.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onFilterLabelsChange([...filterLabels, label.id]);
|
||||
} else {
|
||||
onFilterLabelsChange(
|
||||
filterLabels.filter((id) => id !== label.id),
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{label.name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{/* Filter Combine Mode */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Combine Mode
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFiltersChange({ ...filters, combineMode: 'OR' })}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
|
||||
(filters.combineMode || 'OR') === 'OR'
|
||||
? "bg-blue-500 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">OR</div>
|
||||
<div className="text-xs opacity-80">Match ANY</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFiltersChange({ ...filters, combineMode: 'AND' })}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors text-center ${
|
||||
filters.combineMode === 'AND'
|
||||
? "bg-blue-500 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">AND</div>
|
||||
<div className="text-xs opacity-80">Match ALL</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter by Labels */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Filter by Labels (optional)
|
||||
</label>
|
||||
<div className="border border-gray-300 rounded-md p-2 max-h-32 overflow-y-auto">
|
||||
{labels.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
No labels available
|
||||
</p>
|
||||
) : (
|
||||
labels.map((label) => (
|
||||
<label
|
||||
key={label.id}
|
||||
className="flex items-center py-1 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.labels?.includes(label.id) || false}
|
||||
onChange={(e) => {
|
||||
const currentLabels = filters.labels || [];
|
||||
if (e.target.checked) {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
labels: [...currentLabels, label.id],
|
||||
});
|
||||
} else {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
labels: currentLabels.filter((id) => id !== label.id),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{label.name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import {
|
|||
useNodes,
|
||||
} from '@xyflow/react';
|
||||
import { useGraphStore } from '../../stores/graphStore';
|
||||
import { useSearchStore } from '../../stores/searchStore';
|
||||
import type { Relation } from '../../types';
|
||||
import type { Group } from '../../types';
|
||||
import LabelBadge from '../Common/LabelBadge';
|
||||
import { getFloatingEdgeParams } from '../../utils/edgeUtils';
|
||||
import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters';
|
||||
|
||||
/**
|
||||
* CustomEdge - Represents a relation between actors in the constellation graph
|
||||
|
|
@ -41,7 +41,9 @@ const CustomEdge = ({
|
|||
}: EdgeProps<Relation>) => {
|
||||
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
||||
const labels = useGraphStore((state) => state.labels);
|
||||
const { searchText, selectedRelationTypes, selectedLabels } = useSearchStore();
|
||||
|
||||
// Get active filters based on mode (editing vs presentation)
|
||||
const filters = useActiveFilters();
|
||||
|
||||
// Get all nodes to check if source/target are minimized groups
|
||||
const nodes = useNodes();
|
||||
|
|
@ -124,42 +126,20 @@ const CustomEdge = ({
|
|||
|
||||
// Check if this edge matches the filter criteria
|
||||
const isMatch = useMemo(() => {
|
||||
// Check relation type filter (POSITIVE: if types selected, edge must be one of them)
|
||||
const edgeType = data?.type || '';
|
||||
if (selectedRelationTypes.length > 0) {
|
||||
if (!selectedRelationTypes.includes(edgeType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check label filter (POSITIVE: if labels selected, edge must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const edgeLabels = data?.labels || [];
|
||||
const hasSelectedLabel = edgeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check search text match
|
||||
if (searchText.trim()) {
|
||||
const searchLower = searchText.toLowerCase();
|
||||
const label = data?.label?.toLowerCase() || '';
|
||||
const typeName = edgeTypeConfig?.label?.toLowerCase() || '';
|
||||
|
||||
return label.includes(searchLower) || typeName.includes(searchLower);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [searchText, selectedRelationTypes, selectedLabels, data?.type, data?.label, data?.labels, edgeTypeConfig?.label]);
|
||||
return edgeMatchesFilters(
|
||||
data?.type || '',
|
||||
data?.labels || [],
|
||||
data?.label || '',
|
||||
edgeTypeConfig?.label || '',
|
||||
filters
|
||||
);
|
||||
}, [data?.type, data?.labels, data?.label, edgeTypeConfig?.label, filters]);
|
||||
|
||||
// Determine if filters are active
|
||||
const hasActiveFilters =
|
||||
searchText.trim() !== '' ||
|
||||
selectedRelationTypes.length > 0 ||
|
||||
selectedLabels.length > 0;
|
||||
filters.searchText.trim() !== '' ||
|
||||
filters.selectedRelationTypes.length > 0 ||
|
||||
filters.selectedLabels.length > 0;
|
||||
|
||||
// Calculate opacity based on visibility
|
||||
const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ import "@xyflow/react/dist/style.css";
|
|||
import { useGraphWithHistory } from "../../hooks/useGraphWithHistory";
|
||||
import { useDocumentHistory } from "../../hooks/useDocumentHistory";
|
||||
import { useEditorStore } from "../../stores/editorStore";
|
||||
import { useSearchStore } from "../../stores/searchStore";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useActiveDocument } from "../../stores/workspace/useActiveDocument";
|
||||
import { useWorkspaceStore } from "../../stores/workspaceStore";
|
||||
import { useCreateDocument } from "../../hooks/useCreateDocument";
|
||||
import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters";
|
||||
import CustomNode from "../Nodes/CustomNode";
|
||||
import GroupNode from "../Nodes/GroupNode";
|
||||
import CustomEdge from "../Edges/CustomEdge";
|
||||
|
|
@ -124,13 +124,8 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
|||
fitView,
|
||||
} = useReactFlow();
|
||||
|
||||
// Search and filter state for auto-zoom
|
||||
const {
|
||||
searchText,
|
||||
selectedActorTypes,
|
||||
selectedRelationTypes,
|
||||
selectedLabels,
|
||||
} = useSearchStore();
|
||||
// Get active filters (respects presentation vs editing mode)
|
||||
const filters = useActiveFilters();
|
||||
|
||||
// Settings for auto-zoom
|
||||
const { autoZoomEnabled } = useSettingsStore();
|
||||
|
|
@ -443,59 +438,30 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
|||
if (nodes.length === 0) return;
|
||||
|
||||
// Check if any filters are active
|
||||
const hasSearchText = searchText.trim() !== '';
|
||||
const hasTypeFilters = selectedActorTypes.length > 0 || selectedRelationTypes.length > 0;
|
||||
const hasLabelFilters = selectedLabels.length > 0;
|
||||
const hasSearchText = filters.searchText.trim() !== '';
|
||||
const hasTypeFilters = filters.selectedActorTypes.length > 0;
|
||||
const hasLabelFilters = filters.selectedLabels.length > 0;
|
||||
|
||||
// Skip if no filters are active
|
||||
if (!hasSearchText && !hasTypeFilters && !hasLabelFilters) return;
|
||||
|
||||
// Debounce to avoid excessive viewport changes while typing
|
||||
const timeoutId = setTimeout(() => {
|
||||
const searchLower = searchText.toLowerCase().trim();
|
||||
|
||||
// Calculate matching nodes (same logic as LeftPanel and CustomNode)
|
||||
// Calculate matching nodes using the centralized filter logic
|
||||
const matchingNodeIds = nodes
|
||||
.filter((node) => {
|
||||
const actor = node as Actor;
|
||||
const actorType = actor.data?.type || '';
|
||||
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === actorType);
|
||||
|
||||
// Filter by actor type (POSITIVE: if types selected, node must be one of them)
|
||||
if (selectedActorTypes.length > 0) {
|
||||
if (!selectedActorTypes.includes(actorType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by label (POSITIVE: if labels selected, node must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const nodeLabels = actor.data?.labels || [];
|
||||
const hasSelectedLabel = nodeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if (searchLower) {
|
||||
const label = actor.data?.label?.toLowerCase() || '';
|
||||
const description = actor.data?.description?.toLowerCase() || '';
|
||||
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === actorType);
|
||||
const typeName = nodeTypeConfig?.label?.toLowerCase() || '';
|
||||
|
||||
const matches =
|
||||
label.includes(searchLower) ||
|
||||
description.includes(searchLower) ||
|
||||
typeName.includes(searchLower);
|
||||
|
||||
if (!matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return nodeMatchesFilters(
|
||||
actorType,
|
||||
actor.data?.labels || [],
|
||||
actor.data?.label || '',
|
||||
actor.data?.description || '',
|
||||
nodeTypeConfig?.label || '',
|
||||
filters
|
||||
);
|
||||
})
|
||||
.map((node) => node.id);
|
||||
|
||||
|
|
@ -513,10 +479,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
|||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
searchText,
|
||||
selectedActorTypes,
|
||||
selectedRelationTypes,
|
||||
selectedLabels,
|
||||
filters,
|
||||
autoZoomEnabled,
|
||||
nodes,
|
||||
nodeTypeConfigs,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { memo, useMemo } from "react";
|
||||
import { Handle, Position, NodeProps, useConnection } from "@xyflow/react";
|
||||
import { useGraphStore } from "../../stores/graphStore";
|
||||
import { useSearchStore } from "../../stores/searchStore";
|
||||
import {
|
||||
getContrastColor,
|
||||
adjustColorBrightness,
|
||||
|
|
@ -10,6 +9,7 @@ import { getIconComponent } from "../../utils/iconUtils";
|
|||
import type { Actor } from "../../types";
|
||||
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
|
||||
import LabelBadge from "../Common/LabelBadge";
|
||||
import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters";
|
||||
|
||||
/**
|
||||
* CustomNode - Represents an actor in the constellation graph
|
||||
|
|
@ -25,7 +25,9 @@ import LabelBadge from "../Common/LabelBadge";
|
|||
const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
||||
const labels = useGraphStore((state) => state.labels);
|
||||
const { searchText, selectedActorTypes, selectedLabels } = useSearchStore();
|
||||
|
||||
// Get active filters based on mode (editing vs presentation)
|
||||
const filters = useActiveFilters();
|
||||
|
||||
// Check if any connection is being made (to show handles)
|
||||
const connection = useConnection();
|
||||
|
|
@ -47,57 +49,30 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
|||
// Show handles when selected or when connecting
|
||||
const showHandles = selected || isConnecting;
|
||||
|
||||
// Check if this node matches the search and filter criteria
|
||||
// Check if this node matches the filter criteria
|
||||
const isMatch = useMemo(() => {
|
||||
// Check actor type filter (POSITIVE: if types selected, node must be one of them)
|
||||
if (selectedActorTypes.length > 0) {
|
||||
if (!selectedActorTypes.includes(data.type)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check label filter (POSITIVE: if labels selected, node must have at least one)
|
||||
if (selectedLabels.length > 0) {
|
||||
const nodeLabels = data.labels || [];
|
||||
const hasSelectedLabel = nodeLabels.some((labelId) =>
|
||||
selectedLabels.includes(labelId)
|
||||
);
|
||||
if (!hasSelectedLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check search text match
|
||||
if (searchText.trim()) {
|
||||
const searchLower = searchText.toLowerCase();
|
||||
const label = data.label?.toLowerCase() || "";
|
||||
const description = data.description?.toLowerCase() || "";
|
||||
const typeName = nodeLabel.toLowerCase();
|
||||
|
||||
return (
|
||||
label.includes(searchLower) ||
|
||||
description.includes(searchLower) ||
|
||||
typeName.includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
return nodeMatchesFilters(
|
||||
data.type,
|
||||
data.labels || [],
|
||||
data.label || "",
|
||||
data.description || "",
|
||||
nodeLabel,
|
||||
filters
|
||||
);
|
||||
}, [
|
||||
searchText,
|
||||
selectedActorTypes,
|
||||
selectedLabels,
|
||||
data.type,
|
||||
data.label,
|
||||
data.labels,
|
||||
data.label,
|
||||
data.description,
|
||||
nodeLabel,
|
||||
filters,
|
||||
]);
|
||||
|
||||
// Determine if filters are active
|
||||
const hasActiveFilters =
|
||||
searchText.trim() !== "" ||
|
||||
selectedActorTypes.length > 0 ||
|
||||
selectedLabels.length > 0;
|
||||
filters.searchText.trim() !== "" ||
|
||||
filters.selectedActorTypes.length > 0 ||
|
||||
filters.selectedLabels.length > 0;
|
||||
|
||||
// Calculate opacity based on match status
|
||||
const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
|
||||
|
|
|
|||
699
src/hooks/useActiveFilters.test.ts
Normal file
699
src/hooks/useActiveFilters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
187
src/hooks/useActiveFilters.ts
Normal file
187
src/hooks/useActiveFilters.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@ import { useEffect, useRef } from 'react';
|
|||
import { useTuioStore } from '../stores/tuioStore';
|
||||
import { useSettingsStore } from '../stores/settingsStore';
|
||||
import { useGraphStore } from '../stores/graphStore';
|
||||
import { useSearchStore } from '../stores/searchStore';
|
||||
import { useTimelineStore } from '../stores/timelineStore';
|
||||
import { TuioClientManager } from '../lib/tuio/tuioClient';
|
||||
import type { TuioTangibleInfo } from '../lib/tuio/types';
|
||||
import type { TangibleConfig } from '../types';
|
||||
import { migrateTangibleConfig } from '../utils/tangibleMigration';
|
||||
|
||||
/**
|
||||
* TUIO Integration Hook
|
||||
|
|
@ -29,7 +29,6 @@ export function useTuioIntegration() {
|
|||
if (!presentationMode) {
|
||||
// Disconnect if we're leaving presentation mode
|
||||
if (clientRef.current) {
|
||||
console.log('[TUIO Integration] Presentation mode disabled, disconnecting');
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
useTuioStore.getState().clearActiveTangibles();
|
||||
|
|
@ -37,7 +36,6 @@ export function useTuioIntegration() {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log('[TUIO Integration] Presentation mode enabled, connecting to TUIO server');
|
||||
|
||||
// Create TUIO client if in presentation mode
|
||||
const client = new TuioClientManager(
|
||||
|
|
@ -46,7 +44,6 @@ export function useTuioIntegration() {
|
|||
onTangibleUpdate: handleTangibleUpdate,
|
||||
onTangibleRemove: handleTangibleRemove,
|
||||
onConnectionChange: (connected, error) => {
|
||||
console.log('[TUIO Integration] Connection state changed:', connected, error);
|
||||
useTuioStore.getState().setConnectionState(connected, error);
|
||||
},
|
||||
},
|
||||
|
|
@ -58,14 +55,13 @@ export function useTuioIntegration() {
|
|||
// Connect to TUIO server
|
||||
client
|
||||
.connect(websocketUrl)
|
||||
.catch((error) => {
|
||||
console.error('[TUIO Integration] Failed to connect to TUIO server:', error);
|
||||
.catch(() => {
|
||||
// Connection errors are handled by onConnectionChange callback
|
||||
});
|
||||
|
||||
// Cleanup on unmount or when presentation mode changes
|
||||
return () => {
|
||||
if (clientRef.current) {
|
||||
console.log('[TUIO Integration] Cleaning up, disconnecting');
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
useTuioStore.getState().clearActiveTangibles();
|
||||
|
|
@ -78,7 +74,6 @@ export function useTuioIntegration() {
|
|||
* Handle tangible add event
|
||||
*/
|
||||
function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void {
|
||||
console.log('[TUIO Integration] Tangible added:', hardwareId, info);
|
||||
|
||||
// Update TUIO store
|
||||
useTuioStore.getState().addActiveTangible(hardwareId, info);
|
||||
|
|
@ -89,15 +84,13 @@ function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void {
|
|||
|
||||
if (!tangibleConfig) {
|
||||
// Unknown hardware ID - silently ignore
|
||||
console.log('[TUIO Integration] No configuration found for hardware ID:', hardwareId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TUIO Integration] Tangible configuration found:', tangibleConfig.name, 'mode:', tangibleConfig.mode);
|
||||
|
||||
// Trigger action based on tangible mode
|
||||
if (tangibleConfig.mode === 'filter') {
|
||||
applyFilterTangible(tangibleConfig);
|
||||
applyFilterTangible();
|
||||
} else if (tangibleConfig.mode === 'state') {
|
||||
applyStateTangible(tangibleConfig, hardwareId);
|
||||
}
|
||||
|
|
@ -109,7 +102,6 @@ function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void {
|
|||
* Currently just updates position/angle in store (for future stateDial support)
|
||||
*/
|
||||
function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void {
|
||||
console.log('[TUIO Integration] Tangible updated:', hardwareId, info);
|
||||
useTuioStore.getState().updateActiveTangible(hardwareId, info);
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +109,6 @@ function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void
|
|||
* Handle tangible remove event
|
||||
*/
|
||||
function handleTangibleRemove(hardwareId: string): void {
|
||||
console.log('[TUIO Integration] Tangible removed:', hardwareId);
|
||||
|
||||
// Remove from TUIO store
|
||||
useTuioStore.getState().removeActiveTangible(hardwareId);
|
||||
|
|
@ -127,71 +118,80 @@ function handleTangibleRemove(hardwareId: string): void {
|
|||
const tangibleConfig = tangibles.find((t) => t.hardwareId === hardwareId);
|
||||
|
||||
if (!tangibleConfig) {
|
||||
console.log('[TUIO Integration] No configuration found for removed tangible:', hardwareId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TUIO Integration] Handling removal for configured tangible:', tangibleConfig.name);
|
||||
|
||||
// Handle removal based on tangible mode
|
||||
if (tangibleConfig.mode === 'filter') {
|
||||
removeFilterTangible(tangibleConfig);
|
||||
removeFilterTangible();
|
||||
} else if (tangibleConfig.mode === 'state' || tangibleConfig.mode === 'stateDial') {
|
||||
removeStateTangible(hardwareId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filter tangible - add its labels to selected labels
|
||||
* Recalculate and update presentation mode filters based on all active filter tangibles.
|
||||
* This combines filters from all active tangibles (union/OR across tangibles).
|
||||
* The combineMode of individual tangibles is preserved for filtering logic.
|
||||
*/
|
||||
function applyFilterTangible(tangible: TangibleConfig): void {
|
||||
if (!tangible.filterLabels || tangible.filterLabels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedLabels, toggleSelectedLabel } = useSearchStore.getState();
|
||||
|
||||
// Add labels that aren't already selected
|
||||
tangible.filterLabels.forEach((labelId) => {
|
||||
if (!selectedLabels.includes(labelId)) {
|
||||
toggleSelectedLabel(labelId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove filter tangible - remove its labels if no other active tangible uses them
|
||||
*/
|
||||
function removeFilterTangible(tangible: TangibleConfig): void {
|
||||
if (!tangible.filterLabels || tangible.filterLabels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all remaining active filter tangibles
|
||||
function updatePresentationFilters(): void {
|
||||
const activeTangibles = useTuioStore.getState().activeTangibles;
|
||||
const allTangibles = useGraphStore.getState().tangibles;
|
||||
|
||||
// Build set of labels still in use by other active filter tangibles
|
||||
const labelsStillActive = new Set<string>();
|
||||
// Collect all filters from active filter tangibles
|
||||
const allLabels = new Set<string>();
|
||||
const allActorTypes = new Set<string>();
|
||||
const allRelationTypes = new Set<string>();
|
||||
let combinedMode: 'AND' | 'OR' = 'OR'; // Default to OR
|
||||
|
||||
activeTangibles.forEach((_, hwId) => {
|
||||
const config = allTangibles.find(
|
||||
(t) => t.hardwareId === hwId && t.mode === 'filter'
|
||||
);
|
||||
if (config && config.filterLabels) {
|
||||
config.filterLabels.forEach((labelId) => labelsStillActive.add(labelId));
|
||||
if (config) {
|
||||
// Apply migration to ensure we have filters
|
||||
const migratedConfig = migrateTangibleConfig(config);
|
||||
const filters = migratedConfig.filters;
|
||||
|
||||
if (filters) {
|
||||
// Collect all filter IDs (union across tangibles)
|
||||
filters.labels?.forEach((id) => allLabels.add(id));
|
||||
filters.actorTypes?.forEach((id) => allActorTypes.add(id));
|
||||
filters.relationTypes?.forEach((id) => allRelationTypes.add(id));
|
||||
|
||||
// Use the combine mode from the first tangible (or could be configurable)
|
||||
// For multiple tangibles, we use OR between tangibles, but preserve individual combine modes
|
||||
if (filters.combineMode) {
|
||||
combinedMode = filters.combineMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove labels that are no longer active
|
||||
const { selectedLabels, toggleSelectedLabel } = useSearchStore.getState();
|
||||
|
||||
tangible.filterLabels.forEach((labelId) => {
|
||||
if (selectedLabels.includes(labelId) && !labelsStillActive.has(labelId)) {
|
||||
toggleSelectedLabel(labelId);
|
||||
}
|
||||
// Update presentation filters in tuioStore
|
||||
useTuioStore.getState().setPresentationFilters({
|
||||
labels: Array.from(allLabels),
|
||||
actorTypes: Array.from(allActorTypes),
|
||||
relationTypes: Array.from(allRelationTypes),
|
||||
combineMode: combinedMode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filter tangible - recalculate presentation filters
|
||||
*/
|
||||
function applyFilterTangible(): void {
|
||||
updatePresentationFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove filter tangible - recalculate presentation filters
|
||||
*/
|
||||
function removeFilterTangible(): void {
|
||||
updatePresentationFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply state tangible - switch to its configured state
|
||||
*/
|
||||
|
|
@ -200,7 +200,6 @@ function applyStateTangible(tangible: TangibleConfig, hardwareId: string): void
|
|||
return;
|
||||
}
|
||||
|
||||
console.log('[TUIO Integration] Applying state tangible:', hardwareId, 'stateId:', tangible.stateId);
|
||||
|
||||
// Add to active state tangibles list (at the end)
|
||||
useTuioStore.getState().addActiveStateTangible(hardwareId);
|
||||
|
|
@ -209,25 +208,21 @@ function applyStateTangible(tangible: TangibleConfig, hardwareId: string): void
|
|||
// Pass fromTangible=true to prevent clearing the active state tangibles list
|
||||
useTimelineStore.getState().switchToState(tangible.stateId, true);
|
||||
|
||||
console.log('[TUIO Integration] Active state tangibles:', useTuioStore.getState().activeStateTangibles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove state tangible - switch to next active state tangible if any
|
||||
*/
|
||||
function removeStateTangible(hardwareId: string): void {
|
||||
console.log('[TUIO Integration] Removing state tangible:', hardwareId);
|
||||
|
||||
// Remove from active state tangibles list
|
||||
useTuioStore.getState().removeActiveStateTangible(hardwareId);
|
||||
|
||||
const activeStateTangibles = useTuioStore.getState().activeStateTangibles;
|
||||
console.log('[TUIO Integration] Remaining active state tangibles:', activeStateTangibles);
|
||||
|
||||
// If there are other state tangibles still active, switch to the last one
|
||||
if (activeStateTangibles.length > 0) {
|
||||
const lastActiveHwId = activeStateTangibles[activeStateTangibles.length - 1];
|
||||
console.log('[TUIO Integration] Switching to last active state tangible:', lastActiveHwId);
|
||||
|
||||
// Find the tangible config for this hardware ID
|
||||
const tangibles = useGraphStore.getState().tangibles;
|
||||
|
|
@ -237,7 +232,6 @@ function removeStateTangible(hardwareId: string): void {
|
|||
// Pass fromTangible=true to prevent clearing the active state tangibles list
|
||||
useTimelineStore.getState().switchToState(tangibleConfig.stateId, true);
|
||||
}
|
||||
} else {
|
||||
console.log('[TUIO Integration] No more active state tangibles, staying in current state');
|
||||
}
|
||||
// If no active state tangibles remain, stay in current state
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ export class WebsocketTuioReceiver extends TuioReceiver {
|
|||
constructor(host: string, port: number) {
|
||||
super();
|
||||
|
||||
console.log(`[TUIO] Creating WebSocket receiver for ${host}:${port}`);
|
||||
|
||||
// Create OSC WebSocket client
|
||||
this.osc = new OSC({
|
||||
plugin: new OSC.WebsocketClientPlugin({
|
||||
|
|
@ -34,20 +32,17 @@ export class WebsocketTuioReceiver extends TuioReceiver {
|
|||
|
||||
// Forward all OSC messages to TUIO client
|
||||
this.osc.on('*', (message: OscMessage) => {
|
||||
console.log('[TUIO] OSC message received:', message.address, message.args);
|
||||
this.onOscMessage(message);
|
||||
});
|
||||
|
||||
// Listen for WebSocket connection events
|
||||
this.osc.on('open', () => {
|
||||
console.log('[TUIO] WebSocket connection opened');
|
||||
if (this.onOpenCallback) {
|
||||
this.onOpenCallback();
|
||||
}
|
||||
});
|
||||
|
||||
this.osc.on('close', () => {
|
||||
console.log('[TUIO] WebSocket connection closed');
|
||||
if (this.onCloseCallback) {
|
||||
this.onCloseCallback();
|
||||
}
|
||||
|
|
@ -55,7 +50,6 @@ export class WebsocketTuioReceiver extends TuioReceiver {
|
|||
|
||||
this.osc.on('error', (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'WebSocket error';
|
||||
console.error('[TUIO] WebSocket error:', errorMessage);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorMessage);
|
||||
}
|
||||
|
|
@ -87,7 +81,6 @@ export class WebsocketTuioReceiver extends TuioReceiver {
|
|||
* Open WebSocket connection to TUIO server
|
||||
*/
|
||||
connect(): void {
|
||||
console.log('[TUIO] Opening WebSocket connection...');
|
||||
this.osc.open();
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +88,6 @@ export class WebsocketTuioReceiver extends TuioReceiver {
|
|||
* Close WebSocket connection
|
||||
*/
|
||||
disconnect(): void {
|
||||
console.log('[TUIO] Closing WebSocket connection...');
|
||||
this.osc.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
async connect(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log(`[TUIO] Connecting to ${url} with protocol version ${this.protocolVersion}`);
|
||||
|
||||
// Parse WebSocket URL
|
||||
const wsUrl = new URL(url);
|
||||
|
|
@ -49,11 +48,9 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
|
||||
// Create appropriate client based on protocol version
|
||||
if (this.protocolVersion === '1.1') {
|
||||
console.log('[TUIO] Creating TUIO 1.1 client');
|
||||
this.client11 = new Tuio11Client(this.receiver);
|
||||
this.client20 = null;
|
||||
} else {
|
||||
console.log('[TUIO] Creating TUIO 2.0 client');
|
||||
this.client20 = new Tuio20Client(this.receiver);
|
||||
this.client11 = null;
|
||||
}
|
||||
|
|
@ -61,14 +58,12 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
// Set up connection event handlers
|
||||
this.receiver.setOnOpen(() => {
|
||||
// Connection successful
|
||||
console.log('[TUIO] Connection successful');
|
||||
this.callbacks.onConnectionChange(true);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.receiver.setOnError((error: string) => {
|
||||
// Connection error
|
||||
console.error('TUIO connection error:', error);
|
||||
this.callbacks.onConnectionChange(false, error);
|
||||
reject(new Error(error));
|
||||
});
|
||||
|
|
@ -76,7 +71,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
this.receiver.setOnClose((error?: string) => {
|
||||
// Connection closed
|
||||
if (error) {
|
||||
console.error('TUIO connection closed with error:', error);
|
||||
this.callbacks.onConnectionChange(false, error);
|
||||
} else {
|
||||
this.callbacks.onConnectionChange(false);
|
||||
|
|
@ -85,19 +79,14 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
|
||||
// Add this manager as a listener
|
||||
if (this.client11) {
|
||||
console.log('[TUIO] Adding listener to TUIO 1.1 client');
|
||||
this.client11.addTuioListener(this);
|
||||
console.log('[TUIO] Connecting TUIO 1.1 client');
|
||||
this.client11.connect();
|
||||
} else if (this.client20) {
|
||||
console.log('[TUIO] Adding listener to TUIO 2.0 client');
|
||||
this.client20.addTuioListener(this);
|
||||
console.log('[TUIO] Connecting TUIO 2.0 client');
|
||||
this.client20.connect();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('TUIO connection error:', errorMessage);
|
||||
this.callbacks.onConnectionChange(false, errorMessage);
|
||||
reject(error);
|
||||
}
|
||||
|
|
@ -148,16 +137,13 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 object is added (tangible placed on surface)
|
||||
*/
|
||||
addTuioObject(tuioObject: Tuio11Object): void {
|
||||
console.log('[TUIO] 1.1 Object added - raw object:', tuioObject);
|
||||
|
||||
// Validate symbolId exists
|
||||
if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) {
|
||||
console.warn('[TUIO] 1.1 Object has no symbolId, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
const info = this.extractTangibleInfo11(tuioObject);
|
||||
console.log('[TUIO] 1.1 Object added - extracted info:', info);
|
||||
this.callbacks.onTangibleAdd(info.hardwareId, info);
|
||||
}
|
||||
|
||||
|
|
@ -165,16 +151,13 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 object is updated (position/rotation changed)
|
||||
*/
|
||||
updateTuioObject(tuioObject: Tuio11Object): void {
|
||||
console.log('[TUIO] 1.1 Object updated - raw object:', tuioObject);
|
||||
|
||||
// Validate symbolId exists
|
||||
if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) {
|
||||
console.warn('[TUIO] 1.1 Object has no symbolId, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
const info = this.extractTangibleInfo11(tuioObject);
|
||||
console.log('[TUIO] 1.1 Object updated - extracted info:', info);
|
||||
this.callbacks.onTangibleUpdate(info.hardwareId, info);
|
||||
}
|
||||
|
||||
|
|
@ -182,16 +165,13 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 object is removed (tangible removed from surface)
|
||||
*/
|
||||
removeTuioObject(tuioObject: Tuio11Object): void {
|
||||
console.log('[TUIO] 1.1 Object removed - raw object:', tuioObject);
|
||||
|
||||
// Validate symbolId exists
|
||||
if (tuioObject.symbolId === undefined || tuioObject.symbolId === null) {
|
||||
console.warn('[TUIO] 1.1 Object has no symbolId, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
const hardwareId = String(tuioObject.symbolId);
|
||||
console.log('[TUIO] 1.1 Object removed - hardwareId:', hardwareId);
|
||||
this.callbacks.onTangibleRemove(hardwareId);
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +179,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 cursor is added (not used for tangibles)
|
||||
*/
|
||||
addTuioCursor(): void {
|
||||
console.log('[TUIO] 1.1 Cursor added (ignored)');
|
||||
// Ignore cursors (touch points)
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +186,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 cursor is updated (not used for tangibles)
|
||||
*/
|
||||
updateTuioCursor(): void {
|
||||
console.log('[TUIO] 1.1 Cursor updated (ignored)');
|
||||
// Ignore cursors
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +193,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 cursor is removed (not used for tangibles)
|
||||
*/
|
||||
removeTuioCursor(): void {
|
||||
console.log('[TUIO] 1.1 Cursor removed (ignored)');
|
||||
// Ignore cursors
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +200,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 blob is added (not used for tangibles)
|
||||
*/
|
||||
addTuioBlob(): void {
|
||||
console.log('[TUIO] 1.1 Blob added (ignored)');
|
||||
// Ignore blobs
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +207,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 blob is updated (not used for tangibles)
|
||||
*/
|
||||
updateTuioBlob(): void {
|
||||
console.log('[TUIO] 1.1 Blob updated (ignored)');
|
||||
// Ignore blobs
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +214,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called when a TUIO 1.1 blob is removed (not used for tangibles)
|
||||
*/
|
||||
removeTuioBlob(): void {
|
||||
console.log('[TUIO] 1.1 Blob removed (ignored)');
|
||||
// Ignore blobs
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +221,6 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
* Called on TUIO 1.1 frame refresh (time sync)
|
||||
*/
|
||||
refresh(): void {
|
||||
console.log('[TUIO] 1.1 Frame refresh (ignored)');
|
||||
// Ignore refresh events
|
||||
}
|
||||
|
||||
|
|
@ -259,12 +232,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
tuioAdd(tuioObject: Tuio20Object): void {
|
||||
const token = tuioObject.token;
|
||||
if (!token) {
|
||||
console.log('[TUIO] 2.0 Add event ignored (not a token)');
|
||||
return; // Only handle tokens (tangibles), not pointers
|
||||
}
|
||||
|
||||
const info = this.extractTangibleInfo(tuioObject);
|
||||
console.log('[TUIO] 2.0 Token added:', info);
|
||||
this.callbacks.onTangibleAdd(info.hardwareId, info);
|
||||
}
|
||||
|
||||
|
|
@ -274,12 +245,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
tuioUpdate(tuioObject: Tuio20Object): void {
|
||||
const token = tuioObject.token;
|
||||
if (!token) {
|
||||
console.log('[TUIO] 2.0 Update event ignored (not a token)');
|
||||
return;
|
||||
}
|
||||
|
||||
const info = this.extractTangibleInfo(tuioObject);
|
||||
console.log('[TUIO] 2.0 Token updated:', info);
|
||||
this.callbacks.onTangibleUpdate(info.hardwareId, info);
|
||||
}
|
||||
|
||||
|
|
@ -289,12 +258,10 @@ export class TuioClientManager implements Tuio11Listener, Tuio20Listener {
|
|||
tuioRemove(tuioObject: Tuio20Object): void {
|
||||
const token = tuioObject.token;
|
||||
if (!token) {
|
||||
console.log('[TUIO] 2.0 Remove event ignored (not a token)');
|
||||
return;
|
||||
}
|
||||
|
||||
const hardwareId = String(token.cId);
|
||||
console.log('[TUIO] 2.0 Token removed:', hardwareId);
|
||||
this.callbacks.onTangibleRemove(hardwareId);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
GraphActions
|
||||
} from '../types';
|
||||
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
|
||||
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
|
||||
|
||||
/**
|
||||
* ⚠️ IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS ⚠️
|
||||
|
|
@ -165,9 +166,26 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
})),
|
||||
|
||||
deleteNodeType: (id: string) =>
|
||||
set((state) => ({
|
||||
nodeTypes: state.nodeTypes.filter((type) => type.id !== id),
|
||||
})),
|
||||
set((state) => {
|
||||
// Remove node type ID from tangible filters.actorTypes arrays
|
||||
const updatedTangibles = state.tangibles.map((tangible) => {
|
||||
if (tangible.mode === 'filter' && tangible.filters?.actorTypes) {
|
||||
return {
|
||||
...tangible,
|
||||
filters: {
|
||||
...tangible.filters,
|
||||
actorTypes: tangible.filters.actorTypes.filter((typeId) => typeId !== id),
|
||||
},
|
||||
};
|
||||
}
|
||||
return tangible;
|
||||
});
|
||||
|
||||
return {
|
||||
nodeTypes: state.nodeTypes.filter((type) => type.id !== id),
|
||||
tangibles: updatedTangibles,
|
||||
};
|
||||
}),
|
||||
|
||||
// Edge type operations
|
||||
addEdgeType: (edgeType: EdgeTypeConfig) =>
|
||||
|
|
@ -183,9 +201,26 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
})),
|
||||
|
||||
deleteEdgeType: (id: string) =>
|
||||
set((state) => ({
|
||||
edgeTypes: state.edgeTypes.filter((type) => type.id !== id),
|
||||
})),
|
||||
set((state) => {
|
||||
// Remove edge type ID from tangible filters.relationTypes arrays
|
||||
const updatedTangibles = state.tangibles.map((tangible) => {
|
||||
if (tangible.mode === 'filter' && tangible.filters?.relationTypes) {
|
||||
return {
|
||||
...tangible,
|
||||
filters: {
|
||||
...tangible.filters,
|
||||
relationTypes: tangible.filters.relationTypes.filter((typeId) => typeId !== id),
|
||||
},
|
||||
};
|
||||
}
|
||||
return tangible;
|
||||
});
|
||||
|
||||
return {
|
||||
edgeTypes: state.edgeTypes.filter((type) => type.id !== id),
|
||||
tangibles: updatedTangibles,
|
||||
};
|
||||
}),
|
||||
|
||||
// Label operations
|
||||
addLabel: (label: LabelConfig) =>
|
||||
|
|
@ -221,13 +256,25 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
: edge.data,
|
||||
}));
|
||||
|
||||
// Remove label from tangible filterLabels arrays
|
||||
// Remove label from tangible filterLabels arrays (old format) and filters.labels (new format)
|
||||
const updatedTangibles = state.tangibles.map((tangible) => {
|
||||
if (tangible.mode === 'filter' && tangible.filterLabels) {
|
||||
return {
|
||||
...tangible,
|
||||
filterLabels: tangible.filterLabels.filter((labelId) => labelId !== id),
|
||||
};
|
||||
if (tangible.mode === 'filter') {
|
||||
const updates: Partial<typeof tangible> = {};
|
||||
|
||||
// Handle old format
|
||||
if (tangible.filterLabels) {
|
||||
updates.filterLabels = tangible.filterLabels.filter((labelId) => labelId !== id);
|
||||
}
|
||||
|
||||
// Handle new format
|
||||
if (tangible.filters?.labels) {
|
||||
updates.filters = {
|
||||
...tangible.filters,
|
||||
labels: tangible.filters.labels.filter((labelId) => labelId !== id),
|
||||
};
|
||||
}
|
||||
|
||||
return { ...tangible, ...updates };
|
||||
}
|
||||
return tangible;
|
||||
});
|
||||
|
|
@ -589,6 +636,11 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
return node;
|
||||
});
|
||||
|
||||
// Apply tangible migration for backward compatibility
|
||||
const migratedTangibles = data.tangibles
|
||||
? migrateTangibleConfigs(data.tangibles)
|
||||
: [];
|
||||
|
||||
// Atomic update: all state changes happen in a single set() call
|
||||
set({
|
||||
nodes: sanitizedNodes,
|
||||
|
|
@ -597,7 +649,7 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
nodeTypes: data.nodeTypes,
|
||||
edgeTypes: data.edgeTypes,
|
||||
labels: data.labels || [],
|
||||
tangibles: data.tangibles || [],
|
||||
tangibles: migratedTangibles,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -260,7 +260,6 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
|||
|
||||
// If this is a manual state switch (not from tangible), clear active state tangibles
|
||||
if (!fromTangible) {
|
||||
console.log('[Timeline] Manual state switch detected, clearing active state tangibles');
|
||||
useTuioStore.getState().clearActiveStateTangibles();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,21 @@ interface TuioState {
|
|||
addActiveStateTangible: (hardwareId: string) => void;
|
||||
removeActiveStateTangible: (hardwareId: string) => void;
|
||||
clearActiveStateTangibles: () => void;
|
||||
|
||||
// Presentation mode filters (runtime only - separate from editing mode filters)
|
||||
presentationFilters: {
|
||||
labels: string[];
|
||||
actorTypes: string[];
|
||||
relationTypes: string[];
|
||||
combineMode: 'AND' | 'OR';
|
||||
};
|
||||
setPresentationFilters: (filters: {
|
||||
labels: string[];
|
||||
actorTypes: string[];
|
||||
relationTypes: string[];
|
||||
combineMode: 'AND' | 'OR';
|
||||
}) => void;
|
||||
clearPresentationFilters: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_WEBSOCKET_URL = 'ws://localhost:3333';
|
||||
|
|
@ -111,6 +126,27 @@ export const useTuioStore = create<TuioState>()(
|
|||
|
||||
clearActiveStateTangibles: () =>
|
||||
set({ activeStateTangibles: [] }),
|
||||
|
||||
// Presentation mode filters
|
||||
presentationFilters: {
|
||||
labels: [],
|
||||
actorTypes: [],
|
||||
relationTypes: [],
|
||||
combineMode: 'OR', // Default to OR for presentation mode
|
||||
},
|
||||
|
||||
setPresentationFilters: (filters) =>
|
||||
set({ presentationFilters: filters }),
|
||||
|
||||
clearPresentationFilters: () =>
|
||||
set({
|
||||
presentationFilters: {
|
||||
labels: [],
|
||||
actorTypes: [],
|
||||
relationTypes: [],
|
||||
combineMode: 'OR',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'constellation-tuio-settings',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { getCurrentGraphFromDocument } from './workspace/documentUtils';
|
|||
import { Cite } from '@citation-js/core';
|
||||
import type { CSLReference } from '../types/bibliography';
|
||||
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
|
||||
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
|
||||
|
||||
/**
|
||||
* Workspace Store
|
||||
|
|
@ -83,6 +84,11 @@ function initializeWorkspace(): Workspace {
|
|||
if (savedState.activeDocumentId) {
|
||||
const doc = loadDocumentFromStorage(savedState.activeDocumentId);
|
||||
if (doc) {
|
||||
// Apply tangible migration for backward compatibility
|
||||
if (doc.tangibles) {
|
||||
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
|
||||
}
|
||||
|
||||
documents.set(savedState.activeDocumentId, doc);
|
||||
|
||||
// Load timeline if it exists
|
||||
|
|
@ -307,6 +313,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return;
|
||||
}
|
||||
|
||||
// Apply tangible migration for backward compatibility
|
||||
if (doc.tangibles) {
|
||||
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
|
||||
}
|
||||
|
||||
// Load timeline if it exists
|
||||
if (doc.timeline) {
|
||||
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
|
||||
|
|
@ -610,6 +621,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
importedDoc.metadata.title = importedDoc.metadata.title || 'Imported Analysis';
|
||||
importedDoc.metadata.updatedAt = now;
|
||||
|
||||
// Apply tangible migration for backward compatibility
|
||||
if (importedDoc.tangibles) {
|
||||
importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles);
|
||||
}
|
||||
|
||||
const metadata: DocumentMetadata = {
|
||||
id: documentId,
|
||||
title: importedDoc.metadata.title || 'Imported Analysis',
|
||||
|
|
@ -917,6 +933,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
|
||||
// Save all documents
|
||||
documents.forEach((doc, docId) => {
|
||||
// Apply tangible migration for backward compatibility
|
||||
if (doc.tangibles) {
|
||||
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
|
||||
}
|
||||
|
||||
saveDocumentToStorage(docId, doc);
|
||||
|
||||
const metadata = {
|
||||
|
|
@ -1433,14 +1454,26 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
// 1. Remove from document's labels
|
||||
doc.labels = (doc.labels || []).filter((label) => label.id !== labelId);
|
||||
|
||||
// 2. Remove label from tangible filterLabels arrays
|
||||
// 2. Remove label from tangible filterLabels arrays (old format) and filters.labels (new format)
|
||||
if (doc.tangibles) {
|
||||
doc.tangibles = doc.tangibles.map((tangible) => {
|
||||
if (tangible.mode === 'filter' && tangible.filterLabels) {
|
||||
return {
|
||||
...tangible,
|
||||
filterLabels: tangible.filterLabels.filter((id) => id !== labelId),
|
||||
};
|
||||
if (tangible.mode === 'filter') {
|
||||
const updates: Partial<typeof tangible> = {};
|
||||
|
||||
// Handle old format
|
||||
if (tangible.filterLabels) {
|
||||
updates.filterLabels = tangible.filterLabels.filter((id) => id !== labelId);
|
||||
}
|
||||
|
||||
// Handle new format
|
||||
if (tangible.filters?.labels) {
|
||||
updates.filters = {
|
||||
...tangible.filters,
|
||||
labels: tangible.filters.labels.filter((id) => id !== labelId),
|
||||
};
|
||||
}
|
||||
|
||||
return { ...tangible, ...updates };
|
||||
}
|
||||
return tangible;
|
||||
});
|
||||
|
|
@ -1590,9 +1623,20 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
}
|
||||
|
||||
// Validate mode-specific fields
|
||||
if (tangible.mode === 'filter' && (!tangible.filterLabels || tangible.filterLabels.length === 0)) {
|
||||
useToastStore.getState().showToast('Filter mode requires at least one label', 'error');
|
||||
return;
|
||||
if (tangible.mode === 'filter') {
|
||||
// Check new format first
|
||||
const hasNewFilters = tangible.filters && (
|
||||
(tangible.filters.labels && tangible.filters.labels.length > 0) ||
|
||||
(tangible.filters.actorTypes && tangible.filters.actorTypes.length > 0) ||
|
||||
(tangible.filters.relationTypes && tangible.filters.relationTypes.length > 0)
|
||||
);
|
||||
// Check old format for backward compatibility
|
||||
const hasOldFilters = tangible.filterLabels && tangible.filterLabels.length > 0;
|
||||
|
||||
if (!hasNewFilters && !hasOldFilters) {
|
||||
useToastStore.getState().showToast('Filter mode requires at least one filter (labels, actor types, or relation types)', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) {
|
||||
useToastStore.getState().showToast('State mode requires a state selection', 'error');
|
||||
|
|
@ -1654,9 +1698,20 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
}
|
||||
|
||||
// Validate mode-specific fields if mode is being updated
|
||||
if (updates.mode === 'filter' && (!updates.filterLabels || updates.filterLabels.length === 0)) {
|
||||
useToastStore.getState().showToast('Filter mode requires at least one label', 'error');
|
||||
return;
|
||||
if (updates.mode === 'filter') {
|
||||
// Check new format first
|
||||
const hasNewFilters = updates.filters && (
|
||||
(updates.filters.labels && updates.filters.labels.length > 0) ||
|
||||
(updates.filters.actorTypes && updates.filters.actorTypes.length > 0) ||
|
||||
(updates.filters.relationTypes && updates.filters.relationTypes.length > 0)
|
||||
);
|
||||
// Check old format for backward compatibility
|
||||
const hasOldFilters = updates.filterLabels && updates.filterLabels.length > 0;
|
||||
|
||||
if (!hasNewFilters && !hasOldFilters) {
|
||||
useToastStore.getState().showToast('Filter mode requires at least one filter (labels, actor types, or relation types)', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((updates.mode === 'state' || updates.mode === 'stateDial') && !updates.stateId) {
|
||||
useToastStore.getState().showToast('State mode requires a state selection', 'error');
|
||||
|
|
|
|||
|
|
@ -69,13 +69,26 @@ export interface LabelConfig {
|
|||
// Tangible Configuration
|
||||
export type TangibleMode = 'filter' | 'state' | 'stateDial';
|
||||
|
||||
export type FilterCombineMode = 'AND' | 'OR';
|
||||
|
||||
export interface FilterConfig {
|
||||
labels?: string[]; // Array of LabelConfig IDs
|
||||
actorTypes?: string[]; // Array of NodeTypeConfig IDs
|
||||
relationTypes?: string[]; // Array of EdgeTypeConfig IDs
|
||||
combineMode?: FilterCombineMode; // How to combine filter categories (default: 'OR' for tangibles, 'AND' for editing)
|
||||
}
|
||||
|
||||
export interface TangibleConfig {
|
||||
id: string; // Internal unique identifier (auto-generated from name)
|
||||
name: string;
|
||||
mode: TangibleMode;
|
||||
description?: string;
|
||||
hardwareId?: string; // Hardware token/device ID (editable, must be unique if present)
|
||||
filterLabels?: string[]; // For filter mode: array of LabelConfig IDs
|
||||
/**
|
||||
* @deprecated Use filters instead. This field is kept for backward compatibility.
|
||||
*/
|
||||
filterLabels?: string[]; // For filter mode: array of LabelConfig IDs (deprecated, use filters.labels)
|
||||
filters?: FilterConfig; // For filter mode: filter configuration for labels, actor types, and relation types
|
||||
stateId?: string; // For state/stateDial mode: ConstellationState ID
|
||||
}
|
||||
|
||||
|
|
|
|||
142
src/utils/__tests__/tangibleMigration.test.ts
Normal file
142
src/utils/__tests__/tangibleMigration.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
src/utils/tangibleMigration.ts
Normal file
41
src/utils/tangibleMigration.ts
Normal 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);
|
||||
}
|
||||
66
src/utils/tangibleValidation.ts
Normal file
66
src/utils/tangibleValidation.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue