mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
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:
parent
07c10bbfad
commit
f002e1660d
12 changed files with 2047 additions and 10 deletions
70
package-lock.json
generated
70
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
433
src/__tests__/integration/tuio-integration.test.tsx
Normal file
433
src/__tests__/integration/tuio-integration.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
349
src/components/Config/TuioConnectionConfig.tsx
Normal file
349
src/components/Config/TuioConnectionConfig.tsx
Normal 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;
|
||||
|
|
@ -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<MenuBarProps> = ({ 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<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
<span>Presentation Mode</span>
|
||||
<span className="text-xs text-gray-400">F11</span>
|
||||
</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>
|
||||
|
|
@ -526,6 +541,10 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
isOpen={showTangibleConfig}
|
||||
onClose={() => setShowTangibleConfig(false)}
|
||||
/>
|
||||
<TuioConnectionConfig
|
||||
isOpen={showTuioConfig}
|
||||
onClose={() => setShowTuioConfig(false)}
|
||||
/>
|
||||
<BibliographyConfigModal
|
||||
isOpen={showBibliographyConfig}
|
||||
onClose={() => setShowBibliographyConfig(false)}
|
||||
|
|
|
|||
199
src/hooks/useTuioIntegration.ts
Normal file
199
src/hooks/useTuioIntegration.ts
Normal 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);
|
||||
}
|
||||
93
src/lib/tuio/WebsocketTuioReceiver.ts
Normal file
93
src/lib/tuio/WebsocketTuioReceiver.ts
Normal 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
281
src/lib/tuio/tuioClient.ts
Normal 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
35
src/lib/tuio/types.ts
Normal 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;
|
||||
}
|
||||
465
src/stores/tuioStore.test.ts
Normal file
465
src/stores/tuioStore.test.ts
Normal 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
107
src/stores/tuioStore.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue