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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-16 22:57:06 +01:00
parent 07c10bbfad
commit f002e1660d
12 changed files with 2047 additions and 10 deletions

70
package-lock.json generated
View file

@ -21,8 +21,10 @@
"@xyflow/react": "^12.3.5", "@xyflow/react": "^12.3.5",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"osc-js": "^2.4.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"tuio-client": "^0.1.0",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
@ -143,6 +145,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -399,6 +402,7 @@
"version": "0.7.18", "version": "0.7.18",
"resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.18.tgz", "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.18.tgz",
"integrity": "sha512-EjLuZWA5156dIFGdF7OnyPyWFBW43B8Ckje6Sn/W2RFxHDu0oACvW4/6TNgWT80jhEA4bVFm7ahrZe9MJ2B2UQ==", "integrity": "sha512-EjLuZWA5156dIFGdF7OnyPyWFBW43B8Ckje6Sn/W2RFxHDu0oACvW4/6TNgWT80jhEA4bVFm7ahrZe9MJ2B2UQ==",
"peer": true,
"dependencies": { "dependencies": {
"@citation-js/date": "^0.5.0", "@citation-js/date": "^0.5.0",
"@citation-js/name": "^0.4.2", "@citation-js/name": "^0.4.2",
@ -643,6 +647,7 @@
"url": "https://opencollective.com/csstools" "url": "https://opencollective.com/csstools"
} }
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -687,6 +692,7 @@
"url": "https://opencollective.com/csstools" "url": "https://opencollective.com/csstools"
} }
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -748,6 +754,7 @@
"version": "11.14.0", "version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@ -788,6 +795,7 @@
"version": "11.14.1", "version": "11.14.1",
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@ -1458,6 +1466,7 @@
"version": "5.18.0", "version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz",
"integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.9", "@babel/runtime": "^7.23.9",
"@mui/core-downloads-tracker": "^5.18.0", "@mui/core-downloads-tracker": "^5.18.0",
@ -2078,8 +2087,7 @@
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@ -2216,6 +2224,7 @@
"version": "18.3.26", "version": "18.3.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -2226,6 +2235,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true, "dev": true,
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@ -2290,6 +2300,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "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", "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz",
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.4",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@ -2630,6 +2642,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2898,6 +2911,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.9", "baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746", "caniuse-lite": "^1.0.30001746",
@ -3258,6 +3272,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -3445,8 +3460,7 @@
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/dom-helpers": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
@ -3565,6 +3579,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@ -4064,6 +4079,7 @@
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz",
"integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==", "integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/whatwg-mimetype": "^3.0.2", "@types/whatwg-mimetype": "^3.0.2",
@ -4359,6 +4375,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@ -4617,7 +4634,6 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@ -4832,6 +4848,15 @@
"node": ">= 0.8.0" "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": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -5044,6 +5069,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -5195,7 +5221,6 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@ -5210,7 +5235,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -5222,8 +5246,7 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
@ -5278,6 +5301,7 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@ -5289,6 +5313,7 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@ -6002,6 +6027,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -6110,6 +6136,16 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "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", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6183,6 +6220,12 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "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", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" "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": { "node_modules/vite": {
"version": "5.4.20", "version": "5.4.20",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@ -6291,6 +6341,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@ -6561,7 +6612,6 @@
"version": "8.18.3", "version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },

View file

@ -29,8 +29,10 @@
"@xyflow/react": "^12.3.5", "@xyflow/react": "^12.3.5",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"osc-js": "^2.4.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"tuio-client": "^0.1.0",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -12,6 +12,7 @@ import ToastContainer from "./components/Common/ToastContainer";
import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext"; import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext";
import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import { useDocumentHistory } from "./hooks/useDocumentHistory"; import { useDocumentHistory } from "./hooks/useDocumentHistory";
import { useTuioIntegration } from "./hooks/useTuioIntegration";
import { useWorkspaceStore } from "./stores/workspaceStore"; import { useWorkspaceStore } from "./stores/workspaceStore";
import { usePanelStore } from "./stores/panelStore"; import { usePanelStore } from "./stores/panelStore";
import { useSettingsStore } from "./stores/settingsStore"; import { useSettingsStore } from "./stores/settingsStore";
@ -97,6 +98,9 @@ function AppContent() {
onFocusSearch: () => leftPanelRef.current?.focusSearch(), onFocusSearch: () => leftPanelRef.current?.focusSearch(),
}); });
// Setup TUIO integration for tangible detection
useTuioIntegration();
// Escape key to close property panels // Escape key to close property panels
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {

View file

@ -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<TangibleConfig, 'id'> = {
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<string>();
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);
});
});
});

View file

@ -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<string | null>(null);
const [testActiveTangibles, setTestActiveTangibles] = useState<Map<string, TuioTangibleInfo>>(
new Map()
);
const [isConnecting, setIsConnecting] = useState(false);
const testClientRef = useRef<TuioClientManager | null>(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<typeof t> => t !== undefined);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">TUIO Connection Settings</h2>
<p className="text-sm text-gray-600 mt-1">
Configure connection to TUIO server for tangible detection
</p>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* WebSocket URL */}
<div className="mb-6">
<label htmlFor="websocket-url" className="block text-sm font-medium text-gray-700 mb-2">
WebSocket URL
</label>
<div className="flex gap-2">
<input
id="websocket-url"
type="text"
value={urlInput}
onChange={(e) => 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"
/>
<button
onClick={handleReset}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Reset
</button>
</div>
<p className="text-xs text-gray-500 mt-1">
Example: ws://localhost:3333 or ws://192.168.1.100:3333
</p>
</div>
{/* Protocol Version */}
<div className="mb-6">
<label htmlFor="protocol-version" className="block text-sm font-medium text-gray-700 mb-2">
TUIO Protocol Version
</label>
<select
id="protocol-version"
value={versionInput}
onChange={(e) => setVersionInput(e.target.value as '1.1' | '2.0')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="2.0">TUIO 2.0 (Default)</option>
<option value="1.1">TUIO 1.1</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Select the TUIO protocol version used by your server. Most modern systems use TUIO 2.0.
</p>
</div>
{/* Test Connection */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Test Connection
</label>
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 px-4 py-3 bg-gray-50 border border-gray-200 rounded-md">
<div
className={`w-3 h-3 rounded-full ${
testConnected ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
<span className="text-sm font-medium">
{testConnected ? 'Connected' : 'Disconnected'}
</span>
{testConnectionError && (
<span className="text-sm text-red-600 ml-2">
({testConnectionError})
</span>
)}
</div>
{!testConnected ? (
<button
onClick={handleConnect}
disabled={isConnecting}
className="px-4 py-2 text-sm text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-md transition-colors"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</button>
) : (
<button
onClick={handleDisconnect}
className="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
>
Disconnect
</button>
)}
</div>
<p className="text-xs text-gray-500 mt-1">
Test the connection to verify your TUIO server is reachable and detect tangibles.
</p>
</div>
{/* Active Tangibles */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Active Tangibles
<span className="ml-2 text-xs font-normal text-gray-500">
({testActiveTangibles.size} detected)
</span>
</label>
<div className="border border-gray-200 rounded-md divide-y divide-gray-200 min-h-[100px]">
{activeTangibleConfigs.length > 0 ? (
activeTangibleConfigs.map((tangible) => {
const info = testActiveTangibles.get(tangible.hardwareId!);
return (
<div key={tangible.id} className="px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">
{tangible.name}
</p>
<p className="text-xs text-gray-600">
Hardware ID: {tangible.hardwareId} Mode: {tangible.mode}
</p>
</div>
{info && (
<div className="text-xs text-gray-500">
Position: ({info.x.toFixed(2)}, {info.y.toFixed(2)})
</div>
)}
</div>
</div>
);
})
) : (
<div className="px-4 py-6 text-center text-sm text-gray-500">
{testConnected
? 'No tangibles detected. Place a configured tangible on the TUIO surface.'
: 'Connect to TUIO server to detect tangibles.'}
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-2">
<button
onClick={handleCancel}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Save
</button>
</div>
</div>
</div>
);
};
export default TuioConnectionConfig;

View file

@ -9,6 +9,7 @@ import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import LabelConfigModal from '../Config/LabelConfig'; import LabelConfigModal from '../Config/LabelConfig';
import TangibleConfigModal from '../Config/TangibleConfig'; import TangibleConfigModal from '../Config/TangibleConfig';
import TuioConnectionConfig from '../Config/TuioConnectionConfig';
import BibliographyConfigModal from '../Config/BibliographyConfig'; import BibliographyConfigModal from '../Config/BibliographyConfig';
import InputDialog from '../Common/InputDialog'; import InputDialog from '../Common/InputDialog';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
@ -38,6 +39,7 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
const [showEdgeConfig, setShowEdgeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false);
const [showLabelConfig, setShowLabelConfig] = useState(false); const [showLabelConfig, setShowLabelConfig] = useState(false);
const [showTangibleConfig, setShowTangibleConfig] = useState(false); const [showTangibleConfig, setShowTangibleConfig] = useState(false);
const [showTuioConfig, setShowTuioConfig] = useState(false);
const [showBibliographyConfig, setShowBibliographyConfig] = useState(false); const [showBibliographyConfig, setShowBibliographyConfig] = useState(false);
const [showNewDocDialog, setShowNewDocDialog] = useState(false); const [showNewDocDialog, setShowNewDocDialog] = useState(false);
const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false); const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false);
@ -467,6 +469,19 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
<span>Presentation Mode</span> <span>Presentation Mode</span>
<span className="text-xs text-gray-400">F11</span> <span className="text-xs text-gray-400">F11</span>
</button> </button>
<div className="border-t border-gray-200 my-1" />
{/* TUIO Connection Settings */}
<button
onClick={() => {
setShowTuioConfig(true);
closeMenu();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
TUIO Connection Settings...
</button>
</div> </div>
)} )}
</div> </div>
@ -526,6 +541,10 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
isOpen={showTangibleConfig} isOpen={showTangibleConfig}
onClose={() => setShowTangibleConfig(false)} onClose={() => setShowTangibleConfig(false)}
/> />
<TuioConnectionConfig
isOpen={showTuioConfig}
onClose={() => setShowTuioConfig(false)}
/>
<BibliographyConfigModal <BibliographyConfigModal
isOpen={showBibliographyConfig} isOpen={showBibliographyConfig}
onClose={() => setShowBibliographyConfig(false)} onClose={() => setShowBibliographyConfig(false)}

View file

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

View file

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

281
src/lib/tuio/tuioClient.ts Normal file
View file

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

35
src/lib/tuio/types.ts Normal file
View file

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

View file

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

107
src/stores/tuioStore.ts Normal file
View file

@ -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<string, TuioTangibleInfo>;
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<TuioState>()(
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,
}),
}
)
);