From f002e1660d7143d1485c8637df12fd84ef6f4ad0 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 16 Jan 2026 22:57:06 +0100 Subject: [PATCH] Add TUIO protocol integration for tangible hardware detection Implements WebSocket-based TUIO protocol support to connect physical tangibles to presentation mode. When tangibles are placed on/removed from a touch screen, they trigger configured actions (label filtering or state switching). Features: - TUIO 1.1 and 2.0 protocol support with version selection - WebSocket connection management with real-time status - Test connection feature in configuration dialog - Persistent settings (WebSocket URL and protocol version) - Multi-tangible handling: union for filters, last-wins for states - Automatic connection in presentation mode Implementation: - TuioClientManager: Wrapper for tuio-client library with dual protocol support - WebsocketTuioReceiver: Custom OSC/WebSocket transport layer - useTuioIntegration: React hook bridging TUIO events to app stores - TuioConnectionConfig: Settings UI with real-time tangible detection - tuioStore: Zustand store with localStorage persistence Technical details: - TUIO 1.1 uses symbolId for hardware identification - TUIO 2.0 uses token.cId for hardware identification - Filter mode: Activates labels, union of all active tangibles - State mode: Switches timeline state, last tangible wins - Cleanup: Removes only labels no longer in use by any tangible - Unknown hardware IDs are silently ignored Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 70 ++- package.json | 2 + src/App.tsx | 4 + .../integration/tuio-integration.test.tsx | 433 ++++++++++++++++ .../Config/TuioConnectionConfig.tsx | 349 +++++++++++++ src/components/Menu/MenuBar.tsx | 19 + src/hooks/useTuioIntegration.ts | 199 ++++++++ src/lib/tuio/WebsocketTuioReceiver.ts | 93 ++++ src/lib/tuio/tuioClient.ts | 281 +++++++++++ src/lib/tuio/types.ts | 35 ++ src/stores/tuioStore.test.ts | 465 ++++++++++++++++++ src/stores/tuioStore.ts | 107 ++++ 12 files changed, 2047 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/integration/tuio-integration.test.tsx create mode 100644 src/components/Config/TuioConnectionConfig.tsx create mode 100644 src/hooks/useTuioIntegration.ts create mode 100644 src/lib/tuio/WebsocketTuioReceiver.ts create mode 100644 src/lib/tuio/tuioClient.ts create mode 100644 src/lib/tuio/types.ts create mode 100644 src/stores/tuioStore.test.ts create mode 100644 src/stores/tuioStore.ts diff --git a/package-lock.json b/package-lock.json index 84f7983..f9cc3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,10 @@ "@xyflow/react": "^12.3.5", "html-to-image": "^1.11.11", "jszip": "^3.10.1", + "osc-js": "^2.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "tuio-client": "^0.1.0", "zustand": "^4.5.0" }, "devDependencies": { @@ -143,6 +145,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -399,6 +402,7 @@ "version": "0.7.18", "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.18.tgz", "integrity": "sha512-EjLuZWA5156dIFGdF7OnyPyWFBW43B8Ckje6Sn/W2RFxHDu0oACvW4/6TNgWT80jhEA4bVFm7ahrZe9MJ2B2UQ==", + "peer": true, "dependencies": { "@citation-js/date": "^0.5.0", "@citation-js/name": "^0.4.2", @@ -643,6 +647,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -687,6 +692,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -748,6 +754,7 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -788,6 +795,7 @@ "version": "11.14.1", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1458,6 +1466,7 @@ "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/core-downloads-tracker": "^5.18.0", @@ -2078,8 +2087,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2216,6 +2224,7 @@ "version": "18.3.26", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2226,6 +2235,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2290,6 +2300,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2565,6 +2576,7 @@ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -2630,6 +2642,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2898,6 +2911,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3258,6 +3272,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -3445,8 +3460,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -3565,6 +3579,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4064,6 +4079,7 @@ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz", "integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==", "dev": true, + "peer": true, "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -4359,6 +4375,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4617,7 +4634,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4832,6 +4848,15 @@ "node": ">= 0.8.0" } }, + "node_modules/osc-js": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/osc-js/-/osc-js-2.4.1.tgz", + "integrity": "sha512-QlSeRKJclL47FNvO1MUCAAp9frmCF9zcYbnf6R9HpcklAst8ZyX3ISsk1v/Vghr/5GmXn0bhVjFXF9h+hfnl4Q==", + "license": "MIT", + "dependencies": { + "ws": "^8.16.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5044,6 +5069,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5195,7 +5221,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5210,7 +5235,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -5222,8 +5246,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/process-nextick-args": { "version": "2.0.1", @@ -5278,6 +5301,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5289,6 +5313,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6002,6 +6027,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -6110,6 +6136,16 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/tuio-client": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/tuio-client/-/tuio-client-0.1.0.tgz", + "integrity": "sha512-m8zGGVIMa7oKRFTjZVsiufdvBWglT9Wzfldh6BkWcZC6gtJfkftN+5Oe1DsJZo8AMPvBGaIhwvtMmk01LZJutQ==", + "license": "MIT", + "dependencies": { + "uqr": "0.1.2", + "vecti": "^3.0.13" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6139,6 +6175,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6183,6 +6220,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6205,11 +6248,18 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/vecti": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/vecti/-/vecti-3.1.5.tgz", + "integrity": "sha512-cCDs67CGh7SlcthnZoO9IR0zmgrkkPJ1h54JNbtAJ10cWCXYUvw9eEwRGMlaPMgkwRpNcN8U/QlOgHu3zyuN9Q==", + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.20", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -6291,6 +6341,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -6561,7 +6612,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index f88594f..1522f1e 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,10 @@ "@xyflow/react": "^12.3.5", "html-to-image": "^1.11.11", "jszip": "^3.10.1", + "osc-js": "^2.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "tuio-client": "^0.1.0", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 9238a64..8f6e798 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import ToastContainer from "./components/Common/ToastContainer"; import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useDocumentHistory } from "./hooks/useDocumentHistory"; +import { useTuioIntegration } from "./hooks/useTuioIntegration"; import { useWorkspaceStore } from "./stores/workspaceStore"; import { usePanelStore } from "./stores/panelStore"; import { useSettingsStore } from "./stores/settingsStore"; @@ -97,6 +98,9 @@ function AppContent() { onFocusSearch: () => leftPanelRef.current?.focusSearch(), }); + // Setup TUIO integration for tangible detection + useTuioIntegration(); + // Escape key to close property panels useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/__tests__/integration/tuio-integration.test.tsx b/src/__tests__/integration/tuio-integration.test.tsx new file mode 100644 index 0000000..39a940f --- /dev/null +++ b/src/__tests__/integration/tuio-integration.test.tsx @@ -0,0 +1,433 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useWorkspaceStore } from '../../stores/workspaceStore'; +import { useTuioStore } from '../../stores/tuioStore'; +import { useSearchStore } from '../../stores/searchStore'; +import { useTimelineStore } from '../../stores/timelineStore'; +import { useGraphStore } from '../../stores/graphStore'; +import { resetWorkspaceStore } from '../../test-utils/test-helpers'; +import type { TuioTangibleInfo } from '../../lib/tuio/types'; +import type { TangibleConfig } from '../../types'; + +// Mock TUIO client to avoid needing a real WebSocket connection +vi.mock('../../lib/tuio/tuioClient', () => ({ + TuioClientManager: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + getUrl: vi.fn().mockReturnValue('ws://localhost:3333'), + })), +})); + +/** + * Integration tests for TUIO tangible detection + * + * These tests verify that TUIO tangible detection properly integrates with + * the application stores (searchStore, timelineStore, graphStore) to trigger + * filter activation and state switching. + * + * Key integration points tested: + * - Filter tangible detection activates labels in searchStore + * - State tangible detection switches timeline state + * - Multi-tangible handling (union of filters, last-wins for states) + * - Tangible removal cleanup + */ +describe('TUIO Integration', () => { + beforeEach(() => { + localStorage.clear(); + resetWorkspaceStore(); + + // Reset TUIO store + useTuioStore.setState({ + websocketUrl: 'ws://localhost:3333', + isConnected: false, + connectionError: null, + activeTangibles: new Map(), + lastStateChangeSource: null, + }); + + // Reset search store + useSearchStore.setState({ + searchText: '', + selectedActorTypes: [], + selectedRelationTypes: [], + selectedLabels: [], + }); + }); + + describe('Filter Tangible Detection', () => { + it('should activate labels when filter tangible is detected', () => { + // Setup: Create document with labels and tangible + const docId = useWorkspaceStore.getState().createDocument('TUIO Filter Test'); + + // Add labels to document + useGraphStore.getState().addLabel({ + id: 'label-1', + name: 'Critical', + color: '#ff0000', + appliesTo: 'both', + }); + useGraphStore.getState().addLabel({ + id: 'label-2', + name: 'Important', + color: '#00ff00', + appliesTo: 'both', + }); + + // Add filter tangible configuration + const tangible: Omit = { + name: 'Red Token', + mode: 'filter', + hardwareId: '42', + filterLabels: ['label-1', 'label-2'], + }; + useWorkspaceStore.getState().addTangibleToDocument(docId, tangible as TangibleConfig); + + // Simulate TUIO tangible detection + const tangibleInfo: TuioTangibleInfo = { + hardwareId: '42', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }; + + // Add tangible to TUIO store + useTuioStore.getState().addActiveTangible('42', tangibleInfo); + + // Import the handler function to test it directly + // In real usage, this would be called by the TUIO client + const { toggleSelectedLabel } = useSearchStore.getState(); + + // Manually trigger label activation (simulating the integration hook) + const graphTangibles = useGraphStore.getState().tangibles; + const config = graphTangibles.find((t) => t.hardwareId === '42'); + + if (config && config.mode === 'filter' && config.filterLabels) { + config.filterLabels.forEach((labelId) => { + const { selectedLabels } = useSearchStore.getState(); + if (!selectedLabels.includes(labelId)) { + toggleSelectedLabel(labelId); + } + }); + } + + // Verify labels are selected + const selectedLabels = useSearchStore.getState().selectedLabels; + expect(selectedLabels).toContain('label-1'); + expect(selectedLabels).toContain('label-2'); + expect(selectedLabels.length).toBe(2); + }); + + it('should handle multiple filter tangibles with union of labels', () => { + const docId = useWorkspaceStore.getState().createDocument('Multi-Filter Test'); + + // Add labels + useGraphStore.getState().addLabel({ + id: 'label-1', + name: 'Label 1', + color: '#ff0000', + appliesTo: 'both', + }); + useGraphStore.getState().addLabel({ + id: 'label-2', + name: 'Label 2', + color: '#00ff00', + appliesTo: 'both', + }); + useGraphStore.getState().addLabel({ + id: 'label-3', + name: 'Label 3', + color: '#0000ff', + appliesTo: 'both', + }); + + // Add two tangibles with overlapping labels + useWorkspaceStore.getState().addTangibleToDocument(docId, { + name: 'Token 1', + mode: 'filter', + hardwareId: '42', + filterLabels: ['label-1', 'label-2'], + } as TangibleConfig); + + useWorkspaceStore.getState().addTangibleToDocument(docId, { + name: 'Token 2', + mode: 'filter', + hardwareId: '13', + filterLabels: ['label-2', 'label-3'], + } as TangibleConfig); + + // Activate both tangibles + const { toggleSelectedLabel } = useSearchStore.getState(); + const graphTangibles = useGraphStore.getState().tangibles; + + // Tangible 1 + const config1 = graphTangibles.find((t) => t.hardwareId === '42'); + if (config1?.filterLabels) { + config1.filterLabels.forEach((labelId) => { + const { selectedLabels } = useSearchStore.getState(); + if (!selectedLabels.includes(labelId)) { + toggleSelectedLabel(labelId); + } + }); + } + + // Tangible 2 + const config2 = graphTangibles.find((t) => t.hardwareId === '13'); + if (config2?.filterLabels) { + config2.filterLabels.forEach((labelId) => { + const { selectedLabels } = useSearchStore.getState(); + if (!selectedLabels.includes(labelId)) { + toggleSelectedLabel(labelId); + } + }); + } + + // Verify union of labels (label-1, label-2, label-3) + const selectedLabels = useSearchStore.getState().selectedLabels; + expect(selectedLabels).toContain('label-1'); + expect(selectedLabels).toContain('label-2'); + expect(selectedLabels).toContain('label-3'); + expect(selectedLabels.length).toBe(3); + }); + + it('should remove only unused labels when tangible is removed', () => { + const docId = useWorkspaceStore.getState().createDocument('Remove Filter Test'); + + // Add labels + useGraphStore.getState().addLabel({ + id: 'label-1', + name: 'Label 1', + color: '#ff0000', + appliesTo: 'both', + }); + useGraphStore.getState().addLabel({ + id: 'label-2', + name: 'Label 2', + color: '#00ff00', + appliesTo: 'both', + }); + + // Add two tangibles with shared label + useWorkspaceStore.getState().addTangibleToDocument(docId, { + name: 'Token 1', + mode: 'filter', + hardwareId: '42', + filterLabels: ['label-1', 'label-2'], + } as TangibleConfig); + + useWorkspaceStore.getState().addTangibleToDocument(docId, { + name: 'Token 2', + mode: 'filter', + hardwareId: '13', + filterLabels: ['label-2'], + } as TangibleConfig); + + // Add tangibles to TUIO store (simulate detection) + useTuioStore.getState().addActiveTangible('42', { + hardwareId: '42', + x: 0.3, + y: 0.3, + angle: 0, + lastUpdated: Date.now(), + }); + useTuioStore.getState().addActiveTangible('13', { + hardwareId: '13', + x: 0.7, + y: 0.7, + angle: 0, + lastUpdated: Date.now(), + }); + + // Activate labels for both tangibles + const { toggleSelectedLabel } = useSearchStore.getState(); + const graphTangibles = useGraphStore.getState().tangibles; + + graphTangibles.forEach((config) => { + if (config.mode === 'filter' && config.filterLabels) { + config.filterLabels.forEach((labelId) => { + const { selectedLabels } = useSearchStore.getState(); + if (!selectedLabels.includes(labelId)) { + toggleSelectedLabel(labelId); + } + }); + } + }); + + expect(useSearchStore.getState().selectedLabels).toContain('label-1'); + expect(useSearchStore.getState().selectedLabels).toContain('label-2'); + + // Remove tangible 1 from TUIO store (should remove label-1 but keep label-2) + useTuioStore.getState().removeActiveTangible('42'); + + // Simulate removal logic + const config1 = graphTangibles.find((t) => t.hardwareId === '42'); + if (config1?.filterLabels) { + const activeTangibles = useTuioStore.getState().activeTangibles; + const labelsStillActive = new Set(); + + activeTangibles.forEach((_, hwId) => { + const cfg = graphTangibles.find( + (t) => t.hardwareId === hwId && t.mode === 'filter' + ); + if (cfg?.filterLabels) { + cfg.filterLabels.forEach((labelId) => labelsStillActive.add(labelId)); + } + }); + + config1.filterLabels.forEach((labelId) => { + const { selectedLabels } = useSearchStore.getState(); + if (selectedLabels.includes(labelId) && !labelsStillActive.has(labelId)) { + toggleSelectedLabel(labelId); + } + }); + } + + // label-1 should be removed, label-2 should remain (still used by tangible 2) + const selectedLabels = useSearchStore.getState().selectedLabels; + expect(selectedLabels).not.toContain('label-1'); + expect(selectedLabels).toContain('label-2'); + }); + }); + + describe('State Tangible Detection', () => { + it('should switch to configured state when state tangible is detected', () => { + const docId = useWorkspaceStore.getState().createDocument('TUIO State Test'); + + // Create a new state + const stateId = useTimelineStore.getState().createState('Test State', 'Description'); + + // Add state tangible + useWorkspaceStore.getState().addTangibleToDocument(docId, { + name: 'Blue Token', + mode: 'state', + hardwareId: '99', + stateId: stateId, + } as TangibleConfig); + + // Simulate tangible detection and state switch + const config = useGraphStore.getState().tangibles.find((t) => t.hardwareId === '99'); + + if (config?.stateId) { + useTimelineStore.getState().switchToState(config.stateId); + useTuioStore.getState().setLastStateChangeSource('99'); + } + + // Verify state was switched + const currentStateId = useTimelineStore.getState().timelines.get(docId)?.currentStateId; + expect(currentStateId).toBe(stateId); + expect(useTuioStore.getState().lastStateChangeSource).toBe('99'); + }); + + it('should not revert state when state tangible is removed', () => { + const docId = useWorkspaceStore.getState().createDocument('State Removal Test'); + + // Create state and tangible + const initialStateId = useTimelineStore.getState().timelines.get(docId)?.rootStateId; + const newStateId = useTimelineStore.getState().createState('New State'); + + useWorkspaceStore.getState().addTangibleToDocument(docId, { + name: 'State Token', + mode: 'state', + hardwareId: '100', + stateId: newStateId, + } as TangibleConfig); + + // Switch to new state + useTimelineStore.getState().switchToState(newStateId); + useTuioStore.getState().setLastStateChangeSource('100'); + + // Remove tangible + useTuioStore.getState().removeActiveTangible('100'); + + // State should NOT revert + const currentStateId = useTimelineStore.getState().timelines.get(docId)?.currentStateId; + expect(currentStateId).toBe(newStateId); + expect(currentStateId).not.toBe(initialStateId); + }); + }); + + describe('Unknown Hardware IDs', () => { + it('should silently ignore tangibles with unknown hardware IDs', () => { + useWorkspaceStore.getState().createDocument('Unknown ID Test'); + + // Add tangible to TUIO store with unknown hardware ID + useTuioStore.getState().addActiveTangible('999', { + hardwareId: '999', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + + // No error should occur + const activeTangibles = useTuioStore.getState().activeTangibles; + expect(activeTangibles.size).toBe(1); + expect(activeTangibles.has('999')).toBe(true); + + // Search store should remain unchanged + const selectedLabels = useSearchStore.getState().selectedLabels; + expect(selectedLabels.length).toBe(0); + }); + }); + + describe('TUIO Store State Management', () => { + it('should track active tangibles correctly', () => { + const info1: TuioTangibleInfo = { + hardwareId: '42', + x: 0.3, + y: 0.3, + angle: 0, + lastUpdated: Date.now(), + }; + + const info2: TuioTangibleInfo = { + hardwareId: '13', + x: 0.7, + y: 0.7, + angle: 1.57, + lastUpdated: Date.now(), + }; + + useTuioStore.getState().addActiveTangible('42', info1); + useTuioStore.getState().addActiveTangible('13', info2); + + const activeTangibles = useTuioStore.getState().activeTangibles; + expect(activeTangibles.size).toBe(2); + expect(activeTangibles.get('42')).toEqual(info1); + expect(activeTangibles.get('13')).toEqual(info2); + + // Remove one + useTuioStore.getState().removeActiveTangible('42'); + + const remainingTangibles = useTuioStore.getState().activeTangibles; + expect(remainingTangibles.size).toBe(1); + expect(remainingTangibles.has('42')).toBe(false); + expect(remainingTangibles.has('13')).toBe(true); + }); + + it('should clear all tangibles', () => { + useTuioStore.getState().addActiveTangible('42', { + hardwareId: '42', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + + useTuioStore.getState().addActiveTangible('13', { + hardwareId: '13', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + + expect(useTuioStore.getState().activeTangibles.size).toBe(2); + + useTuioStore.getState().clearActiveTangibles(); + + expect(useTuioStore.getState().activeTangibles.size).toBe(0); + expect(useTuioStore.getState().lastStateChangeSource).toBe(null); + }); + }); +}); diff --git a/src/components/Config/TuioConnectionConfig.tsx b/src/components/Config/TuioConnectionConfig.tsx new file mode 100644 index 0000000..5684567 --- /dev/null +++ b/src/components/Config/TuioConnectionConfig.tsx @@ -0,0 +1,349 @@ +import { useState, useEffect, useRef } from 'react'; +import { useTuioStore } from '../../stores/tuioStore'; +import { useGraphStore } from '../../stores/graphStore'; +import { useToastStore } from '../../stores/toastStore'; +import { TuioClientManager } from '../../lib/tuio/tuioClient'; +import type { TuioTangibleInfo } from '../../lib/tuio/types'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const TuioConnectionConfig = ({ isOpen, onClose }: Props) => { + const { websocketUrl, setWebsocketUrl, protocolVersion, setProtocolVersion } = useTuioStore(); + const { tangibles } = useGraphStore(); + const { showToast } = useToastStore(); + + const [urlInput, setUrlInput] = useState(websocketUrl); + const [versionInput, setVersionInput] = useState<'1.1' | '2.0'>(protocolVersion); + const [testConnected, setTestConnected] = useState(false); + const [testConnectionError, setTestConnectionError] = useState(null); + const [testActiveTangibles, setTestActiveTangibles] = useState>( + new Map() + ); + const [isConnecting, setIsConnecting] = useState(false); + const testClientRef = useRef(null); + + // Sync inputs when modal opens + useEffect(() => { + if (isOpen) { + setUrlInput(websocketUrl); + setVersionInput(protocolVersion); + setTestConnected(false); + setTestConnectionError(null); + setTestActiveTangibles(new Map()); + } + }, [isOpen, websocketUrl, protocolVersion]); + + // Cleanup test connection when modal closes + useEffect(() => { + if (!isOpen && testClientRef.current) { + testClientRef.current.disconnect(); + testClientRef.current = null; + setTestConnected(false); + setTestActiveTangibles(new Map()); + } + }, [isOpen]); + + const handleConnect = async () => { + // Validate URL first + if (!urlInput.trim()) { + showToast('WebSocket URL is required', 'error'); + return; + } + + try { + const url = new URL(urlInput); + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + showToast('URL must start with ws:// or wss://', 'error'); + return; + } + } catch { + showToast('Invalid WebSocket URL format', 'error'); + return; + } + + setIsConnecting(true); + setTestConnectionError(null); + + try { + // Create test client + const client = new TuioClientManager( + { + onTangibleAdd: (hardwareId: string, info: TuioTangibleInfo) => { + setTestActiveTangibles((prev) => { + const newMap = new Map(prev); + newMap.set(hardwareId, info); + return newMap; + }); + }, + onTangibleUpdate: (hardwareId: string, info: TuioTangibleInfo) => { + setTestActiveTangibles((prev) => { + const newMap = new Map(prev); + if (newMap.has(hardwareId)) { + newMap.set(hardwareId, info); + } + return newMap; + }); + }, + onTangibleRemove: (hardwareId: string) => { + setTestActiveTangibles((prev) => { + const newMap = new Map(prev); + newMap.delete(hardwareId); + return newMap; + }); + }, + onConnectionChange: (connected: boolean, error?: string) => { + setTestConnected(connected); + if (error) { + setTestConnectionError(error); + showToast(`Connection failed: ${error}`, 'error'); + } + }, + }, + versionInput + ); + + testClientRef.current = client; + await client.connect(urlInput); + } catch (error) { + // Error already handled by onConnectionChange callback + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setTestConnectionError(errorMessage); + setTestConnected(false); + } finally { + setIsConnecting(false); + } + }; + + const handleDisconnect = () => { + if (testClientRef.current) { + testClientRef.current.disconnect(); + testClientRef.current = null; + } + setTestConnected(false); + setTestConnectionError(null); + setTestActiveTangibles(new Map()); + showToast('Disconnected from TUIO server', 'info'); + }; + + const handleSave = () => { + // Disconnect test connection before saving + if (testClientRef.current) { + testClientRef.current.disconnect(); + testClientRef.current = null; + setTestConnected(false); + setTestActiveTangibles(new Map()); + } + + // Validate WebSocket URL format + if (!urlInput.trim()) { + showToast('WebSocket URL is required', 'error'); + return; + } + + try { + const url = new URL(urlInput); + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + showToast('URL must start with ws:// or wss://', 'error'); + return; + } + } catch { + showToast('Invalid WebSocket URL format', 'error'); + return; + } + + // Save URL and protocol version + setWebsocketUrl(urlInput); + setProtocolVersion(versionInput); + onClose(); + }; + + const handleCancel = () => { + // Disconnect test connection before closing + if (testClientRef.current) { + testClientRef.current.disconnect(); + testClientRef.current = null; + setTestConnected(false); + setTestActiveTangibles(new Map()); + } + onClose(); + }; + + const handleReset = () => { + setUrlInput('ws://localhost:3333'); + }; + + if (!isOpen) return null; + + // Get active tangible configs from test connection + const activeTangibleConfigs = Array.from(testActiveTangibles.keys()) + .map((hwId) => tangibles.find((t) => t.hardwareId === hwId)) + .filter((t): t is NonNullable => t !== undefined); + + return ( +
+
+ {/* Header */} +
+

TUIO Connection Settings

+

+ Configure connection to TUIO server for tangible detection +

+
+ + {/* Content */} +
+ {/* WebSocket URL */} +
+ +
+ setUrlInput(e.target.value)} + placeholder="ws://localhost:3333" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+

+ Example: ws://localhost:3333 or ws://192.168.1.100:3333 +

+
+ + {/* Protocol Version */} +
+ + +

+ Select the TUIO protocol version used by your server. Most modern systems use TUIO 2.0. +

+
+ + {/* Test Connection */} +
+ +
+
+
+ + {testConnected ? 'Connected' : 'Disconnected'} + + {testConnectionError && ( + + ({testConnectionError}) + + )} +
+ {!testConnected ? ( + + ) : ( + + )} +
+

+ Test the connection to verify your TUIO server is reachable and detect tangibles. +

+
+ + {/* Active Tangibles */} +
+ +
+ {activeTangibleConfigs.length > 0 ? ( + activeTangibleConfigs.map((tangible) => { + const info = testActiveTangibles.get(tangible.hardwareId!); + return ( +
+
+
+

+ {tangible.name} +

+

+ Hardware ID: {tangible.hardwareId} • Mode: {tangible.mode} +

+
+ {info && ( +
+ Position: ({info.x.toFixed(2)}, {info.y.toFixed(2)}) +
+ )} +
+
+ ); + }) + ) : ( +
+ {testConnected + ? 'No tangibles detected. Place a configured tangible on the TUIO surface.' + : 'Connect to TUIO server to detect tangibles.'} +
+ )} +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default TuioConnectionConfig; diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx index d2937f3..62fdde8 100644 --- a/src/components/Menu/MenuBar.tsx +++ b/src/components/Menu/MenuBar.tsx @@ -9,6 +9,7 @@ import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import LabelConfigModal from '../Config/LabelConfig'; import TangibleConfigModal from '../Config/TangibleConfig'; +import TuioConnectionConfig from '../Config/TuioConnectionConfig'; import BibliographyConfigModal from '../Config/BibliographyConfig'; import InputDialog from '../Common/InputDialog'; import { useConfirm } from '../../hooks/useConfirm'; @@ -38,6 +39,7 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => const [showEdgeConfig, setShowEdgeConfig] = useState(false); const [showLabelConfig, setShowLabelConfig] = useState(false); const [showTangibleConfig, setShowTangibleConfig] = useState(false); + const [showTuioConfig, setShowTuioConfig] = useState(false); const [showBibliographyConfig, setShowBibliographyConfig] = useState(false); const [showNewDocDialog, setShowNewDocDialog] = useState(false); const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false); @@ -467,6 +469,19 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => Presentation Mode F11 + +
+ + {/* TUIO Connection Settings */} +
)}
@@ -526,6 +541,10 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => isOpen={showTangibleConfig} onClose={() => setShowTangibleConfig(false)} /> + setShowTuioConfig(false)} + /> setShowBibliographyConfig(false)} diff --git a/src/hooks/useTuioIntegration.ts b/src/hooks/useTuioIntegration.ts new file mode 100644 index 0000000..24bfae8 --- /dev/null +++ b/src/hooks/useTuioIntegration.ts @@ -0,0 +1,199 @@ +import { useEffect, useRef } from 'react'; +import { useTuioStore } from '../stores/tuioStore'; +import { useSettingsStore } from '../stores/settingsStore'; +import { useGraphStore } from '../stores/graphStore'; +import { useSearchStore } from '../stores/searchStore'; +import { useTimelineStore } from '../stores/timelineStore'; +import { TuioClientManager } from '../lib/tuio/tuioClient'; +import type { TuioTangibleInfo } from '../lib/tuio/types'; +import type { TangibleConfig } from '../types'; + +/** + * TUIO Integration Hook + * Manages TUIO client lifecycle and integrates tangible detection with application stores + * + * Behavior: + * - Connects to TUIO server when presentation mode is enabled + * - Disconnects when presentation mode is disabled + * - Maps detected tangibles to configured tangible actions + * - Filter mode: Activates label filters + * - State mode: Switches timeline state + */ +export function useTuioIntegration() { + const clientRef = useRef(null); + const { presentationMode } = useSettingsStore(); + const { websocketUrl, protocolVersion } = useTuioStore(); + + useEffect(() => { + // Only connect in presentation mode + if (!presentationMode) { + // Disconnect if we're leaving presentation mode + if (clientRef.current) { + clientRef.current.disconnect(); + clientRef.current = null; + useTuioStore.getState().clearActiveTangibles(); + } + return; + } + + // Create TUIO client if in presentation mode + const client = new TuioClientManager( + { + onTangibleAdd: handleTangibleAdd, + onTangibleUpdate: handleTangibleUpdate, + onTangibleRemove: handleTangibleRemove, + onConnectionChange: (connected, error) => { + useTuioStore.getState().setConnectionState(connected, error); + }, + }, + protocolVersion + ); + + clientRef.current = client; + + // Connect to TUIO server + client + .connect(websocketUrl) + .catch((error) => { + console.error('Failed to connect to TUIO server:', error); + }); + + // Cleanup on unmount or when presentation mode changes + return () => { + if (clientRef.current) { + clientRef.current.disconnect(); + clientRef.current = null; + useTuioStore.getState().clearActiveTangibles(); + } + }; + }, [presentationMode, websocketUrl, protocolVersion]); +} + +/** + * Handle tangible add event + */ +function handleTangibleAdd(hardwareId: string, info: TuioTangibleInfo): void { + // Update TUIO store + useTuioStore.getState().addActiveTangible(hardwareId, info); + + // Find matching tangible configuration + const tangibles = useGraphStore.getState().tangibles; + const tangibleConfig = tangibles.find((t) => t.hardwareId === hardwareId); + + if (!tangibleConfig) { + // Unknown hardware ID - silently ignore + return; + } + + // Trigger action based on tangible mode + if (tangibleConfig.mode === 'filter') { + applyFilterTangible(tangibleConfig); + } else if (tangibleConfig.mode === 'state') { + applyStateTangible(tangibleConfig, hardwareId); + } + // 'stateDial' mode ignored for now +} + +/** + * Handle tangible update event + * Currently just updates position/angle in store (for future stateDial support) + */ +function handleTangibleUpdate(hardwareId: string, info: TuioTangibleInfo): void { + useTuioStore.getState().updateActiveTangible(hardwareId, info); +} + +/** + * Handle tangible remove event + */ +function handleTangibleRemove(hardwareId: string): void { + // Remove from TUIO store + useTuioStore.getState().removeActiveTangible(hardwareId); + + // Find matching tangible configuration + const tangibles = useGraphStore.getState().tangibles; + const tangibleConfig = tangibles.find((t) => t.hardwareId === hardwareId); + + if (!tangibleConfig) { + return; + } + + // Handle removal based on tangible mode + if (tangibleConfig.mode === 'filter') { + removeFilterTangible(tangibleConfig); + } + // State mode: Don't revert on removal (stay in current state) +} + +/** + * Apply filter tangible - add its labels to selected labels + */ +function applyFilterTangible(tangible: TangibleConfig): void { + if (!tangible.filterLabels || tangible.filterLabels.length === 0) { + return; + } + + const { selectedLabels, toggleSelectedLabel } = useSearchStore.getState(); + + // Add labels that aren't already selected + tangible.filterLabels.forEach((labelId) => { + if (!selectedLabels.includes(labelId)) { + toggleSelectedLabel(labelId); + } + }); +} + +/** + * Remove filter tangible - remove its labels if no other active tangible uses them + */ +function removeFilterTangible(tangible: TangibleConfig): void { + if (!tangible.filterLabels || tangible.filterLabels.length === 0) { + return; + } + + // Get all remaining active filter tangibles + const activeTangibles = useTuioStore.getState().activeTangibles; + const allTangibles = useGraphStore.getState().tangibles; + + // Build set of labels still in use by other active filter tangibles + const labelsStillActive = new Set(); + activeTangibles.forEach((_, hwId) => { + const config = allTangibles.find( + (t) => t.hardwareId === hwId && t.mode === 'filter' + ); + if (config && config.filterLabels) { + config.filterLabels.forEach((labelId) => labelsStillActive.add(labelId)); + } + }); + + // Remove labels that are no longer active + const { selectedLabels, toggleSelectedLabel } = useSearchStore.getState(); + + tangible.filterLabels.forEach((labelId) => { + if (selectedLabels.includes(labelId) && !labelsStillActive.has(labelId)) { + toggleSelectedLabel(labelId); + } + }); +} + +/** + * Apply state tangible - switch to its configured state + */ +function applyStateTangible(tangible: TangibleConfig, hardwareId: string): void { + if (!tangible.stateId) { + return; + } + + const { lastStateChangeSource } = useTuioStore.getState(); + + // Only switch if this is a different tangible than the last state change + // (Last added wins strategy) + if (lastStateChangeSource === hardwareId) { + return; // Same tangible, don't re-switch + } + + // Switch to state + useTimelineStore.getState().switchToState(tangible.stateId); + + // Track this as the last state change source + useTuioStore.getState().setLastStateChangeSource(hardwareId); +} diff --git a/src/lib/tuio/WebsocketTuioReceiver.ts b/src/lib/tuio/WebsocketTuioReceiver.ts new file mode 100644 index 0000000..59e627a --- /dev/null +++ b/src/lib/tuio/WebsocketTuioReceiver.ts @@ -0,0 +1,93 @@ +import OSC from 'osc-js'; +import { TuioReceiver } from 'tuio-client'; + +/** + * OSC Message format (not exported by tuio-client) + */ +interface OscMessage { + address: string; + args: (number | string | boolean | Blob | null)[]; +} + +/** + * WebSocket-based TUIO receiver + * Connects to a TUIO server via WebSocket and forwards OSC messages to the TUIO client + */ +export class WebsocketTuioReceiver extends TuioReceiver { + private osc: OSC; + private onOpenCallback?: () => void; + private onCloseCallback?: (error?: string) => void; + private onErrorCallback?: (error: string) => void; + + constructor(host: string, port: number) { + super(); + + // Create OSC WebSocket client + this.osc = new OSC({ + plugin: new OSC.WebsocketClientPlugin({ + host, + port, + }), + }); + + // Forward all OSC messages to TUIO client + this.osc.on('*', (message: OscMessage) => { + this.onOscMessage(message); + }); + + // Listen for WebSocket connection events + this.osc.on('open', () => { + if (this.onOpenCallback) { + this.onOpenCallback(); + } + }); + + this.osc.on('close', () => { + if (this.onCloseCallback) { + this.onCloseCallback(); + } + }); + + this.osc.on('error', (error: unknown) => { + const errorMessage = error instanceof Error ? error.message : 'WebSocket error'; + if (this.onErrorCallback) { + this.onErrorCallback(errorMessage); + } + }); + } + + /** + * Set callback for connection open event + */ + setOnOpen(callback: () => void): void { + this.onOpenCallback = callback; + } + + /** + * Set callback for connection close event + */ + setOnClose(callback: (error?: string) => void): void { + this.onCloseCallback = callback; + } + + /** + * Set callback for connection error event + */ + setOnError(callback: (error: string) => void): void { + this.onErrorCallback = callback; + } + + /** + * Open WebSocket connection to TUIO server + */ + connect(): void { + this.osc.open(); + } + + /** + * Close WebSocket connection + */ + disconnect(): void { + this.osc.close(); + } +} diff --git a/src/lib/tuio/tuioClient.ts b/src/lib/tuio/tuioClient.ts new file mode 100644 index 0000000..d8b0090 --- /dev/null +++ b/src/lib/tuio/tuioClient.ts @@ -0,0 +1,281 @@ +import { + Tuio11Client, + Tuio11Object, + Tuio11Listener, + Tuio20Client, + Tuio20Object, + Tuio20Listener +} from 'tuio-client'; +import { WebsocketTuioReceiver } from './WebsocketTuioReceiver'; +import type { TuioClientCallbacks, TuioTangibleInfo } from './types'; + +export type TuioProtocolVersion = '1.1' | '2.0'; + +/** + * TUIO Client Manager + * Wraps both TUIO 1.1 and TUIO 2.0 clients and provides a simplified interface for tangible detection + */ +export class TuioClientManager implements Tuio11Listener, Tuio20Listener { + private client11: Tuio11Client | null = null; + private client20: Tuio20Client | null = null; + private receiver: WebsocketTuioReceiver | null = null; + private callbacks: TuioClientCallbacks; + private url: string = ''; + private protocolVersion: TuioProtocolVersion; + + constructor(callbacks: TuioClientCallbacks, protocolVersion: TuioProtocolVersion = '2.0') { + this.callbacks = callbacks; + this.protocolVersion = protocolVersion; + } + + /** + * Connect to TUIO server via WebSocket + * @param url WebSocket URL (e.g., 'ws://localhost:3333') + */ + async connect(url: string): Promise { + return new Promise((resolve, reject) => { + try { + // Parse WebSocket URL + const wsUrl = new URL(url); + const host = wsUrl.hostname; + const port = parseInt(wsUrl.port) || 3333; + + this.url = url; + + // Create receiver + this.receiver = new WebsocketTuioReceiver(host, port); + + // Create appropriate client based on protocol version + if (this.protocolVersion === '1.1') { + this.client11 = new Tuio11Client(this.receiver); + this.client20 = null; + } else { + this.client20 = new Tuio20Client(this.receiver); + this.client11 = null; + } + + // Set up connection event handlers + this.receiver.setOnOpen(() => { + // Connection successful + this.callbacks.onConnectionChange(true); + resolve(); + }); + + this.receiver.setOnError((error: string) => { + // Connection error + console.error('TUIO connection error:', error); + this.callbacks.onConnectionChange(false, error); + reject(new Error(error)); + }); + + this.receiver.setOnClose((error?: string) => { + // Connection closed + if (error) { + console.error('TUIO connection closed with error:', error); + this.callbacks.onConnectionChange(false, error); + } else { + this.callbacks.onConnectionChange(false); + } + }); + + // Add this manager as a listener + if (this.client11) { + this.client11.addTuioListener(this); + this.client11.connect(); + } else if (this.client20) { + this.client20.addTuioListener(this); + this.client20.connect(); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('TUIO connection error:', errorMessage); + this.callbacks.onConnectionChange(false, errorMessage); + reject(error); + } + }); + } + + /** + * Disconnect from TUIO server + */ + disconnect(): void { + if (this.client11) { + this.client11.removeTuioListener(this); + this.client11.disconnect(); + this.client11 = null; + } + + if (this.client20) { + this.client20.removeTuioListener(this); + this.client20.disconnect(); + this.client20 = null; + } + + if (this.receiver) { + this.receiver.disconnect(); + this.receiver = null; + } + + this.callbacks.onConnectionChange(false); + } + + /** + * Check if currently connected + */ + isConnected(): boolean { + return (this.client11 !== null || this.client20 !== null) && this.receiver !== null; + } + + /** + * Get current WebSocket URL + */ + getUrl(): string { + return this.url; + } + + // TUIO 1.1 Listener implementation + + /** + * Called when a TUIO 1.1 object is added (tangible placed on surface) + */ + addTuioObject(tuioObject: Tuio11Object): void { + const info = this.extractTangibleInfo11(tuioObject); + this.callbacks.onTangibleAdd(info.hardwareId, info); + } + + /** + * Called when a TUIO 1.1 object is updated (position/rotation changed) + */ + updateTuioObject(tuioObject: Tuio11Object): void { + const info = this.extractTangibleInfo11(tuioObject); + this.callbacks.onTangibleUpdate(info.hardwareId, info); + } + + /** + * Called when a TUIO 1.1 object is removed (tangible removed from surface) + */ + removeTuioObject(tuioObject: Tuio11Object): void { + const hardwareId = String(tuioObject.symbolId); + this.callbacks.onTangibleRemove(hardwareId); + } + + /** + * Called when a TUIO 1.1 cursor is added (not used for tangibles) + */ + addTuioCursor(): void { + // Ignore cursors (touch points) + } + + /** + * Called when a TUIO 1.1 cursor is updated (not used for tangibles) + */ + updateTuioCursor(): void { + // Ignore cursors + } + + /** + * Called when a TUIO 1.1 cursor is removed (not used for tangibles) + */ + removeTuioCursor(): void { + // Ignore cursors + } + + /** + * Called when a TUIO 1.1 blob is added (not used for tangibles) + */ + addTuioBlob(): void { + // Ignore blobs + } + + /** + * Called when a TUIO 1.1 blob is updated (not used for tangibles) + */ + updateTuioBlob(): void { + // Ignore blobs + } + + /** + * Called when a TUIO 1.1 blob is removed (not used for tangibles) + */ + removeTuioBlob(): void { + // Ignore blobs + } + + /** + * Called on TUIO 1.1 frame refresh (time sync) + */ + refresh(): void { + // Ignore refresh events + } + + // TUIO 2.0 Listener implementation + + /** + * Called when a TUIO object is added (tangible placed on surface) + */ + tuioAdd(tuioObject: Tuio20Object): void { + const token = tuioObject.token; + if (!token) return; // Only handle tokens (tangibles), not pointers + + const info = this.extractTangibleInfo(tuioObject); + this.callbacks.onTangibleAdd(info.hardwareId, info); + } + + /** + * Called when a TUIO object is updated (position/rotation changed) + */ + tuioUpdate(tuioObject: Tuio20Object): void { + const token = tuioObject.token; + if (!token) return; + + const info = this.extractTangibleInfo(tuioObject); + this.callbacks.onTangibleUpdate(info.hardwareId, info); + } + + /** + * Called when a TUIO object is removed (tangible removed from surface) + */ + tuioRemove(tuioObject: Tuio20Object): void { + const token = tuioObject.token; + if (!token) return; + + const hardwareId = String(token.cId); + this.callbacks.onTangibleRemove(hardwareId); + } + + /** + * Called on frame refresh (time sync) + * Not used in this implementation + */ + tuioRefresh(): void { + // Ignore refresh events + } + + /** + * Extract tangible information from TUIO 2.0 object + */ + private extractTangibleInfo(tuioObject: Tuio20Object): TuioTangibleInfo { + const token = tuioObject.token!; + + return { + hardwareId: String(token.cId), // Component ID is the unique marker ID + x: token.position.x, + y: token.position.y, + angle: token.angle, + lastUpdated: Date.now(), + }; + } + + /** + * Extract tangible information from TUIO 1.1 object + */ + private extractTangibleInfo11(tuioObject: Tuio11Object): TuioTangibleInfo { + return { + hardwareId: String(tuioObject.symbolId), // Symbol ID is the marker ID + x: tuioObject.position.x, + y: tuioObject.position.y, + angle: tuioObject.angle, + lastUpdated: Date.now(), + }; + } +} diff --git a/src/lib/tuio/types.ts b/src/lib/tuio/types.ts new file mode 100644 index 0000000..806651b --- /dev/null +++ b/src/lib/tuio/types.ts @@ -0,0 +1,35 @@ +/** + * TUIO protocol type definitions + * + * @see https://www.tuio.org/ + */ + +/** + * Information about a detected TUIO tangible object + */ +export interface TuioTangibleInfo { + /** Hardware ID of the tangible (TUIO symbol ID) */ + hardwareId: string; + /** X position (normalized 0-1) */ + x: number; + /** Y position (normalized 0-1) */ + y: number; + /** Rotation angle in radians */ + angle: number; + /** Timestamp of last update */ + lastUpdated: number; +} + +/** + * Callbacks for TUIO client events + */ +export interface TuioClientCallbacks { + /** Called when a tangible is added to the surface */ + onTangibleAdd: (hardwareId: string, info: TuioTangibleInfo) => void; + /** Called when a tangible is updated (position/rotation changed) */ + onTangibleUpdate: (hardwareId: string, info: TuioTangibleInfo) => void; + /** Called when a tangible is removed from the surface */ + onTangibleRemove: (hardwareId: string) => void; + /** Called when connection state changes */ + onConnectionChange: (connected: boolean, error?: string) => void; +} diff --git a/src/stores/tuioStore.test.ts b/src/stores/tuioStore.test.ts new file mode 100644 index 0000000..990fff5 --- /dev/null +++ b/src/stores/tuioStore.test.ts @@ -0,0 +1,465 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useTuioStore } from './tuioStore'; +import type { TuioTangibleInfo } from '../lib/tuio/types'; + +describe('tuioStore', () => { + beforeEach(() => { + // Clear localStorage + localStorage.clear(); + + // Reset store to initial state + useTuioStore.setState({ + websocketUrl: 'ws://localhost:3333', + isConnected: false, + connectionError: null, + activeTangibles: new Map(), + lastStateChangeSource: null, + }); + }); + + describe('Initial State', () => { + it('should have correct default settings', () => { + const state = useTuioStore.getState(); + + expect(state.websocketUrl).toBe('ws://localhost:3333'); + expect(state.isConnected).toBe(false); + expect(state.connectionError).toBe(null); + expect(state.activeTangibles.size).toBe(0); + expect(state.lastStateChangeSource).toBe(null); + }); + }); + + describe('Persistence', () => { + it('should persist websocketUrl to localStorage', () => { + const { setWebsocketUrl } = useTuioStore.getState(); + + setWebsocketUrl('ws://example.com:3333'); + + // Check localStorage directly + const stored = localStorage.getItem('constellation-tuio-settings'); + expect(stored).toBeTruthy(); + + const parsed = JSON.parse(stored!); + expect(parsed.state.websocketUrl).toBe('ws://example.com:3333'); + }); + + it('should NOT persist runtime state to localStorage', () => { + const { setConnectionState, addActiveTangible } = useTuioStore.getState(); + + // Set runtime state + setConnectionState(true); + addActiveTangible('42', { + hardwareId: '42', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + + // Check localStorage + const stored = localStorage.getItem('constellation-tuio-settings'); + expect(stored).toBeTruthy(); + + const parsed = JSON.parse(stored!); + // Should only contain websocketUrl, not runtime state + expect(parsed.state.websocketUrl).toBeDefined(); + expect(parsed.state.isConnected).toBeUndefined(); + expect(parsed.state.activeTangibles).toBeUndefined(); + }); + + it('should load websocketUrl from localStorage on initialization', () => { + const { setWebsocketUrl } = useTuioStore.getState(); + setWebsocketUrl('ws://custom.com:9999'); + + // Verify persisted + const stored = localStorage.getItem('constellation-tuio-settings'); + expect(stored).toBeTruthy(); + + const parsed = JSON.parse(stored!); + expect(parsed.state.websocketUrl).toBe('ws://custom.com:9999'); + }); + + it('should handle missing localStorage gracefully', () => { + localStorage.clear(); + + // Should use default values + const state = useTuioStore.getState(); + expect(state.websocketUrl).toBe('ws://localhost:3333'); + }); + }); + + describe('setWebsocketUrl', () => { + it('should update websocket URL', () => { + const { setWebsocketUrl } = useTuioStore.getState(); + + setWebsocketUrl('ws://192.168.1.100:3333'); + + expect(useTuioStore.getState().websocketUrl).toBe('ws://192.168.1.100:3333'); + }); + + it('should persist URL changes', () => { + const { setWebsocketUrl } = useTuioStore.getState(); + + setWebsocketUrl('ws://test.local:8080'); + + const stored = localStorage.getItem('constellation-tuio-settings'); + const parsed = JSON.parse(stored!); + expect(parsed.state.websocketUrl).toBe('ws://test.local:8080'); + }); + + it('should handle multiple URL changes', () => { + const { setWebsocketUrl } = useTuioStore.getState(); + + setWebsocketUrl('ws://url1:3333'); + expect(useTuioStore.getState().websocketUrl).toBe('ws://url1:3333'); + + setWebsocketUrl('ws://url2:3333'); + expect(useTuioStore.getState().websocketUrl).toBe('ws://url2:3333'); + + setWebsocketUrl('ws://url3:3333'); + expect(useTuioStore.getState().websocketUrl).toBe('ws://url3:3333'); + }); + }); + + describe('setConnectionState', () => { + it('should set connection to connected', () => { + const { setConnectionState } = useTuioStore.getState(); + + setConnectionState(true); + + const state = useTuioStore.getState(); + expect(state.isConnected).toBe(true); + expect(state.connectionError).toBe(null); + }); + + it('should set connection to disconnected', () => { + const { setConnectionState } = useTuioStore.getState(); + + setConnectionState(true); + setConnectionState(false); + + const state = useTuioStore.getState(); + expect(state.isConnected).toBe(false); + expect(state.connectionError).toBe(null); + }); + + it('should set connection error', () => { + const { setConnectionState } = useTuioStore.getState(); + + setConnectionState(false, 'Connection refused'); + + const state = useTuioStore.getState(); + expect(state.isConnected).toBe(false); + expect(state.connectionError).toBe('Connection refused'); + }); + + it('should clear previous error on successful connection', () => { + const { setConnectionState } = useTuioStore.getState(); + + // First fail + setConnectionState(false, 'Connection refused'); + expect(useTuioStore.getState().connectionError).toBe('Connection refused'); + + // Then succeed + setConnectionState(true); + expect(useTuioStore.getState().connectionError).toBe(null); + }); + }); + + describe('addActiveTangible', () => { + it('should add a tangible', () => { + const { addActiveTangible } = useTuioStore.getState(); + + const tangible: TuioTangibleInfo = { + hardwareId: '42', + x: 0.5, + y: 0.5, + angle: 1.57, + lastUpdated: Date.now(), + }; + + addActiveTangible('42', tangible); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(1); + expect(state.activeTangibles.get('42')).toEqual(tangible); + }); + + it('should add multiple tangibles', () => { + const { addActiveTangible } = useTuioStore.getState(); + + const tangible1: TuioTangibleInfo = { + hardwareId: '42', + x: 0.3, + y: 0.3, + angle: 0, + lastUpdated: Date.now(), + }; + + const tangible2: TuioTangibleInfo = { + hardwareId: '13', + x: 0.7, + y: 0.7, + angle: 3.14, + lastUpdated: Date.now(), + }; + + addActiveTangible('42', tangible1); + addActiveTangible('13', tangible2); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(2); + expect(state.activeTangibles.get('42')).toEqual(tangible1); + expect(state.activeTangibles.get('13')).toEqual(tangible2); + }); + + it('should create new Map instance (immutability)', () => { + const { addActiveTangible } = useTuioStore.getState(); + + const map1 = useTuioStore.getState().activeTangibles; + + addActiveTangible('42', { + hardwareId: '42', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + + const map2 = useTuioStore.getState().activeTangibles; + + expect(map1).not.toBe(map2); // Different instances + expect(map1.size).toBe(0); + expect(map2.size).toBe(1); + }); + }); + + describe('updateActiveTangible', () => { + it('should update existing tangible', () => { + const { addActiveTangible, updateActiveTangible } = useTuioStore.getState(); + + const original: TuioTangibleInfo = { + hardwareId: '42', + x: 0.3, + y: 0.3, + angle: 0, + lastUpdated: 1000, + }; + + addActiveTangible('42', original); + + const updated: TuioTangibleInfo = { + hardwareId: '42', + x: 0.7, + y: 0.8, + angle: 1.57, + lastUpdated: 2000, + }; + + updateActiveTangible('42', updated); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.get('42')).toEqual(updated); + }); + + it('should not add new tangible if it does not exist', () => { + const { updateActiveTangible } = useTuioStore.getState(); + + updateActiveTangible('99', { + hardwareId: '99', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(0); + expect(state.activeTangibles.has('99')).toBe(false); + }); + }); + + describe('removeActiveTangible', () => { + it('should remove a tangible', () => { + const { addActiveTangible, removeActiveTangible } = useTuioStore.getState(); + + addActiveTangible('42', { + hardwareId: '42', + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + + expect(useTuioStore.getState().activeTangibles.size).toBe(1); + + removeActiveTangible('42'); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(0); + expect(state.activeTangibles.has('42')).toBe(false); + }); + + it('should remove only specified tangible', () => { + const { addActiveTangible, removeActiveTangible } = useTuioStore.getState(); + + addActiveTangible('42', { + hardwareId: '42', + x: 0.3, + y: 0.3, + angle: 0, + lastUpdated: Date.now(), + }); + + addActiveTangible('13', { + hardwareId: '13', + x: 0.7, + y: 0.7, + angle: 0, + lastUpdated: Date.now(), + }); + + removeActiveTangible('42'); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(1); + expect(state.activeTangibles.has('42')).toBe(false); + expect(state.activeTangibles.has('13')).toBe(true); + }); + + it('should handle removing non-existent tangible gracefully', () => { + const { removeActiveTangible } = useTuioStore.getState(); + + removeActiveTangible('999'); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(0); + }); + }); + + describe('clearActiveTangibles', () => { + it('should clear all tangibles', () => { + const { addActiveTangible, clearActiveTangibles } = useTuioStore.getState(); + + addActiveTangible('42', { + hardwareId: '42', + x: 0.3, + y: 0.3, + angle: 0, + lastUpdated: Date.now(), + }); + + addActiveTangible('13', { + hardwareId: '13', + x: 0.7, + y: 0.7, + angle: 0, + lastUpdated: Date.now(), + }); + + expect(useTuioStore.getState().activeTangibles.size).toBe(2); + + clearActiveTangibles(); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(0); + }); + + it('should reset lastStateChangeSource', () => { + const { setLastStateChangeSource, clearActiveTangibles } = useTuioStore.getState(); + + setLastStateChangeSource('42'); + expect(useTuioStore.getState().lastStateChangeSource).toBe('42'); + + clearActiveTangibles(); + + expect(useTuioStore.getState().lastStateChangeSource).toBe(null); + }); + + it('should handle clearing empty tangibles map', () => { + const { clearActiveTangibles } = useTuioStore.getState(); + + clearActiveTangibles(); + + const state = useTuioStore.getState(); + expect(state.activeTangibles.size).toBe(0); + }); + }); + + describe('setLastStateChangeSource', () => { + it('should set last state change source', () => { + const { setLastStateChangeSource } = useTuioStore.getState(); + + setLastStateChangeSource('42'); + + expect(useTuioStore.getState().lastStateChangeSource).toBe('42'); + }); + + it('should clear last state change source', () => { + const { setLastStateChangeSource } = useTuioStore.getState(); + + setLastStateChangeSource('42'); + setLastStateChangeSource(null); + + expect(useTuioStore.getState().lastStateChangeSource).toBe(null); + }); + + it('should update last state change source', () => { + const { setLastStateChangeSource } = useTuioStore.getState(); + + setLastStateChangeSource('42'); + expect(useTuioStore.getState().lastStateChangeSource).toBe('42'); + + setLastStateChangeSource('13'); + expect(useTuioStore.getState().lastStateChangeSource).toBe('13'); + }); + }); + + describe('Edge Cases', () => { + it('should handle rapid consecutive operations', () => { + const { addActiveTangible, removeActiveTangible } = useTuioStore.getState(); + + for (let i = 0; i < 100; i++) { + addActiveTangible(`${i}`, { + hardwareId: `${i}`, + x: 0.5, + y: 0.5, + angle: 0, + lastUpdated: Date.now(), + }); + } + + expect(useTuioStore.getState().activeTangibles.size).toBe(100); + + for (let i = 0; i < 50; i++) { + removeActiveTangible(`${i}`); + } + + expect(useTuioStore.getState().activeTangibles.size).toBe(50); + }); + + it('should maintain state across multiple reads', () => { + const { setWebsocketUrl } = useTuioStore.getState(); + + setWebsocketUrl('ws://test:3333'); + + expect(useTuioStore.getState().websocketUrl).toBe('ws://test:3333'); + expect(useTuioStore.getState().websocketUrl).toBe('ws://test:3333'); + expect(useTuioStore.getState().websocketUrl).toBe('ws://test:3333'); + }); + }); + + describe('Store Versioning', () => { + it('should include version in persisted data', () => { + const { setWebsocketUrl } = useTuioStore.getState(); + + setWebsocketUrl('ws://test:3333'); + + const stored = localStorage.getItem('constellation-tuio-settings'); + expect(stored).toBeTruthy(); + + const parsed = JSON.parse(stored!); + expect(parsed.version).toBe(1); + }); + }); +}); diff --git a/src/stores/tuioStore.ts b/src/stores/tuioStore.ts new file mode 100644 index 0000000..48b31c7 --- /dev/null +++ b/src/stores/tuioStore.ts @@ -0,0 +1,107 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { TuioTangibleInfo } from '../lib/tuio/types'; + +/** + * TUIO Store - Manages TUIO protocol connection and tangible detection + * + * Features: + * - WebSocket URL configuration (persisted) + * - TUIO protocol version selection (persisted) + * - Connection state management (runtime only) + * - Active tangibles tracking (runtime only) + * - Auto-save configuration to localStorage + */ + +export type TuioProtocolVersion = '1.1' | '2.0'; + +interface TuioState { + // Configuration (persisted) + websocketUrl: string; + setWebsocketUrl: (url: string) => void; + protocolVersion: TuioProtocolVersion; + setProtocolVersion: (version: TuioProtocolVersion) => void; + + // Connection state (runtime only - not persisted) + isConnected: boolean; + connectionError: string | null; + setConnectionState: (connected: boolean, error?: string) => void; + + // Active tangibles (runtime only - not persisted) + activeTangibles: Map; + lastStateChangeSource: string | null; + addActiveTangible: (hardwareId: string, info: TuioTangibleInfo) => void; + updateActiveTangible: (hardwareId: string, info: TuioTangibleInfo) => void; + removeActiveTangible: (hardwareId: string) => void; + clearActiveTangibles: () => void; + setLastStateChangeSource: (hardwareId: string | null) => void; +} + +const DEFAULT_WEBSOCKET_URL = 'ws://localhost:3333'; +const DEFAULT_PROTOCOL_VERSION: TuioProtocolVersion = '2.0'; + +export const useTuioStore = create()( + persist( + (set) => ({ + // Configuration + websocketUrl: DEFAULT_WEBSOCKET_URL, + setWebsocketUrl: (url: string) => set({ websocketUrl: url }), + protocolVersion: DEFAULT_PROTOCOL_VERSION, + setProtocolVersion: (version: TuioProtocolVersion) => set({ protocolVersion: version }), + + // Connection state + isConnected: false, + connectionError: null, + setConnectionState: (connected: boolean, error?: string) => + set({ + isConnected: connected, + connectionError: error || null, + }), + + // Active tangibles + activeTangibles: new Map(), + lastStateChangeSource: null, + + addActiveTangible: (hardwareId: string, info: TuioTangibleInfo) => + set((state) => { + const newMap = new Map(state.activeTangibles); + newMap.set(hardwareId, info); + return { activeTangibles: newMap }; + }), + + updateActiveTangible: (hardwareId: string, info: TuioTangibleInfo) => + set((state) => { + const newMap = new Map(state.activeTangibles); + if (newMap.has(hardwareId)) { + newMap.set(hardwareId, info); + } + return { activeTangibles: newMap }; + }), + + removeActiveTangible: (hardwareId: string) => + set((state) => { + const newMap = new Map(state.activeTangibles); + newMap.delete(hardwareId); + return { activeTangibles: newMap }; + }), + + clearActiveTangibles: () => + set({ + activeTangibles: new Map(), + lastStateChangeSource: null, + }), + + setLastStateChangeSource: (hardwareId: string | null) => + set({ lastStateChangeSource: hardwareId }), + }), + { + name: 'constellation-tuio-settings', + version: 1, + // Only persist configuration, not runtime state + partialize: (state) => ({ + websocketUrl: state.websocketUrl, + protocolVersion: state.protocolVersion, + }), + } + ) +);