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