feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)
- 3D dependencies (three, react-three-fiber, drei, postprocessing, motion), exact versions, supply-chain checked; scene loaded only on demand on the colony page (283 kB gzip), SVG stays as fallback - glass hologram per owner design 2026-10-16: true Liebig Zander shapes (grips, rebate, Wanderboden foot), cool blue edge glow, click any part -> it flies to the front while the rest stays as a faint ghost; focus shows content (feed levels, Bausperre, pockets); frames rise on hover and take their type look (colour + comb pattern) - full-bleed card with frosted overlays and glass buttons - Futtertasche (FRAME_FEEDER): new part kind occupying frame slots with a fill level, catalog row, validator rules in both ports, outline, sheets, follow-ups, settings Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c608f5d405
commit
6e13236d49
100 files changed
+9432
-209
No files matched your search
Generated
+710
-3
@@ -9,23 +9,30 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@react-three/drei": "10.7.9",
|
||||
"@react-three/fiber": "9.8.1",
|
||||
"@react-three/postprocessing": "3.1.3",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"motion": "13.4.6",
|
||||
"next": "16.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"postprocessing": "6.39.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.18.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "0.186.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-three/test-renderer": "9.1.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -34,6 +41,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "0.186.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
@@ -776,6 +784,12 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.75.1",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz",
|
||||
@@ -2357,6 +2371,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.17",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
|
||||
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
@@ -2419,6 +2439,18 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@monogrid/gainmap-js": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
|
||||
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"promise-worker-transferable": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -2706,6 +2738,128 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/drei": {
|
||||
"version": "10.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.9.tgz",
|
||||
"integrity": "sha512-wP4Y6o58KFi+hT1zCfjJarRtPCT6WW/lo53WMiA/aCORuUvBzW916XsCtadnIB1KB+3IQNcDU5JeVh/gYb8MYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@mediapipe/tasks-vision": "0.10.17",
|
||||
"@monogrid/gainmap-js": "^3.0.6",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"camera-controls": "^3.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"detect-gpu": "^5.0.56",
|
||||
"glsl-noise": "^0.0.0",
|
||||
"hls.js": "^1.5.17",
|
||||
"maath": "^0.10.8",
|
||||
"meshline": "^3.3.1",
|
||||
"stats-gl": "^2.2.8",
|
||||
"stats.js": "^0.17.0",
|
||||
"suspend-react": "^0.1.3",
|
||||
"three-mesh-bvh": "^0.8.3",
|
||||
"three-stdlib": "^2.35.6",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"tunnel-rat": "^0.1.2",
|
||||
"use-sync-external-store": "^1.4.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-three/fiber": "^9.0.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"three": ">=0.159"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/fiber": {
|
||||
"version": "9.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.8.1.tgz",
|
||||
"integrity": "sha512-GbNP4r6F/gpclFKkLGHzXcuwFWAzLyKsmrQGcDuwK3wQnwLH6F1TfGtEYjHy8OQoGsajwT19B9jtVLLjjfH09w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/webxr": "*",
|
||||
"base64-js": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
"its-fine": "^2.1.1",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"scheduler": "^0.28.0",
|
||||
"suspend-react": "^0.1.3",
|
||||
"use-sync-external-store": "^1.4.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": ">=43.0",
|
||||
"expo-asset": ">=8.4",
|
||||
"expo-file-system": ">=11.0",
|
||||
"expo-gl": ">=11.0",
|
||||
"react": ">=19 <19.4",
|
||||
"react-dom": ">=19 <19.4",
|
||||
"react-native": ">=0.78",
|
||||
"three": ">=0.156"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-asset": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-file-system": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-gl": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/fiber/node_modules/scheduler": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-three/postprocessing": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.1.3.tgz",
|
||||
"integrity": "sha512-LhCTSC+nli1agIIIgrhJalvJcdSy7/DOXG5EDGxKVlVsrqUxMPYlMnRN/QqgSAjGPKmnnuiEmpVQtOkTKwiidw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"maath": "^0.10.8",
|
||||
"n8ao": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-three/fiber": ">=9.7.0",
|
||||
"postprocessing": "^6.36.0",
|
||||
"react": "^19.0.0",
|
||||
"three": ">= 0.156.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/test-renderer": {
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/test-renderer/-/test-renderer-9.1.1.tgz",
|
||||
"integrity": "sha512-4DmLn0tg+AP8aU0Mb5vekjHqFrwRJ3z11HfQGOJIDj6DxZ/5BjRHRsDxY0Y+g4l8+WHysQ6PeuSdzTDDYoGuXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@react-three/fiber": ">=9.0.0",
|
||||
"react": "^19.0.0",
|
||||
"three": ">=0.156"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3703,6 +3857,12 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -3785,6 +3945,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/draco3d": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
||||
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -3833,11 +3999,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3853,12 +4024,47 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-reconciler": {
|
||||
"version": "0.28.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
|
||||
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.186.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
|
||||
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.3",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
||||
@@ -4508,6 +4714,24 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@use-gesture/core": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
|
||||
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@use-gesture/react": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
|
||||
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@use-gesture/core": "10.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -5035,6 +5259,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.16",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz",
|
||||
@@ -5051,7 +5295,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
@@ -5150,6 +5393,30 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
@@ -5231,6 +5498,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camera-controls": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
|
||||
"integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.126.1"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001809",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
|
||||
@@ -5544,6 +5824,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "src/bin/cross-env.js",
|
||||
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5595,7 +5893,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
@@ -5853,6 +6150,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-gpu": {
|
||||
"version": "5.0.70",
|
||||
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
|
||||
"integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"webgl-constants": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -5920,6 +6226,12 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.8.tgz",
|
||||
"integrity": "sha512-rmmKiIgnv4RKB1hSze/9yRql3+5cxkxn49FUMWfhsJ5ob5tSMyfe0K/aRZsh6HoN/XveZXMjouO/gGK+JgqYPw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -6936,6 +7248,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@@ -7060,6 +7378,29 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "13.5.1",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.5.1.tgz",
|
||||
"integrity": "sha512-HeWOaeHD3kyTSIEMT6+nw7fBn4saIfxxabZyAITbycAl4xr1kzGJKIvGt/OWghSlvIA9qTnZ1zTGelHAe6S8DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "13.5.1",
|
||||
"motion-utils": "13.5.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
@@ -7317,6 +7658,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/glsl-noise": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
|
||||
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -7444,6 +7791,12 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
|
||||
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.3",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz",
|
||||
@@ -7517,6 +7870,26 @@
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -7526,6 +7899,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -8208,6 +8587,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/its-fine": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.1.1.tgz",
|
||||
"integrity": "sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.28.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -8460,6 +8851,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -8844,6 +9244,16 @@
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/maath": {
|
||||
"version": "0.10.8",
|
||||
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
|
||||
"integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/three": ">=0.134.0",
|
||||
"three": ">=0.134.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -8910,6 +9320,21 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/meshline": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
|
||||
"integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.137"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
@@ -9001,12 +9426,59 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "13.4.6",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-13.4.6.tgz",
|
||||
"integrity": "sha512-zDXi6lIdyhda6wZlTsv9QgT2ed6UExAfRegtl/akQmZb5iaeLlArXr9Gmo/hx3iTx2y4R7tmdb9k9koFQmV+NQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^13.4.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "13.5.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.5.1.tgz",
|
||||
"integrity": "sha512-/7CVCD20Mz1KobktorpdgvowV0V41J7UxqE+qWS3GkSJqrRr5ft+2RloOYtEKv+wvBp3JaSFBclhxt4ja8ovHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "13.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.5.0.tgz",
|
||||
"integrity": "sha512-Hqe/sqjIKliPZg8sVjWAkdySDPJNIeKH+euVkDOG8ri3DUUU7ZjndmHsGqEPz2iHpiyVaV5I22iguCMHU2he+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/n8ao": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/n8ao/-/n8ao-2.0.1.tgz",
|
||||
"integrity": "sha512-MoP1QKUdj4/3cKEnhFl917t0eQwWGER1l43ckz5mNOPyYWGRDGQbVoTOQz/VBvy01in6QVNG+AL6Xxx5AjZBYA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"postprocessing": ">=6.30.0",
|
||||
"three": ">=0.137"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
@@ -9837,6 +10309,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postprocessing": {
|
||||
"version": "6.39.5",
|
||||
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.5.tgz",
|
||||
"integrity": "sha512-YmH+t74J2X1lwYjKzK/tgs9C9kqWWPB9JwZiDF5mEf4BdCEAmEFbO8lXBA7NM0/j4g+x6FgD/Tt5/kT7ogbEDA==",
|
||||
"license": "Zlib",
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.168.0 < 0.187.0"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
|
||||
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
|
||||
@@ -9912,6 +10399,22 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"is-promise": "^2.1.0",
|
||||
"lie": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-worker-transferable/node_modules/is-promise": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -10085,6 +10588,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-use-measure": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13",
|
||||
"react-dom": ">=16.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.23.21",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
|
||||
@@ -10895,6 +11413,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stats-gl": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
|
||||
"integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "^0.170.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl/node_modules/three": {
|
||||
"version": "0.170.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
|
||||
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stats.js": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
|
||||
"integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -11238,6 +11782,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/suspend-react": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
|
||||
"integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -11302,6 +11855,44 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.186.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
|
||||
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
|
||||
"integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib": {
|
||||
"version": "2.36.1",
|
||||
"resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
|
||||
"integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/draco3d": "^1.4.0",
|
||||
"@types/offscreencanvas": "^2019.6.4",
|
||||
"@types/webxr": "^0.5.2",
|
||||
"draco3d": "^1.4.1",
|
||||
"fflate": "^0.6.9",
|
||||
"potpack": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.128.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib/node_modules/fflate": {
|
||||
"version": "0.6.11",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz",
|
||||
"integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -11450,6 +12041,36 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-text": {
|
||||
"version": "0.52.5",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz",
|
||||
"integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.2",
|
||||
"troika-three-utils": "^0.52.5",
|
||||
"troika-worker-utils": "^0.52.0",
|
||||
"webgl-sdf-generator": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-utils": {
|
||||
"version": "0.52.5",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz",
|
||||
"integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-worker-utils": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
|
||||
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -11505,6 +12126,43 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-rat": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
|
||||
"integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zustand": "^4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-rat/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tw-animate-css": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||
@@ -11832,6 +12490,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utility-types": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/validate-npm-package-name": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
|
||||
@@ -12072,6 +12739,17 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webgl-constants": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
|
||||
"integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
|
||||
},
|
||||
"node_modules/webgl-sdf-generator": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
|
||||
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
@@ -12365,6 +13043,35 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.15",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
|
||||
"integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,23 +13,30 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@react-three/drei": "10.7.9",
|
||||
"@react-three/fiber": "9.8.1",
|
||||
"@react-three/postprocessing": "3.1.3",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"motion": "13.4.6",
|
||||
"next": "16.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"postprocessing": "6.39.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.18.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "0.186.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-three/test-renderer": "9.1.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -38,6 +45,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "0.186.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
|
||||
@@ -34,6 +34,7 @@ const feeder: HivePartTypeDto = {
|
||||
widthMm: 480,
|
||||
heightMm: 200,
|
||||
frameSlots: null,
|
||||
slotWidth: null,
|
||||
boxDepth: null,
|
||||
stateSchema: [
|
||||
{ key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
|
||||
|
||||
@@ -22,6 +22,7 @@ const feeder: HivePartTypeDto = {
|
||||
widthMm: 480,
|
||||
heightMm: 200,
|
||||
frameSlots: null,
|
||||
slotWidth: null,
|
||||
boxDepth: null,
|
||||
stateSchema: [
|
||||
{
|
||||
@@ -62,6 +63,19 @@ async function pickBox() {
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Zarge" }));
|
||||
}
|
||||
|
||||
async function pickFrameFeeder() {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Futtertasche" }));
|
||||
}
|
||||
|
||||
const pocket: HivePartTypeDto = {
|
||||
...feeder,
|
||||
id: "pt2",
|
||||
kind: "FRAME_FEEDER",
|
||||
name: "Futtertasche",
|
||||
slotWidth: 2,
|
||||
};
|
||||
|
||||
describe("PartTypeDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createHivePartType).mockReset();
|
||||
@@ -247,4 +261,60 @@ describe("PartTypeDialog", () => {
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive");
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("creates a frame feeder with the number of frame slots it covers", async () => {
|
||||
renderDialog();
|
||||
await typeInto(/^Name/, "Futtertasche");
|
||||
await pickFrameFeeder();
|
||||
await typeInto(/^Länge/, "477");
|
||||
await typeInto(/^Breite/, "70");
|
||||
await typeInto(/^Höhe/, "220");
|
||||
await typeInto(/^Belegte Rähmchenplätze/, "2");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() =>
|
||||
expect(createHivePartType).toHaveBeenCalledWith("s1", {
|
||||
kind: "FRAME_FEEDER",
|
||||
name: "Futtertasche",
|
||||
lengthMm: 477,
|
||||
widthMm: 70,
|
||||
heightMm: 220,
|
||||
slotWidth: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the covered slots of a frame feeder and shows none for other kinds", async () => {
|
||||
renderDialog();
|
||||
expect(screen.queryByLabelText(/^Belegte Rähmchenplätze/)).toBeNull();
|
||||
await typeInto(/^Name/, "Futtertasche");
|
||||
await pickFrameFeeder();
|
||||
await typeInto(/^Länge/, "477");
|
||||
await typeInto(/^Breite/, "70");
|
||||
await typeInto(/^Höhe/, "220");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/belegten Rähmchenplätze/);
|
||||
expect(createHivePartType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps the covered slots at the server limit of 5", async () => {
|
||||
renderDialog();
|
||||
await typeInto(/^Name/, "Futtertasche");
|
||||
await pickFrameFeeder();
|
||||
await typeInto(/^Länge/, "477");
|
||||
await typeInto(/^Breite/, "70");
|
||||
await typeInto(/^Höhe/, "220");
|
||||
await typeInto(/^Belegte Rähmchenplätze/, "9");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() =>
|
||||
expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ slotWidth: 5 })),
|
||||
);
|
||||
});
|
||||
|
||||
it("edit: sends only a changed slot width", async () => {
|
||||
renderDialog(pocket);
|
||||
expect(screen.getByLabelText(/^Belegte Rähmchenplätze/)).toHaveValue("2");
|
||||
await typeInto(/^Belegte Rähmchenplätze/, "3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { slotWidth: 3 }));
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
MAX_DIMENSION_MM,
|
||||
MAX_FRAME_SLOTS,
|
||||
MAX_SLOT_WIDTH,
|
||||
draftProblem,
|
||||
emptyDraft,
|
||||
fieldFromDraft,
|
||||
@@ -59,6 +60,7 @@ interface FormValues {
|
||||
heightMm: string;
|
||||
boxDepth: HiveBoxDepth;
|
||||
frameSlots: string;
|
||||
slotWidth: string;
|
||||
}
|
||||
|
||||
function initialValues(partType: HivePartTypeDto | undefined): FormValues {
|
||||
@@ -71,6 +73,7 @@ function initialValues(partType: HivePartTypeDto | undefined): FormValues {
|
||||
heightMm: partType ? String(partType.heightMm) : "",
|
||||
boxDepth: partType?.boxDepth ?? "FULL",
|
||||
frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots),
|
||||
slotWidth: partType?.slotWidth == null ? "" : String(partType.slotWidth),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +91,9 @@ function valuesProblem(values: FormValues): string | null {
|
||||
if (values.kind === "BOX" && values.frameSlots === "") {
|
||||
return "Eine Zarge braucht die Zahl der Rähmchenplätze.";
|
||||
}
|
||||
if (values.kind === "FRAME_FEEDER" && values.slotWidth === "") {
|
||||
return "Eine Futtertasche braucht die Zahl der belegten Rähmchenplätze.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,6 +115,9 @@ function patchFrom(
|
||||
if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth;
|
||||
if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots);
|
||||
}
|
||||
if (partType.kind === "FRAME_FEEDER" && Number(values.slotWidth) !== partType.slotWidth) {
|
||||
patch.slotWidth = Number(values.slotWidth);
|
||||
}
|
||||
if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)];
|
||||
return patch;
|
||||
}
|
||||
@@ -124,6 +133,7 @@ function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTyp
|
||||
widthMm: Number(values.widthMm),
|
||||
heightMm: Number(values.heightMm),
|
||||
...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}),
|
||||
...(kind === "FRAME_FEEDER" ? { slotWidth: Number(values.slotWidth) } : {}),
|
||||
...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}),
|
||||
};
|
||||
}
|
||||
@@ -269,6 +279,23 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
{values.kind === "FRAME_FEEDER" ? (
|
||||
<Field label="Belegte Rähmchenplätze" required hint="So viele Plätze nimmt die Tasche in der Zarge ein.">
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={values.slotWidth}
|
||||
onChange={(slotWidth) => setValue("slotWidth", slotWidth)}
|
||||
min={1}
|
||||
max={MAX_SLOT_WIDTH}
|
||||
unitLabel="Plätze"
|
||||
decrementLabel="Belegte Rähmchenplätze verringern"
|
||||
incrementLabel="Belegte Rähmchenplätze erhöhen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-col gap-3" aria-labelledby="state-fields-heading">
|
||||
<h3 id="state-fields-heading" className="text-sm font-medium">
|
||||
@@ -280,7 +307,7 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
|
||||
draft={newField}
|
||||
onChange={setNewField}
|
||||
onDiscard={() => setNewField(null)}
|
||||
allowFeedType={values.kind === "FEEDER"}
|
||||
allowFeedType={values.kind === "FEEDER" || values.kind === "FRAME_FEEDER"}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -65,7 +65,7 @@ export function PartTypeTable({ partTypes, pendingId, onEdit, onToggleActive }:
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{partType.lengthMm} × {partType.widthMm} × {partType.heightMm} mm
|
||||
</TableCell>
|
||||
<TableCell>{partType.frameSlots ?? "–"}</TableCell>
|
||||
<TableCell>{partType.frameSlots ?? (partType.slotWidth ? `belegt ${partType.slotWidth}` : "–")}</TableCell>
|
||||
<TableCell className="min-w-48 whitespace-normal">
|
||||
{describeStateFields(partType.stateSchema)}
|
||||
</TableCell>
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
--holo-bg: oklch(0.18 0.03 250);
|
||||
--holo-edge: oklch(0.78 0.14 220);
|
||||
--holo-edge-strong: oklch(0.88 0.12 215);
|
||||
--holo-edge: oklch(0.72 0.15 243);
|
||||
--holo-edge-strong: oklch(0.87 0.1 238);
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
@@ -194,8 +194,8 @@
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
--holo-bg: oklch(0.18 0.03 250);
|
||||
--holo-edge: oklch(0.78 0.14 220);
|
||||
--holo-edge-strong: oklch(0.88 0.12 215);
|
||||
--holo-edge: oklch(0.72 0.15 243);
|
||||
--holo-edge-strong: oklch(0.87 0.1 238);
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
|
||||
@@ -28,7 +28,7 @@ function link(path: string, entries: [string, string][]): string {
|
||||
/** The feeder field's own name („Flüssigfutter“), else the feed type's. */
|
||||
function feedName(followUp: Extract<FollowUp, { kind: "feeding" }>, catalog: HiveCatalog): string {
|
||||
for (const type of catalog.partTypes.values()) {
|
||||
if (type.kind !== "FEEDER") continue;
|
||||
if (type.kind !== "FEEDER" && type.kind !== "FRAME_FEEDER") continue;
|
||||
for (const field of type.stateSchema) {
|
||||
if (field.kind === "level" && field.feedType === followUp.feedType && field.unit === followUp.unit) {
|
||||
return field.label;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { feederRange, rangeText } from "@/lib/hive/feeder-slots";
|
||||
import type { CatalogPartType, HiveFrame, HiveOp, HivePart } from "@/lib/hive/types";
|
||||
import { StateFieldControl } from "./state-field-control";
|
||||
import type { FieldDraft } from "./state-field-control";
|
||||
|
||||
// „Futtertasche einsetzen“ in the box sheet: choose the position, then the fill
|
||||
// level. Choosing a position empties the frames it covers in the same batch.
|
||||
|
||||
const POCKET_OPTIONS = [
|
||||
{ value: false, label: "Keine Futtertasche" },
|
||||
{ value: true, label: "Futtertasche einsetzen" },
|
||||
];
|
||||
|
||||
export interface FrameFeederChoice {
|
||||
wants: boolean;
|
||||
/** First slot of the new pocket. */
|
||||
start: number;
|
||||
draft: FieldDraft;
|
||||
}
|
||||
|
||||
function rangeAt(start: number, type: CatalogPartType) {
|
||||
return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
|
||||
}
|
||||
|
||||
function coveredSlots(start: number, type: CatalogPartType): number[] {
|
||||
const range = rangeAt(start, type);
|
||||
if (!range) return [];
|
||||
return Array.from({ length: range.to - range.from + 1 }, (_, index) => range.from + index);
|
||||
}
|
||||
|
||||
/** Frames of the box with the covered slots set to NONE (names of covered frames are dropped). */
|
||||
function emptiedFrames(frames: HiveFrame[], covered: number[]): HiveFrame[] {
|
||||
return frames.map((frame) => (covered.includes(frame.slot) ? { slot: frame.slot, type: "NONE" } : frame));
|
||||
}
|
||||
|
||||
/** Ops for the chosen pocket: setFrames only if a covered frame is not empty yet, then addInsert. */
|
||||
export function frameFeederOps(
|
||||
boxId: string,
|
||||
frames: HiveFrame[],
|
||||
type: CatalogPartType,
|
||||
start: number,
|
||||
pocket: HivePart,
|
||||
): HiveOp[] {
|
||||
const covered = coveredSlots(start, type);
|
||||
const needsEmptying = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
|
||||
const ops: HiveOp[] = [];
|
||||
if (needsEmptying) ops.push({ op: "setFrames", boxId, frames: emptiedFrames(frames, covered) });
|
||||
ops.push({ op: "addInsert", hostId: boxId, part: { ...pocket, slot: start } });
|
||||
return ops;
|
||||
}
|
||||
|
||||
interface FrameFeederPickerProps {
|
||||
boxId: string;
|
||||
type: CatalogPartType;
|
||||
frames: HiveFrame[];
|
||||
starts: number[];
|
||||
choice: FrameFeederChoice;
|
||||
onChange: (choice: FrameFeederChoice) => void;
|
||||
}
|
||||
|
||||
export function FrameFeederPicker({ boxId, type, frames, starts, choice, onChange }: FrameFeederPickerProps) {
|
||||
const groupId = `part-${boxId}-frame-feeder`;
|
||||
const covered = coveredSlots(choice.start, type);
|
||||
const losesFrames = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
|
||||
const startOptions = starts.map((start) => {
|
||||
const range = rangeAt(start, type);
|
||||
const noun = range && range.from !== range.to ? "Plätze" : "Platz";
|
||||
return { value: start, label: `${noun} ${range ? rangeText(range) : start}` };
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span id={groupId} className="text-sm font-medium">
|
||||
Futtertasche
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={POCKET_OPTIONS}
|
||||
value={choice.wants}
|
||||
onChange={(wants) => onChange({ ...choice, wants })}
|
||||
labelledBy={groupId}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
{choice.wants ? (
|
||||
<>
|
||||
<span id={`${groupId}-slot`} className="text-sm font-medium">
|
||||
Position
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={startOptions}
|
||||
value={choice.start}
|
||||
onChange={(start) => onChange({ ...choice, start })}
|
||||
labelledBy={`${groupId}-slot`}
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
{losesFrames ? (
|
||||
<p className="text-sm text-muted-foreground">Rähmchen auf diesen Plätzen werden zu „leer“.</p>
|
||||
) : null}
|
||||
{type.stateSchema.map((field) => (
|
||||
<StateFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
idPrefix={`part-${boxId}-new-frame-feeder`}
|
||||
value={choice.draft[field.key] ?? ""}
|
||||
onChange={(value) => onChange({ ...choice, draft: { ...choice.draft, [field.key]: value } })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,13 +167,29 @@ describe("HiveCard", () => {
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("keeps the outline from growing the card: it scrolls in an absolutely placed child", async () => {
|
||||
it("keeps the outline from growing the card: it scrolls in a panel placed over the stage", async () => {
|
||||
renderCard();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
|
||||
|
||||
const scroll = screen.getByTestId("hive-outline-scroll");
|
||||
expect(scroll).toHaveClass("sm:absolute", "sm:inset-0", "sm:overflow-y-auto");
|
||||
expect(scroll.parentElement).toHaveClass("sm:relative", "sm:w-72");
|
||||
expect(scroll).toHaveClass("overflow-y-auto");
|
||||
// The panel fills the space above the button bar inside an absolutely placed column,
|
||||
// so the stage (and the row) never grows and the buttons stay reachable.
|
||||
const column = scroll.parentElement!.parentElement!;
|
||||
expect(column).toHaveClass("order-1", "min-h-0", "flex-1");
|
||||
expect(column.parentElement).toHaveClass("absolute", "inset-0");
|
||||
const buttons = screen.getByRole("button", { name: "Aufbau als Liste" }).closest(".order-2");
|
||||
expect(buttons?.parentElement).toBe(column.parentElement);
|
||||
});
|
||||
|
||||
it("shows title and facts as a frosted overlay and the actions as glass buttons on the stage", async () => {
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
expect(screen.getByText("Beute")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
|
||||
const list = screen.getByRole("button", { name: "Aufbau als Liste" });
|
||||
expect(list).toHaveTextContent("Liste");
|
||||
expect(screen.getByRole("button", { name: "Verlauf" })).toHaveClass("backdrop-blur-md");
|
||||
});
|
||||
|
||||
it("points „Aufbau als Liste“ at the outline", async () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History, ListTree } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Boxes, History, ListTree } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardTitle } from "@/components/ui/card";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
@@ -21,6 +22,8 @@ import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { FrameSheet } from "./frame-sheet";
|
||||
import { HiveConflictSheet } from "./hive-conflict-sheet";
|
||||
import { HiveFactsLine } from "./hive-facts-line";
|
||||
import { HiveFocusBar, focusChips } from "./hive-focus-bar";
|
||||
import { GLASS_BUTTON, GLASS_SURFACE } from "./hive-glass";
|
||||
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
|
||||
import { HiveInit } from "./hive-init";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
@@ -30,7 +33,7 @@ import { HiveVisualSlot } from "./hive-visual-slot";
|
||||
import { PartSheet } from "./part-sheet";
|
||||
import { useHiveMutation } from "./use-hive-mutation";
|
||||
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
|
||||
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
import { HiveDraftError } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
@@ -45,25 +48,33 @@ export interface HiveCardProps {
|
||||
onOpsChange?: (ops: HiveOp[]) => void;
|
||||
}
|
||||
|
||||
/** Colony page card: model, facts line, outline, history (spec 9). Fixed height below lg, as tall as „Stammdaten“ above. */
|
||||
/**
|
||||
* Colony page card (spec 9): a full-bleed holo stage with frosted overlays for
|
||||
* title and facts, focus bar, glass buttons and the outline panel. Fixed
|
||||
* height below lg; at lg as tall as „Stammdaten“ (everything inside is
|
||||
* positioned over the stage, so the card never grows the row).
|
||||
*/
|
||||
export function HiveCard(props: HiveCardProps) {
|
||||
const [store] = useState(createHiveViewStore);
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<Card className="h-[min(55svh,520px)] min-h-80 lg:col-span-2 lg:h-auto lg:min-h-0">
|
||||
<CardHeader>
|
||||
<CardTitle>Beute</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<HiveCardContent {...props} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<Card className="h-[min(55svh,520px)] min-h-80 gap-0 py-0 lg:col-span-2 lg:h-auto lg:min-h-0">
|
||||
<HiveCardContent {...props} />
|
||||
</Card>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card surface for the states without a stage (no setup yet, error). */
|
||||
function PlainCardBody({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<CardTitle>Beute</CardTitle>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) });
|
||||
@@ -72,10 +83,15 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
|
||||
const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
|
||||
|
||||
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
|
||||
return <HiveInit colonyId={colonyId} colony={colony} canEdit={canEdit} />;
|
||||
return (
|
||||
<PlainCardBody>
|
||||
<HiveInit colonyId={colonyId} colony={colony} canEdit={canEdit} />
|
||||
</PlainCardBody>
|
||||
);
|
||||
}
|
||||
if (hive.isError || systems.isError) {
|
||||
return (
|
||||
<PlainCardBody>
|
||||
<div role="alert" className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm">{LOAD_FAILED_TEXT}</p>
|
||||
<Button
|
||||
@@ -90,6 +106,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
</PlainCardBody>
|
||||
);
|
||||
}
|
||||
if (!hive.data || !catalog) {
|
||||
@@ -97,7 +114,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Beute wird geladen"
|
||||
className="min-h-40 flex-1 rounded-lg bg-muted motion-safe:animate-pulse"
|
||||
className="min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -121,6 +138,7 @@ interface FrameTarget {
|
||||
interface PartTarget {
|
||||
id: string;
|
||||
evaporator: boolean;
|
||||
frameFeeder: boolean;
|
||||
}
|
||||
|
||||
interface PastState {
|
||||
@@ -193,6 +211,13 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
|
||||
};
|
||||
|
||||
const partName = (id: string) => partDisplayName(shownState, catalog, id);
|
||||
const focusedId = focusedIdOf(view);
|
||||
const leaveFocus = () => view.openBox(null);
|
||||
/** Parts whose state the part sheet edits (a box is changed through its frames). */
|
||||
const hasSheet = (id: string) => {
|
||||
const type = findPartKind(shownState, catalog, id);
|
||||
return type !== undefined && type.kind !== "BOX" && (type.stateSchema.length > 0 || type.kind === "FLOOR");
|
||||
};
|
||||
|
||||
function editFrame(boxId: string, slot: number) {
|
||||
if (!editable) return;
|
||||
@@ -200,11 +225,11 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
|
||||
setFrameOpen(true);
|
||||
}
|
||||
|
||||
function editPart(id: string) {
|
||||
function editPart(id: string, intent?: "frameFeeder") {
|
||||
if (!editable) return;
|
||||
// The outline's „Verdunster einsetzen“ action reports its box.
|
||||
const evaporator = findPartKind(workingState, catalog, id)?.kind === "BOX";
|
||||
setPartTarget({ id, evaporator });
|
||||
// The outline's add actions report their box; „Futtertasche einsetzen“ says so, the other is the evaporator.
|
||||
const isBox = findPartKind(workingState, catalog, id)?.kind === "BOX";
|
||||
setPartTarget({ id, evaporator: isBox && intent !== "frameFeeder", frameFeeder: isBox && intent === "frameFeeder" });
|
||||
setPartOpen(true);
|
||||
}
|
||||
|
||||
@@ -222,90 +247,115 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
|
||||
|
||||
return (
|
||||
<>
|
||||
{past ? <HiveVersionBar occurredOn={past.occurredOn} onBack={() => {
|
||||
setPast(null);
|
||||
historyButtonRef.current?.focus();
|
||||
}} /> : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 sm:flex-row">
|
||||
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
|
||||
<div className="absolute inset-0">
|
||||
<HiveVisualSlot
|
||||
layout={layout}
|
||||
catalog={catalog}
|
||||
partName={partName}
|
||||
onSelectPart={selectPart}
|
||||
onSelectFrame={editFrame}
|
||||
/>
|
||||
</div>
|
||||
{view.openBoxId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="touch"
|
||||
className="absolute start-2 top-2"
|
||||
onClick={() => view.openBox(null)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
id={outlineId}
|
||||
className={
|
||||
showOutline
|
||||
? "max-h-40 min-h-0 overflow-y-auto sm:relative sm:max-h-none sm:w-72 sm:shrink-0 sm:overflow-visible"
|
||||
: "sr-only"
|
||||
}
|
||||
>
|
||||
<div className="sm:absolute sm:inset-0 sm:overflow-y-auto" data-testid="hive-outline-scroll">
|
||||
<HiveOutline
|
||||
ref={outlineRef}
|
||||
<div className="relative isolate min-h-0 flex-1 overflow-hidden bg-holo-bg text-holo-label-fg">
|
||||
<div className="absolute inset-0">
|
||||
<HiveVisualSlot
|
||||
layout={layout}
|
||||
state={shownState}
|
||||
catalog={catalog}
|
||||
canEdit={editable}
|
||||
announcement={announcement}
|
||||
onEditPart={editPart}
|
||||
onEditFrame={editFrame}
|
||||
partName={partName}
|
||||
onSelectPart={selectPart}
|
||||
onSelectFrame={editFrame}
|
||||
/>
|
||||
</div>
|
||||
{focusedId ? (
|
||||
<HiveFocusBar
|
||||
focusedId={focusedId}
|
||||
name={partName(focusedId)}
|
||||
chips={focusChips(shownState, catalog, focusedId)}
|
||||
canEdit={editable && hasSheet(focusedId)}
|
||||
onEdit={() => editPart(focusedId)}
|
||||
onBack={leaveFocus}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`pointer-events-none absolute start-3 top-3 max-w-[min(26rem,calc(100%-1.5rem))] rounded-2xl px-4 py-3 ${GLASS_SURFACE}`}
|
||||
>
|
||||
<CardTitle className="text-xl font-semibold tracking-tight">Beute</CardTitle>
|
||||
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
|
||||
</div>
|
||||
)}
|
||||
{/* One column for the outline panel and the button bar: the panel always ends above the
|
||||
buttons, however they wrap. DOM order keeps the buttons before the tree (tab order). */}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col gap-2 p-2">
|
||||
<div className="order-2 flex flex-col items-center gap-2 p-1">
|
||||
{past ? (
|
||||
<div className="pointer-events-auto">
|
||||
<HiveVersionBar
|
||||
occurredOn={past.occurredOn}
|
||||
onBack={() => {
|
||||
setPast(null);
|
||||
historyButtonRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{focusedId ? (
|
||||
// In focus mode the top belongs to the focus bar; the facts stay readable here.
|
||||
// Phones keep the room for the part; the focus chips carry its state there.
|
||||
<div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}>
|
||||
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-auto flex max-w-full flex-wrap justify-center gap-1.5 sm:gap-2">
|
||||
{showRebuild && !offline ? (
|
||||
<Link href={`/colonies/${encodeURIComponent(colonyId)}/hive`} className={GLASS_BUTTON}>
|
||||
<Boxes aria-hidden />
|
||||
Umbauen
|
||||
</Link>
|
||||
) : null}
|
||||
{showRebuild && offline ? (
|
||||
<button type="button" disabled aria-describedby="hive-rebuild-hint" className={GLASS_BUTTON}>
|
||||
<Boxes aria-hidden />
|
||||
Umbauen
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={showOutline}
|
||||
aria-controls={outlineId}
|
||||
aria-label="Aufbau als Liste"
|
||||
onClick={() => setShowOutline(!showOutline)}
|
||||
className={GLASS_BUTTON}
|
||||
>
|
||||
<ListTree aria-hidden />
|
||||
Liste
|
||||
</button>
|
||||
<button ref={historyButtonRef} type="button" onClick={() => setHistoryOpen(true)} className={GLASS_BUTTON}>
|
||||
<History aria-hidden />
|
||||
Verlauf
|
||||
</button>
|
||||
</div>
|
||||
{showRebuild && offline ? (
|
||||
<p id="hive-rebuild-hint" className={`rounded-full px-3 py-1 text-xs ${GLASS_SURFACE}`}>
|
||||
{OFFLINE_REBUILD_HINT}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="order-1 flex min-h-0 flex-1 justify-end">
|
||||
<div
|
||||
id={outlineId}
|
||||
className={
|
||||
showOutline
|
||||
? "pointer-events-auto z-10 flex w-[min(18rem,100%)] flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/95 text-card-foreground shadow-xl backdrop-blur-md duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-right-4"
|
||||
: "sr-only"
|
||||
}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2" data-testid="hive-outline-scroll">
|
||||
<HiveOutline
|
||||
ref={outlineRef}
|
||||
state={shownState}
|
||||
catalog={catalog}
|
||||
canEdit={editable}
|
||||
announcement={announcement}
|
||||
onEditPart={editPart}
|
||||
onEditFrame={editFrame}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showRebuild && !offline ? (
|
||||
<Link
|
||||
href={`/colonies/${encodeURIComponent(colonyId)}/hive`}
|
||||
className={buttonVariants({ size: "touch" })}
|
||||
>
|
||||
Umbauen
|
||||
</Link>
|
||||
) : null}
|
||||
{showRebuild && offline ? (
|
||||
<Button size="touch" disabled aria-describedby="hive-rebuild-hint">
|
||||
Umbauen
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
aria-pressed={showOutline}
|
||||
aria-controls={outlineId}
|
||||
onClick={() => setShowOutline(!showOutline)}
|
||||
>
|
||||
<ListTree aria-hidden />
|
||||
Aufbau als Liste
|
||||
</Button>
|
||||
<Button ref={historyButtonRef} type="button" variant="outline" size="touch" onClick={() => setHistoryOpen(true)}>
|
||||
<History aria-hidden />
|
||||
Verlauf
|
||||
</Button>
|
||||
</div>
|
||||
{showRebuild && offline ? (
|
||||
<p id="hive-rebuild-hint" className="text-xs text-muted-foreground">
|
||||
{OFFLINE_REBUILD_HINT}
|
||||
</p>
|
||||
) : null}
|
||||
<HiveSheetShell open={historyOpen} onOpenChange={setHistoryOpen} title="Verlauf">
|
||||
<HiveHistory
|
||||
colonyId={colonyId}
|
||||
@@ -337,6 +387,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
|
||||
catalog={catalog}
|
||||
partId={partTarget.id}
|
||||
startWithEvaporator={partTarget.evaporator}
|
||||
startWithFrameFeeder={partTarget.frameFeeder}
|
||||
submit={submit}
|
||||
outlineRef={outlineRef}
|
||||
/>
|
||||
|
||||
@@ -5,15 +5,17 @@ export interface HiveFactsLineProps {
|
||||
facts: HiveFacts;
|
||||
/** The latest change; omitted while a past state is shown. */
|
||||
lastChange?: { occurredOn: string; source: HiveChangeSource } | null;
|
||||
/** "stage": light text for the frosted overlay on the holo stage. */
|
||||
tone?: "card" | "stage";
|
||||
}
|
||||
|
||||
/** „2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin“ and „Stand: 12.10. aus Durchsicht“ (spec 9.4). */
|
||||
export function HiveFactsLine({ facts, lastChange }: HiveFactsLineProps) {
|
||||
export function HiveFactsLine({ facts, lastChange, tone = "card" }: HiveFactsLineProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">{hiveFactsText(facts)}</p>
|
||||
{lastChange ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className={tone === "stage" ? "text-xs text-holo-label-fg/80" : "text-xs text-muted-foreground"}>
|
||||
Stand: {shortGermanDay(lastChange.occurredOn)} aus {sourceLabel(lastChange.source)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveFocusBar, focusChips } from "./hive-focus-bar";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", { frames: frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame)) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
|
||||
part("lid", "inner-lid", { state: { feedHole: "CLOSED" } }),
|
||||
],
|
||||
};
|
||||
|
||||
function renderBar(extra: Partial<Parameters<typeof HiveFocusBar>[0]> = {}) {
|
||||
const props = {
|
||||
focusedId: "fe",
|
||||
name: "Futterzarge",
|
||||
chips: focusChips(state, catalog, "fe"),
|
||||
canEdit: true,
|
||||
onEdit: vi.fn(),
|
||||
onBack: vi.fn(),
|
||||
...extra,
|
||||
};
|
||||
render(<HiveFocusBar {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("focusChips", () => {
|
||||
it("lists levels with their maximum and choices with their German text", () => {
|
||||
expect(focusChips(state, catalog, "fe").map((chip) => chip.text)).toEqual([
|
||||
"Flüssigfutter 2,5 l von 3 l",
|
||||
"Futterteig 1 kg von 2,5 kg",
|
||||
]);
|
||||
expect(focusChips(state, catalog, "lid").map((chip) => chip.text)).toEqual(["Futterloch: zu"]);
|
||||
});
|
||||
|
||||
it("counts the frames of a box and says whether the floor holds a Bausperre", () => {
|
||||
expect(focusChips(state, catalog, "b1")[0].text).toBe("8 Rähmchen");
|
||||
expect(focusChips(state, catalog, "f").at(-1)!.text).toBe("Bausperre drin");
|
||||
});
|
||||
|
||||
it("finds inserts and returns nothing for unknown ids", () => {
|
||||
expect(focusChips(state, catalog, "fi")).toEqual([]);
|
||||
expect(focusChips(state, catalog, "nope")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveFocusBar", () => {
|
||||
it("renders nothing outside focus mode", () => {
|
||||
const { container } = render(
|
||||
<HiveFocusBar focusedId={null} name="" chips={[]} canEdit={false} onEdit={vi.fn()} onBack={vi.fn()} />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows „Zurück“, the name, the state chips and „Ändern“", () => {
|
||||
const props = renderBar();
|
||||
expect(screen.getByText("Futterzarge")).toBeInTheDocument();
|
||||
expect(screen.getByText("Flüssigfutter 2,5 l von 3 l")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Zurück" }));
|
||||
expect(props.onBack).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ändern" }));
|
||||
expect(props.onEdit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides „Ändern“ for readers", () => {
|
||||
renderBar({ canEdit: false });
|
||||
expect(screen.queryByRole("button", { name: "Ändern" })).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves focus mode on Esc", () => {
|
||||
const props = renderBar();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(props.onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves Esc to an open sheet", () => {
|
||||
const props = renderBar();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
const input = document.createElement("input");
|
||||
dialog.appendChild(input);
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(props.onBack).not.toHaveBeenCalled();
|
||||
dialog.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
|
||||
import type { HiveCatalog, HivePart, HiveState } from "@/lib/hive/types";
|
||||
import { GLASS_PILL, GLASS_SURFACE } from "./hive-glass";
|
||||
import { formatAmount } from "./hive-scene/scene-model";
|
||||
|
||||
// DOM overlay of focus mode (spec 9.4): glass „Zurück“ pill, the part's name
|
||||
// and its state as small glass chips, „Ändern“ for parts with a sheet.
|
||||
// Esc leaves focus mode unless a dialog (sheet) has the keyboard.
|
||||
|
||||
export interface FocusChip {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function findPart(state: HiveState, id: string): HivePart | undefined {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === id) return part;
|
||||
const insert = part.inserts?.find((candidate) => candidate.id === id);
|
||||
if (insert) return insert;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Chips for the focused part: frame count of a box, every state field with its value. */
|
||||
export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] {
|
||||
const part = findPart(state, id);
|
||||
const type = part ? catalog.partTypes.get(part.partTypeId) : undefined;
|
||||
if (!part || !type) return [];
|
||||
const chips: FocusChip[] = [];
|
||||
if (type.kind === "BOX") {
|
||||
const frames = (part.frames ?? []).filter((frame) => frame.type !== "NONE").length;
|
||||
chips.push({ key: "frames", text: frames === 1 ? "1 Rähmchen" : `${frames} Rähmchen` });
|
||||
}
|
||||
for (const field of type.stateSchema) {
|
||||
const raw = part.state[field.key];
|
||||
if (field.kind === "level") {
|
||||
const value = typeof raw === "number" ? raw : 0;
|
||||
chips.push({ key: field.key, text: `${field.label} ${formatAmount(value, field.unit)} von ${formatAmount(field.max, field.unit)}` });
|
||||
} else {
|
||||
const index = Math.max(0, field.options.indexOf(String(raw)));
|
||||
chips.push({ key: field.key, text: `${field.label}: ${field.optionLabels[index] ?? field.options[index]}` });
|
||||
}
|
||||
}
|
||||
const inserts = (part.inserts ?? []).map((insert) => catalog.partTypes.get(insert.partTypeId)?.kind);
|
||||
if (type.kind === "FLOOR") chips.push({ key: "insert", text: inserts.includes("FLOOR_INSERT") ? "Bausperre drin" : "ohne Bausperre" });
|
||||
return chips;
|
||||
}
|
||||
|
||||
export interface HiveFocusBarProps {
|
||||
/** Part in focus mode; nothing is rendered without one. */
|
||||
focusedId: string | null;
|
||||
name: string;
|
||||
chips: FocusChip[];
|
||||
/** Shows „Ändern“ (the part has a sheet and the user may edit). */
|
||||
canEdit: boolean;
|
||||
onEdit: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function HiveFocusBar({ focusedId, name, chips, canEdit, onEdit, onBack }: HiveFocusBarProps) {
|
||||
useEffect(() => {
|
||||
if (!focusedId) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
// A sheet handles its own Esc.
|
||||
if (event.target instanceof Element && event.target.closest("[role='dialog'], [role='alertdialog']")) return;
|
||||
onBack();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [focusedId, onBack]);
|
||||
|
||||
if (!focusedId) return null;
|
||||
|
||||
// Fades in (150 ms); only at motion `full` it also slides down (motion-full variant, spec 9.8).
|
||||
return (
|
||||
<div
|
||||
key={focusedId}
|
||||
className="pointer-events-none absolute inset-x-3 top-3 flex flex-col items-start gap-2 duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-top-2"
|
||||
>
|
||||
<div className="flex max-w-full items-center gap-3">
|
||||
<button type="button" onClick={onBack} className={`pointer-events-auto ${GLASS_PILL}`}>
|
||||
<ArrowLeft aria-hidden className="size-4" />
|
||||
Zurück
|
||||
</button>
|
||||
<p className="truncate text-lg font-semibold text-holo-label-fg drop-shadow-[0_1px_8px_rgb(0_0_0/0.6)]">{name}</p>
|
||||
</div>
|
||||
{chips.length > 0 || canEdit ? (
|
||||
<ul className="flex max-w-full flex-wrap gap-1.5" aria-label={`Zustand von ${name}`}>
|
||||
{chips.map((chip) => (
|
||||
<li key={chip.key} className={`rounded-full px-3 py-1 text-xs font-medium ${GLASS_SURFACE}`}>
|
||||
{chip.text}
|
||||
</li>
|
||||
))}
|
||||
{canEdit ? (
|
||||
<li>
|
||||
<button type="button" onClick={onEdit} className={`pointer-events-auto ${GLASS_PILL}`}>
|
||||
<SlidersHorizontal aria-hidden className="size-4" />
|
||||
Ändern
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is
|
||||
// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well
|
||||
// above 4.5:1; focus rings use the strong edge colour.
|
||||
|
||||
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md";
|
||||
|
||||
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`;
|
||||
|
||||
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`;
|
||||
|
||||
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${GLASS_SURFACE}`;
|
||||
@@ -319,4 +319,87 @@ describe("HiveOutline", () => {
|
||||
expect(document.body).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame feeder", () => {
|
||||
const pocketState: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b1", "box", {
|
||||
frames: Array.from({ length: 6 }, (_, index) => ({
|
||||
slot: index + 1,
|
||||
type: index === 2 || index === 3 ? ("NONE" as const) : ("BROOD" as const),
|
||||
})),
|
||||
inserts: [part("ff", "frame-feeder", { slot: 3, state: { syrupLiters: 1.5 } })],
|
||||
}),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
|
||||
function open(props: Partial<React.ComponentProps<typeof HiveOutline>> = {}) {
|
||||
const result = setup({ state: pocketState, ...props });
|
||||
act(() => result.store.openBox("b1"));
|
||||
return result;
|
||||
}
|
||||
|
||||
it("lists the pocket at its slot position and hides the covered frames", () => {
|
||||
open();
|
||||
const items = names();
|
||||
const start = items.findIndex((t) => t?.startsWith("R1"));
|
||||
expect(items.slice(start, start + 5)).toEqual([
|
||||
"R1 · Brutwabe",
|
||||
"R2 · Brutwabe",
|
||||
"F3–4 · Futtertasche · 1,5 l",
|
||||
"R5 · Brutwabe",
|
||||
"R6 · Brutwabe",
|
||||
]);
|
||||
expect(items.some((t) => t?.startsWith("R3") || t?.startsWith("R4"))).toBe(false);
|
||||
});
|
||||
|
||||
it("nests the pocket one level below its box and edits it on Enter", async () => {
|
||||
const { user, onEditPart } = open();
|
||||
const pocket = screen.getByRole("treeitem", { name: /^F3–4 · Futtertasche/ });
|
||||
expect(pocket).toHaveAttribute("aria-level", "2");
|
||||
await user.click(pocket);
|
||||
expect(onEditPart).toHaveBeenCalledWith("ff");
|
||||
});
|
||||
|
||||
it("offers Futtertasche einsetzen while a position is free and passes the intent", async () => {
|
||||
const { user, onEditPart } = open();
|
||||
await user.click(screen.getByRole("treeitem", { name: /Futtertasche einsetzen/ }));
|
||||
expect(onEditPart).toHaveBeenCalledWith("b1", "frameFeeder");
|
||||
});
|
||||
|
||||
it("hides the add action without canEdit or when no position is left", () => {
|
||||
const { unmount } = open({ canEdit: false });
|
||||
expect(screen.queryByRole("treeitem", { name: /Futtertasche einsetzen/ })).toBeNull();
|
||||
unmount();
|
||||
const full: HiveState = {
|
||||
...pocketState,
|
||||
stack: pocketState.stack.map((p) =>
|
||||
p.id === "b1"
|
||||
? {
|
||||
...p,
|
||||
frames: Array.from({ length: 10 }, (_, index) => ({ slot: index + 1, type: "NONE" as const })),
|
||||
inserts: [1, 3, 5, 7, 9].map((slot) => part(`ff${slot}`, "frame-feeder", { slot })),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
};
|
||||
open({ state: full });
|
||||
expect(screen.queryByRole("treeitem", { name: /Futtertasche einsetzen/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("focusPart on the pocket expands the box and focuses the pocket", () => {
|
||||
const ref = createRef<HiveOutlineHandle>();
|
||||
const store = createHiveViewStore();
|
||||
render(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline ref={ref} state={pocketState} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
act(() => ref.current?.focusPart("ff"));
|
||||
expect(screen.getByRole("treeitem", { name: /^F3–4 · Futtertasche/ })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { FocusEvent, KeyboardEvent, Ref } from "react";
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots";
|
||||
import type { SlotRange } from "@/lib/hive/feeder-slots";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types";
|
||||
import { useHiveView } from "./use-hive-view";
|
||||
@@ -26,6 +28,8 @@ interface OutlineNode {
|
||||
isBox: boolean;
|
||||
isFloor: boolean;
|
||||
hasState: boolean;
|
||||
/** Which add action an `action` node stands for. */
|
||||
action?: "evaporator" | "frameFeeder";
|
||||
setSize: number;
|
||||
position: number;
|
||||
}
|
||||
@@ -59,6 +63,58 @@ function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: Hive
|
||||
}
|
||||
|
||||
const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …";
|
||||
const ADD_FRAME_FEEDER_LABEL = "Futtertasche einsetzen …";
|
||||
|
||||
type Child =
|
||||
| { type: "insert"; part: HivePart; range: SlotRange | null }
|
||||
| { type: "frame"; frame: NonNullable<HivePart["frames"]>[number] };
|
||||
|
||||
/**
|
||||
* Children of a box in outline order: other inserts (evaporator), then the
|
||||
* frames and frame feeders by slot. Frames a pocket covers are not listed.
|
||||
*/
|
||||
function childrenOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): Child[] {
|
||||
const inserts = visibleInserts(part, catalog).map((insert) => ({
|
||||
part: insert,
|
||||
range: feederRange(insert, catalog.partTypes.get(insert.partTypeId)),
|
||||
}));
|
||||
const others: Child[] = inserts.filter((i) => !i.range).map((i) => ({ type: "insert", ...i }));
|
||||
if (type?.kind !== "BOX") return inserts.map((i) => ({ type: "insert", ...i }));
|
||||
const pockets = inserts.filter((i) => i.range);
|
||||
const bySlot: Child[] = [];
|
||||
for (const frame of part.frames ?? []) {
|
||||
const pocket = pockets.find((p) => p.range && frame.slot >= p.range.from && frame.slot <= p.range.to);
|
||||
if (!pocket) bySlot.push({ type: "frame", frame });
|
||||
else if (pocket.range?.from === frame.slot) bySlot.push({ type: "insert", ...pocket });
|
||||
}
|
||||
// A pocket whose slot lies outside the frames still shows up, after the frames.
|
||||
for (const pocket of pockets) {
|
||||
if (!bySlot.some((child) => child.type === "insert" && child.part.id === pocket.part.id)) {
|
||||
bySlot.push({ type: "insert", ...pocket });
|
||||
}
|
||||
}
|
||||
return [...others, ...bySlot];
|
||||
}
|
||||
|
||||
function firstActiveType(catalog: HiveCatalog, kind: CatalogPartType["kind"]): CatalogPartType | undefined {
|
||||
return [...catalog.partTypes.values()].find((t) => t.kind === kind && t.active);
|
||||
}
|
||||
|
||||
function addActions(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog, canEdit: boolean) {
|
||||
if (type?.kind !== "BOX" || !canEdit) return [];
|
||||
const actions: { action: "evaporator" | "frameFeeder"; label: string }[] = [];
|
||||
const hasEvaporator = (part.inserts ?? []).some(
|
||||
(insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR",
|
||||
);
|
||||
if (!hasEvaporator && firstActiveType(catalog, "EVAPORATOR")) {
|
||||
actions.push({ action: "evaporator", label: ADD_EVAPORATOR_LABEL });
|
||||
}
|
||||
const feederType = firstActiveType(catalog, "FRAME_FEEDER");
|
||||
if (feederType && freeFeederStarts(part, type, feederType, catalog).length > 0) {
|
||||
actions.push({ action: "frameFeeder", label: ADD_FRAME_FEEDER_LABEL });
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildNodes(
|
||||
state: HiveState,
|
||||
@@ -66,18 +122,22 @@ function buildNodes(
|
||||
expanded: ReadonlySet<string>,
|
||||
canEdit: boolean,
|
||||
): OutlineNode[] {
|
||||
const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active);
|
||||
const nodes: OutlineNode[] = [];
|
||||
const topDown = [...state.stack].reverse();
|
||||
|
||||
const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => {
|
||||
const addPart = (
|
||||
part: HivePart,
|
||||
level: number,
|
||||
parentKey: string | null,
|
||||
position: number,
|
||||
setSize: number,
|
||||
range: SlotRange | null = null,
|
||||
) => {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
const inserts = visibleInserts(part, catalog);
|
||||
const frames = type?.kind === "BOX" ? (part.frames ?? []) : [];
|
||||
const isBox = type?.kind === "BOX";
|
||||
const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR");
|
||||
const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator;
|
||||
const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0);
|
||||
const children = childrenOf(part, type, catalog);
|
||||
const actions = addActions(part, type, catalog, canEdit);
|
||||
const childCount = children.length + actions.length;
|
||||
const displayName = partDisplayName(state, catalog, part.id);
|
||||
nodes.push({
|
||||
key: part.id,
|
||||
kind: "part",
|
||||
@@ -85,23 +145,28 @@ function buildNodes(
|
||||
level,
|
||||
parentKey,
|
||||
childCount,
|
||||
name: partDisplayName(state, catalog, part.id),
|
||||
name: range ? `F${rangeText(range)} · ${displayName}` : displayName,
|
||||
hint: hintOf(part, type, catalog),
|
||||
isBox,
|
||||
isBox: type?.kind === "BOX",
|
||||
isFloor: type?.kind === "FLOOR",
|
||||
hasState: (type?.stateSchema.length ?? 0) > 0,
|
||||
setSize,
|
||||
position,
|
||||
});
|
||||
if (!expanded.has(part.id)) return;
|
||||
inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount));
|
||||
frames.forEach((frame, index) => {
|
||||
const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
|
||||
children.forEach((child, index) => {
|
||||
if (child.type === "insert") {
|
||||
addPart(child.part, level + 1, part.id, index + 1, childCount, child.range);
|
||||
return;
|
||||
}
|
||||
const label = [`R${child.frame.slot}`, FRAME_TYPE_LABELS[child.frame.type], child.frame.name]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
nodes.push({
|
||||
key: `${part.id}#${frame.slot}`,
|
||||
key: `${part.id}#${child.frame.slot}`,
|
||||
kind: "frame",
|
||||
id: part.id,
|
||||
slot: frame.slot,
|
||||
slot: child.frame.slot,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
@@ -111,26 +176,27 @@ function buildNodes(
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: inserts.length + index + 1,
|
||||
position: index + 1,
|
||||
});
|
||||
});
|
||||
if (hasAddAction) {
|
||||
actions.forEach((action, index) => {
|
||||
nodes.push({
|
||||
key: `${part.id}#add-evaporator`,
|
||||
key: `${part.id}#add-${action.action}`,
|
||||
kind: "action",
|
||||
id: part.id,
|
||||
action: action.action,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
name: ADD_EVAPORATOR_LABEL,
|
||||
name: action.label,
|
||||
hint: null,
|
||||
isBox: false,
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: childCount,
|
||||
position: children.length + index + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length));
|
||||
@@ -167,7 +233,8 @@ export interface HiveOutlineProps {
|
||||
canEdit: boolean;
|
||||
/** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */
|
||||
announcement: string;
|
||||
onEditPart: (id: string) => void;
|
||||
/** `intent` is set by the box's „Futtertasche einsetzen“ action. */
|
||||
onEditPart: (id: string, intent?: "frameFeeder") => void;
|
||||
onEditFrame: (boxId: string, slot: number) => void;
|
||||
}
|
||||
|
||||
@@ -282,7 +349,9 @@ export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEdit
|
||||
|
||||
const activateNode = (node: OutlineNode): void => {
|
||||
if (node.kind === "action") {
|
||||
if (canEdit) onEditPart(node.id);
|
||||
if (!canEdit) return;
|
||||
if (node.action === "frameFeeder") onEditPart(node.id, "frameFeeder");
|
||||
else onEditPart(node.id);
|
||||
return;
|
||||
}
|
||||
if (node.kind === "frame") {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cameraBasis, cameraPosition, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
|
||||
|
||||
describe("cameraBasis", () => {
|
||||
it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
|
||||
const { back, right, up } = cameraBasis();
|
||||
expect(Math.asin(back[1]) * (180 / Math.PI)).toBeCloseTo(20, 6);
|
||||
expect(Math.atan2(back[0], back[2]) * (180 / Math.PI)).toBeCloseTo(30, 6);
|
||||
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
expect(dot(back, right)).toBeCloseTo(0, 9);
|
||||
expect(dot(back, up)).toBeCloseTo(0, 9);
|
||||
expect(dot(right, up)).toBeCloseTo(0, 9);
|
||||
expect(up[1]).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitDistance", () => {
|
||||
const hive: [number, number, number] = [5.25, 7, 4.3];
|
||||
|
||||
it("moves the camera further away for a taller model", () => {
|
||||
expect(fitDistance([5.25, 10, 4.3], 1.6)).toBeGreaterThan(fitDistance(hive, 1.6));
|
||||
});
|
||||
|
||||
it("moves further away in a narrow card than in a wide one", () => {
|
||||
expect(fitDistance(hive, 0.6)).toBeGreaterThan(fitDistance(hive, 2));
|
||||
});
|
||||
|
||||
it("grows with the margin and survives a zero aspect", () => {
|
||||
expect(fitDistance(hive, 1.5, 1.4)).toBeGreaterThan(fitDistance(hive, 1.5, 1.2));
|
||||
expect(Number.isFinite(fitDistance(hive, 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a minimum distance for a flat part", () => {
|
||||
expect(fitDistance([0, 0, 0], 1)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlayMargin", () => {
|
||||
it("leaves more room on a short card than on a tall one, capped at 2", () => {
|
||||
expect(overlayMargin(1000, 150)).toBeCloseTo(1000 / 850, 9);
|
||||
expect(overlayMargin(460, 150)).toBeCloseTo(460 / 310, 9);
|
||||
expect(overlayMargin(200, 150)).toBe(2);
|
||||
expect(overlayMargin(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("focusFrame", () => {
|
||||
it("pulls the view toward the ghost hive and keeps the focused part inside the box", () => {
|
||||
const frame = focusFrame([-2, 1, 5], [6, 3, 5], [0, 4, 0], [0.5, 0.5, 0.5]);
|
||||
expect(frame.center).toEqual([-1, 2.5, 2.5]);
|
||||
expect(frame.size).toEqual([8, 6, 10]);
|
||||
});
|
||||
|
||||
it("is the focused part itself without pull", () => {
|
||||
expect(focusFrame([1, 2, 3], [4, 5, 6], [0, 0, 0], [0, 0, 0])).toEqual({ center: [1, 2, 3], size: [4, 5, 6] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cameraPosition", () => {
|
||||
it("places the camera along the viewing direction from the centre", () => {
|
||||
const [x, y, z] = cameraPosition([1, 2, 3], 10);
|
||||
const { back } = cameraBasis();
|
||||
expect(x).toBeCloseTo(1 + back[0] * 10, 9);
|
||||
expect(y).toBeCloseTo(2 + back[1] * 10, 9);
|
||||
expect(z).toBeCloseTo(3 + back[2] * 10, 9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
|
||||
// Camera placement for the card (spec 9.2): fixed viewing direction slightly
|
||||
// from above and in front, distance chosen so the target box fits the card.
|
||||
|
||||
export const CAMERA_ELEVATION_DEG = 20;
|
||||
export const CAMERA_AZIMUTH_DEG = 30;
|
||||
export const CAMERA_FOV_DEG = 30;
|
||||
/** Space around the model, as a factor on its projected size. */
|
||||
export const FIT_MARGIN = 1.05;
|
||||
/** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */
|
||||
export const FOCUS_FIT_MARGIN = 0.95;
|
||||
/** Card pixels taken by the frosted overlays (title or focus bar on top, buttons below). */
|
||||
export const OVERLAY_RESERVE_PX = 150;
|
||||
/** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */
|
||||
export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3];
|
||||
|
||||
const toRad = (deg: number): number => (deg * Math.PI) / 180;
|
||||
|
||||
export interface CameraBasis {
|
||||
/** Unit vector from the target to the camera. */
|
||||
back: Vec3;
|
||||
right: Vec3;
|
||||
up: Vec3;
|
||||
}
|
||||
|
||||
export function cameraBasis(
|
||||
elevationDeg: number = CAMERA_ELEVATION_DEG,
|
||||
azimuthDeg: number = CAMERA_AZIMUTH_DEG,
|
||||
): CameraBasis {
|
||||
const elevation = toRad(elevationDeg);
|
||||
const azimuth = toRad(azimuthDeg);
|
||||
const back: Vec3 = [
|
||||
Math.sin(azimuth) * Math.cos(elevation),
|
||||
Math.sin(elevation),
|
||||
Math.cos(azimuth) * Math.cos(elevation),
|
||||
];
|
||||
const right: Vec3 = [Math.cos(azimuth), 0, -Math.sin(azimuth)];
|
||||
// up = back x right
|
||||
const up: Vec3 = [
|
||||
back[1] * right[2] - back[2] * right[1],
|
||||
back[2] * right[0] - back[0] * right[2],
|
||||
back[0] * right[1] - back[1] * right[0],
|
||||
];
|
||||
return { back, right, up };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra margin so the model fits the part of the card the overlays leave free:
|
||||
* about 1.2 on a tall desktop card, about 1.5 on a phone. Never more than 2.
|
||||
*/
|
||||
export function overlayMargin(heightPx: number, reservePx: number = OVERLAY_RESERVE_PX): number {
|
||||
if (!(heightPx > 0)) return 1;
|
||||
return heightPx / Math.max(heightPx - reservePx, heightPx / 2);
|
||||
}
|
||||
|
||||
/** Half extent of an axis-aligned box of `size` projected on a unit axis. */
|
||||
function halfExtent(size: Vec3, axis: Vec3): number {
|
||||
return (Math.abs(axis[0]) * size[0] + Math.abs(axis[1]) * size[1] + Math.abs(axis[2]) * size[2]) / 2;
|
||||
}
|
||||
|
||||
/** Distance from the box centre at which the box fills the view with `margin` around it. */
|
||||
export function fitDistance(
|
||||
size: Vec3,
|
||||
aspect: number,
|
||||
margin: number = FIT_MARGIN,
|
||||
fovDeg: number = CAMERA_FOV_DEG,
|
||||
basis: CameraBasis = cameraBasis(),
|
||||
): number {
|
||||
const tanHalf = Math.tan(toRad(fovDeg) / 2);
|
||||
const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1;
|
||||
const halfWidth = halfExtent(size, basis.right);
|
||||
const halfHeight = halfExtent(size, basis.up);
|
||||
const halfDepth = halfExtent(size, basis.back);
|
||||
const distance = Math.max(halfHeight / tanHalf, halfWidth / (tanHalf * safeAspect)) * margin;
|
||||
// The near face of the box sits half its depth closer to the camera.
|
||||
return Math.max(distance + halfDepth, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Box the camera fits in focus mode: the focused part plus part of the way
|
||||
* toward the ghost hive, so the ghost peeks in behind it (mockup h2) and a
|
||||
* tall card is not left empty above a flat part.
|
||||
*/
|
||||
export function focusFrame(
|
||||
focusCenter: Vec3,
|
||||
focusSize: Vec3,
|
||||
hiveCenter: Vec3,
|
||||
pull: Vec3 = FOCUS_PULL,
|
||||
): { center: Vec3; size: Vec3 } {
|
||||
const shift: Vec3 = [
|
||||
(hiveCenter[0] - focusCenter[0]) * pull[0],
|
||||
(hiveCenter[1] - focusCenter[1]) * pull[1],
|
||||
(hiveCenter[2] - focusCenter[2]) * pull[2],
|
||||
];
|
||||
return {
|
||||
center: [focusCenter[0] + shift[0], focusCenter[1] + shift[1], focusCenter[2] + shift[2]],
|
||||
// The focused part stays fully inside the box around the shifted centre.
|
||||
size: [
|
||||
focusSize[0] + 2 * Math.abs(shift[0]),
|
||||
focusSize[1] + 2 * Math.abs(shift[1]),
|
||||
focusSize[2] + 2 * Math.abs(shift[2]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Camera position looking at `center` along the fixed viewing direction. */
|
||||
export function cameraPosition(center: Vec3, distance: number, basis: CameraBasis = cameraBasis()): Vec3 {
|
||||
return [
|
||||
center[0] + basis.back[0] * distance,
|
||||
center[1] + basis.back[1] * distance,
|
||||
center[2] + basis.back[2] * distance,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
|
||||
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
|
||||
import { useSceneKit } from "./scene-kit";
|
||||
import { placeCamera } from "./scene-mutations";
|
||||
import { MAX_FRAME_SECONDS } from "./use-animated-node";
|
||||
import { restBounds, stepToward } from "./use-hive-motion";
|
||||
import type { FocusInfo } from "./use-hive-motion";
|
||||
|
||||
// Auto-fit (spec 9.2): the camera looks from 20° above and 30° to the side
|
||||
// and moves so the hive (or the focused part) fills the card. It refits on
|
||||
// layout, card size and focus changes; damped at `full`, at once otherwise.
|
||||
|
||||
export interface CameraRigProps {
|
||||
layout: HiveLayout;
|
||||
focus: FocusInfo | null;
|
||||
/** Receives the current look-at point every frame; the model rig turns around it. */
|
||||
onPivot: (center: Vec3) => void;
|
||||
}
|
||||
|
||||
export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
|
||||
const { motion } = useSceneKit();
|
||||
const camera = useThree((state) => state.camera);
|
||||
const width = useThree((state) => state.size.width);
|
||||
const height = useThree((state) => state.size.height);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
|
||||
const goal = useMemo(() => {
|
||||
const aspect = height > 0 ? width / height : 1;
|
||||
const room = overlayMargin(height);
|
||||
const bounds = restBounds(layout);
|
||||
if (focus) {
|
||||
const frame = focusFrame(focus.center, focus.size, bounds.center);
|
||||
return { center: frame.center, distance: fitDistance(frame.size, aspect, FOCUS_FIT_MARGIN * room) };
|
||||
}
|
||||
return { center: bounds.center, distance: fitDistance(bounds.size, aspect, FIT_MARGIN * room) };
|
||||
}, [focus, layout, width, height]);
|
||||
|
||||
const current = useRef<{ center: Vec3; distance: number } | null>(null);
|
||||
useEffect(() => {
|
||||
invalidate();
|
||||
}, [goal, invalidate]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const damped = motion === "full";
|
||||
const dt = Math.min(delta, MAX_FRAME_SECONDS);
|
||||
if (!current.current) {
|
||||
current.current = { center: [...goal.center], distance: goal.distance };
|
||||
} else {
|
||||
const state = current.current;
|
||||
state.center = [
|
||||
stepToward(state.center[0], goal.center[0], dt, damped),
|
||||
stepToward(state.center[1], goal.center[1], dt, damped),
|
||||
stepToward(state.center[2], goal.center[2], dt, damped),
|
||||
];
|
||||
state.distance = stepToward(state.distance, goal.distance, dt, damped);
|
||||
}
|
||||
const { center, distance } = current.current;
|
||||
onPivot(center);
|
||||
placeCamera(camera, center, distance);
|
||||
const settled =
|
||||
distance === goal.distance && center.every((value, axis) => value === goal.center[axis]);
|
||||
if (!settled) invalidate();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { BoxGeometry, CylinderGeometry, MeshStandardMaterial } from "three";
|
||||
import type { Mesh } from "three";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import { createLiquidMaterial } from "./materials";
|
||||
import { MAX_FRAME_SECONDS } from "./use-animated-node";
|
||||
import { stepToward } from "./use-hive-motion";
|
||||
import { noRaycast, useSceneKit } from "./scene-kit";
|
||||
import { placeFill, setUniform } from "./scene-mutations";
|
||||
|
||||
// Levels (spec 9.3): a glowing, half-transparent volume whose surface moves
|
||||
// gently at motion `full`. Height = value / max of the space it fills.
|
||||
|
||||
/** Surface wave height in layout units (2 mm). */
|
||||
const WOBBLE = 0.02;
|
||||
|
||||
export type FillKind = "honey" | "acid" | "fondant";
|
||||
|
||||
export interface FillVolumeProps {
|
||||
/** Centre and size of the full space (local to the part). */
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
/** 0…1 */
|
||||
ratio: number;
|
||||
kind: FillKind;
|
||||
shape?: "box" | "cylinder";
|
||||
}
|
||||
|
||||
export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVolumeProps) {
|
||||
const { theme, motion } = useSceneKit();
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const mesh = useRef<Mesh>(null);
|
||||
const shown = useRef(ratio);
|
||||
|
||||
const geometry = useMemo(
|
||||
() => (shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12)),
|
||||
[shape],
|
||||
);
|
||||
const material = useMemo(() => {
|
||||
if (kind === "fondant") {
|
||||
return new MeshStandardMaterial({
|
||||
color: theme.fondant,
|
||||
roughness: 0.55,
|
||||
emissive: theme.fondant,
|
||||
emissiveIntensity: 0.18,
|
||||
transparent: true,
|
||||
opacity: 0.94,
|
||||
});
|
||||
}
|
||||
return kind === "acid"
|
||||
? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 })
|
||||
: createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 });
|
||||
}, [kind, theme]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
},
|
||||
[geometry, material],
|
||||
);
|
||||
useEffect(() => {
|
||||
invalidate();
|
||||
}, [ratio, invalidate]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const node = mesh.current;
|
||||
if (!node) return;
|
||||
const dt = Math.min(delta, MAX_FRAME_SECONDS);
|
||||
shown.current = stepToward(shown.current, ratio, dt, motion === "full");
|
||||
const height = placeFill(node, center, size, shown.current, shape === "cylinder");
|
||||
if ("uniforms" in material) {
|
||||
setUniform(material, "uTime", state.clock.elapsedTime);
|
||||
// The wave lives in unit space; divide by the height so it stays 2 mm.
|
||||
setUniform(material, "uWobble", motion === "full" && kind !== "fondant" ? WOBBLE / height : 0);
|
||||
}
|
||||
if (shown.current !== ratio) invalidate();
|
||||
});
|
||||
|
||||
return <mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { MeshStandardMaterial } from "three";
|
||||
import type { Group, ShaderMaterial } from "three";
|
||||
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
||||
import { HoloLabel } from "./hive-labels";
|
||||
import { createEdgeMaterial, createGlassMaterial } from "./materials";
|
||||
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
|
||||
import type { PartShapeSpec } from "./part-geometry";
|
||||
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
||||
import { setMaterialOpacity, setUniform } from "./scene-mutations";
|
||||
import type { SceneLabel } from "./scene-model";
|
||||
import type { NodeHandlers } from "./stack-part";
|
||||
import { useAnimatedNode } from "./use-animated-node";
|
||||
import type { AnimatedValues } from "./use-animated-node";
|
||||
import type { FrameTarget } from "./use-hive-motion";
|
||||
|
||||
// Frames (spec 9.3/9.4). At rest only their top bars are hinted inside the
|
||||
// box. In an open box each frame is a clear glass plate; hovered or selected
|
||||
// it rises and takes its type look (colour and comb pattern).
|
||||
|
||||
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
|
||||
/** Faint top bars of all frames of a box, one merged mesh using the box's materials. */
|
||||
export function FrameHints({
|
||||
frames,
|
||||
hostCenter,
|
||||
glass,
|
||||
edges,
|
||||
}: {
|
||||
frames: FrameBox[];
|
||||
hostCenter: Vec3;
|
||||
glass: ShaderMaterial;
|
||||
edges: ShaderMaterial;
|
||||
}) {
|
||||
const geometries = useMemo(() => {
|
||||
const hints = frames
|
||||
.filter((frame) => frame.type !== "NONE")
|
||||
.map((frame) => frameHintSpec(frame.size, sub(frame.center, hostCenter)));
|
||||
return {
|
||||
glass: buildGlassGeometry(hints.map((hint) => hint.slab)),
|
||||
edges: buildEdgeGeometry(hints.flatMap((hint) => hint.edges)),
|
||||
};
|
||||
}, [frames, hostCenter]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometries.glass.dispose();
|
||||
geometries.edges.dispose();
|
||||
},
|
||||
[geometries],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<mesh geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
|
||||
<mesh geometry={geometries.edges} material={edges} raycast={noRaycast} renderOrder={4} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FramePlateProps {
|
||||
frame: FrameBox;
|
||||
hostCenter: Vec3;
|
||||
target: FrameTarget;
|
||||
handlers: NodeHandlers;
|
||||
label?: SceneLabel;
|
||||
}
|
||||
|
||||
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
|
||||
|
||||
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
|
||||
const { theme, geometries, textures, motion } = useSceneKit();
|
||||
const group = useRef<Group>(null);
|
||||
const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]);
|
||||
const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x");
|
||||
const bars = geometries.shape(`frame-bars|${sizeKey}`, (): PartShapeSpec => ({
|
||||
slabs: spec.bars,
|
||||
edges: spec.edges,
|
||||
grids: [],
|
||||
frost: 0,
|
||||
}));
|
||||
const comb = spec.comb
|
||||
? geometries.shape(`frame-comb|${sizeKey}|${spec.comb.size[0].toFixed(3)}`, (): PartShapeSpec => ({
|
||||
slabs: spec.comb ? [spec.comb] : [],
|
||||
edges: [],
|
||||
grids: [],
|
||||
frost: 0,
|
||||
}))
|
||||
: null;
|
||||
const texture = textures.get(frame.type);
|
||||
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
bars: createGlassMaterial(theme, { frost: 0.28 }),
|
||||
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
|
||||
edges: createEdgeMaterial(theme),
|
||||
typed: texture
|
||||
? new MeshStandardMaterial({
|
||||
map: texture,
|
||||
emissiveMap: texture,
|
||||
emissive: "#ffffff",
|
||||
emissiveIntensity: 0.95,
|
||||
roughness: frame.type === "HONEY" ? 0.2 : 0.45,
|
||||
metalness: 0.05,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
})
|
||||
: null,
|
||||
}),
|
||||
[theme, texture, frame.type],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
materials.bars.dispose();
|
||||
materials.comb.dispose();
|
||||
materials.edges.dispose();
|
||||
materials.typed?.dispose();
|
||||
},
|
||||
[materials],
|
||||
);
|
||||
|
||||
const typeLook = target.look === "type";
|
||||
const apply = ({ opacity, glow, look }: AnimatedValues) => {
|
||||
setUniform(materials.bars, "uOpacity", opacity);
|
||||
setUniform(materials.bars, "uGlow", glow);
|
||||
// The glass comb gives way to the textured comb as the look fades in.
|
||||
setUniform(materials.comb, "uOpacity", opacity * (1 - (materials.typed ? look * 0.92 : 0)));
|
||||
setUniform(materials.comb, "uGlow", glow);
|
||||
setUniform(materials.edges, "uOpacity", opacity);
|
||||
setUniform(materials.edges, "uIntensity", 1.3 * glow);
|
||||
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
|
||||
};
|
||||
useAnimatedNode(
|
||||
group,
|
||||
{ position: target.position, scale: 1, opacity: target.opacity, glow: typeLook ? 1.7 : 1, look: typeLook ? 1 : 0 },
|
||||
motion,
|
||||
apply,
|
||||
);
|
||||
|
||||
const restY = frame.center[1] - hostCenter[1];
|
||||
const rise = Math.max(0, target.position[1] - restY);
|
||||
const [fx, fy, fz] = frame.size;
|
||||
|
||||
return (
|
||||
<group ref={group}>
|
||||
<mesh geometry={bars.glass} material={materials.bars} raycast={noRaycast} renderOrder={1} dispose={null} />
|
||||
{comb ? (
|
||||
<mesh geometry={comb.glass} material={materials.comb} raycast={noRaycast} renderOrder={1} dispose={null} />
|
||||
) : null}
|
||||
{comb && materials.typed ? (
|
||||
<mesh geometry={comb.glass} material={materials.typed} raycast={noRaycast} renderOrder={2} dispose={null} />
|
||||
) : null}
|
||||
<mesh geometry={bars.edges} material={materials.edges} raycast={noRaycast} renderOrder={4} dispose={null} />
|
||||
{/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */}
|
||||
<mesh
|
||||
position={[0, -rise / 2, 0]}
|
||||
raycast={target.interactive ? meshRaycast : noRaycast}
|
||||
onPointerOver={handlers.onPointerOver}
|
||||
onPointerOut={handlers.onPointerOut}
|
||||
onPointerDown={handlers.onPointerDown}
|
||||
onClick={handlers.onClick}
|
||||
userData={{ hit: `${frame.boxId}#${frame.slot}` }}
|
||||
>
|
||||
<boxGeometry args={[fx, fy + rise, fz]} />
|
||||
<meshBasicMaterial visible={false} />
|
||||
</mesh>
|
||||
{label ? <HoloLabel position={[0, fy / 2 + 0.12, 0]} text={label.text} tone="frame" /> : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FRAME_TYPES } from "@/lib/hive/types";
|
||||
import type { FrameType } from "@/lib/hive/types";
|
||||
import { createFrameTextureCache, framePatternSpec } from "./frame-textures";
|
||||
|
||||
/** A canvas whose 2D context records every fill colour and shape drawn. */
|
||||
function fakeCanvas() {
|
||||
const fills: string[] = [];
|
||||
let fillStyle: unknown = "";
|
||||
const gradient = { addColorStop: vi.fn() };
|
||||
const context = {
|
||||
get fillStyle() {
|
||||
return fillStyle;
|
||||
},
|
||||
set fillStyle(value: unknown) {
|
||||
fillStyle = value;
|
||||
},
|
||||
strokeStyle: "",
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
fillRect: vi.fn(() => fills.push(`rect:${String(fillStyle)}`)),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
ellipse: vi.fn(),
|
||||
fill: vi.fn(() => fills.push(typeof fillStyle === "string" ? fillStyle : "gradient")),
|
||||
stroke: vi.fn(),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
createLinearGradient: vi.fn(() => gradient),
|
||||
};
|
||||
const canvas = { width: 0, height: 0, getContext: vi.fn(() => context) };
|
||||
return { canvas: canvas as unknown as HTMLCanvasElement, fills };
|
||||
}
|
||||
|
||||
describe("framePatternSpec", () => {
|
||||
it("has no pattern for the open frame, the divider and an empty slot", () => {
|
||||
expect(framePatternSpec("DRONE_FRAME")).toBeNull();
|
||||
expect(framePatternSpec("DIVIDER")).toBeNull();
|
||||
expect(framePatternSpec("NONE")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every comb type its own pattern", () => {
|
||||
const combs = FRAME_TYPES.filter((type) => framePatternSpec(type) !== null);
|
||||
expect(combs).toEqual(["BROOD", "DRONE", "HONEY", "POLLEN", "FEED", "FOUNDATION", "EMPTY_COMB"]);
|
||||
const serialised = new Set(combs.map((type) => JSON.stringify(framePatternSpec(type))));
|
||||
expect(serialised.size).toBe(combs.length);
|
||||
});
|
||||
|
||||
it("draws drone cells larger and domed, honey glossy, pollen speckled, foundation without fills", () => {
|
||||
expect(framePatternSpec("DRONE")!.cellRadius).toBeGreaterThan(framePatternSpec("BROOD")!.cellRadius);
|
||||
expect(framePatternSpec("DRONE")!.dome).toBe(true);
|
||||
expect(framePatternSpec("HONEY")!.gloss).toBeGreaterThan(framePatternSpec("BROOD")!.gloss);
|
||||
expect(framePatternSpec("POLLEN")!.speckle).toBe(true);
|
||||
expect(framePatternSpec("FOUNDATION")!.fills).toHaveLength(0);
|
||||
expect(framePatternSpec("EMPTY_COMB")!.open).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFrameTextureCache", () => {
|
||||
it("returns one cached texture per type and none for types without a comb", () => {
|
||||
const made: ReturnType<typeof fakeCanvas>[] = [];
|
||||
const cache = createFrameTextureCache(() => {
|
||||
const fake = fakeCanvas();
|
||||
made.push(fake);
|
||||
return fake.canvas;
|
||||
});
|
||||
const brood = cache.get("BROOD");
|
||||
expect(brood).not.toBeNull();
|
||||
expect(cache.get("BROOD")).toBe(brood);
|
||||
expect(cache.get("NONE")).toBeNull();
|
||||
expect(cache.get("DIVIDER")).toBeNull();
|
||||
expect(made).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("draws different pixels for different types", () => {
|
||||
const drawn = new Map<FrameType, string[]>();
|
||||
let current: FrameType = "BROOD";
|
||||
const cache = createFrameTextureCache(() => {
|
||||
const fake = fakeCanvas();
|
||||
drawn.set(current, fake.fills);
|
||||
return fake.canvas;
|
||||
});
|
||||
for (const type of ["BROOD", "HONEY", "POLLEN", "FOUNDATION"] as FrameType[]) {
|
||||
current = type;
|
||||
cache.get(type);
|
||||
}
|
||||
const signatures = new Set([...drawn.values()].map((fills) => fills.join("|")));
|
||||
expect(signatures.size).toBe(4);
|
||||
expect(drawn.get("BROOD")!.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("disposes every texture it made", () => {
|
||||
const cache = createFrameTextureCache(() => fakeCanvas().canvas);
|
||||
const texture = cache.get("HONEY")!;
|
||||
const dispose = vi.spyOn(texture, "dispose");
|
||||
cache.dispose();
|
||||
expect(dispose).toHaveBeenCalled();
|
||||
expect(cache.get("HONEY")).not.toBe(texture);
|
||||
});
|
||||
|
||||
it("returns null when the canvas has no 2D context", () => {
|
||||
const cache = createFrameTextureCache(
|
||||
() => ({ width: 0, height: 0, getContext: () => null }) as unknown as HTMLCanvasElement,
|
||||
);
|
||||
expect(cache.get("BROOD")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { CanvasTexture, ClampToEdgeWrapping, SRGBColorSpace } from "three";
|
||||
import type { FrameType } from "@/lib/hive/types";
|
||||
|
||||
// Procedural comb textures for the frame look (spec 9.3): colour AND pattern
|
||||
// per frame type, drawn once on a canvas and cached per type. No image assets.
|
||||
|
||||
const WIDTH = 512;
|
||||
const HEIGHT = 288;
|
||||
|
||||
interface WeightedColor {
|
||||
color: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface FramePatternSpec {
|
||||
/** Wax / comb background. */
|
||||
base: string;
|
||||
/** Cell wall colour. */
|
||||
rim: string;
|
||||
/** Hexagon radius in canvas pixels. */
|
||||
cellRadius: number;
|
||||
/** Colours of filled cells, picked by weight. */
|
||||
fills: WeightedColor[];
|
||||
/** Share of cells drawn as open (empty, dark centre). */
|
||||
open: number;
|
||||
/** Capped cells bulge (drone brood). */
|
||||
dome: boolean;
|
||||
/** Strength of the glossy highlight on capped cells (0…1). */
|
||||
gloss: number;
|
||||
/** Small coloured grains inside cells (pollen). */
|
||||
speckle: boolean;
|
||||
/** Seed of the pseudo-random pattern, so every type looks the same each time. */
|
||||
seed: number;
|
||||
}
|
||||
|
||||
const SPECS: Partial<Record<FrameType, FramePatternSpec>> = {
|
||||
BROOD: {
|
||||
base: "#b8741f",
|
||||
rim: "#ffd27a",
|
||||
cellRadius: 13,
|
||||
fills: [
|
||||
{ color: "#eaa443", weight: 6 },
|
||||
{ color: "#dc952f", weight: 3 },
|
||||
{ color: "#f4b65a", weight: 2 },
|
||||
],
|
||||
open: 0.03,
|
||||
dome: false,
|
||||
gloss: 0.18,
|
||||
speckle: false,
|
||||
seed: 11,
|
||||
},
|
||||
DRONE: {
|
||||
base: "#8a4e14",
|
||||
rim: "#e8a24c",
|
||||
cellRadius: 17,
|
||||
fills: [
|
||||
{ color: "#c8822e", weight: 5 },
|
||||
{ color: "#b67224", weight: 3 },
|
||||
],
|
||||
open: 0.06,
|
||||
dome: true,
|
||||
gloss: 0.3,
|
||||
speckle: false,
|
||||
seed: 23,
|
||||
},
|
||||
HONEY: {
|
||||
base: "#b9800f",
|
||||
rim: "#ffe08a",
|
||||
cellRadius: 13,
|
||||
fills: [
|
||||
{ color: "#f6c64a", weight: 6 },
|
||||
{ color: "#ffd766", weight: 3 },
|
||||
{ color: "#e9b032", weight: 2 },
|
||||
],
|
||||
open: 0.04,
|
||||
dome: false,
|
||||
gloss: 0.75,
|
||||
speckle: false,
|
||||
seed: 37,
|
||||
},
|
||||
POLLEN: {
|
||||
base: "#8a4612",
|
||||
rim: "#f0a050",
|
||||
cellRadius: 13,
|
||||
fills: [
|
||||
{ color: "#f08a24", weight: 4 },
|
||||
{ color: "#f4c430", weight: 3 },
|
||||
{ color: "#d0461c", weight: 2 },
|
||||
{ color: "#9a6a2a", weight: 1 },
|
||||
],
|
||||
open: 0.2,
|
||||
dome: false,
|
||||
gloss: 0.08,
|
||||
speckle: true,
|
||||
seed: 41,
|
||||
},
|
||||
FEED: {
|
||||
base: "#b89a52",
|
||||
rim: "#fff2c2",
|
||||
cellRadius: 13,
|
||||
fills: [
|
||||
{ color: "#f3dc94", weight: 5 },
|
||||
{ color: "#ead08a", weight: 3 },
|
||||
],
|
||||
open: 0.08,
|
||||
dome: false,
|
||||
gloss: 0.45,
|
||||
speckle: false,
|
||||
seed: 53,
|
||||
},
|
||||
FOUNDATION: {
|
||||
base: "#e9d8a8",
|
||||
rim: "#fff8de",
|
||||
cellRadius: 13,
|
||||
fills: [],
|
||||
open: 0,
|
||||
dome: false,
|
||||
gloss: 0,
|
||||
speckle: false,
|
||||
seed: 61,
|
||||
},
|
||||
EMPTY_COMB: {
|
||||
base: "#4a3418",
|
||||
rim: "#a8834c",
|
||||
cellRadius: 13,
|
||||
fills: [],
|
||||
open: 1,
|
||||
dome: false,
|
||||
gloss: 0,
|
||||
speckle: false,
|
||||
seed: 71,
|
||||
},
|
||||
};
|
||||
|
||||
/** Pattern of a frame type, or null for types without a comb (open frame, divider, empty slot). */
|
||||
export function framePatternSpec(type: FrameType): FramePatternSpec | null {
|
||||
return SPECS[type] ?? null;
|
||||
}
|
||||
|
||||
/** Small deterministic PRNG (mulberry32). */
|
||||
function random(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function pick(fills: WeightedColor[], value: number): string {
|
||||
const total = fills.reduce((sum, entry) => sum + entry.weight, 0);
|
||||
let left = value * total;
|
||||
for (const entry of fills) {
|
||||
left -= entry.weight;
|
||||
if (left <= 0) return entry.color;
|
||||
}
|
||||
return fills[fills.length - 1].color;
|
||||
}
|
||||
|
||||
function hexPath(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
|
||||
context.beginPath();
|
||||
for (let corner = 0; corner < 6; corner += 1) {
|
||||
const angle = (Math.PI / 3) * corner + Math.PI / 6;
|
||||
const px = x + radius * Math.cos(angle);
|
||||
const py = y + radius * Math.sin(angle);
|
||||
if (corner === 0) context.moveTo(px, py);
|
||||
else context.lineTo(px, py);
|
||||
}
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
function drawCell(
|
||||
context: CanvasRenderingContext2D,
|
||||
spec: FramePatternSpec,
|
||||
x: number,
|
||||
y: number,
|
||||
next: () => number,
|
||||
): void {
|
||||
const inner = spec.cellRadius * 0.82;
|
||||
const isOpen = spec.fills.length === 0 ? spec.open > 0 : next() < spec.open;
|
||||
if (isOpen) {
|
||||
const hollow = context.createRadialGradient(x, y, 0, x, y, inner);
|
||||
hollow.addColorStop(0, "rgba(74, 38, 8, 0.95)");
|
||||
hollow.addColorStop(1, "rgba(150, 92, 30, 0.6)");
|
||||
context.fillStyle = hollow;
|
||||
hexPath(context, x, y, inner);
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
if (spec.fills.length === 0) return;
|
||||
const color = pick(spec.fills, next());
|
||||
if (spec.dome) {
|
||||
const dome = context.createRadialGradient(x - inner * 0.3, y - inner * 0.3, inner * 0.1, x, y, inner);
|
||||
dome.addColorStop(0, "#f0b868");
|
||||
dome.addColorStop(1, color);
|
||||
context.fillStyle = dome;
|
||||
} else {
|
||||
context.fillStyle = color;
|
||||
}
|
||||
hexPath(context, x, y, inner);
|
||||
context.fill();
|
||||
if (spec.speckle) {
|
||||
context.fillStyle = pick(spec.fills, next());
|
||||
context.beginPath();
|
||||
context.arc(x + (next() - 0.5) * inner, y + (next() - 0.5) * inner, 1 + next() * 1.6, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
if (spec.gloss > 0) {
|
||||
context.globalAlpha = spec.gloss;
|
||||
context.fillStyle = "#fff6dc";
|
||||
context.beginPath();
|
||||
context.ellipse(x - inner * 0.28, y - inner * 0.32, inner * 0.34, inner * 0.18, -0.5, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function drawPattern(context: CanvasRenderingContext2D, spec: FramePatternSpec): void {
|
||||
const next = random(spec.seed);
|
||||
context.fillStyle = spec.base;
|
||||
context.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
const radius = spec.cellRadius;
|
||||
const stepX = Math.sqrt(3) * radius;
|
||||
const stepY = 1.5 * radius;
|
||||
context.strokeStyle = spec.rim;
|
||||
context.lineWidth = Math.max(1.2, radius * 0.16);
|
||||
for (let row = 0, y = 0; y < HEIGHT + radius; row += 1, y += stepY) {
|
||||
const offset = row % 2 === 0 ? 0 : stepX / 2;
|
||||
for (let x = offset; x < WIDTH + radius; x += stepX) {
|
||||
drawCell(context, spec, x, y, next);
|
||||
hexPath(context, x, y, radius);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface FrameTextureCache {
|
||||
/** Texture of a frame type, created on first use; null for types without a comb or without 2D canvas. */
|
||||
get(type: FrameType): CanvasTexture | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const defaultCanvas = (): HTMLCanvasElement => document.createElement("canvas");
|
||||
|
||||
export function createFrameTextureCache(makeCanvas: () => HTMLCanvasElement = defaultCanvas): FrameTextureCache {
|
||||
const textures = new Map<FrameType, CanvasTexture | null>();
|
||||
return {
|
||||
get(type) {
|
||||
if (textures.has(type)) return textures.get(type) ?? null;
|
||||
const spec = framePatternSpec(type);
|
||||
if (!spec) return null;
|
||||
const canvas = makeCanvas();
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
textures.set(type, null);
|
||||
return null;
|
||||
}
|
||||
drawPattern(context, spec);
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.wrapS = ClampToEdgeWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.anisotropy = 4;
|
||||
textures.set(type, texture);
|
||||
return texture;
|
||||
},
|
||||
dispose() {
|
||||
textures.forEach((texture) => texture?.dispose());
|
||||
textures.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import type { HiveLayout } from "@/lib/hive/layout";
|
||||
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
|
||||
import { useHiveView } from "../use-hive-view";
|
||||
import { CAMERA_FOV_DEG } from "./camera-fit";
|
||||
import { HiveSceneContent } from "./hive-scene-content";
|
||||
import { HiveStage, detectStageTier } from "./hive-stage";
|
||||
import { SceneKitProvider } from "./scene-kit";
|
||||
import { shouldIdle } from "./use-hive-motion";
|
||||
|
||||
export interface HiveCanvasProps {
|
||||
layout: HiveLayout;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
partName: (id: string) => string;
|
||||
onSelectPart: (id: string) => void;
|
||||
onSelectFrame: (boxId: string, slot: number) => void;
|
||||
/** The first frame is on screen: the SVG may cross-fade out. */
|
||||
onReady: () => void;
|
||||
/** The WebGL context was lost; the browser may or may not restore it. */
|
||||
onContextLost: () => void;
|
||||
}
|
||||
|
||||
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
|
||||
function useOnScreen(target: RefObject<HTMLElement | null>): { visible: boolean; tabVisible: boolean } {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [tabVisible, setTabVisible] = useState(() => typeof document === "undefined" || !document.hidden);
|
||||
useEffect(() => {
|
||||
const element = target.current;
|
||||
if (!element || typeof IntersectionObserver === "undefined") return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
setVisible(entries.some((entry) => entry.isIntersecting));
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [target]);
|
||||
useEffect(() => {
|
||||
const onChange = () => setTabVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", onChange);
|
||||
return () => document.removeEventListener("visibilitychange", onChange);
|
||||
}, []);
|
||||
return { visible, tabVisible };
|
||||
}
|
||||
|
||||
/**
|
||||
* The 3D hive (spec 9.3/9.4): holo stage plus the interactive model. Loaded
|
||||
* only through next/dynamic; the canvas is aria-hidden, the outline is the
|
||||
* accessible path. Renders on demand: frames only while something moves.
|
||||
*/
|
||||
export default function HiveCanvas({
|
||||
layout,
|
||||
state,
|
||||
catalog,
|
||||
partName,
|
||||
onSelectPart,
|
||||
onSelectFrame,
|
||||
onReady,
|
||||
onContextLost,
|
||||
}: HiveCanvasProps) {
|
||||
const motion = useEffectiveMotionLevel();
|
||||
const view = useHiveView();
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { visible, tabVisible } = useOnScreen(root);
|
||||
const [tier] = useState(detectStageTier);
|
||||
const idle = shouldIdle({ motion, visible, tabVisible });
|
||||
|
||||
return (
|
||||
<div ref={root} className="h-full w-full">
|
||||
<Canvas
|
||||
frameloop="demand"
|
||||
dpr={[1, 2]}
|
||||
aria-hidden
|
||||
// Vertical swipes scroll the page; horizontal drags turn the model.
|
||||
style={{ touchAction: "pan-y" }}
|
||||
camera={{ fov: CAMERA_FOV_DEG, near: 0.1, far: 200, position: [8, 6, 14] }}
|
||||
gl={{ antialias: false, stencil: false, powerPreference: "high-performance" }}
|
||||
onPointerMissed={() => {
|
||||
// A click or tap into empty space leaves focus mode and clears the hover.
|
||||
view.openBox(null);
|
||||
view.setHovered(null);
|
||||
view.setHoveredFrame(null);
|
||||
}}
|
||||
onCreated={({ gl, invalidate }) => {
|
||||
gl.domElement.addEventListener("webglcontextlost", (event) => {
|
||||
// Without preventDefault the browser never restores the context.
|
||||
event.preventDefault();
|
||||
onContextLost();
|
||||
});
|
||||
invalidate();
|
||||
onReady();
|
||||
}}
|
||||
>
|
||||
<SceneKitProvider motion={motion}>
|
||||
<HiveStage tier={tier} />
|
||||
<HiveSceneContent
|
||||
layout={layout}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
partName={partName}
|
||||
onSelectPart={onSelectPart}
|
||||
onSelectFrame={onSelectFrame}
|
||||
idle={idle}
|
||||
/>
|
||||
</SceneKitProvider>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Html } from "@react-three/drei";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import type { SceneLabel } from "./scene-model";
|
||||
|
||||
// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass
|
||||
// pill with a thin leader line. At most three at once (sceneLabels caps it).
|
||||
|
||||
const PILL =
|
||||
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md";
|
||||
|
||||
export interface HoloLabelProps {
|
||||
position: Vec3;
|
||||
text: string;
|
||||
tone: SceneLabel["tone"];
|
||||
}
|
||||
|
||||
/** A label anchored at `position` (local to its parent): beside a part, above a frame. */
|
||||
export function HoloLabel({ position, text, tone }: HoloLabelProps) {
|
||||
return (
|
||||
<Html position={position} zIndexRange={[30, 10]} style={{ pointerEvents: "none" }}>
|
||||
{tone === "frame" ? (
|
||||
<div className="pointer-events-none flex -translate-x-1/2 -translate-y-full flex-col items-center select-none">
|
||||
<span className={PILL}>{text}</span>
|
||||
<span aria-hidden className="h-6 w-px bg-holo-edge/80" />
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none flex -translate-y-1/2 items-center select-none">
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
||||
<span aria-hidden className="h-px w-7 bg-holo-edge/80" />
|
||||
<span className={PILL}>
|
||||
{tone === "level" ? <span aria-hidden className="me-2 inline-block size-2 rounded-full bg-amber-400 align-middle" /> : null}
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import ReactThreeTestRenderer from "@react-three/test-renderer";
|
||||
import type { ReactThreeTest } from "@react-three/test-renderer";
|
||||
import { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
||||
import type { HiveViewStore } from "../use-hive-view";
|
||||
import { HiveSceneContent } from "./hive-scene-content";
|
||||
import { IDLE_FLOAT, ModelRig } from "./model-rig";
|
||||
import { SceneKitProvider } from "./scene-kit";
|
||||
|
||||
// The scene content runs on the r3f test renderer (no WebGL). Labels are DOM
|
||||
// portals (drei Html), so they are replaced by a marker here.
|
||||
vi.mock("./hive-labels", () => ({
|
||||
HoloLabel: ({ text }: { text: string }) => <group name={`label:${text}`} />,
|
||||
}));
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
function hiveState(): HiveState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", {
|
||||
frames: frames(10).map((frame) =>
|
||||
frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame.slot === 4 ? { ...frame, type: "BROOD" as const } : frame,
|
||||
),
|
||||
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })],
|
||||
}),
|
||||
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface Rendered {
|
||||
renderer: Awaited<ReturnType<typeof ReactThreeTestRenderer.create>>;
|
||||
store: HiveViewStore;
|
||||
onSelectPart: ReturnType<typeof vi.fn>;
|
||||
onSelectFrame: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
let current: Rendered | null = null;
|
||||
|
||||
async function renderScene(motion: MotionLevel = "full"): Promise<Rendered> {
|
||||
const state = hiveState();
|
||||
const layout = hiveLayout(state, catalog);
|
||||
const store = createHiveViewStore();
|
||||
const onSelectPart = vi.fn();
|
||||
const onSelectFrame = vi.fn();
|
||||
const renderer = await ReactThreeTestRenderer.create(
|
||||
<HiveViewProvider store={store}>
|
||||
<SceneKitProvider motion={motion}>
|
||||
<HiveSceneContent
|
||||
layout={layout}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
partName={(id) => partDisplayName(state, catalog, id)}
|
||||
onSelectPart={onSelectPart}
|
||||
onSelectFrame={onSelectFrame}
|
||||
idle={false}
|
||||
/>
|
||||
</SceneKitProvider>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
current = { renderer, store, onSelectPart, onSelectFrame };
|
||||
return current;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await current?.renderer.unmount();
|
||||
current = null;
|
||||
});
|
||||
|
||||
const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactThreeTestInstance =>
|
||||
renderer.scene.find((node) => node.props.userData?.hit === id);
|
||||
const hits = (renderer: Rendered["renderer"]): string[] =>
|
||||
renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit);
|
||||
|
||||
async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") {
|
||||
const target = hit(renderer, id);
|
||||
await renderer.fireEvent(target, "onPointerDown", { pointerType });
|
||||
await renderer.fireEvent(target, "onClick", { delta: 0 });
|
||||
}
|
||||
|
||||
describe("HiveSceneContent", () => {
|
||||
it("renders one hit box per part and insert, and frames only as hints at rest", async () => {
|
||||
const { renderer } = await renderScene();
|
||||
expect(hits(renderer).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
|
||||
});
|
||||
|
||||
it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await act(async () => store.openBox("b1"));
|
||||
const frameHits = hits(renderer).filter((id) => id.startsWith("b1#"));
|
||||
expect(frameHits).toHaveLength(8);
|
||||
expect(frameHits).not.toContain("b1#1");
|
||||
expect(frameHits).not.toContain("b1#2");
|
||||
});
|
||||
|
||||
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
|
||||
expect(store.getState().hoveredId).toBe("b1");
|
||||
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOut", { pointerType: "mouse" });
|
||||
expect(store.getState().hoveredId).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a box on click and focuses any other part", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await tap(renderer, "b1");
|
||||
expect(store.getState().openBoxId).toBe("b1");
|
||||
await act(async () => store.openBox(null));
|
||||
await tap(renderer, "fe");
|
||||
expect(store.getState()).toMatchObject({ focusedPartId: "fe", openBoxId: null });
|
||||
});
|
||||
|
||||
it("opens the part sheet when the focused part's content is clicked", async () => {
|
||||
const { renderer, store, onSelectPart } = await renderScene();
|
||||
await act(async () => store.focusPart("fe"));
|
||||
await tap(renderer, "fe");
|
||||
expect(onSelectPart).toHaveBeenCalledWith("fe");
|
||||
});
|
||||
|
||||
it("does not react to clicks on ghost parts in focus mode", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await act(async () => store.focusPart("fe"));
|
||||
// Ghosts do not raycast; the scene also ignores them if an event arrives.
|
||||
expect(hit(renderer, "b1").props.raycast).not.toBe(hit(renderer, "fe").props.raycast);
|
||||
});
|
||||
|
||||
it("hovers and selects frames of the open box", async () => {
|
||||
const { renderer, store, onSelectFrame } = await renderScene();
|
||||
await act(async () => store.openBox("b1"));
|
||||
await renderer.fireEvent(hit(renderer, "b1#4"), "onPointerOver", { pointerType: "mouse" });
|
||||
expect(store.getState().hoveredFrame).toEqual({ boxId: "b1", slot: 4 });
|
||||
await tap(renderer, "b1#4");
|
||||
expect(onSelectFrame).toHaveBeenCalledWith("b1", 4);
|
||||
});
|
||||
|
||||
it("focuses the frame feeder of an open box on click", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await act(async () => store.openBox("b1"));
|
||||
await tap(renderer, "ff");
|
||||
expect(store.getState().focusedPartId).toBe("ff");
|
||||
});
|
||||
|
||||
it("on touch hovers with the first tap and selects with the second", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await tap(renderer, "b1", "touch");
|
||||
expect(store.getState()).toMatchObject({ hoveredId: "b1", openBoxId: null });
|
||||
await tap(renderer, "b1", "touch");
|
||||
expect(store.getState().openBoxId).toBe("b1");
|
||||
});
|
||||
|
||||
it("ignores touch pointer-over (taps drive the hover)", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "touch" });
|
||||
expect(store.getState().hoveredId).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a click that ended a drag", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await renderer.fireEvent(hit(renderer, "b1"), "onPointerDown", { pointerType: "mouse" });
|
||||
await renderer.fireEvent(hit(renderer, "b1"), "onClick", { delta: 40 });
|
||||
expect(store.getState().openBoxId).toBeNull();
|
||||
});
|
||||
|
||||
it("labels the hovered part", async () => {
|
||||
const { renderer, store } = await renderScene();
|
||||
await act(async () => store.setHovered("b1"));
|
||||
expect(renderer.scene.findAll((node) => node.props.name === "label:Zarge · 8 Rähmchen")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lifts the hovered part toward its target over a few frames at full", async () => {
|
||||
const { renderer, store } = await renderScene("full");
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
const group = hit(renderer, "b1").parent!;
|
||||
const before = group.instance.position.y;
|
||||
await act(async () => store.setHovered("b1"));
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
const midway = group.instance.position.y;
|
||||
await renderer.advanceFrames(30, 1 / 60);
|
||||
const settled = group.instance.position.y;
|
||||
expect(midway).toBeGreaterThan(before);
|
||||
expect(midway).toBeLessThan(settled);
|
||||
expect(settled - before).toBeCloseTo(0.08, 3);
|
||||
});
|
||||
|
||||
it("jumps to the target at once below full", async () => {
|
||||
const { renderer, store } = await renderScene("subtle");
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
const group = hit(renderer, "b1").parent!;
|
||||
const before = group.instance.position.y;
|
||||
await act(async () => store.setHovered("b1"));
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
expect(group.instance.position.y - before).toBeCloseTo(0.08, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelRig", () => {
|
||||
async function renderRig(motion: MotionLevel, idle: boolean) {
|
||||
const pivot: Vec3 = [0, 1, 0];
|
||||
const renderer = await ReactThreeTestRenderer.create(
|
||||
<SceneKitProvider motion={motion}>
|
||||
<ModelRig getPivot={() => pivot} idle={idle}>
|
||||
<mesh name="probe" />
|
||||
</ModelRig>
|
||||
</SceneKitProvider>,
|
||||
);
|
||||
const outer = renderer.scene.children[0];
|
||||
return { renderer, outer };
|
||||
}
|
||||
|
||||
it("floats the model at full while idle", async () => {
|
||||
const { renderer, outer } = await renderRig("full", true);
|
||||
const heights = new Set<number>();
|
||||
for (let step = 0; step < 6; step += 1) {
|
||||
await renderer.advanceFrames(1, 0.25);
|
||||
heights.add(Number(outer.instance.position.y.toFixed(4)));
|
||||
}
|
||||
expect(heights.size).toBeGreaterThan(1);
|
||||
for (const height of heights) expect(Math.abs(height - 1)).toBeLessThanOrEqual(IDLE_FLOAT + 1e-9);
|
||||
await renderer.unmount();
|
||||
});
|
||||
|
||||
it("keeps the model still at subtle, even when idle is allowed", async () => {
|
||||
const { renderer, outer } = await renderRig("subtle", true);
|
||||
for (let step = 0; step < 6; step += 1) {
|
||||
await renderer.advanceFrames(1, 0.25);
|
||||
expect(outer.instance.position.y).toBe(1);
|
||||
expect(outer.instance.rotation.y).toBe(0);
|
||||
}
|
||||
await renderer.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
|
||||
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||
import { focusedIdOf, useHiveView } from "../use-hive-view";
|
||||
import type { HoveredFrame } from "../use-hive-view";
|
||||
import { CameraRig } from "./camera-rig";
|
||||
import { FramePlate } from "./frame-plate";
|
||||
import { ModelRig } from "./model-rig";
|
||||
import { buildSceneModel, sceneLabels } from "./scene-model";
|
||||
import type { SceneLabel } from "./scene-model";
|
||||
import { setCanvasCursor } from "./scene-mutations";
|
||||
import { StackPart } from "./stack-part";
|
||||
import type { NodeHandlers } from "./stack-part";
|
||||
import { frameKey, hiveMotionTargets, restBounds } from "./use-hive-motion";
|
||||
import type { HiveMotionTargets } from "./use-hive-motion";
|
||||
import { useTouchSelect } from "./use-touch-select";
|
||||
|
||||
// Everything inside the canvas that knows about the hive: parts, inserts,
|
||||
// frames, labels, camera and turning; it must sit inside a SceneKitProvider.
|
||||
// Pointer events go to the shared view store (spec 9.1), so the outline, the
|
||||
// SVG and the scene stay in step.
|
||||
|
||||
/** A press that moved further than this is a drag, not a click. */
|
||||
export const CLICK_SLOP_PX = 8;
|
||||
|
||||
export interface HiveSceneContentProps {
|
||||
layout: HiveLayout;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
partName: (id: string) => string;
|
||||
/** Content of a focused part was clicked (opens its sheet). */
|
||||
onSelectPart: (id: string) => void;
|
||||
/** A frame of the open box was clicked (opens the frame sheet). */
|
||||
onSelectFrame: (boxId: string, slot: number) => void;
|
||||
/** Idle motion may run (see shouldIdle). */
|
||||
idle: boolean;
|
||||
}
|
||||
|
||||
const partKey = (id: string): string => `part:${id}`;
|
||||
const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`;
|
||||
|
||||
function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null {
|
||||
if (key.startsWith("part:")) return { kind: "part", id: key.slice(5) };
|
||||
if (key.startsWith("frame:")) {
|
||||
const rest = key.slice(6);
|
||||
const split = rest.lastIndexOf("#");
|
||||
const slot = Number(rest.slice(split + 1));
|
||||
if (split < 0 || !Number.isInteger(slot)) return null;
|
||||
return { kind: "frame", boxId: rest.slice(0, split), slot };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function labelsFor(labels: SceneLabel[], id: string): SceneLabel[] {
|
||||
return labels.filter((label) => label.anchor.kind === "node" && label.anchor.id === id);
|
||||
}
|
||||
|
||||
function frameLabel(labels: SceneLabel[], boxId: string, slot: number): SceneLabel | undefined {
|
||||
return labels.find(
|
||||
(label) => label.anchor.kind === "frame" && label.anchor.boxId === boxId && label.anchor.slot === slot,
|
||||
);
|
||||
}
|
||||
|
||||
export function HiveSceneContent({
|
||||
layout,
|
||||
state,
|
||||
catalog,
|
||||
partName,
|
||||
onSelectPart,
|
||||
onSelectFrame,
|
||||
idle,
|
||||
}: HiveSceneContentProps) {
|
||||
const view = useHiveView();
|
||||
const focusedId = focusedIdOf(view);
|
||||
const gl = useThree((root) => root.gl);
|
||||
|
||||
// The frame whose sheet was opened stays lifted until the focus changes.
|
||||
const [selectedFrame, setSelectedFrame] = useState<HoveredFrame | null>(null);
|
||||
const [seenFocus, setSeenFocus] = useState(focusedId);
|
||||
if (seenFocus !== focusedId) {
|
||||
setSeenFocus(focusedId);
|
||||
setSelectedFrame(null);
|
||||
}
|
||||
|
||||
const model = useMemo(() => buildSceneModel(layout, state, catalog, partName), [layout, state, catalog, partName]);
|
||||
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
|
||||
const targets: HiveMotionTargets = useMemo(() => hiveMotionTargets(layout, motionView), [layout, motionView]);
|
||||
const labels = useMemo(() => sceneLabels(model, layout, motionView), [model, layout, motionView]);
|
||||
|
||||
const setCursor = (pointer: boolean) => setCanvasCursor(gl, pointer);
|
||||
|
||||
const selectPart = (id: string) => {
|
||||
const part = model.get(id);
|
||||
if (!part) return;
|
||||
if (focusedId === id) {
|
||||
// A click on the focused part is a click on its content: open its sheet.
|
||||
if (part.kind !== "BOX") onSelectPart(id);
|
||||
return;
|
||||
}
|
||||
view.setHovered(null);
|
||||
if (part.kind === "BOX") view.openBox(id);
|
||||
else view.focusPart(id);
|
||||
};
|
||||
|
||||
const selectFrame = (boxId: string, slot: number) => {
|
||||
setSelectedFrame({ boxId, slot });
|
||||
onSelectFrame(boxId, slot);
|
||||
};
|
||||
|
||||
const hoverKey = (key: string | null) => {
|
||||
const parsed = key === null ? null : parseKey(key);
|
||||
if (!parsed) {
|
||||
view.setHovered(null);
|
||||
view.setHoveredFrame(null);
|
||||
} else if (parsed.kind === "part") {
|
||||
view.setHovered(parsed.id);
|
||||
} else {
|
||||
view.setHoveredFrame({ boxId: parsed.boxId, slot: parsed.slot });
|
||||
}
|
||||
};
|
||||
|
||||
const touch = useTouchSelect({
|
||||
onHover: hoverKey,
|
||||
onSelect: (key) => {
|
||||
const parsed = parseKey(key);
|
||||
if (parsed?.kind === "part") selectPart(parsed.id);
|
||||
else if (parsed?.kind === "frame") selectFrame(parsed.boxId, parsed.slot);
|
||||
},
|
||||
});
|
||||
|
||||
// A tap into empty space (hover cleared) or a new focus starts the two-tap cycle over.
|
||||
const nothingHovered = view.hoveredId === null && view.hoveredFrame === null;
|
||||
useEffect(() => {
|
||||
if (nothingHovered) touch.reset();
|
||||
}, [nothingHovered, focusedId, touch]);
|
||||
|
||||
const pointerType = useRef("mouse");
|
||||
const handlersFor = (key: string): NodeHandlers => ({
|
||||
onPointerOver(event) {
|
||||
event.stopPropagation();
|
||||
if (event.pointerType === "touch") return;
|
||||
hoverKey(key);
|
||||
setCursor(true);
|
||||
},
|
||||
onPointerOut(event) {
|
||||
if (event.pointerType === "touch") return;
|
||||
const parsed = parseKey(key);
|
||||
if (parsed?.kind === "part" && view.hoveredId === parsed.id) view.setHovered(null);
|
||||
if (
|
||||
parsed?.kind === "frame" &&
|
||||
view.hoveredFrame?.boxId === parsed.boxId &&
|
||||
view.hoveredFrame.slot === parsed.slot
|
||||
) {
|
||||
view.setHoveredFrame(null);
|
||||
}
|
||||
setCursor(false);
|
||||
},
|
||||
onPointerDown(event) {
|
||||
pointerType.current = event.pointerType ?? "mouse";
|
||||
},
|
||||
onClick(event) {
|
||||
event.stopPropagation();
|
||||
if ((event.delta ?? 0) > CLICK_SLOP_PX) return;
|
||||
touch.tap(key, pointerType.current);
|
||||
},
|
||||
});
|
||||
|
||||
const pivot = useRef<Vec3>(restBounds(layout).center);
|
||||
const setPivot = useCallback((center: Vec3) => {
|
||||
pivot.current = center;
|
||||
}, []);
|
||||
const getPivot = useCallback(() => pivot.current, []);
|
||||
const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} />
|
||||
<ModelRig getPivot={getPivot} idle={idle}>
|
||||
{layout.parts.map((placed) => {
|
||||
const part = model.get(placed.id);
|
||||
const target = targets.nodes.get(placed.id);
|
||||
if (!part || !target) return null;
|
||||
const frames = layout.frames.filter((frame) => frame.boxId === placed.id);
|
||||
return (
|
||||
<StackPart
|
||||
key={placed.id}
|
||||
part={part}
|
||||
target={target}
|
||||
focused={focusedId === placed.id}
|
||||
handlers={handlersFor(partKey(placed.id))}
|
||||
labels={labelsFor(labels, placed.id)}
|
||||
hintFrames={placed.kind === "BOX" ? frames : undefined}
|
||||
>
|
||||
{insertsOf(placed).map((insert) => {
|
||||
const insertPart = model.get(insert.id);
|
||||
const insertTarget = targets.nodes.get(insert.id);
|
||||
if (!insertPart || !insertTarget) return null;
|
||||
return (
|
||||
<StackPart
|
||||
key={insert.id}
|
||||
part={insertPart}
|
||||
target={insertTarget}
|
||||
focused={focusedId === insert.id}
|
||||
handlers={handlersFor(partKey(insert.id))}
|
||||
labels={labelsFor(labels, insert.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{target.content
|
||||
? frames.map((frame) => {
|
||||
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
|
||||
if (!frameTarget || frameTarget.look === "hidden") return null;
|
||||
return (
|
||||
<FramePlate
|
||||
key={frame.slot}
|
||||
frame={frame}
|
||||
hostCenter={placed.center}
|
||||
target={frameTarget}
|
||||
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
|
||||
label={frameLabel(labels, frame.boxId, frame.slot)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</StackPart>
|
||||
);
|
||||
})}
|
||||
</ModelRig>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { MeshReflectorMaterial } from "@react-three/drei";
|
||||
import { Bloom, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
|
||||
import { ToneMappingMode } from "postprocessing";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
BufferGeometry,
|
||||
CanvasTexture,
|
||||
Float32BufferAttribute,
|
||||
NormalBlending,
|
||||
SRGBColorSpace,
|
||||
ShaderMaterial,
|
||||
} from "three";
|
||||
import type { Points } from "three";
|
||||
import { useSceneKit } from "./scene-kit";
|
||||
import { setSceneBackground, setUniform } from "./scene-mutations";
|
||||
import type { HoloTheme } from "./theme";
|
||||
|
||||
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
|
||||
// cool light from behind, glossy dark floor with a faint hexagon grid, a few
|
||||
// slow bokeh particles, bloom at half resolution, vignette. Cool light only:
|
||||
// warm colours belong to the content (owner 2026-10-16).
|
||||
|
||||
/** Objects on this layer are seen by the camera but not by the floor reflection. */
|
||||
const OVERLAY_LAYER = 1;
|
||||
const FLOOR_SIZE = 240;
|
||||
const PARTICLE_COUNT = 42;
|
||||
|
||||
export type StageTier = "high" | "low";
|
||||
|
||||
/** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */
|
||||
export function detectStageTier(): StageTier {
|
||||
if (typeof window === "undefined") return "low";
|
||||
const cores = navigator.hardwareConcurrency ?? 4;
|
||||
const coarse = window.matchMedia?.("(pointer: coarse)").matches ?? false;
|
||||
return cores >= 6 && !coarse ? "high" : "low";
|
||||
}
|
||||
|
||||
function backdropTexture(theme: HoloTheme): CanvasTexture | null {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return null;
|
||||
const base = `#${theme.background.getHexString()}`;
|
||||
const vertical = context.createLinearGradient(0, 0, 0, 512);
|
||||
vertical.addColorStop(0, "#081633");
|
||||
vertical.addColorStop(0.55, base);
|
||||
vertical.addColorStop(1, "#040814");
|
||||
context.fillStyle = vertical;
|
||||
context.fillRect(0, 0, 512, 512);
|
||||
// Cool rim light glowing behind the hive.
|
||||
const glow = context.createRadialGradient(290, 170, 10, 290, 170, 300);
|
||||
glow.addColorStop(0, "rgba(70, 150, 255, 0.55)");
|
||||
glow.addColorStop(0.45, "rgba(40, 100, 220, 0.22)");
|
||||
glow.addColorStop(1, "rgba(10, 30, 80, 0)");
|
||||
context.fillStyle = glow;
|
||||
context.fillRect(0, 0, 512, 512);
|
||||
// Soft light shafts from the upper left.
|
||||
context.globalCompositeOperation = "lighter";
|
||||
for (const [x, width, alpha] of [
|
||||
[120, 60, 0.05],
|
||||
[210, 34, 0.04],
|
||||
[300, 80, 0.035],
|
||||
] as const) {
|
||||
const shaft = context.createLinearGradient(x, 0, x + width, 0);
|
||||
shaft.addColorStop(0, "rgba(120, 180, 255, 0)");
|
||||
shaft.addColorStop(0.5, `rgba(140, 200, 255, ${alpha})`);
|
||||
shaft.addColorStop(1, "rgba(120, 180, 255, 0)");
|
||||
context.save();
|
||||
context.translate(x, 0);
|
||||
context.rotate(-0.38);
|
||||
context.translate(-x, 0);
|
||||
context.fillStyle = shaft;
|
||||
context.fillRect(x, -80, width, 560);
|
||||
context.restore();
|
||||
}
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function Backdrop() {
|
||||
const { theme } = useSceneKit();
|
||||
const scene = useThree((state) => state.scene);
|
||||
useEffect(() => {
|
||||
const texture = backdropTexture(theme);
|
||||
setSceneBackground(scene, texture ?? theme.background);
|
||||
return () => {
|
||||
setSceneBackground(scene, null);
|
||||
texture?.dispose();
|
||||
};
|
||||
}, [scene, theme]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const FLOOR_VERTEX = /* glsl */ `
|
||||
varying vec2 vXZ;
|
||||
void main() {
|
||||
vec4 world = modelMatrix * vec4(position, 1.0);
|
||||
vXZ = world.xz;
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}
|
||||
`;
|
||||
|
||||
const HEX_FRAGMENT = /* glsl */ `
|
||||
uniform vec3 uGrid;
|
||||
uniform vec3 uPool;
|
||||
uniform vec3 uFade;
|
||||
uniform float uHex;
|
||||
uniform float uBase;
|
||||
varying vec2 vXZ;
|
||||
float hexDistance(vec2 p) {
|
||||
p = abs(p);
|
||||
return max(dot(p, normalize(vec2(1.0, 1.7320508))), p.x);
|
||||
}
|
||||
void main() {
|
||||
vec2 uv = vXZ / uHex;
|
||||
vec2 r = vec2(1.0, 1.7320508);
|
||||
vec2 h = r * 0.5;
|
||||
vec2 a = mod(uv, r) - h;
|
||||
vec2 b = mod(uv - h, r) - h;
|
||||
vec2 g = dot(a, a) < dot(b, b) ? a : b;
|
||||
float edge = 0.5 - hexDistance(g);
|
||||
float width = fwidth(edge) * 1.4;
|
||||
float line = 1.0 - smoothstep(0.0, width + 0.006, edge);
|
||||
float distance = length(vXZ);
|
||||
float fade = 1.0 - smoothstep(3.0, 17.0, distance);
|
||||
float pool = exp(-distance * distance / 14.0);
|
||||
vec3 color = uGrid * line * fade * 0.32 + uPool * pool * 0.18;
|
||||
// uBase > 0: opaque fake floor (low tier) that also fades into the backdrop.
|
||||
vec3 floorColor = mix(uFade, uFade * 1.6 + uPool * 0.06, pool) + color;
|
||||
float far = smoothstep(12.0, 45.0, distance);
|
||||
vec3 outColor = mix(color, mix(floorColor, uFade, far), uBase);
|
||||
gl_FragColor = vec4(outColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
function hexMaterial(theme: HoloTheme, opaque: boolean): ShaderMaterial {
|
||||
return new ShaderMaterial({
|
||||
uniforms: {
|
||||
uGrid: { value: theme.grid.clone() },
|
||||
uPool: { value: theme.rim.clone() },
|
||||
uFade: { value: theme.background.clone().multiplyScalar(0.75) },
|
||||
uHex: { value: 2.4 },
|
||||
uBase: { value: opaque ? 1 : 0 },
|
||||
},
|
||||
vertexShader: FLOOR_VERTEX,
|
||||
fragmentShader: HEX_FRAGMENT,
|
||||
transparent: !opaque,
|
||||
depthWrite: opaque,
|
||||
blending: opaque ? NormalBlending : AdditiveBlending,
|
||||
});
|
||||
}
|
||||
|
||||
const FADE_FRAGMENT = /* glsl */ `
|
||||
uniform vec3 uFade;
|
||||
varying vec2 vXZ;
|
||||
void main() {
|
||||
float far = smoothstep(12.0, 45.0, length(vXZ));
|
||||
gl_FragColor = vec4(uFade, far);
|
||||
}
|
||||
`;
|
||||
|
||||
function Floor({ tier }: { tier: StageTier }) {
|
||||
const { theme } = useSceneKit();
|
||||
const camera = useThree((state) => state.camera);
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
grid: hexMaterial(theme, tier === "low"),
|
||||
fade: new ShaderMaterial({
|
||||
uniforms: { uFade: { value: theme.background.clone().multiplyScalar(0.75) } },
|
||||
vertexShader: FLOOR_VERTEX,
|
||||
fragmentShader: FADE_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
}),
|
||||
}),
|
||||
[theme, tier],
|
||||
);
|
||||
useEffect(() => {
|
||||
camera.layers.enable(OVERLAY_LAYER);
|
||||
return () => {
|
||||
materials.grid.dispose();
|
||||
materials.fade.dispose();
|
||||
};
|
||||
}, [camera, materials]);
|
||||
|
||||
if (tier === "low") {
|
||||
return (
|
||||
<mesh rotation-x={-Math.PI / 2} material={materials.grid} renderOrder={-2}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<mesh rotation-x={-Math.PI / 2} renderOrder={-3}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
<MeshReflectorMaterial
|
||||
resolution={512}
|
||||
blur={[420, 140]}
|
||||
mixBlur={0.9}
|
||||
mixStrength={3.2}
|
||||
mixContrast={1.1}
|
||||
mirror={0.75}
|
||||
depthScale={0.5}
|
||||
minDepthThreshold={0.4}
|
||||
maxDepthThreshold={1.3}
|
||||
color="#060b18"
|
||||
roughness={0.9}
|
||||
metalness={0.55}
|
||||
/>
|
||||
</mesh>
|
||||
{/* The grid and the distance fade sit on a layer the reflection camera does not see. */}
|
||||
<mesh
|
||||
rotation-x={-Math.PI / 2}
|
||||
position-y={0.004}
|
||||
material={materials.grid}
|
||||
renderOrder={-2}
|
||||
layers={OVERLAY_LAYER}
|
||||
>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
rotation-x={-Math.PI / 2}
|
||||
position-y={0.008}
|
||||
material={materials.fade}
|
||||
renderOrder={-1}
|
||||
layers={OVERLAY_LAYER}
|
||||
>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const PARTICLE_VERTEX = /* glsl */ `
|
||||
attribute float aSize;
|
||||
attribute float aPhase;
|
||||
uniform float uTime;
|
||||
uniform float uPixelRatio;
|
||||
varying float vSoft;
|
||||
varying float vPhase;
|
||||
void main() {
|
||||
vec3 p = position;
|
||||
p.y += sin(uTime * 0.11 + aPhase) * 0.35;
|
||||
p.x += cos(uTime * 0.07 + aPhase * 1.7) * 0.25;
|
||||
vec4 view = modelViewMatrix * vec4(p, 1.0);
|
||||
gl_PointSize = aSize * uPixelRatio * (9.0 / max(-view.z, 0.5));
|
||||
vSoft = clamp(aSize / 18.0, 0.0, 1.0);
|
||||
vPhase = aPhase;
|
||||
gl_Position = projectionMatrix * view;
|
||||
}
|
||||
`;
|
||||
|
||||
const PARTICLE_FRAGMENT = /* glsl */ `
|
||||
uniform vec3 uCool;
|
||||
uniform vec3 uWhite;
|
||||
uniform float uTime;
|
||||
varying float vSoft;
|
||||
varying float vPhase;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - 0.5) * 2.0;
|
||||
// Small motes are crisp, large ones are soft bokeh discs with a brighter rim.
|
||||
float disc = 1.0 - smoothstep(0.55 - vSoft * 0.35, 1.0, d);
|
||||
float rim = smoothstep(0.6, 0.9, d) * (1.0 - smoothstep(0.9, 1.0, d)) * vSoft;
|
||||
float twinkle = 0.75 + 0.25 * sin(uTime * 0.6 + vPhase * 3.0);
|
||||
vec3 color = mix(uWhite, uCool, vSoft);
|
||||
float alpha = (disc * (0.9 - vSoft * 0.6) + rim * 0.35) * twinkle;
|
||||
gl_FragColor = vec4(color * alpha, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Deterministic placement, so the stage looks the same on every visit (Park-Miller). */
|
||||
function seededRandom(start: number): () => number {
|
||||
let seed = start;
|
||||
return () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return seed / 2147483647;
|
||||
};
|
||||
}
|
||||
|
||||
function Particles() {
|
||||
const { theme } = useSceneKit();
|
||||
const dpr = useThree((state) => state.viewport.dpr);
|
||||
const points = useRef<Points>(null);
|
||||
const { geometry, material } = useMemo(() => {
|
||||
const next = seededRandom(7);
|
||||
const positions: number[] = [];
|
||||
const sizes: number[] = [];
|
||||
const phases: number[] = [];
|
||||
for (let index = 0; index < PARTICLE_COUNT; index += 1) {
|
||||
positions.push((next() - 0.5) * 22, 0.4 + next() * 9, (next() - 0.7) * 18);
|
||||
sizes.push(next() < 0.7 ? 2 + next() * 5 : 10 + next() * 14);
|
||||
phases.push(next() * Math.PI * 2);
|
||||
}
|
||||
const built = new BufferGeometry();
|
||||
built.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
built.setAttribute("aSize", new Float32BufferAttribute(sizes, 1));
|
||||
built.setAttribute("aPhase", new Float32BufferAttribute(phases, 1));
|
||||
const shader = new ShaderMaterial({
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uPixelRatio: { value: 1 },
|
||||
uCool: { value: theme.edge.clone() },
|
||||
uWhite: { value: theme.hot.clone() },
|
||||
},
|
||||
vertexShader: PARTICLE_VERTEX,
|
||||
fragmentShader: PARTICLE_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
});
|
||||
return { geometry: built, material: shader };
|
||||
}, [theme]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
},
|
||||
[geometry, material],
|
||||
);
|
||||
useFrame((state) => {
|
||||
setUniform(material, "uTime", state.clock.elapsedTime);
|
||||
setUniform(material, "uPixelRatio", dpr);
|
||||
});
|
||||
return <points ref={points} geometry={geometry} material={material} renderOrder={5} raycast={() => {}} />;
|
||||
}
|
||||
|
||||
function Lights() {
|
||||
const { theme } = useSceneKit();
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.55} color="#cfe6ff" />
|
||||
<directionalLight position={[3, 9, 7]} intensity={1.5} color="#ffffff" />
|
||||
<directionalLight position={[-5, 5, -7]} intensity={1.3} color={theme.rim} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Effects({ tier }: { tier: StageTier }) {
|
||||
return (
|
||||
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
|
||||
<Bloom
|
||||
mipmapBlur
|
||||
luminanceThreshold={0.7}
|
||||
luminanceSmoothing={0.28}
|
||||
intensity={0.85}
|
||||
radius={0.72}
|
||||
resolutionScale={0.5}
|
||||
/>
|
||||
<ToneMapping mode={ToneMappingMode.NEUTRAL} />
|
||||
<Vignette offset={0.26} darkness={0.7} />
|
||||
</EffectComposer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HiveStage({ tier }: { tier: StageTier }) {
|
||||
return (
|
||||
<>
|
||||
<Backdrop />
|
||||
<Lights />
|
||||
<Floor tier={tier} />
|
||||
<Particles />
|
||||
<Effects tier={tier} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
|
||||
import type { HoloTheme } from "./theme";
|
||||
|
||||
// Hand-written shaders instead of transmission: a fake studio environment
|
||||
// (white soft box above, blue rim light behind, faint cool bounce below) with
|
||||
// fresnel gives the glass look at a fraction of the cost (spec 9.9).
|
||||
|
||||
const GLASS_VERTEX = /* glsl */ `
|
||||
varying vec3 vNormalW;
|
||||
varying vec3 vViewDir;
|
||||
void main() {
|
||||
vec4 world = modelMatrix * vec4(position, 1.0);
|
||||
vNormalW = normalize(mat3(modelMatrix) * normal);
|
||||
vViewDir = cameraPosition - world.xyz;
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}
|
||||
`;
|
||||
|
||||
const GLASS_FRAGMENT = /* glsl */ `
|
||||
uniform vec3 uTint;
|
||||
uniform vec3 uRim;
|
||||
uniform vec3 uBounce;
|
||||
uniform float uOpacity;
|
||||
uniform float uFrost;
|
||||
uniform float uGlow;
|
||||
varying vec3 vNormalW;
|
||||
varying vec3 vViewDir;
|
||||
void main() {
|
||||
vec3 n = normalize(vNormalW);
|
||||
vec3 v = normalize(vViewDir);
|
||||
if (!gl_FrontFacing) n = -n;
|
||||
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
|
||||
float fresnel = pow(1.0 - ndv, 3.0);
|
||||
vec3 r = reflect(-v, n);
|
||||
float sky = smoothstep(-0.1, 0.9, r.y);
|
||||
float ground = smoothstep(0.05, -0.7, r.y);
|
||||
float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0);
|
||||
float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0);
|
||||
vec3 env = uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
|
||||
vec3 color = uTint * (0.16 + uFrost * 0.3) + env * (0.25 + 0.75 * fresnel);
|
||||
color += uBounce * 0.08 * smoothstep(0.5, -0.9, n.y);
|
||||
float alpha = 0.085 + uFrost * 0.16 + fresnel * 0.55 + softbox * 0.45;
|
||||
if (!gl_FrontFacing) {
|
||||
// Faintly frosted inner faces.
|
||||
alpha *= 0.6;
|
||||
color = mix(color, uTint * 0.45, 0.35);
|
||||
}
|
||||
color *= 0.78 + 0.22 * uGlow;
|
||||
gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface GlassOptions {
|
||||
/** 0 = clear, 1 = milky (insulation, divider board). */
|
||||
frost?: number;
|
||||
}
|
||||
|
||||
export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial {
|
||||
return new ShaderMaterial({
|
||||
uniforms: {
|
||||
uTint: { value: theme.tint.clone() },
|
||||
uRim: { value: theme.rim.clone() },
|
||||
uBounce: { value: theme.bounce.clone() },
|
||||
uOpacity: { value: 1 },
|
||||
uFrost: { value: options.frost ?? 0 },
|
||||
uGlow: { value: 1 },
|
||||
},
|
||||
vertexShader: GLASS_VERTEX,
|
||||
fragmentShader: GLASS_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
blending: NormalBlending,
|
||||
});
|
||||
}
|
||||
|
||||
const EDGE_VERTEX = /* glsl */ `
|
||||
attribute float aHot;
|
||||
attribute float aStrength;
|
||||
varying float vHot;
|
||||
varying float vStrength;
|
||||
void main() {
|
||||
vHot = aHot;
|
||||
vStrength = aStrength;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const EDGE_FRAGMENT = /* glsl */ `
|
||||
uniform vec3 uCyan;
|
||||
uniform vec3 uHot;
|
||||
uniform float uIntensity;
|
||||
uniform float uOpacity;
|
||||
varying float vHot;
|
||||
varying float vStrength;
|
||||
void main() {
|
||||
float hot = vHot * vHot;
|
||||
vec3 color = mix(uCyan, uHot, hot);
|
||||
float energy = uIntensity * vStrength * (1.0 + hot * 1.6);
|
||||
gl_FragColor = vec4(color * energy * uOpacity, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Glowing edges: additive, white-hot at the corners, electric cyan along the edge. */
|
||||
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
|
||||
return new ShaderMaterial({
|
||||
uniforms: {
|
||||
uCyan: { value: theme.edge.clone() },
|
||||
uHot: { value: theme.hot.clone() },
|
||||
uIntensity: { value: 1.6 },
|
||||
uOpacity: { value: 1 },
|
||||
},
|
||||
vertexShader: EDGE_VERTEX,
|
||||
fragmentShader: EDGE_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
});
|
||||
}
|
||||
|
||||
const GRID_VERTEX = /* glsl */ `
|
||||
varying vec3 vLocal;
|
||||
void main() {
|
||||
vLocal = position;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const GRID_FRAGMENT = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform float uCell;
|
||||
uniform float uBars;
|
||||
uniform float uOpacity;
|
||||
varying vec3 vLocal;
|
||||
float gridLine(float coord) {
|
||||
float scaled = coord / uCell;
|
||||
float distance = abs(fract(scaled - 0.5) - 0.5) / max(fwidth(scaled), 1e-4);
|
||||
return 1.0 - min(distance / 1.2, 1.0);
|
||||
}
|
||||
void main() {
|
||||
float along = gridLine(vLocal.z);
|
||||
float across = uBars > 0.5 ? 0.0 : gridLine(vLocal.x);
|
||||
float line = max(along, across);
|
||||
gl_FragColor = vec4(uColor * (0.25 + line * 1.1) * uOpacity, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface GridOptions {
|
||||
/** Line spacing in layout units. */
|
||||
cell: number;
|
||||
/** Only parallel bars (queen excluder) instead of a mesh. */
|
||||
bars?: boolean;
|
||||
}
|
||||
|
||||
/** Screens, Bausperre and excluder: a fine glowing mesh, additive. */
|
||||
export function createGridMaterial(theme: HoloTheme, options: GridOptions): ShaderMaterial {
|
||||
return new ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: theme.edge.clone().multiplyScalar(0.4) },
|
||||
uCell: { value: options.cell },
|
||||
uBars: { value: options.bars ? 1 : 0 },
|
||||
uOpacity: { value: 1 },
|
||||
},
|
||||
vertexShader: GRID_VERTEX,
|
||||
fragmentShader: GRID_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
blending: AdditiveBlending,
|
||||
});
|
||||
}
|
||||
|
||||
const LIQUID_VERTEX = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uWobble;
|
||||
varying vec3 vNormalW;
|
||||
varying vec3 vViewDir;
|
||||
varying float vTop;
|
||||
varying float vDepth;
|
||||
void main() {
|
||||
vec3 p = position;
|
||||
vTop = step(0.49, p.y);
|
||||
vDepth = 0.5 - p.y;
|
||||
if (p.y > 0.49) {
|
||||
p.y += uWobble * (0.6 * sin(p.x * 9.0 + uTime * 1.7) + 0.4 * sin(p.z * 7.0 - uTime * 1.3));
|
||||
}
|
||||
vec4 world = modelMatrix * vec4(p, 1.0);
|
||||
vNormalW = normalize(mat3(modelMatrix) * normal);
|
||||
vViewDir = cameraPosition - world.xyz;
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}
|
||||
`;
|
||||
|
||||
const LIQUID_FRAGMENT = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uDeep;
|
||||
uniform float uGlow;
|
||||
uniform float uOpacity;
|
||||
uniform float uAlpha;
|
||||
varying vec3 vNormalW;
|
||||
varying vec3 vViewDir;
|
||||
varying float vTop;
|
||||
varying float vDepth;
|
||||
void main() {
|
||||
vec3 n = normalize(vNormalW);
|
||||
vec3 v = normalize(vViewDir);
|
||||
float fresnel = pow(1.0 - clamp(abs(dot(n, v)), 0.0, 1.0), 2.0);
|
||||
// Deep amber below, bright gold toward the surface and the rims.
|
||||
float lightness = clamp(0.25 + 0.55 * (1.0 - vDepth) + 0.4 * fresnel, 0.0, 1.0);
|
||||
vec3 color = mix(uDeep, uColor, lightness);
|
||||
color += uColor * vTop * 0.45;
|
||||
float sparkle = pow(max(dot(reflect(-v, n), normalize(vec3(-0.3, 0.9, 0.2))), 0.0), 40.0);
|
||||
color += vec3(1.0, 0.95, 0.8) * sparkle * vTop * 1.4;
|
||||
float alpha = uAlpha * (0.62 + 0.38 * max(fresnel, vTop));
|
||||
gl_FragColor = vec4(color * uGlow, alpha * uOpacity);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface LiquidOptions {
|
||||
color: Color;
|
||||
deep: Color;
|
||||
/** Brightness; above 1 the liquid blooms softly. */
|
||||
glow: number;
|
||||
alpha: number;
|
||||
}
|
||||
|
||||
/** Glowing liquid with a gently moving surface (wobble set per frame at motion `full`). */
|
||||
export function createLiquidMaterial(options: LiquidOptions): ShaderMaterial {
|
||||
return new ShaderMaterial({
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uWobble: { value: 0 },
|
||||
uColor: { value: options.color.clone() },
|
||||
uDeep: { value: options.deep.clone() },
|
||||
uGlow: { value: options.glow },
|
||||
uOpacity: { value: 1 },
|
||||
uAlpha: { value: options.alpha },
|
||||
},
|
||||
vertexShader: LIQUID_VERTEX,
|
||||
fragmentShader: LIQUID_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets the per-node uniforms shared by every material here. */
|
||||
export function setOpacity(material: ShaderMaterial, opacity: number): void {
|
||||
const uniform = material.uniforms.uOpacity;
|
||||
if (uniform) uniform.value = opacity;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import type { Group } from "three";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import { useSceneKit } from "./scene-kit";
|
||||
import { MAX_FRAME_SECONDS } from "./use-animated-node";
|
||||
import { stepToward } from "./use-hive-motion";
|
||||
|
||||
// Turning and resting motion of the whole model (spec 9.4):
|
||||
// - horizontal drag (mouse, pen, one finger) turns it ±60° around the
|
||||
// vertical axis through the camera target; 4 s after the last drag it
|
||||
// springs back. Vertical swipes scroll the page (touch-action: pan-y).
|
||||
// - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°.
|
||||
|
||||
export const MAX_TURN = (60 * Math.PI) / 180;
|
||||
export const MAX_TILT = (6 * Math.PI) / 180;
|
||||
export const IDLE_FLOAT = 0.04;
|
||||
export const IDLE_PERIOD_SECONDS = 6;
|
||||
export const SPRING_BACK_MS = 4000;
|
||||
/** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */
|
||||
export const IDLE_FRAME_MS = 33;
|
||||
/** Pixels of horizontal travel before a press counts as a drag. */
|
||||
const DRAG_START_PX = 8;
|
||||
/** A drag across the full card width turns the model by this much. */
|
||||
const TURN_PER_WIDTH = Math.PI;
|
||||
|
||||
export function clampTurn(angle: number): number {
|
||||
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
|
||||
}
|
||||
|
||||
interface RigInput {
|
||||
turn: number;
|
||||
tiltX: number;
|
||||
tiltY: number;
|
||||
dragging: boolean;
|
||||
}
|
||||
|
||||
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
|
||||
const gl = useThree((state) => state.gl);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const input = useRef<RigInput>({ turn: 0, tiltX: 0, tiltY: 0, dragging: false });
|
||||
|
||||
useEffect(() => {
|
||||
const element = gl.domElement as HTMLElement | undefined;
|
||||
if (!element || typeof element.addEventListener !== "function") return;
|
||||
let press: { x: number; y: number; turn: number; id: number } | null = null;
|
||||
let springBack: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const onDown = (event: PointerEvent) => {
|
||||
press = { x: event.clientX, y: event.clientY, turn: input.current.turn, id: event.pointerId };
|
||||
if (springBack) clearTimeout(springBack);
|
||||
};
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (press && press.id === event.pointerId) {
|
||||
const dx = event.clientX - press.x;
|
||||
const dy = event.clientY - press.y;
|
||||
if (!input.current.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
|
||||
input.current.dragging = true;
|
||||
}
|
||||
if (input.current.dragging) {
|
||||
input.current.turn = clampTurn(press.turn + (dx / Math.max(rect.width, 1)) * TURN_PER_WIDTH);
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (enabledTilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
|
||||
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
|
||||
input.current.tiltY = nx * MAX_TILT;
|
||||
input.current.tiltX = ny * MAX_TILT * 0.5;
|
||||
invalidate();
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
if (input.current.dragging) {
|
||||
springBack = setTimeout(() => {
|
||||
input.current.turn = 0;
|
||||
invalidate();
|
||||
}, SPRING_BACK_MS);
|
||||
}
|
||||
press = null;
|
||||
// Let the click that follows a drag see `dragging`, then clear it.
|
||||
setTimeout(() => {
|
||||
input.current.dragging = false;
|
||||
}, 0);
|
||||
};
|
||||
const onLeave = () => {
|
||||
input.current.tiltX = 0;
|
||||
input.current.tiltY = 0;
|
||||
invalidate();
|
||||
};
|
||||
element.addEventListener("pointerdown", onDown);
|
||||
element.addEventListener("pointermove", onMove);
|
||||
element.addEventListener("pointerup", onUp);
|
||||
element.addEventListener("pointercancel", onUp);
|
||||
element.addEventListener("pointerleave", onLeave);
|
||||
return () => {
|
||||
if (springBack) clearTimeout(springBack);
|
||||
element.removeEventListener("pointerdown", onDown);
|
||||
element.removeEventListener("pointermove", onMove);
|
||||
element.removeEventListener("pointerup", onUp);
|
||||
element.removeEventListener("pointercancel", onUp);
|
||||
element.removeEventListener("pointerleave", onLeave);
|
||||
};
|
||||
}, [gl, invalidate, enabledTilt]);
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Asks for a frame about every 33 ms while the idle motion runs (demand frame loop). */
|
||||
function useIdleTicker(active: boolean): void {
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (active) invalidate();
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
};
|
||||
}, [active, invalidate]);
|
||||
useFrame(() => {
|
||||
if (!active || timer.current !== null) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
invalidate();
|
||||
}, IDLE_FRAME_MS);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ModelRigProps {
|
||||
/** Current camera target; the model turns around it. */
|
||||
getPivot: () => Vec3;
|
||||
/** Idle motion may run (motion `full`, card visible, tab visible). */
|
||||
idle: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
|
||||
const { motion } = useSceneKit();
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const full = motion === "full";
|
||||
const input = useRigInput(full);
|
||||
const outer = useRef<Group>(null);
|
||||
const inner = useRef<Group>(null);
|
||||
const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 });
|
||||
useIdleTicker(idle && full);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!outer.current || !inner.current) return;
|
||||
const dt = Math.min(delta, MAX_FRAME_SECONDS);
|
||||
const now = current.current;
|
||||
// Idle time only advances while idle frames run, so the float resumes smoothly.
|
||||
now.time += dt;
|
||||
const goal = input.current;
|
||||
// A drag follows the finger directly at every level; only the way back is animated.
|
||||
now.turn = goal.dragging ? goal.turn : stepToward(now.turn, goal.turn, dt, full);
|
||||
now.tiltX = stepToward(now.tiltX, full ? goal.tiltX : 0, dt, full);
|
||||
now.tiltY = stepToward(now.tiltY, full ? goal.tiltY : 0, dt, full);
|
||||
const floating = idle && full;
|
||||
now.float = floating
|
||||
? IDLE_FLOAT * Math.sin((now.time / IDLE_PERIOD_SECONDS) * Math.PI * 2)
|
||||
: stepToward(now.float, 0, dt, full);
|
||||
const [px, py, pz] = getPivot();
|
||||
outer.current.position.set(px, py + now.float, pz);
|
||||
outer.current.rotation.set(now.tiltX, now.turn + now.tiltY, 0);
|
||||
inner.current.position.set(-px, -py, -pz);
|
||||
const moving =
|
||||
now.turn !== goal.turn ||
|
||||
now.tiltX !== (full ? goal.tiltX : 0) ||
|
||||
now.tiltY !== (full ? goal.tiltY : 0) ||
|
||||
(!floating && now.float !== 0);
|
||||
if (moving) invalidate();
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={outer}>
|
||||
<group ref={inner}>{children}</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FLOOR_GRID_DEPTH } from "@/lib/hive/layout";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import type { HivePartKind } from "@/lib/hive/types";
|
||||
import {
|
||||
buildEdgeGeometry,
|
||||
buildGlassGeometry,
|
||||
feederChambers,
|
||||
frameShapeSpec,
|
||||
partShapeSpec,
|
||||
shapeKey,
|
||||
} from "./part-geometry";
|
||||
import type { PartShapeSpec } from "./part-geometry";
|
||||
|
||||
const BOX: Vec3 = [5.25, 2.3, 4.3];
|
||||
const FLOOR: Vec3 = [5.25, 1.2, 4.3];
|
||||
|
||||
/** Outer extent of every slab, to check nothing sticks out of the footprint. */
|
||||
function extent(spec: PartShapeSpec): { min: Vec3; max: Vec3 } {
|
||||
const min: Vec3 = [Infinity, Infinity, Infinity];
|
||||
const max: Vec3 = [-Infinity, -Infinity, -Infinity];
|
||||
for (const slab of spec.slabs) {
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
min[axis] = Math.min(min[axis], slab.at[axis] - slab.size[axis] / 2);
|
||||
max[axis] = Math.max(max[axis], slab.at[axis] + slab.size[axis] / 2);
|
||||
}
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
describe("partShapeSpec", () => {
|
||||
it("builds a box as four glass walls with hand grips on both short sides", () => {
|
||||
const spec = partShapeSpec("BOX", BOX);
|
||||
expect(spec.slabs).toHaveLength(4);
|
||||
const gripEdges = spec.edges.filter((edge) => edge.tag === "grip");
|
||||
expect(gripEdges.length).toBeGreaterThan(8);
|
||||
// Short sides are the ±x faces (430 mm wide).
|
||||
expect(gripEdges.some((edge) => edge.a[0] > BOX[0] / 2 - 0.1)).toBe(true);
|
||||
expect(gripEdges.some((edge) => edge.a[0] < -BOX[0] / 2 + 0.1)).toBe(true);
|
||||
expect(gripEdges.every((edge) => Math.abs(edge.a[2]) < BOX[2] / 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks the inner rebate (Falz) where the frame lugs rest", () => {
|
||||
const spec = partShapeSpec("BOX", BOX);
|
||||
expect(spec.edges.filter((edge) => edge.tag === "rebate").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("keeps the Liebig floor inside its footprint: no landing board, no legs", () => {
|
||||
const { min, max } = extent(partShapeSpec("FLOOR", FLOOR, { varroaTray: "IN" }));
|
||||
expect(min[0]).toBeGreaterThanOrEqual(-FLOOR[0] / 2 - 1e-9);
|
||||
expect(max[0]).toBeLessThanOrEqual(FLOOR[0] / 2 + 1e-9);
|
||||
expect(min[1]).toBeGreaterThanOrEqual(-FLOOR[1] / 2 - 1e-9);
|
||||
expect(max[2]).toBeLessThanOrEqual(FLOOR[2] / 2 + 0.05);
|
||||
});
|
||||
|
||||
it("gives the floor a top grid, an entrance slider at the front and an inspection flap at the back", () => {
|
||||
const spec = partShapeSpec("FLOOR", FLOOR, {});
|
||||
expect(spec.grids).toHaveLength(1);
|
||||
expect(spec.grids[0].at[1]).toBeGreaterThan(0);
|
||||
expect(spec.edges.some((edge) => edge.tag === "entrance" && edge.a[2] > FLOOR[2] / 2 - 0.1)).toBe(true);
|
||||
expect(spec.edges.some((edge) => edge.tag === "flap" && edge.a[2] < -FLOOR[2] / 2 + 0.1)).toBe(true);
|
||||
});
|
||||
|
||||
it("puts the grid 40 mm below the floor top, where the layout lays the Bausperre", () => {
|
||||
expect(partShapeSpec("FLOOR", FLOOR, {}).grids[0].at[1]).toBeCloseTo(FLOOR[1] / 2 - FLOOR_GRID_DEPTH, 9);
|
||||
});
|
||||
|
||||
it("slides the varroa board out to the back when it is out", () => {
|
||||
const trayIn = partShapeSpec("FLOOR", FLOOR, { varroaTray: "IN" }).slabs.find((slab) => slab.tag === "tray")!;
|
||||
const trayOut = partShapeSpec("FLOOR", FLOOR, { varroaTray: "OUT" }).slabs.find((slab) => slab.tag === "tray")!;
|
||||
expect(trayOut.at[2]).toBeLessThan(trayIn.at[2] - 1);
|
||||
expect(trayIn.at[1]).toBeLessThan(partShapeSpec("FLOOR", FLOOR, {}).grids[0].at[1]);
|
||||
});
|
||||
|
||||
it("narrows the entrance slider opening with the entrance state", () => {
|
||||
const opening = (entrance: string) =>
|
||||
partShapeSpec("FLOOR", FLOOR, { entrance }).edges.filter((edge) => edge.tag === "opening");
|
||||
const width = (edges: ReturnType<typeof opening>) =>
|
||||
Math.max(...edges.map((edge) => edge.a[0])) - Math.min(...edges.map((edge) => edge.a[0]));
|
||||
expect(width(opening("NARROW"))).toBeLessThan(width(opening("OPEN")));
|
||||
expect(opening("CLOSED")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("gives the roof a slight overhang and the lid a feed hole", () => {
|
||||
const roof = extent(partShapeSpec("ROOF", [5.25, 0.6, 4.3]));
|
||||
expect(roof.max[0]).toBeGreaterThan(5.25 / 2);
|
||||
expect(roof.max[0]).toBeLessThan(5.25 / 2 + 0.3);
|
||||
expect(partShapeSpec("INNER_LID", [5.25, 0.35, 4.3]).edges.some((edge) => edge.tag === "hole")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a shape for every kind", () => {
|
||||
const kinds: HivePartKind[] = [
|
||||
"FLOOR",
|
||||
"BOX",
|
||||
"DIVIDER_BOARD",
|
||||
"QUEEN_EXCLUDER",
|
||||
"FEEDER",
|
||||
"COVER_SHEET",
|
||||
"PROPOLIS_SCREEN",
|
||||
"INNER_LID",
|
||||
"INSULATION",
|
||||
"ROOF",
|
||||
"FLOOR_INSERT",
|
||||
"EVAPORATOR",
|
||||
"FRAME_FEEDER",
|
||||
];
|
||||
for (const kind of kinds) {
|
||||
const spec = partShapeSpec(kind, [2, 0.5, 2]);
|
||||
expect(spec.slabs.length + spec.grids.length).toBeGreaterThan(0);
|
||||
expect(spec.edges.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keys the shape cache by kind, size and the state that changes the shape", () => {
|
||||
expect(shapeKey("FLOOR", FLOOR, { varroaTray: "OUT" })).not.toBe(shapeKey("FLOOR", FLOOR, { varroaTray: "IN" }));
|
||||
expect(shapeKey("BOX", BOX, { anything: 1 })).toBe(shapeKey("BOX", BOX, {}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("feederChambers", () => {
|
||||
it("splits the feeder into one chamber per level field", () => {
|
||||
const chambers = feederChambers([5.2, 0.7, 4.2], 2);
|
||||
expect(chambers).toHaveLength(2);
|
||||
expect(chambers[0].center[0]).toBeLessThan(chambers[1].center[0]);
|
||||
expect(chambers[0].size[1]).toBeGreaterThan(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("frameShapeSpec", () => {
|
||||
const size: Vec3 = [0.25, 2.2, 3.9];
|
||||
|
||||
it("has a long top bar with lugs that reach into the rebate", () => {
|
||||
const spec = frameShapeSpec(size, "BROOD");
|
||||
const top = spec.bars.find((bar) => bar.tag === "topBar")!;
|
||||
expect(top.size[2]).toBeGreaterThan(size[2]);
|
||||
});
|
||||
|
||||
it("has a comb for built frames, a thin one for foundation and none for the open drone frame", () => {
|
||||
expect(frameShapeSpec(size, "HONEY").comb!.size[0]).toBeGreaterThan(frameShapeSpec(size, "FOUNDATION").comb!.size[0]);
|
||||
expect(frameShapeSpec(size, "DRONE_FRAME").comb).toBeNull();
|
||||
expect(frameShapeSpec(size, "DIVIDER").comb).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("geometry builders", () => {
|
||||
it("merges the glass slabs into one geometry", () => {
|
||||
const geometry = buildGlassGeometry(partShapeSpec("BOX", BOX).slabs);
|
||||
expect(geometry.getAttribute("position").count).toBeGreaterThan(100);
|
||||
geometry.dispose();
|
||||
});
|
||||
|
||||
it("returns an empty geometry for no slabs (a box with only empty slots)", () => {
|
||||
const geometry = buildGlassGeometry([]);
|
||||
expect(geometry.getAttribute("position")).toBeUndefined();
|
||||
geometry.dispose();
|
||||
});
|
||||
|
||||
it("builds edge tubes with a hot attribute that is 1 at the corners", () => {
|
||||
const geometry = buildEdgeGeometry(partShapeSpec("BOX", BOX).edges);
|
||||
const hot = geometry.getAttribute("aHot");
|
||||
expect(hot).toBeDefined();
|
||||
let max = 0;
|
||||
let min = 1;
|
||||
for (let index = 0; index < hot.count; index += 1) {
|
||||
max = Math.max(max, hot.getX(index));
|
||||
min = Math.min(min, hot.getX(index));
|
||||
}
|
||||
expect(max).toBe(1);
|
||||
expect(min).toBe(0);
|
||||
geometry.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,625 @@
|
||||
import { BufferGeometry, CylinderGeometry, Float32BufferAttribute, PlaneGeometry } from "three";
|
||||
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
|
||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||
import { FLOOR_GRID_DEPTH } from "@/lib/hive/layout";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import type { FrameType, HivePart, HivePartKind } from "@/lib/hive/types";
|
||||
|
||||
// Shapes of the parts in local coordinates (centre at 0, layout units,
|
||||
// x = length 525 mm, y = height, z = width 430 mm; front = +z, short sides ±x).
|
||||
// The specs are pure data so the owner's details (Liebig Zander proportions,
|
||||
// hand grips, rebate, Wanderboden without landing board) are testable.
|
||||
|
||||
export interface Slab {
|
||||
size: Vec3;
|
||||
at: Vec3;
|
||||
radius: number;
|
||||
shape?: "box" | "cylinder";
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface EdgeSegment {
|
||||
a: Vec3;
|
||||
b: Vec3;
|
||||
/** 1 = white-hot end (corner), 0 = plain cyan. */
|
||||
hotA: number;
|
||||
hotB: number;
|
||||
/** Brightness factor, 1 for outer edges, lower for inner light lines. */
|
||||
strength: number;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface GridPanel {
|
||||
/** Horizontal panel: size along x and z. */
|
||||
size: [number, number];
|
||||
at: Vec3;
|
||||
cell: number;
|
||||
bars: boolean;
|
||||
}
|
||||
|
||||
export interface PartShapeSpec {
|
||||
slabs: Slab[];
|
||||
edges: EdgeSegment[];
|
||||
grids: GridPanel[];
|
||||
frost: number;
|
||||
}
|
||||
|
||||
type PartState = HivePart["state"];
|
||||
|
||||
const WALL = 0.2;
|
||||
const EDGE_RADIUS = 0.022;
|
||||
|
||||
function segment(a: Vec3, b: Vec3, strength: number, hot = 0, tag?: string): EdgeSegment {
|
||||
return { a, b, hotA: hot, hotB: hot, strength, ...(tag ? { tag } : {}) };
|
||||
}
|
||||
|
||||
/** The 12 edges of a box, white-hot at its corners. */
|
||||
function boxEdges(size: Vec3, at: Vec3 = [0, 0, 0], strength = 1, hot = 1, tag?: string): EdgeSegment[] {
|
||||
const [hx, hy, hz] = [size[0] / 2, size[1] / 2, size[2] / 2];
|
||||
const corner = (sx: number, sy: number, sz: number): Vec3 => [at[0] + sx * hx, at[1] + sy * hy, at[2] + sz * hz];
|
||||
const edges: EdgeSegment[] = [];
|
||||
for (const sy of [-1, 1]) {
|
||||
for (const sz of [-1, 1]) edges.push(segment(corner(-1, sy, sz), corner(1, sy, sz), strength, hot, tag));
|
||||
for (const sx of [-1, 1]) edges.push(segment(corner(sx, sy, -1), corner(sx, sy, 1), strength, hot, tag));
|
||||
}
|
||||
for (const sx of [-1, 1]) {
|
||||
for (const sz of [-1, 1]) edges.push(segment(corner(sx, -1, sz), corner(sx, 1, sz), strength, hot, tag));
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Horizontal rectangle at height y. */
|
||||
function rectXZ(halfX: number, halfZ: number, y: number, strength: number, hot = 0, tag?: string): EdgeSegment[] {
|
||||
const p = (sx: number, sz: number): Vec3 => [sx * halfX, y, sz * halfZ];
|
||||
return [
|
||||
segment(p(-1, -1), p(1, -1), strength, hot, tag),
|
||||
segment(p(1, -1), p(1, 1), strength, hot, tag),
|
||||
segment(p(1, 1), p(-1, 1), strength, hot, tag),
|
||||
segment(p(-1, 1), p(-1, -1), strength, hot, tag),
|
||||
];
|
||||
}
|
||||
|
||||
type Axis = 0 | 1 | 2;
|
||||
|
||||
/** Closed loop of points in the plane spanned by axes u and v around `center`. */
|
||||
function loop(points: [number, number][], center: Vec3, u: Axis, w: Axis, strength: number, tag?: string): EdgeSegment[] {
|
||||
const toVec = ([pu, pw]: [number, number]): Vec3 => {
|
||||
const out: Vec3 = [center[0], center[1], center[2]];
|
||||
out[u] += pu;
|
||||
out[w] += pw;
|
||||
return out;
|
||||
};
|
||||
return points.map((point, index) => segment(toVec(point), toVec(points[(index + 1) % points.length]), strength, 0, tag));
|
||||
}
|
||||
|
||||
function roundedRectPoints(width: number, height: number, radius: number, steps = 5): [number, number][] {
|
||||
const r = Math.min(radius, width / 2, height / 2);
|
||||
const hw = width / 2 - r;
|
||||
const hh = height / 2 - r;
|
||||
const corners: [number, number, number][] = [
|
||||
[hw, hh, 0],
|
||||
[-hw, hh, Math.PI / 2],
|
||||
[-hw, -hh, Math.PI],
|
||||
[hw, -hh, (3 * Math.PI) / 2],
|
||||
];
|
||||
const points: [number, number][] = [];
|
||||
for (const [cx, cy, start] of corners) {
|
||||
for (let step = 0; step <= steps; step += 1) {
|
||||
const angle = start + (step / steps) * (Math.PI / 2);
|
||||
points.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function circlePoints(radius: number, steps = 32): [number, number][] {
|
||||
return Array.from({ length: steps }, (_, index) => {
|
||||
const angle = (index / steps) * Math.PI * 2;
|
||||
return [radius * Math.cos(angle), radius * Math.sin(angle)] as [number, number];
|
||||
});
|
||||
}
|
||||
|
||||
/** Four glass walls of a body with an open top and bottom. */
|
||||
function walls(size: Vec3, wall = WALL, radius = EDGE_RADIUS): Slab[] {
|
||||
const [sx, sy, sz] = size;
|
||||
return [
|
||||
{ size: [sx, sy, wall], at: [0, 0, sz / 2 - wall / 2], radius },
|
||||
{ size: [sx, sy, wall], at: [0, 0, -sz / 2 + wall / 2], radius },
|
||||
{ size: [wall, sy, sz - 2 * wall], at: [sx / 2 - wall / 2, 0, 0], radius },
|
||||
{ size: [wall, sy, sz - 2 * wall], at: [-sx / 2 + wall / 2, 0, 0], radius },
|
||||
];
|
||||
}
|
||||
|
||||
/** Thin inner light lines of a hollow body: top and bottom inner rims and inner corners. */
|
||||
function innerLines(size: Vec3, wall = WALL): EdgeSegment[] {
|
||||
const [sx, sy, sz] = size;
|
||||
const ix = sx / 2 - wall;
|
||||
const iz = sz / 2 - wall;
|
||||
const lines = [...rectXZ(ix, iz, sy / 2, 0.4, 0.4), ...rectXZ(ix, iz, -sy / 2, 0.18)];
|
||||
for (const cx of [-1, 1]) {
|
||||
for (const cz of [-1, 1]) lines.push(segment([cx * ix, -sy / 2, cz * iz], [cx * ix, sy / 2, cz * iz], 0.12));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function boxShape(size: Vec3): PartShapeSpec {
|
||||
const [sx, sy, sz] = size;
|
||||
const edges = [...boxEdges(size), ...innerLines(size)];
|
||||
// Rebate (Falz) on the inner faces of the long walls, where the frame lugs rest.
|
||||
const ix = sx / 2 - WALL;
|
||||
for (const side of [-1, 1]) {
|
||||
edges.push(segment([-ix, sy / 2 - 0.12, side * (sz / 2 - WALL)], [ix, sy / 2 - 0.12, side * (sz / 2 - WALL)], 0.35, 0, "rebate"));
|
||||
edges.push(segment([-ix, sy / 2, side * (sz / 2 - WALL * 0.45)], [ix, sy / 2, side * (sz / 2 - WALL * 0.45)], 0.25, 0, "rebate"));
|
||||
}
|
||||
// Recessed hand grips (Griffmulden) on both short sides (±x faces).
|
||||
const gripY = sy * 0.14;
|
||||
for (const side of [-1, 1]) {
|
||||
edges.push(...loop(roundedRectPoints(1.4, 0.34, 0.16), [side * (sx / 2 + 0.004), gripY, 0], 2, 1, 0.95, "grip"));
|
||||
edges.push(...loop(roundedRectPoints(1.24, 0.2, 0.09), [side * (sx / 2 - 0.07), gripY, 0], 2, 1, 0.3, "grip"));
|
||||
}
|
||||
return { slabs: walls(size), edges, grids: [], frost: 0 };
|
||||
}
|
||||
|
||||
const ENTRANCE_OPENING: Record<string, number> = { OPEN: 1.6, NARROW: 0.55, CLOSED: 0 };
|
||||
|
||||
/** Liebig Wanderboden: flat box, stainless grid on top, varroa board from the back, flap, entrance slider. */
|
||||
function floorShape(size: Vec3, state: PartState): PartShapeSpec {
|
||||
const [sx, sy, sz] = size;
|
||||
const slotHeight = Math.min(0.2, sy * 0.25);
|
||||
const innerX = sx - 2 * WALL;
|
||||
const innerZ = sz - 2 * WALL;
|
||||
const bottom = -sy / 2;
|
||||
const slabs: Slab[] = [
|
||||
{ size: [WALL, sy, sz], at: [sx / 2 - WALL / 2, 0, 0], radius: EDGE_RADIUS },
|
||||
{ size: [WALL, sy, sz], at: [-sx / 2 + WALL / 2, 0, 0], radius: EDGE_RADIUS },
|
||||
{ size: [innerX, sy, WALL], at: [0, 0, -sz / 2 + WALL / 2], radius: EDGE_RADIUS },
|
||||
// Front wall above the entrance slot.
|
||||
{ size: [innerX, sy - slotHeight, WALL], at: [0, bottom + slotHeight + (sy - slotHeight) / 2, sz / 2 - WALL / 2], radius: EDGE_RADIUS },
|
||||
{ size: [innerX, 0.05, innerZ], at: [0, bottom + 0.025, 0], radius: 0.01 },
|
||||
// Plastic entrance slider in front of the slot (flush with the front face).
|
||||
{ size: [innerX * 0.86, slotHeight * 0.9, 0.03], at: [0, bottom + slotHeight * 0.5, sz / 2 - 0.015], radius: 0.01, tag: "slider" },
|
||||
];
|
||||
// Sticky / varroa board beneath the grid, slid in from the back.
|
||||
const trayOut = state.varroaTray === "OUT";
|
||||
const trayDepth = innerZ - 0.06;
|
||||
const trayZ = trayOut ? -sz * 0.42 : 0;
|
||||
slabs.push({ size: [innerX - 0.08, 0.035, trayDepth], at: [0, bottom + 0.16, trayZ], radius: 0.008, tag: "tray" });
|
||||
slabs.push({ size: [innerX - 0.08, 0.1, 0.035], at: [0, bottom + 0.2, trayZ - trayDepth / 2], radius: 0.01, tag: "tray" });
|
||||
|
||||
const gridY = sy / 2 - FLOOR_GRID_DEPTH;
|
||||
const edges = [
|
||||
...boxEdges(size),
|
||||
...rectXZ(innerX / 2, innerZ / 2, gridY, 0.6, 0.4),
|
||||
...rectXZ(innerX / 2, innerZ / 2, sy / 2, 0.4),
|
||||
];
|
||||
// Entrance slot across the front, the slider and its opening.
|
||||
edges.push(...loop(roundedRectPoints(innerX, slotHeight, 0.02, 1), [0, bottom + slotHeight / 2, sz / 2 + 0.004], 0, 1, 0.75, "entrance"));
|
||||
const opening = ENTRANCE_OPENING[String(state.entrance ?? "OPEN")] ?? ENTRANCE_OPENING.OPEN;
|
||||
if (opening > 0) {
|
||||
edges.push(...loop(roundedRectPoints(opening, slotHeight * 0.45, 0.04), [0, bottom + slotHeight * 0.45, sz / 2 + 0.006], 0, 1, 1, "opening"));
|
||||
}
|
||||
// Inspection flap on the back wall, between the board slot and the grid, hinged at its top.
|
||||
const flapHeight = Math.max(0.1, gridY - (bottom + 0.3));
|
||||
const flapCenter = bottom + 0.27 + flapHeight / 2;
|
||||
edges.push(...loop(roundedRectPoints(innerX * 0.7, flapHeight, 0.04, 2), [0, flapCenter, -sz / 2 - 0.004], 0, 1, 0.55, "flap"));
|
||||
edges.push(segment([-innerX * 0.33, flapCenter + flapHeight / 2, -sz / 2 - 0.006], [innerX * 0.33, flapCenter + flapHeight / 2, -sz / 2 - 0.006], 0.35, 0, "flap"));
|
||||
// Board edges.
|
||||
edges.push(...boxEdges([innerX - 0.08, 0.035, trayDepth], [0, bottom + 0.16, trayZ], 0.35, 0));
|
||||
return {
|
||||
slabs,
|
||||
edges,
|
||||
grids: [{ size: [innerX, innerZ], at: [0, gridY, 0], cell: 0.07, bars: false }],
|
||||
frost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Chambers of a feeder, one per level field, left to right (local coordinates). */
|
||||
export function feederChambers(size: Vec3, count: number): { center: Vec3; size: Vec3 }[] {
|
||||
const wall = 0.15;
|
||||
const chambers = Math.max(1, count);
|
||||
const innerX = size[0] - 2 * wall;
|
||||
const innerZ = size[2] - 2 * wall;
|
||||
const divider = 0.06;
|
||||
const width = (innerX - (chambers - 1) * divider) / chambers;
|
||||
const height = size[1] - 0.06;
|
||||
return Array.from({ length: chambers }, (_, index) => ({
|
||||
center: [-innerX / 2 + width / 2 + index * (width + divider), -size[1] / 2 + 0.04 + height / 2, 0] as Vec3,
|
||||
size: [width, height, innerZ] as Vec3,
|
||||
}));
|
||||
}
|
||||
|
||||
function feederShape(size: Vec3, state: PartState, levelCount: number): PartShapeSpec {
|
||||
void state;
|
||||
const [sx, sy, sz] = size;
|
||||
const wall = 0.15;
|
||||
const slabs = walls(size, wall);
|
||||
slabs.push({ size: [sx - 2 * wall, 0.04, sz - 2 * wall], at: [0, -sy / 2 + 0.02, 0], radius: 0.01 });
|
||||
const chambers = feederChambers(size, levelCount);
|
||||
const edges = [...boxEdges(size), ...innerLines(size, wall)];
|
||||
for (let index = 1; index < chambers.length; index += 1) {
|
||||
const x = (chambers[index - 1].center[0] + chambers[index].center[0]) / 2;
|
||||
slabs.push({ size: [0.06, sy - 0.05, sz - 2 * wall], at: [x, 0.0, 0], radius: 0.01 });
|
||||
edges.push(segment([x, sy / 2 - 0.03, -sz / 2 + wall], [x, sy / 2 - 0.03, sz / 2 - wall], 0.6, 0.6));
|
||||
}
|
||||
return { slabs, edges, grids: [], frost: 0 };
|
||||
}
|
||||
|
||||
function lidShape(size: Vec3, state: PartState): PartShapeSpec {
|
||||
const [, sy] = size;
|
||||
const slabs: Slab[] = [{ size, at: [0, 0, 0], radius: 0.03 }];
|
||||
const edges = [...boxEdges(size)];
|
||||
edges.push(...loop(circlePoints(0.34), [0, sy / 2 + 0.004, 0], 0, 2, 0.95, "hole"));
|
||||
edges.push(...loop(circlePoints(0.27), [0, sy / 2 - 0.08, 0], 0, 2, 0.4, "hole"));
|
||||
if (state.feedHole === "CLOSED") {
|
||||
slabs.push({ size: [0.84, 0.03, 0.84], at: [0, sy / 2 + 0.015, 0], radius: 0.42, shape: "cylinder", tag: "cap" });
|
||||
}
|
||||
return { slabs, edges, grids: [], frost: 0.15 };
|
||||
}
|
||||
|
||||
/** Flat stainless roof with a slight overhang over the part below. */
|
||||
function roofShape(size: Vec3): PartShapeSpec {
|
||||
const [sx, sy, sz] = size;
|
||||
const overhang = 0.13;
|
||||
const outer: Vec3 = [sx + 2 * overhang, 0.08, sz + 2 * overhang];
|
||||
const skirt = 0.03;
|
||||
const skirtHeight = sy - outer[1];
|
||||
const skirtY = -sy / 2 + skirtHeight / 2;
|
||||
const slabs: Slab[] = [
|
||||
{ size: outer, at: [0, sy / 2 - outer[1] / 2, 0], radius: 0.025 },
|
||||
{ size: [outer[0], skirtHeight, skirt], at: [0, skirtY, outer[2] / 2 - skirt / 2], radius: 0.01 },
|
||||
{ size: [outer[0], skirtHeight, skirt], at: [0, skirtY, -outer[2] / 2 + skirt / 2], radius: 0.01 },
|
||||
{ size: [skirt, skirtHeight, outer[2] - 2 * skirt], at: [outer[0] / 2 - skirt / 2, skirtY, 0], radius: 0.01 },
|
||||
{ size: [skirt, skirtHeight, outer[2] - 2 * skirt], at: [-outer[0] / 2 + skirt / 2, skirtY, 0], radius: 0.01 },
|
||||
];
|
||||
const edges = [
|
||||
...boxEdges(outer, [0, sy / 2 - outer[1] / 2, 0], 1.1, 1),
|
||||
...rectXZ(outer[0] / 2, outer[2] / 2, -sy / 2, 0.7, 0.6),
|
||||
];
|
||||
return { slabs, edges, grids: [], frost: 0.05 };
|
||||
}
|
||||
|
||||
function sheetShape(size: Vec3): PartShapeSpec {
|
||||
const thin: Vec3 = [size[0], Math.max(size[1], 0.025), size[2]];
|
||||
return { slabs: [{ size: thin, at: [0, 0, 0], radius: 0.006 }], edges: boxEdges(thin, [0, 0, 0], 0.6, 0.5), grids: [], frost: 0.1 };
|
||||
}
|
||||
|
||||
/** A flat screen: thin rim bars around a glowing mesh. */
|
||||
function screenShape(size: Vec3, cell: number, bars: boolean): PartShapeSpec {
|
||||
const thick: Vec3 = [size[0], Math.max(size[1], 0.04), size[2]];
|
||||
const rim = 0.12;
|
||||
const [sx, sy, sz] = thick;
|
||||
const slabs: Slab[] = [
|
||||
{ size: [sx, sy, rim], at: [0, 0, sz / 2 - rim / 2], radius: 0.01 },
|
||||
{ size: [sx, sy, rim], at: [0, 0, -sz / 2 + rim / 2], radius: 0.01 },
|
||||
{ size: [rim, sy, sz - 2 * rim], at: [sx / 2 - rim / 2, 0, 0], radius: 0.01 },
|
||||
{ size: [rim, sy, sz - 2 * rim], at: [-sx / 2 + rim / 2, 0, 0], radius: 0.01 },
|
||||
];
|
||||
return {
|
||||
slabs,
|
||||
edges: [...boxEdges(thick, [0, 0, 0], 0.85, 0.8), ...rectXZ(sx / 2 - rim, sz / 2 - rim, 0, 0.4)],
|
||||
grids: [{ size: [sx - 2 * rim, sz - 2 * rim], at: [0, 0, 0], cell, bars }],
|
||||
frost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function dividerShape(size: Vec3, state: PartState): PartShapeSpec {
|
||||
const [sx, , sz] = size;
|
||||
const slabs = walls(size);
|
||||
slabs.push({ size: [sx - 2 * WALL, 0.06, sz - 2 * WALL], at: [0, 0, 0], radius: 0.01 });
|
||||
const edges = [...boxEdges(size), ...innerLines(size)];
|
||||
for (const x of [-1.1, 1.1]) {
|
||||
edges.push(...loop(circlePoints(0.22), [x, 0.035, 0], 0, 2, 0.85, "hole"));
|
||||
if (state.bungHoles === "CLOSED") {
|
||||
slabs.push({ size: [0.5, 0.04, 0.5], at: [x, 0.05, 0], radius: 0.25, shape: "cylinder", tag: "cap" });
|
||||
}
|
||||
}
|
||||
return { slabs, edges, grids: [], frost: 0.1 };
|
||||
}
|
||||
|
||||
/** Nassenheider evaporator: tray with wick plate and an upright bottle. */
|
||||
export function evaporatorBottle(size: Vec3): { center: Vec3; radius: number; height: number } {
|
||||
const radius = Math.min(size[0], size[2]) * 0.24;
|
||||
const height = size[1] - 0.32;
|
||||
return { center: [size[0] * 0.18, -size[1] / 2 + 0.2 + height / 2, 0], radius, height };
|
||||
}
|
||||
|
||||
function evaporatorShape(size: Vec3): PartShapeSpec {
|
||||
const [sx, sy, sz] = size;
|
||||
const bottle = evaporatorBottle(size);
|
||||
const trayHeight = 0.16;
|
||||
const slabs: Slab[] = [
|
||||
{ size: [sx, trayHeight, sz], at: [0, -sy / 2 + trayHeight / 2, 0], radius: 0.03 },
|
||||
{ size: [sx - 0.14, 0.03, sz - 0.14], at: [0, -sy / 2 + trayHeight + 0.015, 0], radius: 0.01 },
|
||||
{
|
||||
size: [bottle.radius * 2, bottle.height, bottle.radius * 2],
|
||||
at: bottle.center,
|
||||
radius: bottle.radius,
|
||||
shape: "cylinder",
|
||||
},
|
||||
{
|
||||
size: [bottle.radius * 0.9, 0.1, bottle.radius * 0.9],
|
||||
at: [bottle.center[0], bottle.center[1] + bottle.height / 2 + 0.05, 0],
|
||||
radius: bottle.radius * 0.45,
|
||||
shape: "cylinder",
|
||||
},
|
||||
];
|
||||
const top = bottle.center[1] + bottle.height / 2;
|
||||
const base = bottle.center[1] - bottle.height / 2;
|
||||
const edges = [
|
||||
...boxEdges([sx, trayHeight, sz], [0, -sy / 2 + trayHeight / 2, 0], 0.9, 1),
|
||||
...loop(circlePoints(bottle.radius), [bottle.center[0], top, 0], 0, 2, 0.9),
|
||||
...loop(circlePoints(bottle.radius), [bottle.center[0], base, 0], 0, 2, 0.6),
|
||||
];
|
||||
return { slabs, edges, grids: [], frost: 0 };
|
||||
}
|
||||
|
||||
/** Futtertasche: a narrow glass pocket hanging in the box with a top bar and lugs. */
|
||||
function pocketShape(size: Vec3): PartShapeSpec {
|
||||
const [sx, sy, sz] = size;
|
||||
const wall = 0.045;
|
||||
const slabs = walls(size, wall, 0.012);
|
||||
slabs.push({ size: [sx - 2 * wall, 0.04, sz - 2 * wall], at: [0, -sy / 2 + 0.02, 0], radius: 0.01 });
|
||||
const topBar: Vec3 = [sx + 0.02, 0.08, sz + 0.24];
|
||||
slabs.push({ size: topBar, at: [0, sy / 2 - 0.04, 0], radius: 0.015 });
|
||||
return {
|
||||
slabs,
|
||||
edges: [...boxEdges(size, [0, 0, 0], 0.9, 1), ...boxEdges(topBar, [0, sy / 2 - 0.04, 0], 0.7, 0.6)],
|
||||
grids: [],
|
||||
frost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inner liquid space of a frame feeder (local coordinates). */
|
||||
export function pocketInner(size: Vec3): { center: Vec3; size: Vec3 } {
|
||||
const wall = 0.045;
|
||||
const height = size[1] - 0.2;
|
||||
return { center: [0, -size[1] / 2 + 0.04 + height / 2, 0], size: [size[0] - 2 * wall, height, size[2] - 2 * wall] };
|
||||
}
|
||||
|
||||
export function partShapeSpec(kind: HivePartKind, size: Vec3, state: PartState = {}, levelCount = 0): PartShapeSpec {
|
||||
switch (kind) {
|
||||
case "BOX":
|
||||
return boxShape(size);
|
||||
case "FLOOR":
|
||||
return floorShape(size, state);
|
||||
case "FEEDER":
|
||||
return feederShape(size, state, levelCount);
|
||||
case "INNER_LID":
|
||||
return lidShape(size, state);
|
||||
case "ROOF":
|
||||
return roofShape(size);
|
||||
case "COVER_SHEET":
|
||||
return sheetShape(size);
|
||||
case "PROPOLIS_SCREEN":
|
||||
return screenShape(size, 0.06, false);
|
||||
case "QUEEN_EXCLUDER":
|
||||
return screenShape(size, 0.045, true);
|
||||
case "FLOOR_INSERT":
|
||||
return screenShape(size, 0.25, false);
|
||||
case "INSULATION":
|
||||
return { slabs: [{ size, at: [0, 0, 0], radius: 0.04 }], edges: boxEdges(size, [0, 0, 0], 0.6, 0.5), grids: [], frost: 0.9 };
|
||||
case "DIVIDER_BOARD":
|
||||
return dividerShape(size, state);
|
||||
case "EVAPORATOR":
|
||||
return evaporatorShape(size);
|
||||
case "FRAME_FEEDER":
|
||||
return pocketShape(size);
|
||||
default:
|
||||
return { slabs: [{ size, at: [0, 0, 0], radius: EDGE_RADIUS }], edges: boxEdges(size), grids: [], frost: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** State keys that change a part's shape (everything else is content). */
|
||||
const SHAPE_STATE_KEYS: Partial<Record<HivePartKind, string[]>> = {
|
||||
FLOOR: ["varroaTray", "entrance"],
|
||||
INNER_LID: ["feedHole"],
|
||||
DIVIDER_BOARD: ["bungHoles"],
|
||||
};
|
||||
|
||||
export function shapeKey(kind: HivePartKind, size: Vec3, state: PartState = {}, levelCount = 0): string {
|
||||
const keys = SHAPE_STATE_KEYS[kind] ?? [];
|
||||
const stateKey = keys.map((key) => `${key}=${String(state[key] ?? "")}`).join(",");
|
||||
return `${kind}|${size.map((value) => value.toFixed(4)).join("x")}|${stateKey}|${kind === "FEEDER" ? levelCount : 0}`;
|
||||
}
|
||||
|
||||
// ---- Frames ----
|
||||
|
||||
export interface FrameShapeSpec {
|
||||
bars: Slab[];
|
||||
comb: Slab | null;
|
||||
edges: EdgeSegment[];
|
||||
}
|
||||
|
||||
const COMB_THICKNESS: Partial<Record<FrameType, number>> = { FOUNDATION: 0.03, DIVIDER: 0.12 };
|
||||
|
||||
/** Frame in local coordinates: long top bar with lugs, side bars, bottom bar and the comb. */
|
||||
export function frameShapeSpec(size: Vec3, type: FrameType): FrameShapeSpec {
|
||||
const [fx, fy, fz] = size;
|
||||
const topBar: Slab = { size: [fx, 0.1, fz + 0.26], at: [0, fy / 2 - 0.05, 0], radius: 0.015, tag: "topBar" };
|
||||
const sideHeight = fy - 0.1;
|
||||
const bars: Slab[] = [
|
||||
topBar,
|
||||
{ size: [fx * 0.85, sideHeight, 0.09], at: [0, -0.05, fz / 2 - 0.045], radius: 0.012 },
|
||||
{ size: [fx * 0.85, sideHeight, 0.09], at: [0, -0.05, -fz / 2 + 0.045], radius: 0.012 },
|
||||
{ size: [fx * 0.6, 0.06, fz - 0.18], at: [0, -fy / 2 + 0.03, 0], radius: 0.01 },
|
||||
];
|
||||
const comb: Slab | null =
|
||||
type === "DRONE_FRAME" || type === "NONE"
|
||||
? null
|
||||
: {
|
||||
size: [COMB_THICKNESS[type] ?? 0.17, fy - 0.2, fz - 0.18],
|
||||
at: [0, -0.04, 0],
|
||||
radius: 0.01,
|
||||
tag: "comb",
|
||||
};
|
||||
const edges = [
|
||||
...boxEdges(topBar.size, topBar.at, 0.75, 0.7),
|
||||
...loop(
|
||||
[
|
||||
[fz / 2, fy / 2 - 0.1],
|
||||
[-fz / 2, fy / 2 - 0.1],
|
||||
[-fz / 2, -fy / 2],
|
||||
[fz / 2, -fy / 2],
|
||||
],
|
||||
[0, 0, 0],
|
||||
2,
|
||||
1,
|
||||
0.4,
|
||||
),
|
||||
];
|
||||
return { bars, comb, edges };
|
||||
}
|
||||
|
||||
/** Rest-view hint of a frame: only its top bar with lugs, as a faint glass outline. */
|
||||
export function frameHintSpec(size: Vec3, at: Vec3): { slab: Slab; edges: EdgeSegment[] } {
|
||||
const slab: Slab = { size: [size[0] * 0.8, 0.08, size[2] + 0.26], at: [at[0], at[1] + size[1] / 2 - 0.04, at[2]], radius: 0.012 };
|
||||
return { slab, edges: boxEdges(slab.size, slab.at, 0.32, 0.2) };
|
||||
}
|
||||
|
||||
// ---- three.js builders ----
|
||||
|
||||
function slabGeometry(slab: Slab): BufferGeometry {
|
||||
const [w, h, d] = slab.size;
|
||||
const geometry =
|
||||
slab.shape === "cylinder"
|
||||
? new CylinderGeometry(w / 2, w / 2, h, 40, 1)
|
||||
: new RoundedBoxGeometry(w, h, d, 2, Math.max(0, Math.min(slab.radius, Math.min(w, h, d) / 2 - 1e-4)));
|
||||
geometry.translate(slab.at[0], slab.at[1], slab.at[2]);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** All slabs of a shape as one geometry (one draw call per part). */
|
||||
export function buildGlassGeometry(slabs: Slab[]): BufferGeometry {
|
||||
if (slabs.length === 0) return new BufferGeometry();
|
||||
const parts = slabs.map((slab) => {
|
||||
const geometry = slabGeometry(slab);
|
||||
// mergeGeometries needs the same attributes and index state everywhere.
|
||||
return geometry.index ? geometry.toNonIndexed() : geometry;
|
||||
});
|
||||
const merged = mergeGeometries(parts, false) ?? new BufferGeometry();
|
||||
parts.forEach((part) => part.dispose());
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** About 1.5 px at rest-view distance, thicker when a part is in focus. */
|
||||
const EDGE_WIDTH = 0.024;
|
||||
/** Length of the white-hot zone at each corner. */
|
||||
const HOT_ZONE = 0.22;
|
||||
|
||||
function perpendicular(direction: Vec3): [Vec3, Vec3] {
|
||||
const up: Vec3 = Math.abs(direction[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
|
||||
const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
const normalize = (a: Vec3): Vec3 => {
|
||||
const length = Math.hypot(a[0], a[1], a[2]) || 1;
|
||||
return [a[0] / length, a[1] / length, a[2] / length];
|
||||
};
|
||||
const u = normalize(cross(direction, up));
|
||||
return [u, normalize(cross(u, direction))];
|
||||
}
|
||||
|
||||
/** Thin square tubes along every edge, with `aHot` (corner glow) and `aStrength` attributes. */
|
||||
export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const hot: number[] = [];
|
||||
const strength: number[] = [];
|
||||
const indices: number[] = [];
|
||||
const half = width / 2;
|
||||
for (const edge of segments) {
|
||||
const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]];
|
||||
const length = Math.hypot(delta[0], delta[1], delta[2]);
|
||||
if (length < 1e-6) continue;
|
||||
const direction: Vec3 = [delta[0] / length, delta[1] / length, delta[2] / length];
|
||||
const [u, w] = perpendicular(direction);
|
||||
const zone = Math.min(0.35, HOT_ZONE / length);
|
||||
const rings: [number, number][] = [
|
||||
[0, edge.hotA],
|
||||
[zone, 0],
|
||||
[1 - zone, 0],
|
||||
[1, edge.hotB],
|
||||
];
|
||||
const base = positions.length / 3;
|
||||
for (const [t, ringHot] of rings) {
|
||||
for (const [su, sw] of [
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
]) {
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
positions.push(edge.a[axis] + delta[axis] * t + (u[axis] * su + w[axis] * sw) * half);
|
||||
}
|
||||
hot.push(ringHot);
|
||||
strength.push(edge.strength);
|
||||
}
|
||||
}
|
||||
for (let band = 0; band < 3; band += 1) {
|
||||
for (let side = 0; side < 4; side += 1) {
|
||||
const a = base + band * 4 + side;
|
||||
const b = base + band * 4 + ((side + 1) % 4);
|
||||
const c = base + (band + 1) * 4 + ((side + 1) % 4);
|
||||
const d = base + (band + 1) * 4 + side;
|
||||
indices.push(a, b, c, a, c, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1));
|
||||
geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeBoundingSphere();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** One horizontal grid panel as a plane in the xz plane. */
|
||||
export function buildGridGeometry(panel: GridPanel): BufferGeometry {
|
||||
const geometry = new PlaneGeometry(panel.size[0], panel.size[1]);
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
geometry.translate(panel.at[0], panel.at[1], panel.at[2]);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export interface BuiltShape {
|
||||
glass: BufferGeometry;
|
||||
edges: BufferGeometry;
|
||||
grids: { geometry: BufferGeometry; panel: GridPanel }[];
|
||||
frost: number;
|
||||
}
|
||||
|
||||
export function buildShape(spec: PartShapeSpec): BuiltShape {
|
||||
return {
|
||||
glass: buildGlassGeometry(spec.slabs),
|
||||
edges: buildEdgeGeometry(spec.edges),
|
||||
grids: spec.grids.map((panel) => ({ geometry: buildGridGeometry(panel), panel })),
|
||||
frost: spec.frost,
|
||||
};
|
||||
}
|
||||
|
||||
export function disposeShape(shape: BuiltShape): void {
|
||||
shape.glass.dispose();
|
||||
shape.edges.dispose();
|
||||
shape.grids.forEach((grid) => grid.geometry.dispose());
|
||||
}
|
||||
|
||||
/** Geometries shared by every node of the same kind and size; disposed with the scene. */
|
||||
export interface GeometryCache {
|
||||
shape(key: string, build: () => PartShapeSpec): BuiltShape;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createGeometryCache(): GeometryCache {
|
||||
const shapes = new Map<string, BuiltShape>();
|
||||
return {
|
||||
shape(key, build) {
|
||||
const known = shapes.get(key);
|
||||
if (known) return known;
|
||||
const built = buildShape(build());
|
||||
shapes.set(key, built);
|
||||
return built;
|
||||
},
|
||||
dispose() {
|
||||
shapes.forEach(disposeShape);
|
||||
shapes.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Mesh } from "three";
|
||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||
import { createFrameTextureCache } from "./frame-textures";
|
||||
import type { FrameTextureCache } from "./frame-textures";
|
||||
import { createGeometryCache } from "./part-geometry";
|
||||
import type { GeometryCache } from "./part-geometry";
|
||||
import { readHoloTheme } from "./theme";
|
||||
import type { HoloTheme } from "./theme";
|
||||
|
||||
// Shared per canvas: theme (tokens read once), geometries per part kind and
|
||||
// size, comb textures per frame type, the motion level. Everything is
|
||||
// disposed when the canvas unmounts (spec 9.9: no leak between colonies).
|
||||
|
||||
export interface SceneKit {
|
||||
theme: HoloTheme;
|
||||
geometries: GeometryCache;
|
||||
textures: FrameTextureCache;
|
||||
motion: MotionLevel;
|
||||
}
|
||||
|
||||
const SceneKitContext = createContext<SceneKit | null>(null);
|
||||
|
||||
export function SceneKitProvider({ motion, children }: { motion: MotionLevel; children: ReactNode }) {
|
||||
const [theme] = useState(readHoloTheme);
|
||||
const [geometries] = useState(createGeometryCache);
|
||||
const [textures] = useState(() => createFrameTextureCache());
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometries.dispose();
|
||||
textures.dispose();
|
||||
},
|
||||
[geometries, textures],
|
||||
);
|
||||
return (
|
||||
<SceneKitContext.Provider value={{ theme, geometries, textures, motion }}>{children}</SceneKitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSceneKit(): SceneKit {
|
||||
const kit = useContext(SceneKitContext);
|
||||
if (!kit) throw new Error("useSceneKit must be used inside a SceneKitProvider");
|
||||
return kit;
|
||||
}
|
||||
|
||||
/** Raycast that never hits: for visible meshes, so only the hit boxes take pointer events. */
|
||||
export const noRaycast = (): void => {};
|
||||
/** The default mesh raycast, for hit boxes that take pointer events. */
|
||||
export const meshRaycast = Mesh.prototype.raycast;
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import type { MotionView } from "./use-hive-motion";
|
||||
import { MAX_LABELS, buildSceneModel, formatAmount, levelText, sceneLabels } from "./scene-model";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
function state(): HiveState {
|
||||
const boxFrames = frames(10).map((frame) =>
|
||||
frame.slot <= 2
|
||||
? { ...frame, type: "NONE" as const }
|
||||
: frame.slot === 4
|
||||
? { ...frame, type: "BROOD" as const, name: "Königin" }
|
||||
: frame,
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", {
|
||||
frames: boxFrames,
|
||||
inserts: [
|
||||
part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } }),
|
||||
part("ev", "evaporator", { state: { acidMl: 250 } }),
|
||||
],
|
||||
}),
|
||||
part("b2", "box", { frames: frames(10) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
|
||||
part("lid", "inner-lid", { state: { feedHole: "CLOSED" } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const hiveState = state();
|
||||
const layout = hiveLayout(hiveState, catalog);
|
||||
const name = (id: string) => partDisplayName(hiveState, catalog, id);
|
||||
const model = buildSceneModel(layout, hiveState, catalog, name);
|
||||
const rest: MotionView = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null };
|
||||
|
||||
describe("formatAmount", () => {
|
||||
it("uses the German decimal comma and drops trailing zeros", () => {
|
||||
expect(formatAmount(2.5, "l")).toBe("2,5 l");
|
||||
expect(formatAmount(3, "l")).toBe("3 l");
|
||||
expect(formatAmount(0.25, "l")).toBe("0,25 l");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSceneModel", () => {
|
||||
it("knows every part and insert with its kind, name and host", () => {
|
||||
expect(model.get("b2")).toMatchObject({ kind: "BOX", name: "Zarge 2", hostId: null });
|
||||
expect(model.get("ff")).toMatchObject({ kind: "FRAME_FEEDER", hostId: "b1" });
|
||||
expect(model.get("fi")).toMatchObject({ kind: "FLOOR_INSERT", hostId: "f" });
|
||||
});
|
||||
|
||||
it("reads level fields with value, max and fill ratio, solid for feed paste", () => {
|
||||
const feeder = model.get("fe")!;
|
||||
expect(feeder.levels).toEqual([
|
||||
expect.objectContaining({ key: "syrupLiters", value: 2.5, max: 3, ratio: 2.5 / 3, solid: false }),
|
||||
expect.objectContaining({ key: "fondantKg", value: 1, max: 2.5, ratio: 0.4, solid: true }),
|
||||
]);
|
||||
expect(levelText(feeder.levels[0])).toBe("Flüssigfutter 2,5 l von 3 l");
|
||||
expect(levelText(feeder.levels[1])).toBe("Futterteig 1 kg von 2,5 kg");
|
||||
});
|
||||
|
||||
it("treats a missing level as 0 and clamps the ratio", () => {
|
||||
const empty = buildSceneModel(
|
||||
layout,
|
||||
{ ...hiveState, stack: hiveState.stack.map((p) => (p.id === "fe" ? { ...p, state: { syrupLiters: 9 } } : p)) },
|
||||
catalog,
|
||||
name,
|
||||
).get("fe")!;
|
||||
expect(empty.levels[0].ratio).toBe(1);
|
||||
expect(empty.levels[1]).toMatchObject({ value: 0, ratio: 0 });
|
||||
});
|
||||
|
||||
it("reads choice fields with their German text, the first option when missing", () => {
|
||||
expect(model.get("lid")!.choices).toEqual([
|
||||
{ key: "feedHole", label: "Futterloch", value: "CLOSED", text: "zu" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts the frames of a box that are not empty", () => {
|
||||
expect(model.get("b1")!.frameCount).toBe(8);
|
||||
expect(model.get("b2")!.frameCount).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sceneLabels", () => {
|
||||
it("shows nothing in the rest view without hover", () => {
|
||||
expect(sceneLabels(model, layout, rest)).toEqual([]);
|
||||
});
|
||||
|
||||
it("names the hovered part, a box with its frame count", () => {
|
||||
expect(sceneLabels(model, layout, { ...rest, hoveredId: "b1" })).toEqual([
|
||||
{ key: "part:b1", anchor: { kind: "node", id: "b1" }, text: "Zarge 1 · 8 Rähmchen", tone: "part" },
|
||||
]);
|
||||
expect(sceneLabels(model, layout, { ...rest, hoveredId: "r" })[0].text).toBe("Dach");
|
||||
});
|
||||
|
||||
it("labels the hovered frame „R4 · Brutwabe · Name“ in an open box", () => {
|
||||
const labels = sceneLabels(model, layout, { ...rest, openBoxId: "b1", hoveredFrame: { boxId: "b1", slot: 4 } });
|
||||
expect(labels[0]).toEqual({
|
||||
key: "frame:b1#4",
|
||||
anchor: { kind: "frame", boxId: "b1", slot: 4 },
|
||||
text: "R4 · Brutwabe · Königin",
|
||||
tone: "frame",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the label of a selected frame", () => {
|
||||
const labels = sceneLabels(model, layout, { ...rest, openBoxId: "b2", selectedFrame: { boxId: "b2", slot: 7 } });
|
||||
expect(labels[0].text).toBe("R7 · Mittelwand");
|
||||
});
|
||||
|
||||
it("shows the levels of a focused feeder", () => {
|
||||
const labels = sceneLabels(model, layout, { ...rest, focusedPartId: "fe" });
|
||||
expect(labels.map((label) => label.text)).toEqual(["Flüssigfutter 2,5 l von 3 l", "Futterteig 1 kg von 2,5 kg"]);
|
||||
expect(labels[0]).toMatchObject({ anchor: { kind: "node", id: "fe" }, tone: "level" });
|
||||
});
|
||||
|
||||
it("shows the levels of the inserts of an open box and never more than three labels", () => {
|
||||
const labels = sceneLabels(model, layout, {
|
||||
...rest,
|
||||
openBoxId: "b1",
|
||||
hoveredId: "ev",
|
||||
hoveredFrame: { boxId: "b1", slot: 4 },
|
||||
});
|
||||
expect(labels.length).toBeLessThanOrEqual(MAX_LABELS);
|
||||
expect(MAX_LABELS).toBe(3);
|
||||
expect(labels[0].tone).toBe("frame");
|
||||
expect(labels.some((label) => label.text === "Flüssigfutter 1,5 l von 2 l")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not label ghost parts hovered from the outline in focus mode", () => {
|
||||
expect(sceneLabels(model, layout, { ...rest, focusedPartId: "lid", hoveredId: "b2" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import type { HiveLayout, PartBox } from "@/lib/hive/layout";
|
||||
import type { CatalogPartType, HiveCatalog, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||
import { focusedIdOf } from "../use-hive-view";
|
||||
import type { MotionView } from "./use-hive-motion";
|
||||
|
||||
// What the scene shows about each part besides its shape: name, levels,
|
||||
// choices and frame count. Pure, so the labels can be tested without WebGL.
|
||||
|
||||
/** Spec 9.3: never more than three labels at once. */
|
||||
export const MAX_LABELS = 3;
|
||||
|
||||
export interface SceneLevel {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
max: number;
|
||||
/** value / max, clamped to 0…1. */
|
||||
ratio: number;
|
||||
/** Feed paste is a block, everything else a liquid. */
|
||||
solid: boolean;
|
||||
}
|
||||
|
||||
export interface SceneChoice {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ScenePart {
|
||||
id: string;
|
||||
kind: HivePartKind;
|
||||
type: CatalogPartType;
|
||||
box: PartBox;
|
||||
hostId: string | null;
|
||||
name: string;
|
||||
levels: SceneLevel[];
|
||||
choices: SceneChoice[];
|
||||
/** Frames that are not empty slots (boxes only). */
|
||||
frameCount: number;
|
||||
state: HivePart["state"];
|
||||
}
|
||||
|
||||
export type SceneModel = Map<string, ScenePart>;
|
||||
|
||||
const amountFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 });
|
||||
|
||||
export function formatAmount(value: number, unit: string): string {
|
||||
return `${amountFormat.format(value)} ${unit}`;
|
||||
}
|
||||
|
||||
export function levelText(level: SceneLevel): string {
|
||||
return `${level.label} ${formatAmount(level.value, level.unit)} von ${formatAmount(level.max, level.unit)}`;
|
||||
}
|
||||
|
||||
function readLevels(part: HivePart, type: CatalogPartType): SceneLevel[] {
|
||||
return type.stateSchema.flatMap((field) => {
|
||||
if (field.kind !== "level") return [];
|
||||
const raw = part.state[field.key];
|
||||
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||
const ratio = field.max > 0 ? Math.min(1, Math.max(0, value / field.max)) : 0;
|
||||
return [
|
||||
{
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
unit: field.unit,
|
||||
value,
|
||||
max: field.max,
|
||||
ratio,
|
||||
solid: field.feedType === "FEED_PASTE" || field.unit === "kg",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function readChoices(part: HivePart, type: CatalogPartType): SceneChoice[] {
|
||||
return type.stateSchema.flatMap((field) => {
|
||||
if (field.kind !== "choice" || field.options.length === 0) return [];
|
||||
const raw = part.state[field.key];
|
||||
const index = Math.max(0, field.options.indexOf(String(raw)));
|
||||
return [
|
||||
{
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
value: field.options[index],
|
||||
text: field.optionLabels[index] ?? field.options[index],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSceneModel(
|
||||
layout: HiveLayout,
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
partName: (id: string) => string,
|
||||
): SceneModel {
|
||||
const boxes = new Map<string, PartBox>();
|
||||
for (const placed of [...layout.parts, ...layout.inserts]) boxes.set(placed.id, placed);
|
||||
const model: SceneModel = new Map();
|
||||
const add = (part: HivePart, hostId: string | null): void => {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
const box = boxes.get(part.id);
|
||||
if (!type || !box) return;
|
||||
model.set(part.id, {
|
||||
id: part.id,
|
||||
kind: type.kind,
|
||||
type,
|
||||
box,
|
||||
hostId,
|
||||
name: partName(part.id),
|
||||
levels: readLevels(part, type),
|
||||
choices: readChoices(part, type),
|
||||
frameCount: (part.frames ?? []).filter((frame) => frame.type !== "NONE").length,
|
||||
state: part.state,
|
||||
});
|
||||
};
|
||||
for (const part of state.stack) {
|
||||
add(part, null);
|
||||
for (const insert of part.inserts ?? []) add(insert, part.id);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
export type LabelAnchor = { kind: "node"; id: string } | { kind: "frame"; boxId: string; slot: number };
|
||||
|
||||
export interface SceneLabel {
|
||||
key: string;
|
||||
anchor: LabelAnchor;
|
||||
text: string;
|
||||
tone: "part" | "frame" | "level";
|
||||
}
|
||||
|
||||
function frameLabel(layout: HiveLayout, view: MotionView): SceneLabel | null {
|
||||
if (!view.openBoxId) return null;
|
||||
const target = view.hoveredFrame ?? view.selectedFrame;
|
||||
if (!target || target.boxId !== view.openBoxId) return null;
|
||||
const frame = layout.frames.find((candidate) => candidate.boxId === target.boxId && candidate.slot === target.slot);
|
||||
if (!frame || frame.type === "NONE") return null;
|
||||
const parts = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type]];
|
||||
if (frame.name) parts.push(frame.name);
|
||||
return {
|
||||
key: `frame:${frame.boxId}#${frame.slot}`,
|
||||
anchor: { kind: "frame", boxId: frame.boxId, slot: frame.slot },
|
||||
text: parts.join(" · "),
|
||||
tone: "frame",
|
||||
};
|
||||
}
|
||||
|
||||
function hoverLabel(model: SceneModel, view: MotionView): SceneLabel | null {
|
||||
const id = view.hoveredId;
|
||||
if (!id) return null;
|
||||
const part = model.get(id);
|
||||
if (!part) return null;
|
||||
const focused = focusedIdOf(view);
|
||||
// In focus mode only the open box's inserts can be hovered; ghosts stay unlabelled.
|
||||
if (focused !== null && (view.openBoxId === null || part.hostId !== view.openBoxId)) return null;
|
||||
if (focused === null && part.hostId !== null) return null;
|
||||
const text = part.kind === "BOX" && part.frameCount > 0 ? `${part.name} · ${part.frameCount} Rähmchen` : part.name;
|
||||
return { key: `part:${id}`, anchor: { kind: "node", id }, text, tone: "part" };
|
||||
}
|
||||
|
||||
function levelLabels(model: SceneModel, view: MotionView): SceneLabel[] {
|
||||
const focused = focusedIdOf(view);
|
||||
if (!focused) return [];
|
||||
const inserts = view.openBoxId
|
||||
? [...model.values()].filter((part) => part.hostId === view.openBoxId).map((part) => part.id)
|
||||
: [];
|
||||
const shown = [focused, ...inserts];
|
||||
return shown.flatMap((id) => {
|
||||
const part = model.get(id);
|
||||
if (!part) return [];
|
||||
return part.levels.map((level) => ({
|
||||
key: `level:${id}:${level.key}`,
|
||||
anchor: { kind: "node", id } as LabelAnchor,
|
||||
text: levelText(level),
|
||||
tone: "level" as const,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/** Labels to show for the view state: frame first, then hover, then levels; at most three. */
|
||||
export function sceneLabels(model: SceneModel, layout: HiveLayout, view: MotionView): SceneLabel[] {
|
||||
const labels = [frameLabel(layout, view), hoverLabel(model, view), ...levelLabels(model, view)].filter(
|
||||
(label): label is SceneLabel => label !== null,
|
||||
);
|
||||
return labels.slice(0, MAX_LABELS);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import { cameraPosition } from "./camera-fit";
|
||||
|
||||
// Imperative three.js updates used from frame callbacks and effects. They live
|
||||
// outside the components on purpose: scene objects are mutable by design,
|
||||
// while React values (props, memoised objects) must not be mutated in render
|
||||
// code (react-hooks/immutability).
|
||||
|
||||
export function setUniform(material: ShaderMaterial, name: string, value: number): void {
|
||||
const uniform = material.uniforms[name];
|
||||
if (uniform) uniform.value = value;
|
||||
}
|
||||
|
||||
export function setUniformColor(material: ShaderMaterial, name: string, color: Color): void {
|
||||
const uniform = material.uniforms[name];
|
||||
if (uniform) (uniform.value as Color).copy(color);
|
||||
}
|
||||
|
||||
export function setMaterialOpacity(material: Material, opacity: number): void {
|
||||
material.opacity = opacity;
|
||||
material.visible = opacity > 0.01;
|
||||
}
|
||||
|
||||
/** Puts the camera on the fixed viewing direction at `distance` from `center`. */
|
||||
export function placeCamera(camera: Camera, center: Vec3, distance: number): void {
|
||||
const [x, y, z] = cameraPosition(center, distance);
|
||||
camera.position.set(x, y, z);
|
||||
camera.lookAt(center[0], center[1], center[2]);
|
||||
}
|
||||
|
||||
export function setSceneBackground(scene: Scene, background: Texture | Color | null): void {
|
||||
scene.background = background;
|
||||
}
|
||||
|
||||
export function setCanvasCursor(gl: WebGLRenderer, pointer: boolean): void {
|
||||
const style = (gl.domElement as HTMLElement | undefined)?.style;
|
||||
if (style) style.cursor = pointer ? "pointer" : "";
|
||||
}
|
||||
|
||||
/** Scales a unit fill mesh to `ratio` of the space (centre, size) and sets its surface wave. */
|
||||
export function placeFill(
|
||||
mesh: Mesh,
|
||||
center: Vec3,
|
||||
size: Vec3,
|
||||
ratio: number,
|
||||
cylinder: boolean,
|
||||
): number {
|
||||
const height = Math.max(size[1] * ratio, 1e-4);
|
||||
mesh.visible = ratio > 0.002;
|
||||
mesh.scale.set(size[0], height, cylinder ? size[0] : size[2]);
|
||||
mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]);
|
||||
return height;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import type { ThreeEvent } from "@react-three/fiber";
|
||||
import { BackSide, FrontSide } from "three";
|
||||
import type { Group } from "three";
|
||||
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
||||
import { FillVolume } from "./fill-volume";
|
||||
import { FrameHints } from "./frame-plate";
|
||||
import { HoloLabel } from "./hive-labels";
|
||||
import { createEdgeMaterial, createGlassMaterial, createGridMaterial } from "./materials";
|
||||
import { evaporatorBottle, feederChambers, partShapeSpec, pocketInner, shapeKey } from "./part-geometry";
|
||||
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
||||
import { setUniform, setUniformColor } from "./scene-mutations";
|
||||
import type { SceneLabel, ScenePart } from "./scene-model";
|
||||
import { useAnimatedNode } from "./use-animated-node";
|
||||
import type { AnimatedValues } from "./use-animated-node";
|
||||
import type { NodeTarget } from "./use-hive-motion";
|
||||
|
||||
// One part of the stack or one insert: glass body, glowing edges, screens,
|
||||
// an invisible hit box for pointer events, its content in focus mode and its
|
||||
// label. Inserts and frames are children, so they fly with their host.
|
||||
|
||||
export interface NodeHandlers {
|
||||
onPointerOver(event: ThreeEvent<PointerEvent>): void;
|
||||
onPointerOut(event: ThreeEvent<PointerEvent>): void;
|
||||
onPointerDown(event: ThreeEvent<PointerEvent>): void;
|
||||
onClick(event: ThreeEvent<MouseEvent>): void;
|
||||
}
|
||||
|
||||
/** Edge brightness at glow 1 (rest); bloom picks it up above the threshold. */
|
||||
const EDGE_INTENSITY = 1.25;
|
||||
|
||||
export interface StackPartProps {
|
||||
part: ScenePart;
|
||||
/** Position relative to the parent: world for stack parts, the host centre for inserts. */
|
||||
target: NodeTarget;
|
||||
/** This part is the one in focus mode. */
|
||||
focused: boolean;
|
||||
handlers: NodeHandlers;
|
||||
/** Labels anchored at this part (hover name, levels). */
|
||||
labels: SceneLabel[];
|
||||
/** Frames of a box, drawn as faint top-bar hints at rest. */
|
||||
hintFrames?: FrameBox[];
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) {
|
||||
const { theme, geometries, motion } = useSceneKit();
|
||||
const group = useRef<Group>(null);
|
||||
const { kind, box, state, levels } = part;
|
||||
const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () =>
|
||||
partShapeSpec(kind, box.size, state, levels.length),
|
||||
);
|
||||
|
||||
const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]);
|
||||
const edges = useMemo(() => createEdgeMaterial(theme), [theme]);
|
||||
const grids = useMemo(
|
||||
() => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })),
|
||||
[theme, shape.grids],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
glass.dispose();
|
||||
edges.dispose();
|
||||
grids.forEach((material) => material.dispose());
|
||||
},
|
||||
[glass, edges, grids],
|
||||
);
|
||||
|
||||
const apply = ({ opacity, glow }: AnimatedValues) => {
|
||||
setUniform(glass, "uOpacity", opacity);
|
||||
setUniform(glass, "uGlow", glow);
|
||||
// Ghost edges keep a little more presence than the faces, so the outline of the hive reads.
|
||||
setUniform(edges, "uOpacity", Math.min(1, opacity * 1.6));
|
||||
setUniform(edges, "uIntensity", EDGE_INTENSITY * glow);
|
||||
setUniformColor(edges, "uCyan", glow > 1.2 ? theme.edgeStrong : theme.edge);
|
||||
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
|
||||
};
|
||||
useAnimatedNode(group, { ...target, look: 0 }, motion, apply);
|
||||
|
||||
const showHints = hintFrames && hintFrames.length > 0 && !target.content;
|
||||
const sy = box.size[1];
|
||||
|
||||
return (
|
||||
<group ref={group}>
|
||||
<mesh geometry={shape.glass} material={glass} raycast={noRaycast} renderOrder={1} dispose={null} />
|
||||
{shape.grids.map((grid, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
geometry={grid.geometry}
|
||||
material={grids[index]}
|
||||
raycast={noRaycast}
|
||||
renderOrder={2}
|
||||
dispose={null}
|
||||
/>
|
||||
))}
|
||||
<mesh geometry={shape.edges} material={edges} raycast={noRaycast} renderOrder={4} dispose={null} />
|
||||
{showHints ? <FrameHints frames={hintFrames} hostCenter={box.center} glass={glass} edges={edges} /> : null}
|
||||
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
|
||||
<mesh
|
||||
position={[0, -target.lift / 2, 0]}
|
||||
raycast={target.interactive ? meshRaycast : noRaycast}
|
||||
onPointerOver={handlers.onPointerOver}
|
||||
onPointerOut={handlers.onPointerOut}
|
||||
onPointerDown={handlers.onPointerDown}
|
||||
onClick={handlers.onClick}
|
||||
userData={{ hit: part.id }}
|
||||
>
|
||||
<boxGeometry args={[box.size[0], box.size[1] + target.lift, box.size[2]]} />
|
||||
{/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */}
|
||||
<meshBasicMaterial visible={false} side={focused ? BackSide : FrontSide} />
|
||||
</mesh>
|
||||
{target.content ? <PartContent part={part} labels={labels} /> : null}
|
||||
{labels
|
||||
.filter((label) => label.tone !== "level")
|
||||
.map((label) => (
|
||||
<HoloLabel key={label.key} position={[0, sy / 2 + 0.06, 0]} text={label.text} tone="frame" />
|
||||
))}
|
||||
{children}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Below this canvas width level labels would run off the card; the focus chips carry the same text. */
|
||||
const LEVEL_LABEL_MIN_WIDTH = 520;
|
||||
|
||||
function levelLabel(labels: SceneLabel[], partId: string, key: string): SceneLabel | undefined {
|
||||
return labels.find((label) => label.key === `level:${partId}:${key}`);
|
||||
}
|
||||
|
||||
/** What a focused part shows inside (spec 9.3): feed, acid, pocket liquid. */
|
||||
function PartContent({ part, labels: allLabels }: { part: ScenePart; labels: SceneLabel[] }) {
|
||||
const { kind, box, levels } = part;
|
||||
const wide = useThree((state) => state.size.width >= LEVEL_LABEL_MIN_WIDTH);
|
||||
const labels = wide ? allLabels : [];
|
||||
if (kind === "FEEDER") {
|
||||
const chambers = feederChambers(box.size, levels.length);
|
||||
return (
|
||||
<>
|
||||
{levels.map((level, index) => {
|
||||
const chamber = chambers[index];
|
||||
const label = levelLabel(labels, part.id, level.key);
|
||||
const top: Vec3 = [chamber.center[0], chamber.center[1] + chamber.size[1] / 2, chamber.size[2] / 2];
|
||||
return (
|
||||
<group key={level.key}>
|
||||
{level.solid ? (
|
||||
<FillVolume
|
||||
center={chamber.center}
|
||||
size={[chamber.size[0] * 0.7, chamber.size[1] * 0.9, chamber.size[2] * 0.55]}
|
||||
ratio={level.ratio}
|
||||
kind="fondant"
|
||||
/>
|
||||
) : (
|
||||
<FillVolume center={chamber.center} size={chamber.size} ratio={level.ratio} kind="honey" />
|
||||
)}
|
||||
{label ? <HoloLabel position={top} text={label.text} tone="level" /> : null}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (kind === "FRAME_FEEDER" && levels[0]) {
|
||||
const inner = pocketInner(box.size);
|
||||
const label = levelLabel(labels, part.id, levels[0].key);
|
||||
return (
|
||||
<>
|
||||
<FillVolume center={inner.center} size={inner.size} ratio={levels[0].ratio} kind="honey" />
|
||||
{label ? <HoloLabel position={[box.size[0] / 2, inner.center[1], 0]} text={label.text} tone="level" /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (kind === "EVAPORATOR" && levels[0]) {
|
||||
const bottle = evaporatorBottle(box.size);
|
||||
const inner: Vec3 = [bottle.radius * 1.8, bottle.height - 0.06, bottle.radius * 1.8];
|
||||
const label = levelLabel(labels, part.id, levels[0].key);
|
||||
return (
|
||||
<>
|
||||
<FillVolume center={bottle.center} size={inner} ratio={levels[0].ratio} kind="acid" shape="cylinder" />
|
||||
{label ? (
|
||||
<HoloLabel
|
||||
position={[bottle.center[0] + bottle.radius, bottle.center[1], 0]}
|
||||
text={label.text}
|
||||
tone="level"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Color } from "three";
|
||||
|
||||
// Scene colours. The holo tokens (spec 9.3) are read once from :root; the
|
||||
// material colours (honey, wax, white-hot corners) are fixed design values.
|
||||
// Lighting stays cool blue/cyan and white; warm colours only appear as
|
||||
// content (feed in focus, lifted frame looks).
|
||||
|
||||
export interface HoloTheme {
|
||||
background: Color;
|
||||
/** Electric cyan along the edges. */
|
||||
edge: Color;
|
||||
/** Brighter edge of a hovered or focused part. */
|
||||
edgeStrong: Color;
|
||||
/** White-hot corners. */
|
||||
hot: Color;
|
||||
/** Glass body tint. */
|
||||
tint: Color;
|
||||
/** Cool rim light from behind. */
|
||||
rim: Color;
|
||||
/** Faint cool light bouncing up from the floor (owner: no warm light in the rest view). */
|
||||
bounce: Color;
|
||||
honey: Color;
|
||||
honeyDeep: Color;
|
||||
fondant: Color;
|
||||
acid: Color;
|
||||
/** Hex lines on the floor. */
|
||||
grid: Color;
|
||||
}
|
||||
|
||||
const FALLBACKS = {
|
||||
"--holo-bg": "#0b1224",
|
||||
"--holo-edge": "#33c2f0",
|
||||
"--holo-edge-strong": "#8fe3ff",
|
||||
} as const;
|
||||
|
||||
type TokenName = keyof typeof FALLBACKS;
|
||||
|
||||
/**
|
||||
* Resolves a CSS colour token (the holo tokens are oklch) to a hex string
|
||||
* three.js understands, by letting a 2D canvas convert it. Falls back when
|
||||
* there is no 2D context (jsdom) or the token is missing.
|
||||
*/
|
||||
export function resolveCssColor(property: TokenName): string {
|
||||
const fallback = FALLBACKS[property];
|
||||
if (typeof document === "undefined") return fallback;
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim();
|
||||
if (!value) return fallback;
|
||||
const context = document.createElement("canvas").getContext("2d");
|
||||
if (!context) return fallback;
|
||||
context.fillStyle = value;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** Reads the tokens; call once per canvas (useState initialiser). */
|
||||
export function readHoloTheme(): HoloTheme {
|
||||
return {
|
||||
background: new Color(resolveCssColor("--holo-bg")),
|
||||
edge: new Color(resolveCssColor("--holo-edge")),
|
||||
edgeStrong: new Color(resolveCssColor("--holo-edge-strong")),
|
||||
hot: new Color("#f2fbff"),
|
||||
tint: new Color("#7fb8e8"),
|
||||
rim: new Color("#4aa8ff"),
|
||||
bounce: new Color("#9fd8ff"),
|
||||
honey: new Color("#ffb21a"),
|
||||
honeyDeep: new Color("#8a3a00"),
|
||||
fondant: new Color("#f1d79a"),
|
||||
acid: new Color("#c8f4ff"),
|
||||
grid: new Color("#2a6fd6"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import type { Object3D } from "three";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||
import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion";
|
||||
|
||||
// Moves a node toward its target (spec 9.8): `full` damps position, scale and
|
||||
// fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
|
||||
// `off` sets everything at once. Frames are only requested while moving.
|
||||
|
||||
/** After a pause the demand loop reports a long delta; never step more than this. */
|
||||
export const MAX_FRAME_SECONDS = 1 / 30;
|
||||
|
||||
export interface AnimatedTarget {
|
||||
position: Vec3;
|
||||
scale: number;
|
||||
opacity: number;
|
||||
glow: number;
|
||||
/** 0 = clear glass, 1 = type look (frames only). */
|
||||
look?: number;
|
||||
}
|
||||
|
||||
export interface AnimatedValues {
|
||||
opacity: number;
|
||||
glow: number;
|
||||
look: number;
|
||||
}
|
||||
|
||||
function stepFade(current: number, target: number, dt: number, motion: MotionLevel): number {
|
||||
if (motion === "full") return stepToward(current, target, dt, true);
|
||||
if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function useAnimatedNode(
|
||||
ref: RefObject<Object3D | null>,
|
||||
target: AnimatedTarget,
|
||||
motion: MotionLevel,
|
||||
apply: (values: AnimatedValues) => void,
|
||||
): void {
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const current = useRef<{ position: Vec3; scale: number; values: AnimatedValues } | null>(null);
|
||||
const latestApply = useRef(apply);
|
||||
useEffect(() => {
|
||||
latestApply.current = apply;
|
||||
});
|
||||
|
||||
// A new target starts the frame loop.
|
||||
const [px, py, pz] = target.position;
|
||||
useEffect(() => {
|
||||
invalidate();
|
||||
}, [px, py, pz, target.scale, target.opacity, target.glow, target.look, invalidate]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 };
|
||||
if (!current.current) {
|
||||
// First frame: appear in place, no fly-in from the origin.
|
||||
current.current = { position: [...target.position], scale: target.scale, values: goal };
|
||||
} else {
|
||||
const dt = Math.min(delta, MAX_FRAME_SECONDS);
|
||||
const damped = motion === "full";
|
||||
const state = current.current;
|
||||
state.position = [
|
||||
stepToward(state.position[0], target.position[0], dt, damped),
|
||||
stepToward(state.position[1], target.position[1], dt, damped),
|
||||
stepToward(state.position[2], target.position[2], dt, damped),
|
||||
];
|
||||
state.scale = stepToward(state.scale, target.scale, dt, damped);
|
||||
state.values = {
|
||||
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion),
|
||||
glow: stepFade(state.values.glow, goal.glow, dt, motion),
|
||||
look: stepFade(state.values.look, goal.look, dt, motion),
|
||||
};
|
||||
}
|
||||
const state = current.current;
|
||||
node.position.set(state.position[0], state.position[1], state.position[2]);
|
||||
node.scale.setScalar(state.scale);
|
||||
latestApply.current(state.values);
|
||||
const settled =
|
||||
state.position.every((value, axis) => value === target.position[axis]) &&
|
||||
state.scale === target.scale &&
|
||||
state.values.opacity === goal.opacity &&
|
||||
state.values.glow === goal.glow &&
|
||||
state.values.look === goal.look;
|
||||
if (!settled) invalidate();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import type { HiveViewState } from "../use-hive-view";
|
||||
import {
|
||||
FOCUS_SCALE,
|
||||
FRAME_LIFT_RATIO,
|
||||
GHOST_OPACITY,
|
||||
HOVER_LIFT,
|
||||
focusPlacement,
|
||||
frameKey,
|
||||
hiveMotionTargets,
|
||||
REST_GAP,
|
||||
restBounds,
|
||||
restCenter,
|
||||
shouldIdle,
|
||||
stepToward,
|
||||
} from "./use-hive-motion";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
function sampleState(): HiveState {
|
||||
const boxFrames = frames(10).map((frame) =>
|
||||
frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame.slot === 4 ? { ...frame, type: "BROOD" as const } : frame,
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", {
|
||||
frames: boxFrames,
|
||||
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })],
|
||||
}),
|
||||
part("b2", "box", { frames: frames(10) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const layout = hiveLayout(sampleState(), catalog);
|
||||
const rest: HiveViewState = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null };
|
||||
const box = (id: string) => layout.parts.find((candidate) => candidate.id === id)!;
|
||||
|
||||
describe("hiveMotionTargets", () => {
|
||||
it("keeps every part at its layout place, full opacity and interactive in the rest view", () => {
|
||||
const targets = hiveMotionTargets(layout, rest);
|
||||
for (const placed of layout.parts) {
|
||||
const node = targets.nodes.get(placed.id)!;
|
||||
expect(node.position).toEqual(restCenter(placed));
|
||||
expect(node.scale).toBe(1);
|
||||
expect(node.opacity).toBe(1);
|
||||
expect(node.interactive).toBe(true);
|
||||
expect(node.content).toBe(false);
|
||||
}
|
||||
expect(targets.focus).toBeNull();
|
||||
});
|
||||
|
||||
it("places inserts relative to their host and keeps them passive in the rest view", () => {
|
||||
const targets = hiveMotionTargets(layout, rest);
|
||||
const insert = layout.inserts.find((candidate) => candidate.id === "fi")!;
|
||||
const node = targets.nodes.get("fi")!;
|
||||
expect(node.hostId).toBe("f");
|
||||
expect(node.position[1]).toBeCloseTo(insert.center[1] - box("f").center[1], 9);
|
||||
expect(node.interactive).toBe(false);
|
||||
});
|
||||
|
||||
it("shows frames only as hints at rest and hides empty slots", () => {
|
||||
const targets = hiveMotionTargets(layout, rest);
|
||||
expect(targets.frames.get(frameKey("b1", 4))!.look).toBe("hint");
|
||||
expect(targets.frames.get(frameKey("b1", 1))!.look).toBe("hidden");
|
||||
expect(targets.frames.get(frameKey("b1", 4))!.interactive).toBe(false);
|
||||
});
|
||||
|
||||
it("lifts the hovered part by 8 mm and makes its edges brighter, others stay", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, hoveredId: "b2" });
|
||||
const hovered = targets.nodes.get("b2")!;
|
||||
expect(hovered.position[1]).toBeCloseTo(restCenter(box("b2"))[1] + HOVER_LIFT, 9);
|
||||
expect(HOVER_LIFT).toBeCloseTo(0.08, 9);
|
||||
expect(hovered.glow).toBeGreaterThan(targets.nodes.get("b1")!.glow);
|
||||
expect(targets.nodes.get("b1")!.position).toEqual(restCenter(box("b1")));
|
||||
});
|
||||
|
||||
it("reports the lift, so the hit box can stay under the pointer", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, hoveredId: "b2" });
|
||||
expect(targets.nodes.get("b2")!.lift).toBeCloseTo(HOVER_LIFT, 9);
|
||||
expect(targets.nodes.get("b1")!.lift).toBe(0);
|
||||
});
|
||||
|
||||
it("opens the rest stack by a small gap per level and reports the bounds with the gaps", () => {
|
||||
expect(restCenter(box("b2"))[1]).toBeCloseTo(box("b2").center[1] + 2 * REST_GAP, 9);
|
||||
const bounds = restBounds(layout);
|
||||
expect(bounds.size[1]).toBeCloseTo(layout.bounds.h + (layout.parts.length - 1) * REST_GAP, 9);
|
||||
expect(bounds.center[1]).toBeCloseTo(bounds.size[1] / 2, 9);
|
||||
});
|
||||
|
||||
it("flies an open box to the focus place, scales it and ghosts every other part at ≈ 0.08", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b2" });
|
||||
const focused = targets.nodes.get("b2")!;
|
||||
expect(focused.position).toEqual(focusPlacement(layout.bounds, box("b2").size));
|
||||
expect(focused.scale).toBe(FOCUS_SCALE);
|
||||
expect(focused.opacity).toBe(1);
|
||||
expect(focused.content).toBe(true);
|
||||
for (const [id, node] of targets.nodes) {
|
||||
if (id === "b2") continue;
|
||||
expect(node.opacity).toBeCloseTo(GHOST_OPACITY, 9);
|
||||
expect(node.interactive).toBe(false);
|
||||
}
|
||||
expect(GHOST_OPACITY).toBeCloseTo(0.08, 9);
|
||||
expect(targets.focus).toMatchObject({ id: "b2", kind: "BOX" });
|
||||
});
|
||||
|
||||
it("keeps the focus place clear of the ghost hive", () => {
|
||||
const size = box("b2").size;
|
||||
const [, , z] = focusPlacement(layout.bounds, size);
|
||||
expect(z - (size[2] * FOCUS_SCALE) / 2).toBeGreaterThan(layout.bounds.d / 2);
|
||||
});
|
||||
|
||||
it("shows the frames of the open box as clear glass plates, ghosts the others", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b2" });
|
||||
const plate = targets.frames.get(frameKey("b2", 3))!;
|
||||
expect(plate.look).toBe("glass");
|
||||
expect(plate.interactive).toBe(true);
|
||||
expect(plate.opacity).toBe(1);
|
||||
const other = targets.frames.get(frameKey("b1", 4))!;
|
||||
expect(other.look).toBe("hint");
|
||||
expect(other.opacity).toBeCloseTo(GHOST_OPACITY, 9);
|
||||
});
|
||||
|
||||
it("lifts the hovered frame by 40 % of its height and gives it its type look", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1", hoveredFrame: { boxId: "b1", slot: 4 } });
|
||||
const frame = layout.frames.find((candidate) => candidate.boxId === "b1" && candidate.slot === 4)!;
|
||||
const lifted = targets.frames.get(frameKey("b1", 4))!;
|
||||
expect(lifted.position[1]).toBeCloseTo(frame.center[1] - box("b1").center[1] + FRAME_LIFT_RATIO * frame.size[1], 9);
|
||||
expect(lifted.look).toBe("type");
|
||||
const neighbour = targets.frames.get(frameKey("b1", 5))!;
|
||||
expect(neighbour.look).toBe("glass");
|
||||
expect(neighbour.position[1]).toBeCloseTo(frame.center[1] - box("b1").center[1], 9);
|
||||
});
|
||||
|
||||
it("keeps a selected frame lifted with its type look after the pointer left", () => {
|
||||
const targets = hiveMotionTargets(layout, {
|
||||
...rest,
|
||||
openBoxId: "b1",
|
||||
selectedFrame: { boxId: "b1", slot: 5 },
|
||||
});
|
||||
expect(targets.frames.get(frameKey("b1", 5))!.look).toBe("type");
|
||||
expect(targets.frames.get(frameKey("b1", 5))!.position[1]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("makes the inserts of the open box interactive and shows their content", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1" });
|
||||
const pocket = targets.nodes.get("ff")!;
|
||||
expect(pocket.interactive).toBe(true);
|
||||
expect(pocket.content).toBe(true);
|
||||
expect(pocket.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it("lifts a hovered frame feeder like a frame", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1", hoveredId: "ff" });
|
||||
const insert = layout.inserts.find((candidate) => candidate.id === "ff")!;
|
||||
expect(targets.nodes.get("ff")!.position[1]).toBeCloseTo(
|
||||
insert.center[1] - box("b1").center[1] + FRAME_LIFT_RATIO * insert.size[1],
|
||||
9,
|
||||
);
|
||||
expect(targets.nodes.get("ff")!.lift).toBeCloseTo(FRAME_LIFT_RATIO * insert.size[1], 9);
|
||||
});
|
||||
|
||||
it("flies a focused insert out on its own and ghosts its host", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, focusedPartId: "fi" });
|
||||
const insert = layout.inserts.find((candidate) => candidate.id === "fi")!;
|
||||
const place = focusPlacement(layout.bounds, insert.size);
|
||||
const node = targets.nodes.get("fi")!;
|
||||
const host = restCenter(box("f"));
|
||||
// Inserts are positioned relative to the host, which stays at its rest place.
|
||||
expect(node.position[0] + host[0]).toBeCloseTo(place[0], 9);
|
||||
expect(node.position[1] + host[1]).toBeCloseTo(place[1], 9);
|
||||
expect(node.position[2] + host[2]).toBeCloseTo(place[2], 9);
|
||||
expect(node.opacity).toBe(1);
|
||||
expect(node.content).toBe(true);
|
||||
expect(targets.nodes.get("f")!.opacity).toBeCloseTo(GHOST_OPACITY, 9);
|
||||
expect(targets.nodes.get("f")!.position).toEqual(host);
|
||||
});
|
||||
|
||||
it("focuses a feeder and shows its content", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, focusedPartId: "fe" });
|
||||
const feeder = targets.nodes.get("fe")!;
|
||||
expect(feeder.content).toBe(true);
|
||||
expect(feeder.interactive).toBe(true);
|
||||
expect(targets.focus).toMatchObject({ id: "fe", kind: "FEEDER" });
|
||||
expect(targets.nodes.get("b1")!.opacity).toBeCloseTo(GHOST_OPACITY, 9);
|
||||
});
|
||||
|
||||
it("ignores a focus id that is not in the layout", () => {
|
||||
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "gone" });
|
||||
expect(targets.focus).toBeNull();
|
||||
expect(targets.nodes.get("b1")!.opacity).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepToward", () => {
|
||||
it("jumps at once when not damped", () => {
|
||||
expect(stepToward(0, 1, 0.016, false)).toBe(1);
|
||||
});
|
||||
|
||||
it("moves part of the way per frame and settles within about 350 ms", () => {
|
||||
let value = 0;
|
||||
const first = stepToward(value, 1, 1 / 60, true);
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(first).toBeLessThan(1);
|
||||
for (let frame = 0; frame < 21; frame += 1) value = stepToward(value, 1, 1 / 60, true);
|
||||
expect(value).toBeGreaterThan(0.98);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldIdle", () => {
|
||||
it("runs only at full, while the card and the tab are visible", () => {
|
||||
expect(shouldIdle({ motion: "full", visible: true, tabVisible: true })).toBe(true);
|
||||
expect(shouldIdle({ motion: "subtle", visible: true, tabVisible: true })).toBe(false);
|
||||
expect(shouldIdle({ motion: "off", visible: true, tabVisible: true })).toBe(false);
|
||||
expect(shouldIdle({ motion: "full", visible: false, tabVisible: true })).toBe(false);
|
||||
expect(shouldIdle({ motion: "full", visible: true, tabVisible: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
|
||||
import type { HivePartKind } from "@/lib/hive/types";
|
||||
import type { HiveViewState, HoveredFrame } from "../use-hive-view";
|
||||
import { focusedIdOf } from "../use-hive-view";
|
||||
|
||||
// Pure targets for every node of the scene (spec 9.4). The scene animates
|
||||
// toward them at motion level `full` and sets them directly otherwise.
|
||||
// Units are layout units (1 = 100 mm, y up).
|
||||
|
||||
/** A hovered part rises 8 mm. */
|
||||
export const HOVER_LIFT = 0.08;
|
||||
/** Every part outside the focus stays as a faint ghost of the whole hive. */
|
||||
export const GHOST_OPACITY = 0.08;
|
||||
/** The focused part flies out and grows by this factor. */
|
||||
export const FOCUS_SCALE = 1.25;
|
||||
/** A hovered or selected frame rises by 40 % of its height. */
|
||||
export const FRAME_LIFT_RATIO = 0.4;
|
||||
/** The rest stack is slightly exploded (mockup g2): this gap per stack level. */
|
||||
export const REST_GAP = 0.14;
|
||||
/** Clear space between the ghost hive and the focused part. */
|
||||
const FOCUS_GAP = 0.45;
|
||||
/** The focused part floats a little above the floor. */
|
||||
const FOCUS_FLOAT = 0.18;
|
||||
/** Share of the hive width the focus place moves to the left (camera at 30° azimuth). */
|
||||
const FOCUS_SHIFT = 0.35;
|
||||
|
||||
export const GLOW = { rest: 1, hover: 2.3, focus: 1.5, ghost: 0.45 } as const;
|
||||
|
||||
export type FrameLook = "hidden" | "hint" | "glass" | "type";
|
||||
|
||||
export interface NodeTarget {
|
||||
/** Stack parts: world position. Inserts: relative to the host's centre. */
|
||||
position: Vec3;
|
||||
scale: number;
|
||||
opacity: number;
|
||||
/** Edge brightness multiplier. */
|
||||
glow: number;
|
||||
/** Takes pointer events (ghosts and parts inside a closed host do not). */
|
||||
interactive: boolean;
|
||||
/** Shows its content (levels, frame looks) - only in focus mode. */
|
||||
content: boolean;
|
||||
hostId: string | null;
|
||||
/** How far the node is raised above its place (hover); its hit box reaches back down. */
|
||||
lift: number;
|
||||
}
|
||||
|
||||
export interface FrameTarget {
|
||||
/** Relative to the box centre. */
|
||||
position: Vec3;
|
||||
opacity: number;
|
||||
look: FrameLook;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
export interface FocusInfo {
|
||||
id: string;
|
||||
kind: HivePartKind;
|
||||
/** World centre of the focused part at its focus place. */
|
||||
center: Vec3;
|
||||
/** Scaled size, with head room for a lifted frame. */
|
||||
size: Vec3;
|
||||
}
|
||||
|
||||
export interface HiveMotionTargets {
|
||||
nodes: Map<string, NodeTarget>;
|
||||
frames: Map<string, FrameTarget>;
|
||||
focus: FocusInfo | null;
|
||||
}
|
||||
|
||||
export interface MotionView extends HiveViewState {
|
||||
/** Frame whose sheet was opened from the scene: stays lifted until focus changes. */
|
||||
selectedFrame?: HoveredFrame | null;
|
||||
}
|
||||
|
||||
export const frameKey = (boxId: string, slot: number): string => `${boxId}#${slot}`;
|
||||
|
||||
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]];
|
||||
|
||||
/** Rest place of a stack part: its layout centre raised by the stack gap. */
|
||||
export function restCenter(placed: PartBox): Vec3 {
|
||||
return lift(placed.center, placed.stackIndex * REST_GAP);
|
||||
}
|
||||
|
||||
/** World box of the rest stack (centre and size), gaps included. */
|
||||
export function restBounds(layout: HiveLayout): { center: Vec3; size: Vec3 } {
|
||||
const levels = layout.parts.length;
|
||||
const height = layout.bounds.h + Math.max(0, levels - 1) * REST_GAP;
|
||||
return { center: [0, height / 2, 0], size: [layout.bounds.w, height, layout.bounds.d] };
|
||||
}
|
||||
|
||||
/**
|
||||
* World centre a focused part flies to: in front of the hive and to the left,
|
||||
* so the ghost hive stays visible behind it on the right (camera azimuth 30°).
|
||||
*/
|
||||
export function focusPlacement(bounds: HiveLayout["bounds"], size: Vec3): Vec3 {
|
||||
return [
|
||||
-bounds.w * FOCUS_SHIFT,
|
||||
(size[1] * FOCUS_SCALE) / 2 + FOCUS_FLOAT,
|
||||
bounds.d / 2 + (size[2] * FOCUS_SCALE) / 2 + FOCUS_GAP,
|
||||
];
|
||||
}
|
||||
|
||||
function hostOf(layout: HiveLayout, insert: PartBox): PartBox | undefined {
|
||||
return layout.parts.find((candidate) => candidate.stackIndex === insert.stackIndex);
|
||||
}
|
||||
|
||||
const sameFrame = (frame: FrameBox, other: HoveredFrame | null | undefined): boolean =>
|
||||
other != null && other.boxId === frame.boxId && other.slot === frame.slot;
|
||||
|
||||
interface FocusState {
|
||||
id: string;
|
||||
box: PartBox;
|
||||
isInsert: boolean;
|
||||
hostId: string | null;
|
||||
}
|
||||
|
||||
function findFocus(layout: HiveLayout, view: MotionView): FocusState | null {
|
||||
const id = focusedIdOf(view);
|
||||
if (!id) return null;
|
||||
const stackPart = layout.parts.find((candidate) => candidate.id === id);
|
||||
if (stackPart) return { id, box: stackPart, isInsert: false, hostId: null };
|
||||
const insert = layout.inserts.find((candidate) => candidate.id === id);
|
||||
if (!insert) return null;
|
||||
return { id, box: insert, isInsert: true, hostId: hostOf(layout, insert)?.id ?? null };
|
||||
}
|
||||
|
||||
function focusInfo(layout: HiveLayout, focus: FocusState): FocusInfo {
|
||||
const { box } = focus;
|
||||
const center = focusPlacement(layout.bounds, box.size);
|
||||
const size: Vec3 = [box.size[0] * FOCUS_SCALE, box.size[1] * FOCUS_SCALE, box.size[2] * FOCUS_SCALE];
|
||||
if (box.kind !== "BOX") return { id: focus.id, kind: box.kind, center, size };
|
||||
// Leave room above the box for a frame lifted by 40 % of its height.
|
||||
const frameHeight = layout.frames.find((frame) => frame.boxId === box.id)?.size[1] ?? 0;
|
||||
const headRoom = FRAME_LIFT_RATIO * frameHeight * FOCUS_SCALE;
|
||||
return {
|
||||
id: focus.id,
|
||||
kind: box.kind,
|
||||
center: lift(center, headRoom / 2),
|
||||
size: [size[0], size[1] + headRoom, size[2]],
|
||||
};
|
||||
}
|
||||
|
||||
function stackTargets(layout: HiveLayout, view: MotionView, focus: FocusState | null): Map<string, NodeTarget> {
|
||||
const nodes = new Map<string, NodeTarget>();
|
||||
for (const placed of layout.parts) {
|
||||
const focused = focus !== null && !focus.isInsert && focus.id === placed.id;
|
||||
if (focused) {
|
||||
nodes.set(placed.id, {
|
||||
position: focusPlacement(layout.bounds, placed.size),
|
||||
scale: FOCUS_SCALE,
|
||||
opacity: 1,
|
||||
glow: GLOW.focus,
|
||||
interactive: true,
|
||||
content: true,
|
||||
hostId: null,
|
||||
lift: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const home = restCenter(placed);
|
||||
if (focus) {
|
||||
nodes.set(placed.id, {
|
||||
position: home,
|
||||
scale: 1,
|
||||
opacity: GHOST_OPACITY,
|
||||
glow: GLOW.ghost,
|
||||
interactive: false,
|
||||
content: false,
|
||||
hostId: null,
|
||||
lift: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const hovered = view.hoveredId === placed.id;
|
||||
nodes.set(placed.id, {
|
||||
position: hovered ? lift(home, HOVER_LIFT) : home,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
glow: hovered ? GLOW.hover : GLOW.rest,
|
||||
interactive: true,
|
||||
content: false,
|
||||
hostId: null,
|
||||
lift: hovered ? HOVER_LIFT : 0,
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function insertTarget(
|
||||
layout: HiveLayout,
|
||||
view: MotionView,
|
||||
focus: FocusState | null,
|
||||
insert: PartBox,
|
||||
host: PartBox,
|
||||
): NodeTarget {
|
||||
const local = sub(insert.center, host.center);
|
||||
if (focus?.isInsert && focus.id === insert.id) {
|
||||
return {
|
||||
position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host)),
|
||||
scale: FOCUS_SCALE,
|
||||
opacity: 1,
|
||||
glow: GLOW.focus,
|
||||
interactive: true,
|
||||
content: true,
|
||||
hostId: host.id,
|
||||
lift: 0,
|
||||
};
|
||||
}
|
||||
const hostFocused = focus !== null && !focus.isInsert && focus.id === host.id;
|
||||
if (hostFocused) {
|
||||
const hovered = view.hoveredId === insert.id;
|
||||
const rise = insert.kind === "FRAME_FEEDER" ? FRAME_LIFT_RATIO * insert.size[1] : HOVER_LIFT;
|
||||
return {
|
||||
position: hovered ? lift(local, rise) : local,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
glow: hovered ? GLOW.hover : GLOW.rest,
|
||||
interactive: true,
|
||||
content: true,
|
||||
hostId: host.id,
|
||||
lift: hovered ? rise : 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: local,
|
||||
scale: 1,
|
||||
opacity: focus ? GHOST_OPACITY : 1,
|
||||
glow: focus ? GLOW.ghost : GLOW.rest,
|
||||
interactive: false,
|
||||
content: false,
|
||||
hostId: host.id,
|
||||
lift: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: FocusState | null): FrameTarget {
|
||||
const local = sub(frame.center, host.center);
|
||||
if (frame.type === "NONE") return { position: local, opacity: 0, look: "hidden", interactive: false };
|
||||
const open = focus !== null && !focus.isInsert && focus.id === host.id;
|
||||
if (!open) {
|
||||
return { position: local, opacity: focus ? GHOST_OPACITY : 1, look: "hint", interactive: false };
|
||||
}
|
||||
const raised = sameFrame(frame, view.hoveredFrame) || sameFrame(frame, view.selectedFrame);
|
||||
return {
|
||||
position: raised ? lift(local, FRAME_LIFT_RATIO * frame.size[1]) : local,
|
||||
opacity: 1,
|
||||
look: raised ? "type" : "glass",
|
||||
interactive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Targets for every part, insert and frame of the layout in the given view state. */
|
||||
export function hiveMotionTargets(layout: HiveLayout, view: MotionView): HiveMotionTargets {
|
||||
const focus = findFocus(layout, view);
|
||||
const nodes = stackTargets(layout, view, focus);
|
||||
for (const insert of layout.inserts) {
|
||||
const host = hostOf(layout, insert);
|
||||
if (host) nodes.set(insert.id, insertTarget(layout, view, focus, insert, host));
|
||||
}
|
||||
const frames = new Map<string, FrameTarget>();
|
||||
for (const frame of layout.frames) {
|
||||
const host = layout.parts.find((candidate) => candidate.id === frame.boxId);
|
||||
if (host) frames.set(frameKey(frame.boxId, frame.slot), frameTarget(frame, host, view, focus));
|
||||
}
|
||||
return { nodes, frames, focus: focus ? focusInfo(layout, focus) : null };
|
||||
}
|
||||
|
||||
/** Damping rate: about 95 % of the way after 250 ms, settled at about 350 ms (spec 9.8). */
|
||||
export const DAMPING = 12;
|
||||
/** Values closer than this count as arrived. */
|
||||
export const SETTLE_EPSILON = 1e-3;
|
||||
|
||||
/**
|
||||
* One frame of exponential damping toward a target (frame-rate independent),
|
||||
* or the target itself when motion is not `full`.
|
||||
*/
|
||||
export function stepToward(current: number, target: number, deltaSeconds: number, damped: boolean): number {
|
||||
if (!damped) return target;
|
||||
const next = current + (target - current) * (1 - Math.exp(-DAMPING * deltaSeconds));
|
||||
return Math.abs(target - next) < SETTLE_EPSILON ? target : next;
|
||||
}
|
||||
|
||||
/** Linear fade used at motion level `subtle` (150 ms). */
|
||||
export const SUBTLE_FADE_SECONDS = 0.15;
|
||||
|
||||
export function fadeToward(current: number, target: number, deltaSeconds: number, seconds: number): number {
|
||||
if (seconds <= 0) return target;
|
||||
const step = deltaSeconds / seconds;
|
||||
if (Math.abs(target - current) <= step) return target;
|
||||
return current + Math.sign(target - current) * step;
|
||||
}
|
||||
|
||||
export interface IdleConditions {
|
||||
motion: "full" | "subtle" | "off";
|
||||
/** The card is in the viewport. */
|
||||
visible: boolean;
|
||||
/** The tab is in the foreground. */
|
||||
tabVisible: boolean;
|
||||
}
|
||||
|
||||
/** Idle float, pointer tilt and liquid waves run only at `full`, on screen, in the foreground tab. */
|
||||
export function shouldIdle({ motion, visible, tabVisible }: IdleConditions): boolean {
|
||||
return motion === "full" && visible && tabVisible;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useTouchSelect } from "./use-touch-select";
|
||||
|
||||
function setup() {
|
||||
const hover = vi.fn();
|
||||
const select = vi.fn();
|
||||
const { result, rerender } = renderHook(({ onHover, onSelect }) => useTouchSelect({ onHover, onSelect }), {
|
||||
initialProps: { onHover: hover, onSelect: select },
|
||||
});
|
||||
return { hover, select, result, rerender };
|
||||
}
|
||||
|
||||
describe("useTouchSelect", () => {
|
||||
it("hovers on the first tap and selects on the second tap of the same target", () => {
|
||||
const { hover, select, result } = setup();
|
||||
result.current.tap("part:b1", "touch");
|
||||
expect(hover).toHaveBeenLastCalledWith("part:b1");
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
result.current.tap("part:b1", "touch");
|
||||
expect(select).toHaveBeenCalledWith("part:b1");
|
||||
});
|
||||
|
||||
it("starts over after a selection", () => {
|
||||
const { select, result } = setup();
|
||||
result.current.tap("part:b1", "touch");
|
||||
result.current.tap("part:b1", "touch");
|
||||
result.current.tap("part:b1", "touch");
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("moves the hover to another target instead of selecting", () => {
|
||||
const { hover, select, result } = setup();
|
||||
result.current.tap("part:b1", "touch");
|
||||
result.current.tap("frame:b1#4", "touch");
|
||||
expect(hover).toHaveBeenLastCalledWith("frame:b1#4");
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
result.current.tap("frame:b1#4", "touch");
|
||||
expect(select).toHaveBeenCalledWith("frame:b1#4");
|
||||
});
|
||||
|
||||
it("resets on a tap elsewhere", () => {
|
||||
const { hover, select, result } = setup();
|
||||
result.current.tap("part:b1", "touch");
|
||||
result.current.tap(null, "touch");
|
||||
expect(hover).toHaveBeenLastCalledWith(null);
|
||||
result.current.tap("part:b1", "touch");
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selects at once with a mouse or pen", () => {
|
||||
const { hover, select, result } = setup();
|
||||
result.current.tap("part:b1", "mouse");
|
||||
expect(select).toHaveBeenCalledWith("part:b1");
|
||||
result.current.tap("part:b2", "pen");
|
||||
expect(select).toHaveBeenLastCalledWith("part:b2");
|
||||
expect(hover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a stable api and uses the latest handlers", () => {
|
||||
const { result, rerender } = setup();
|
||||
const first = result.current;
|
||||
const nextSelect = vi.fn();
|
||||
rerender({ onHover: vi.fn(), onSelect: nextSelect });
|
||||
expect(result.current).toBe(first);
|
||||
result.current.tap("part:x", "mouse");
|
||||
expect(nextSelect).toHaveBeenCalledWith("part:x");
|
||||
});
|
||||
|
||||
it("forgets the armed target on reset", () => {
|
||||
const { select, result } = setup();
|
||||
result.current.tap("part:b1", "touch");
|
||||
result.current.reset();
|
||||
result.current.tap("part:b1", "touch");
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
// Spec 9.4: on touch the first tap hovers (the target lifts and shows its
|
||||
// label), the second tap on the same target selects it; a tap elsewhere
|
||||
// starts over. Mouse and pen select on click, hover comes from pointer-over.
|
||||
|
||||
export interface TouchSelectHandlers {
|
||||
onHover(key: string | null): void;
|
||||
onSelect(key: string): void;
|
||||
}
|
||||
|
||||
export interface TouchSelect {
|
||||
/** A tap or click on `key` (null: on nothing) with the pointer type of its pointerdown. */
|
||||
tap(key: string | null, pointerType: string): void;
|
||||
/** Forget the armed target (focus changed, scene left). */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function useTouchSelect(handlers: TouchSelectHandlers): TouchSelect {
|
||||
const latest = useRef(handlers);
|
||||
useEffect(() => {
|
||||
latest.current = handlers;
|
||||
});
|
||||
const armed = useRef<string | null>(null);
|
||||
|
||||
return useMemo<TouchSelect>(
|
||||
() => ({
|
||||
tap(key, pointerType) {
|
||||
if (pointerType !== "touch") {
|
||||
armed.current = null;
|
||||
if (key !== null) latest.current.onSelect(key);
|
||||
return;
|
||||
}
|
||||
if (key !== null && armed.current === key) {
|
||||
armed.current = null;
|
||||
latest.current.onSelect(key);
|
||||
return;
|
||||
}
|
||||
armed.current = key;
|
||||
latest.current.onHover(key);
|
||||
},
|
||||
reset() {
|
||||
armed.current = null;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveVisualSlot } from "./hive-visual-slot";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const webgl = vi.hoisted(() => ({ canUseWebGL: vi.fn(), shouldUse3D: vi.fn() }));
|
||||
vi.mock("@/lib/hive/webgl", () => webgl);
|
||||
|
||||
// The scene (three, WebGL) never runs in specs: next/dynamic returns a stub.
|
||||
const canvasMounts = vi.hoisted(() => ({ count: 0 }));
|
||||
vi.mock("next/dynamic", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
default: () =>
|
||||
function StubCanvas(props: { onReady: () => void; onContextLost: () => void }) {
|
||||
React.useEffect(() => {
|
||||
canvasMounts.count += 1;
|
||||
}, []);
|
||||
return (
|
||||
<div data-testid="canvas">
|
||||
<button onClick={props.onReady}>ready</button>
|
||||
<button onClick={props.onContextLost}>lose</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = { schemaVersion: 1, stack: [part("f", "floor"), part("r", "roof")] };
|
||||
const layout = hiveLayout(state, catalog);
|
||||
|
||||
interface ObserverEntry {
|
||||
callback: IntersectionObserverCallback;
|
||||
disconnected: boolean;
|
||||
}
|
||||
let observed: ObserverEntry | null = null;
|
||||
|
||||
class FakeObserver {
|
||||
entry: ObserverEntry;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.entry = { callback, disconnected: false };
|
||||
observed = this.entry;
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
disconnect() {
|
||||
this.entry.disconnected = true;
|
||||
}
|
||||
}
|
||||
|
||||
function enterViewport() {
|
||||
act(() => {
|
||||
observed!.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSlot() {
|
||||
return render(
|
||||
<HiveViewProvider store={createHiveViewStore()}>
|
||||
<HiveVisualSlot
|
||||
layout={layout}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
partName={(id) => id}
|
||||
onSelectPart={vi.fn()}
|
||||
onSelectFrame={vi.fn()}
|
||||
/>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function canvasLayer() {
|
||||
return screen.getByTestId("canvas").parentElement!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observed = null;
|
||||
canvasMounts.count = 0;
|
||||
webgl.canUseWebGL.mockReturnValue(true);
|
||||
webgl.shouldUse3D.mockReturnValue(true);
|
||||
vi.stubGlobal("IntersectionObserver", FakeObserver);
|
||||
window.localStorage.removeItem("apicore.motion");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.localStorage.removeItem("apicore.motion");
|
||||
});
|
||||
|
||||
describe("HiveVisualSlot", () => {
|
||||
it("shows the SVG at once and no canvas before the card is visible", () => {
|
||||
const { container } = renderSlot();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads the scene when the card enters the viewport and 3D is allowed", () => {
|
||||
renderSlot();
|
||||
enterViewport();
|
||||
expect(screen.getByTestId("canvas")).toBeInTheDocument();
|
||||
expect(observed!.disconnected).toBe(true);
|
||||
});
|
||||
|
||||
it("never loads the scene when shouldUse3D is false", () => {
|
||||
webgl.shouldUse3D.mockReturnValue(false);
|
||||
const { container } = renderSlot();
|
||||
if (observed) enterViewport();
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("passes WebGL support, motion level, cores and forced colors to shouldUse3D", () => {
|
||||
window.localStorage.setItem("apicore.motion", "subtle");
|
||||
renderSlot();
|
||||
enterViewport();
|
||||
expect(webgl.shouldUse3D).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ webgl: true, motion: "subtle", forcedColors: false }),
|
||||
);
|
||||
expect(typeof webgl.shouldUse3D.mock.calls.at(-1)![0].cores).toBe("number");
|
||||
});
|
||||
|
||||
it("keeps the SVG visible until the scene is ready, then cross-fades (400 ms at full)", () => {
|
||||
renderSlot();
|
||||
enterViewport();
|
||||
expect(canvasLayer().style.opacity).toBe("0");
|
||||
fireEvent.click(screen.getByText("ready"));
|
||||
expect(canvasLayer().style.opacity).toBe("1");
|
||||
expect(canvasLayer().style.transitionDuration).toBe("400ms");
|
||||
});
|
||||
|
||||
it("fades in 150 ms at subtle and instantly at off", () => {
|
||||
window.localStorage.setItem("apicore.motion", "subtle");
|
||||
const subtle = renderSlot();
|
||||
enterViewport();
|
||||
fireEvent.click(screen.getByText("ready"));
|
||||
expect(canvasLayer().style.transitionDuration).toBe("150ms");
|
||||
subtle.unmount();
|
||||
|
||||
window.localStorage.setItem("apicore.motion", "off");
|
||||
renderSlot();
|
||||
enterViewport();
|
||||
fireEvent.click(screen.getByText("ready"));
|
||||
expect(canvasLayer().style.transitionDuration).toBe("0ms");
|
||||
});
|
||||
|
||||
it("lets the SVG keep the pointer until the scene is ready, then makes the scene interactive", () => {
|
||||
renderSlot();
|
||||
enterViewport();
|
||||
expect(canvasLayer()).toHaveClass("pointer-events-none");
|
||||
fireEvent.click(screen.getByText("ready"));
|
||||
expect(canvasLayer()).not.toHaveClass("pointer-events-none");
|
||||
});
|
||||
|
||||
it("remounts the scene once after the first context loss", () => {
|
||||
renderSlot();
|
||||
enterViewport();
|
||||
expect(canvasMounts.count).toBe(1);
|
||||
fireEvent.click(screen.getByText("lose"));
|
||||
expect(screen.getByTestId("canvas")).toBeInTheDocument();
|
||||
expect(canvasMounts.count).toBe(2);
|
||||
expect(canvasLayer().style.opacity).toBe("0");
|
||||
});
|
||||
|
||||
it("stays on the SVG after the second context loss", () => {
|
||||
const { container } = renderSlot();
|
||||
enterViewport();
|
||||
fireEvent.click(screen.getByText("lose"));
|
||||
fireEvent.click(screen.getByText("lose"));
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { HiveLayout } from "@/lib/hive/layout";
|
||||
import type { HiveCatalog } from "@/lib/hive/types";
|
||||
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
|
||||
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
|
||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||
import { HiveSvg } from "./hive-svg";
|
||||
|
||||
// three, fiber and friends live only in this chunk, fetched when the card is on screen.
|
||||
const HiveCanvas = dynamic(() => import("./hive-scene/hive-canvas"), { ssr: false });
|
||||
|
||||
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: 150, off: 0 };
|
||||
const FORCED_COLORS_QUERY = "(forced-colors: active)";
|
||||
/** First context loss remounts the scene once, the second keeps the SVG for good. */
|
||||
const MAX_CONTEXT_LOSSES = 1;
|
||||
|
||||
export interface HiveVisualSlotProps {
|
||||
layout: HiveLayout;
|
||||
/** The state the layout was made from (levels and choices for the 3D content). */
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
partName: (id: string) => string;
|
||||
onSelectPart: (id: string) => void;
|
||||
onSelectFrame: (boxId: string, slot: number) => void;
|
||||
}
|
||||
|
||||
function allows3D(motion: MotionLevel): boolean {
|
||||
return shouldUse3D({
|
||||
webgl: canUseWebGL(),
|
||||
motion,
|
||||
cores: navigator.hardwareConcurrency ?? 0,
|
||||
forcedColors: window.matchMedia?.(FORCED_COLORS_QUERY).matches ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of the hive. L2 shows the SVG; the 3D scene (with the SVG as
|
||||
* fallback) replaces this body without changing the props.
|
||||
* The model of the hive. Shows the SVG at once; when the card is visible and
|
||||
* the device allows it, the 3D scene loads and cross-fades over it and takes
|
||||
* the pointer. Any failure to keep a context leaves the SVG, which is always
|
||||
* rendered below.
|
||||
*/
|
||||
export function HiveVisualSlot({ layout, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
|
||||
return <HiveSvg layout={layout} partName={partName} onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} />;
|
||||
export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
|
||||
const motion = useEffectiveMotionLevel();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [losses, setLosses] = useState(0);
|
||||
const [readyKey, setReadyKey] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || typeof IntersectionObserver === "undefined") return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const show3D = visible && losses <= MAX_CONTEXT_LOSSES && allows3D(motion);
|
||||
const ready = readyKey === losses;
|
||||
const onReady = useCallback(() => setReadyKey(losses), [losses]);
|
||||
const onContextLost = useCallback(() => setLosses((count) => count + 1), []);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative h-full w-full">
|
||||
<HiveSvg layout={layout} partName={partName} onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} />
|
||||
{show3D ? (
|
||||
<div
|
||||
// Until the first frame is up the SVG below keeps the pointer.
|
||||
className={`absolute inset-0 print:hidden ${ready ? "" : "pointer-events-none"}`}
|
||||
data-testid="hive-3d-layer"
|
||||
style={{
|
||||
opacity: ready ? 1 : 0,
|
||||
transitionProperty: "opacity",
|
||||
transitionDuration: `${FADE_MS[motion]}ms`,
|
||||
}}
|
||||
>
|
||||
<HiveCanvas
|
||||
key={losses}
|
||||
layout={layout}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
partName={partName}
|
||||
onSelectPart={onSelectPart}
|
||||
onSelectFrame={onSelectFrame}
|
||||
onReady={onReady}
|
||||
onContextLost={onContextLost}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,11 @@ interface HarnessProps {
|
||||
initialState?: HiveState;
|
||||
submit?: (ops: HiveOp[]) => Promise<unknown>;
|
||||
startWithEvaporator?: boolean;
|
||||
startWithFrameFeeder?: boolean;
|
||||
}
|
||||
|
||||
/** Applies submitted ops to its own state, like the optimistic cache does. */
|
||||
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) {
|
||||
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator, startWithFrameFeeder }: HarnessProps) {
|
||||
const [state, setState] = useState(initialState);
|
||||
const [open, setOpen] = useState(true);
|
||||
const outlineRef = useRef<HiveOutlineHandle>(null);
|
||||
@@ -62,6 +63,7 @@ function Harness({ partId, initialState = baseState(), submit, startWithEvaporat
|
||||
submit={send}
|
||||
outlineRef={outlineRef}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
startWithFrameFeeder={startWithFrameFeeder}
|
||||
/>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
@@ -329,4 +331,99 @@ describe("PartSheet", () => {
|
||||
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame feeder", () => {
|
||||
const withPocket = (): HiveState => {
|
||||
const s = baseState();
|
||||
s.stack[1] = part("b", "box", {
|
||||
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
|
||||
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 0.5 } })],
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
it("inserts a frame feeder: setFrames clears the covered slots, addInsert hangs it", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
await user.click(screen.getByRole("radio", { name: "Plätze 3–4" }));
|
||||
expect(screen.getByText(/werden zu „leer“/)).toBeInTheDocument();
|
||||
const syrup = within(dialog()).getByLabelText("Flüssigfutter");
|
||||
await user.clear(syrup);
|
||||
await user.type(syrup, "1,5");
|
||||
await user.click(save());
|
||||
const expectedFrames = frames(10).map((f) => (f.slot === 3 || f.slot === 4 ? { ...f, type: "NONE" } : f));
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "setFrames", boxId: "b", frames: expectedFrames },
|
||||
{
|
||||
op: "addInsert",
|
||||
hostId: "b",
|
||||
part: { id: expect.any(String), partTypeId: "frame-feeder", state: { syrupLiters: 1.5 }, slot: 3 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips setFrames when the covered slots are already empty", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
const state = baseState();
|
||||
state.stack[1] = part("b", "box", {
|
||||
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
|
||||
});
|
||||
render(<Harness partId="b" initialState={state} submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
expect(screen.queryByText(/werden zu „leer“/)).toBeNull();
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "addInsert", hostId: "b", part: expect.objectContaining({ partTypeId: "frame-feeder", slot: 1 }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers only positions where the slots are still free", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" initialState={withPocket()} />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
expect(screen.queryByRole("radio", { name: "Plätze 1–2" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Plätze 2–3" })).toBeNull();
|
||||
expect(screen.getByRole("radio", { name: "Plätze 3–4" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preselects the frame feeder when opened from the outline action", () => {
|
||||
render(<Harness partId="b" startWithFrameFeeder />);
|
||||
expect(screen.getByRole("radio", { name: "Futtertasche einsetzen" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("moves focus to the new pocket item after inserting", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="b" />);
|
||||
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /^F1–2 · Futtertasche/ })).toHaveFocus());
|
||||
});
|
||||
|
||||
it("edits the pocket level with a stepper", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
|
||||
expect(screen.getByRole("dialog", { name: "Futtertasche" })).toBeInTheDocument();
|
||||
await user.click(within(dialog()).getByRole("button", { name: /Flüssigfutter erhöhen/ }));
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ff", key: "syrupLiters", value: 0.75 }]);
|
||||
});
|
||||
|
||||
it("asks before removing the pocket, then removes it and focuses the box", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
|
||||
await user.click(screen.getByRole("button", { name: "Futtertasche entfernen" }));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("group", { name: "Futtertasche wirklich entfernen?" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Ja, entfernen" }));
|
||||
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ff" }]);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,12 @@ import type { RefObject } from "react";
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { freeFeederStarts } from "@/lib/hive/feeder-slots";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||
import { FrameFeederPicker, frameFeederOps } from "./frame-feeder-picker";
|
||||
import type { FrameFeederChoice } from "./frame-feeder-picker";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import {
|
||||
@@ -25,6 +28,11 @@ const INSERT_OPTIONS = [
|
||||
{ value: true, label: "drin" },
|
||||
{ value: false, label: "draußen" },
|
||||
];
|
||||
// Inserts the sheet can remove, with the noun used in its buttons.
|
||||
const REMOVABLE_NOUNS: Partial<Record<HivePartKind, string>> = {
|
||||
EVAPORATOR: "Verdunster",
|
||||
FRAME_FEEDER: "Futtertasche",
|
||||
};
|
||||
const EVAPORATOR_OPTIONS = [
|
||||
{ value: false, label: "Kein Verdunster" },
|
||||
{ value: true, label: "Verdunster einsetzen" },
|
||||
@@ -42,6 +50,8 @@ export interface PartSheetProps {
|
||||
outlineRef: RefObject<HiveOutlineHandle | null>;
|
||||
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
|
||||
startWithEvaporator?: boolean;
|
||||
/** Opens a box with the frame feeder already chosen (the outline's „Futtertasche einsetzen“ action). */
|
||||
startWithFrameFeeder?: boolean;
|
||||
}
|
||||
|
||||
interface Located {
|
||||
@@ -78,18 +88,26 @@ interface PartFormProps {
|
||||
type: CatalogPartType;
|
||||
catalog: HiveCatalog;
|
||||
startWithEvaporator: boolean;
|
||||
startWithFrameFeeder: boolean;
|
||||
submit: SubmitOps;
|
||||
/** `focusId` is the part whose item should get focus once the sheet is closed. */
|
||||
onDone: (focusId: string) => void;
|
||||
}
|
||||
|
||||
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
|
||||
function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameFeeder, submit, onDone }: PartFormProps) {
|
||||
const { part, host } = located;
|
||||
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
|
||||
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
|
||||
const evaporatorType =
|
||||
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
|
||||
|
||||
const frameFeederType = type.kind === "BOX" ? activeTypeOf(catalog, "FRAME_FEEDER") : undefined;
|
||||
const feederStarts = frameFeederType ? freeFeederStarts(part, type, frameFeederType, catalog) : [];
|
||||
const [feederChoice, setFeederChoice] = useState<FrameFeederChoice>(() => ({
|
||||
wants: startWithFrameFeeder && feederStarts.length > 0,
|
||||
start: feederStarts[0] ?? 1,
|
||||
draft: frameFeederType ? draftOf(frameFeederType, {}) : {},
|
||||
}));
|
||||
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
|
||||
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
|
||||
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
|
||||
@@ -108,8 +126,13 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
wasConfirming.current = confirmingRemoval;
|
||||
}, [confirmingRemoval]);
|
||||
const { send, pending, error } = useSheetSubmit(submit);
|
||||
const removalNoun = REMOVABLE_NOUNS[type.kind] ?? null;
|
||||
// Fixed per form, so a retry after an error sends the same ids.
|
||||
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
|
||||
const [newIds] = useState(() => ({
|
||||
floorInsert: newIdempotencyKey(),
|
||||
evaporator: newIdempotencyKey(),
|
||||
frameFeeder: newIdempotencyKey(),
|
||||
}));
|
||||
|
||||
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
|
||||
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
|
||||
@@ -124,19 +147,29 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
}
|
||||
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
|
||||
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
|
||||
const feederToInsert =
|
||||
frameFeederType && feederChoice.wants && feederStarts.includes(feederChoice.start)
|
||||
? newPart(newIds.frameFeeder, frameFeederType, feederChoice.draft)
|
||||
: null;
|
||||
if (frameFeederType && feederToInsert) {
|
||||
ops.push(...frameFeederOps(part.id, part.frames ?? [], frameFeederType, feederChoice.start, feederToInsert));
|
||||
}
|
||||
|
||||
const valid =
|
||||
areDraftFieldsValid(type, draft) &&
|
||||
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
|
||||
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft)) &&
|
||||
(!frameFeederType || !feederChoice.wants || areDraftFieldsValid(frameFeederType, feederChoice.draft));
|
||||
|
||||
async function save() {
|
||||
// After inserting an evaporator, focus goes to the new item.
|
||||
// After inserting an evaporator or a frame feeder, focus goes to the new item.
|
||||
const outcome = await send(ops);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
|
||||
if (outcome !== "failed") {
|
||||
onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? feederToInsert?.id ?? part.id) : part.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
// An evaporator is an insert: focus goes to its box afterwards.
|
||||
// An evaporator or frame feeder is an insert: focus goes to its box afterwards.
|
||||
const outcome = await send([{ op: "removePart", id: part.id }]);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
|
||||
}
|
||||
@@ -197,6 +230,16 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{frameFeederType && feederStarts.length > 0 ? (
|
||||
<FrameFeederPicker
|
||||
boxId={part.id}
|
||||
type={frameFeederType}
|
||||
frames={part.frames ?? []}
|
||||
starts={feederStarts}
|
||||
choice={feederChoice}
|
||||
onChange={setFeederChoice}
|
||||
/>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
@@ -206,7 +249,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
{type.kind === "EVAPORATOR" ? (
|
||||
{removalNoun ? (
|
||||
confirmingRemoval ? (
|
||||
<div
|
||||
role="group"
|
||||
@@ -214,7 +257,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
|
||||
>
|
||||
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
|
||||
Verdunster wirklich entfernen?
|
||||
{removalNoun} wirklich entfernen?
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
|
||||
@@ -233,7 +276,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
size="touch"
|
||||
onClick={() => setConfirmingRemoval(true)}
|
||||
>
|
||||
Verdunster entfernen
|
||||
{removalNoun} entfernen
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
@@ -255,6 +298,7 @@ export function PartSheet({
|
||||
submit,
|
||||
outlineRef,
|
||||
startWithEvaporator = false,
|
||||
startWithFrameFeeder = false,
|
||||
}: PartSheetProps) {
|
||||
const located = locate(state, partId);
|
||||
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
|
||||
@@ -279,6 +323,7 @@ export function PartSheet({
|
||||
type={type}
|
||||
catalog={catalog}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
startWithFrameFeeder={startWithFrameFeeder}
|
||||
submit={submit}
|
||||
onDone={(id) => {
|
||||
focusAfterSave.current = id;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
|
||||
describe("createHiveViewStore", () => {
|
||||
it("starts empty and keeps the same state object until something changes", () => {
|
||||
const store = createHiveViewStore();
|
||||
const first = store.getState();
|
||||
expect(first).toEqual({ hoveredId: null, openBoxId: null, hoveredFrame: null });
|
||||
expect(first).toEqual({ hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null });
|
||||
store.setHovered(null);
|
||||
expect(store.getState()).toBe(first);
|
||||
store.setHovered("a");
|
||||
@@ -42,6 +42,27 @@ describe("createHiveViewStore", () => {
|
||||
expect(store.getState()).toMatchObject({ openBoxId: null, hoveredFrame: null });
|
||||
});
|
||||
|
||||
it("focusPart focuses a non-box part and closes an open box", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b");
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
store.focusPart("fe");
|
||||
expect(store.getState()).toMatchObject({ openBoxId: null, focusedPartId: "fe", hoveredFrame: null });
|
||||
expect(focusedIdOf(store.getState())).toBe("fe");
|
||||
});
|
||||
|
||||
it("openBox replaces a focused part, openBox(null) leaves focus mode completely", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.focusPart("fe");
|
||||
store.openBox("b");
|
||||
expect(store.getState()).toMatchObject({ openBoxId: "b", focusedPartId: null });
|
||||
expect(focusedIdOf(store.getState())).toBe("b");
|
||||
store.focusPart("fe");
|
||||
store.openBox(null);
|
||||
expect(store.getState()).toMatchObject({ openBoxId: null, focusedPartId: null });
|
||||
expect(focusedIdOf(store.getState())).toBeNull();
|
||||
});
|
||||
|
||||
it("treats an equal frame as no change", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
@@ -63,6 +84,8 @@ describe("useHiveView", () => {
|
||||
expect(result.current.hoveredId).toBe("x");
|
||||
act(() => result.current.openBox("b"));
|
||||
expect(result.current.openBoxId).toBe("b");
|
||||
act(() => result.current.focusPart("fe"));
|
||||
expect(result.current.focusedPartId).toBe("fe");
|
||||
});
|
||||
|
||||
it("throws outside a provider", () => {
|
||||
|
||||
@@ -13,22 +13,36 @@ export interface HoveredFrame {
|
||||
|
||||
export interface HiveViewState {
|
||||
hoveredId: string | null;
|
||||
/** The box in focus mode (its frames are shown). */
|
||||
openBoxId: string | null;
|
||||
/** Any other part in focus mode (feeder, floor, lid, insert …); never set together with openBoxId. */
|
||||
focusedPartId: string | null;
|
||||
hoveredFrame: HoveredFrame | null;
|
||||
}
|
||||
|
||||
/** The part in focus mode, box or not; null in the rest view. */
|
||||
export function focusedIdOf(state: HiveViewState): string | null {
|
||||
return state.openBoxId ?? state.focusedPartId;
|
||||
}
|
||||
|
||||
export interface HiveViewStore {
|
||||
getState(): HiveViewState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
setHovered(id: string | null): void;
|
||||
/** Opens a box (clears the hovered frame); null closes it and clears the hovered frame too. */
|
||||
/**
|
||||
* Opens a box (clears the hovered frame and any focused part); null leaves
|
||||
* focus mode completely (no open box, no focused part, no hovered frame).
|
||||
*/
|
||||
openBox(id: string | null): void;
|
||||
/** Focuses a part that is not a box (closes an open box, clears the hovered frame). */
|
||||
focusPart(id: string): void;
|
||||
setHoveredFrame(frame: HoveredFrame | null): void;
|
||||
}
|
||||
|
||||
export interface HiveView extends HiveViewState {
|
||||
setHovered(id: string | null): void;
|
||||
openBox(id: string | null): void;
|
||||
focusPart(id: string): void;
|
||||
setHoveredFrame(frame: HoveredFrame | null): void;
|
||||
}
|
||||
|
||||
@@ -36,7 +50,7 @@ const sameFrame = (a: HoveredFrame | null, b: HoveredFrame | null): boolean =>
|
||||
a === b || (a !== null && b !== null && a.boxId === b.boxId && a.slot === b.slot);
|
||||
|
||||
export function createHiveViewStore(): HiveViewStore {
|
||||
let state: HiveViewState = { hoveredId: null, openBoxId: null, hoveredFrame: null };
|
||||
let state: HiveViewState = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// The state object only changes when a value really changes, so
|
||||
@@ -46,6 +60,7 @@ export function createHiveViewStore(): HiveViewStore {
|
||||
if (
|
||||
next.hoveredId === state.hoveredId &&
|
||||
next.openBoxId === state.openBoxId &&
|
||||
next.focusedPartId === state.focusedPartId &&
|
||||
sameFrame(next.hoveredFrame, state.hoveredFrame)
|
||||
) {
|
||||
return;
|
||||
@@ -63,7 +78,8 @@ export function createHiveViewStore(): HiveViewStore {
|
||||
};
|
||||
},
|
||||
setHovered: (id) => update({ hoveredId: id }),
|
||||
openBox: (id) => update({ openBoxId: id, hoveredFrame: null }),
|
||||
openBox: (id) => update({ openBoxId: id, focusedPartId: null, hoveredFrame: null }),
|
||||
focusPart: (id) => update({ openBoxId: null, focusedPartId: id, hoveredFrame: null }),
|
||||
setHoveredFrame: (frame) => update({ hoveredFrame: frame }),
|
||||
};
|
||||
}
|
||||
@@ -83,6 +99,7 @@ export function useHiveView(): HiveView {
|
||||
...state,
|
||||
setHovered: store.setHovered,
|
||||
openBox: store.openBox,
|
||||
focusPart: store.focusPart,
|
||||
setHoveredFrame: store.setHoveredFrame,
|
||||
}),
|
||||
[state, store],
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface HivePartTypeInput {
|
||||
heightMm: number;
|
||||
boxDepth?: HiveBoxDepth;
|
||||
frameSlots?: number;
|
||||
/** Only FRAME_FEEDER: frame slots the pocket covers (1-5). */
|
||||
slotWidth?: number;
|
||||
stateSchema?: StateField[];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { StateField } from "./types";
|
||||
export const MAX_DIMENSION_MM = 5000;
|
||||
/** Upper bound of `frameSlots` in the backend DTO. */
|
||||
export const MAX_FRAME_SLOTS = 20;
|
||||
/** Upper bound of `slotWidth` (frame feeder) in the backend DTO. */
|
||||
export const MAX_SLOT_WIDTH = 5;
|
||||
export const MAX_KEY_LENGTH = 40;
|
||||
export const MAX_FIELD_LABEL_LENGTH = 60;
|
||||
export const MAX_UNIT_LENGTH = 10;
|
||||
|
||||
@@ -20,6 +20,7 @@ export function catalogFromSystem(system: HiveSystemDto): HiveCatalog {
|
||||
widthMm: row.widthMm,
|
||||
heightMm: row.heightMm,
|
||||
frameSlots: row.frameSlots,
|
||||
slotWidth: row.slotWidth ?? null,
|
||||
stateSchema: row.stateSchema,
|
||||
active: row.active,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { feederRange, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
|
||||
import { frames, inlineCatalog, part } from "./test-utils";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const boxType = catalog.partTypes.get("box")!;
|
||||
const feederType = catalog.partTypes.get("frame-feeder")!;
|
||||
|
||||
describe("feeder slots", () => {
|
||||
it("reads the covered range from slot and slotWidth", () => {
|
||||
expect(feederRange(part("p", "frame-feeder", { slot: 3 }), feederType)).toEqual({ from: 3, to: 4 });
|
||||
expect(feederRange(part("p", "frame-feeder"), feederType)).toBeNull();
|
||||
expect(feederRange(part("e", "evaporator", { slot: 1 }), catalog.partTypes.get("evaporator"))).toBeNull();
|
||||
});
|
||||
|
||||
it("offers every start in an empty box", () => {
|
||||
const box = part("b", "box", { frames: frames(10) });
|
||||
expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
it("leaves out starts that overlap a pocket", () => {
|
||||
const box = part("b", "box", { frames: frames(10), inserts: [part("p", "frame-feeder", { slot: 4 })] });
|
||||
expect(pocketRanges(box, catalog)).toEqual([{ from: 4, to: 5 }]);
|
||||
expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
it("offers nothing in a box without slots", () => {
|
||||
const empty = { ...boxType, frameSlots: 0 };
|
||||
expect(freeFeederStarts(part("b", "box"), empty, feederType, catalog)).toEqual([]);
|
||||
});
|
||||
|
||||
it("words a range", () => {
|
||||
expect(rangeText({ from: 1, to: 2 })).toBe("1–2");
|
||||
expect(rangeText({ from: 3, to: 3 })).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CatalogPartType, HiveCatalog, HivePart } from "./types";
|
||||
|
||||
// Frame feeder (Futtertasche) positions in a box. A pocket hangs in place of
|
||||
// `slotWidth` frames from its `slot` on (spec 4.1); the server rule is
|
||||
// RULE_FEEDER_SLOT.
|
||||
|
||||
export interface SlotRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** Slots a frame feeder insert covers; null without a usable slot or width. */
|
||||
export function feederRange(insert: HivePart, type: CatalogPartType | undefined): SlotRange | null {
|
||||
if (type?.kind !== "FRAME_FEEDER") return null;
|
||||
const width = type.slotWidth;
|
||||
if (typeof insert.slot !== "number" || !width) return null;
|
||||
return { from: insert.slot, to: insert.slot + width - 1 };
|
||||
}
|
||||
|
||||
/** Ranges of the frame feeders already hanging in a box. */
|
||||
export function pocketRanges(box: HivePart, catalog: HiveCatalog): SlotRange[] {
|
||||
return (box.inserts ?? []).flatMap((insert) => {
|
||||
const range = feederRange(insert, catalog.partTypes.get(insert.partTypeId));
|
||||
return range ? [range] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** First slots where a new frame feeder of `feederType` still fits (inside the box, no overlap). */
|
||||
export function freeFeederStarts(
|
||||
box: HivePart,
|
||||
boxType: CatalogPartType,
|
||||
feederType: CatalogPartType,
|
||||
catalog: HiveCatalog,
|
||||
): number[] {
|
||||
const width = feederType.slotWidth ?? 0;
|
||||
const slots = boxType.frameSlots ?? 0;
|
||||
if (width < 1) return [];
|
||||
const taken = pocketRanges(box, catalog);
|
||||
const starts: number[] = [];
|
||||
for (let from = 1; from + width - 1 <= slots; from++) {
|
||||
const to = from + width - 1;
|
||||
if (!taken.some((range) => from <= range.to && to >= range.from)) starts.push(from);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
/** „1–2“ for a range, „3“ for a single slot. */
|
||||
export function rangeText(range: SlotRange): string {
|
||||
return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`;
|
||||
}
|
||||
@@ -114,4 +114,40 @@ describe("hiveFollowUps", () => {
|
||||
{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("frame feeder", () => {
|
||||
function withPocket(syrup?: number): HiveState {
|
||||
const state = templateState();
|
||||
const frames = state.stack[1].frames!;
|
||||
frames[0] = { slot: 1, type: "NONE" };
|
||||
frames[1] = { slot: 2, type: "NONE" };
|
||||
state.stack[1].inserts = [
|
||||
{ ...part("ff", "frame-feeder", { slot: 1 }), state: syrup === undefined ? {} : { syrupLiters: syrup } },
|
||||
];
|
||||
return state;
|
||||
}
|
||||
|
||||
it("suggests a feeding when the pocket level rises", () => {
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 1.5 }];
|
||||
expect(run(withPocket(), ops)).toEqual([
|
||||
{ kind: "feeding", amount: 1.5, unit: "l", feedType: "SUGAR_WATER" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suggests nothing when the pocket level falls", () => {
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 0.5 }];
|
||||
expect(run(withPocket(1.5), ops)).toEqual([]);
|
||||
});
|
||||
|
||||
it("suggests the filled amount for a pocket added already filled", () => {
|
||||
const before = templateState();
|
||||
const frames = before.stack[1].frames!;
|
||||
frames[0] = { slot: 1, type: "NONE" };
|
||||
frames[1] = { slot: 2, type: "NONE" };
|
||||
const filled = { ...part("ff", "frame-feeder", { slot: 1 }), state: { syrupLiters: 1 } };
|
||||
expect(run(before, [{ op: "addInsert", hostId: "b", part: filled }])).toEqual([
|
||||
{ kind: "feeding", amount: 1, unit: "l", feedType: "SUGAR_WATER" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,9 @@ export type FollowUp =
|
||||
|
||||
const FEED_TYPES: readonly FeedType[] = ["SUGAR_WATER", "FEED_SYRUP", "FEED_PASTE", "OWN_HONEY", "OTHER"];
|
||||
const ACID_KEY = "acidMl";
|
||||
|
||||
/** Part kinds that hold liquid or paste feed (stack feeder and frame feeder pocket). */
|
||||
const isFeedKind = (kind: string | undefined): boolean => kind === "FEEDER" || kind === "FRAME_FEEDER";
|
||||
const ROUNDING = 1000;
|
||||
|
||||
function findPart(state: HiveState, id: string): HivePart | undefined {
|
||||
@@ -89,13 +92,13 @@ export function hiveFollowUps(
|
||||
const was = findPart(before, op.id);
|
||||
const kind = typeOf(now, catalog)?.kind;
|
||||
if (now && kind === "EVAPORATOR" && op.key === ACID_KEY) followUps.push(treatmentFor(was, now));
|
||||
else if (now && kind === "FEEDER") followUps.push(feedingForPart(was, now, op.key, catalog));
|
||||
else if (now && isFeedKind(kind)) followUps.push(feedingForPart(was, now, op.key, catalog));
|
||||
} else if (op.op === "addInsert" || op.op === "addPart") {
|
||||
const now = findPart(after, op.part.id);
|
||||
const type = typeOf(now, catalog);
|
||||
if (now && type?.kind === "EVAPORATOR" && once(now.id, ACID_KEY)) {
|
||||
followUps.push(treatmentFor(findPart(before, now.id), now));
|
||||
} else if (now && type?.kind === "FEEDER") {
|
||||
} else if (now && type && isFeedKind(type.kind)) {
|
||||
for (const field of type.stateSchema) {
|
||||
if (field.kind === "level" && once(now.id, field.key)) {
|
||||
followUps.push(feedingForPart(findPart(before, now.id), now, field.key, catalog));
|
||||
|
||||
@@ -27,4 +27,5 @@ export const PART_KIND_LABELS: Record<HivePartKind, string> = {
|
||||
ROOF: "Dach",
|
||||
FLOOR_INSERT: "Bausperre",
|
||||
EVAPORATOR: "Verdunster",
|
||||
FRAME_FEEDER: "Futtertasche",
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hiveLayout } from "./layout";
|
||||
import { FLOOR_GRID_DEPTH, hiveLayout } from "./layout";
|
||||
import { inlineCatalog, part, templateState } from "./test-utils";
|
||||
|
||||
describe("hiveLayout", () => {
|
||||
@@ -49,14 +49,16 @@ describe("hiveLayout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the floor insert 5 mm above the floor bottom and the evaporator on the box bottom", () => {
|
||||
it("lays the floor insert on the floor's grid (40 mm below the floor top) and the evaporator on the box bottom", () => {
|
||||
const state = templateState();
|
||||
state.stack[0].inserts = [part("fi", "floor-insert")];
|
||||
state.stack[1].inserts = [part("ev", "evaporator")];
|
||||
const layout = hiveLayout(state, catalog);
|
||||
const insert = layout.inserts.find((i) => i.id === "fi")!;
|
||||
const evaporator = layout.inserts.find((i) => i.id === "ev")!;
|
||||
expect(insert.center[1]).toBeCloseTo(0.05, 9);
|
||||
// Floor top 1.2, grid 40 mm (0.4 units) below it, Bausperre 5 mm thick lying on the grid.
|
||||
expect(insert.center[1]).toBeCloseTo(1.2 - FLOOR_GRID_DEPTH + 0.025, 9);
|
||||
expect(FLOOR_GRID_DEPTH).toBeCloseTo(0.4, 9);
|
||||
expect(insert.kind).toBe("FLOOR_INSERT");
|
||||
expect(evaporator.center[1]).toBeCloseTo(1.2 + 0.6, 9);
|
||||
expect(evaporator.stackIndex).toBe(1);
|
||||
@@ -66,4 +68,27 @@ describe("hiveLayout", () => {
|
||||
const layout = hiveLayout({ schemaVersion: 1, stack: [] }, catalog);
|
||||
expect(layout).toEqual({ parts: [], inserts: [], frames: [], bounds: { w: 0, h: 0, d: 0 } });
|
||||
});
|
||||
|
||||
it("lays a frame feeder over its slots with the frame height", () => {
|
||||
const state = templateState();
|
||||
const frames = state.stack[1].frames!;
|
||||
frames[2] = { slot: 3, type: "NONE" };
|
||||
frames[3] = { slot: 4, type: "NONE" };
|
||||
state.stack[1].inserts = [part("ff", "frame-feeder", { slot: 3 })];
|
||||
const layout = hiveLayout(state, catalog);
|
||||
const pocket = layout.inserts.find((i) => i.id === "ff")!;
|
||||
const inner = 5.25 - 0.4;
|
||||
const pitch = inner / 10;
|
||||
const box = layout.parts[1];
|
||||
expect(pocket.kind).toBe("FRAME_FEEDER");
|
||||
expect(pocket.stackIndex).toBe(1);
|
||||
expect(pocket.size[0]).toBeCloseTo(2 * pitch, 9);
|
||||
expect(pocket.size[1]).toBeCloseTo(2.2, 9);
|
||||
expect(pocket.size[2]).toBeCloseTo(4.3 - 0.4, 9);
|
||||
expect(pocket.center[0]).toBeCloseTo(-inner / 2 + pitch * 3, 9);
|
||||
expect(pocket.center[1]).toBeCloseTo(box.center[1], 9);
|
||||
// The pocket sits exactly where frames 3 and 4 would hang.
|
||||
const covered = layout.frames.filter((f) => f.slot === 3 || f.slot === 4).map((f) => f.center[0]);
|
||||
expect(pocket.center[0]).toBeCloseTo((covered[0] + covered[1]) / 2, 9);
|
||||
});
|
||||
});
|
||||
@@ -37,10 +37,14 @@ const ROOF_OVERLAP_MM = 35;
|
||||
const BOX_WALL_MM = 20;
|
||||
const FRAME_THICKNESS_MM = 25;
|
||||
const FRAME_HEIGHT_MARGIN_MM = 10;
|
||||
const FLOOR_INSERT_LIFT_MM = 5;
|
||||
/** The stainless grid of the floor (Liebig Wanderboden) lies this far below its top edge. */
|
||||
const FLOOR_GRID_DEPTH_MM = 40;
|
||||
|
||||
const mm = (value: number): number => value / UNIT_MM;
|
||||
|
||||
/** Depth of the floor's grid below the floor's top edge, in layout units (shared with the 3D floor). */
|
||||
export const FLOOR_GRID_DEPTH = mm(FLOOR_GRID_DEPTH_MM);
|
||||
|
||||
function sizeOf(type: CatalogPartType): Vec3 {
|
||||
return [mm(type.lengthMm), mm(type.heightMm), mm(type.widthMm)];
|
||||
}
|
||||
@@ -52,11 +56,11 @@ function insertBox(
|
||||
): PartBox {
|
||||
const size = sizeOf(type);
|
||||
const hostBottom = host.center[1] - host.size[1] / 2;
|
||||
// FLOOR_INSERT lies just above the floor's bottom area (its centre y = floor
|
||||
// bottom + 5 mm, y is always a centre); every other insert
|
||||
// (the EVAPORATOR) stands on the bottom of the box it sits in.
|
||||
const hostTop = host.center[1] + host.size[1] / 2;
|
||||
// FLOOR_INSERT (Bausperre) lies on the floor's grid near its top edge; every
|
||||
// other insert (the EVAPORATOR) stands on the bottom of the box it sits in.
|
||||
const centerY =
|
||||
type.kind === "FLOOR_INSERT" ? hostBottom + mm(FLOOR_INSERT_LIFT_MM) : hostBottom + size[1] / 2;
|
||||
type.kind === "FLOOR_INSERT" ? hostTop - FLOOR_GRID_DEPTH + size[1] / 2 : hostBottom + size[1] / 2;
|
||||
return {
|
||||
id: insert.id,
|
||||
partTypeId: insert.partTypeId,
|
||||
@@ -67,22 +71,42 @@ function insertBox(
|
||||
};
|
||||
}
|
||||
|
||||
function frameBoxes(part: HivePart, type: CatalogPartType, box: PartBox): FrameBox[] {
|
||||
const frames = part.frames ?? [];
|
||||
if (frames.length === 0) return [];
|
||||
/** Frame grid of a box: x of the first slot's left edge, slot pitch, frame size. */
|
||||
function frameGrid(part: HivePart, type: CatalogPartType, box: PartBox): { left: number; pitch: number; size: Vec3 } | null {
|
||||
const count = part.frames?.length ?? 0;
|
||||
if (count === 0) return null;
|
||||
const inner = box.size[0] - 2 * mm(BOX_WALL_MM);
|
||||
const pitch = inner / frames.length;
|
||||
const size: Vec3 = [
|
||||
mm(FRAME_THICKNESS_MM),
|
||||
mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM),
|
||||
box.size[2] - 2 * mm(BOX_WALL_MM),
|
||||
];
|
||||
return {
|
||||
left: -inner / 2,
|
||||
pitch: inner / count,
|
||||
size: [mm(FRAME_THICKNESS_MM), mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM), box.size[2] - 2 * mm(BOX_WALL_MM)],
|
||||
};
|
||||
}
|
||||
|
||||
/** A frame feeder hangs where its slots' frames would: width = slotWidth x pitch, frame height. */
|
||||
function feederBox(insert: HivePart, type: CatalogPartType, host: PartBox, grid: ReturnType<typeof frameGrid>): PartBox | null {
|
||||
if (!grid || typeof insert.slot !== "number" || !type.slotWidth) return null;
|
||||
const width = grid.pitch * type.slotWidth;
|
||||
return {
|
||||
id: insert.id,
|
||||
partTypeId: insert.partTypeId,
|
||||
kind: type.kind,
|
||||
center: [grid.left + grid.pitch * (insert.slot - 1) + width / 2, host.center[1], 0],
|
||||
size: [width, grid.size[1], grid.size[2]],
|
||||
stackIndex: host.stackIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function frameBoxes(part: HivePart, box: PartBox, grid: ReturnType<typeof frameGrid>): FrameBox[] {
|
||||
const frames = part.frames ?? [];
|
||||
if (!grid) return [];
|
||||
const { left, pitch, size } = grid;
|
||||
return frames.map((frame, index) => ({
|
||||
boxId: part.id,
|
||||
slot: frame.slot,
|
||||
type: frame.type,
|
||||
...(frame.name !== undefined ? { name: frame.name } : {}),
|
||||
center: [-inner / 2 + pitch * (index + 0.5), box.center[1], 0],
|
||||
center: [left + pitch * (index + 0.5), box.center[1], 0],
|
||||
size,
|
||||
}));
|
||||
}
|
||||
@@ -116,11 +140,15 @@ export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout {
|
||||
width = Math.max(width, size[0]);
|
||||
depth = Math.max(depth, size[2]);
|
||||
|
||||
const grid = type.kind === "BOX" ? frameGrid(part, type, box) : null;
|
||||
for (const insert of part.inserts ?? []) {
|
||||
const insertType = catalog.partTypes.get(insert.partTypeId);
|
||||
if (insertType) inserts.push(insertBox(insert, insertType, box));
|
||||
if (!insertType) continue;
|
||||
const placed =
|
||||
insertType.kind === "FRAME_FEEDER" ? feederBox(insert, insertType, box, grid) : insertBox(insert, insertType, box);
|
||||
if (placed) inserts.push(placed);
|
||||
}
|
||||
if (type.kind === "BOX") frames.push(...frameBoxes(part, type, box));
|
||||
if (type.kind === "BOX") frames.push(...frameBoxes(part, box, grid));
|
||||
});
|
||||
|
||||
return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } };
|
||||
|
||||
@@ -8,6 +8,7 @@ const TEXTS: Record<string, string> = {
|
||||
FRAMES_LENGTH: "Dieses Rähmchen gibt es in der Zarge nicht.",
|
||||
RULE_INSERT_HOST: "Dieses Teil passt nicht an diese Stelle.",
|
||||
RULE_INSERT_LIMIT: "Davon passt nur eines an diese Stelle.",
|
||||
RULE_FEEDER_SLOT: "Die Futtertasche passt dort nicht hin.",
|
||||
RULE_FLOOR_BOTTOM: "Der Boden muss ganz unten liegen.",
|
||||
RULE_ROOF_TOP: "Das Dach muss ganz oben liegen.",
|
||||
STATE_OUT_OF_RANGE: "Dieser Wert ist nicht erlaubt.",
|
||||
|
||||
@@ -66,6 +66,7 @@ function type(
|
||||
widthMm,
|
||||
heightMm,
|
||||
frameSlots: null,
|
||||
slotWidth: null,
|
||||
stateSchema: noState,
|
||||
active: true,
|
||||
...extra,
|
||||
@@ -93,6 +94,12 @@ export function inlineCatalog(): HiveCatalog {
|
||||
}),
|
||||
type("insulation", "INSULATION", 465, 430, 20),
|
||||
type("roof", "ROOF", 525, 430, 60),
|
||||
type("frame-feeder", "FRAME_FEEDER", 477, 70, 220, {
|
||||
slotWidth: 2,
|
||||
stateSchema: [
|
||||
{ key: "syrupLiters", label: "Flüssigfutter", kind: "level", unit: "l", max: 2, step: 0.25, feedType: "SUGAR_WATER" },
|
||||
],
|
||||
}),
|
||||
type("evaporator", "EVAPORATOR", 200, 150, 120, {
|
||||
stateSchema: [{ key: "acidMl", label: "Säure", kind: "level", unit: "ml", max: 300, step: 10 }],
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,8 @@ export type HivePartKind =
|
||||
| "INSULATION"
|
||||
| "ROOF"
|
||||
| "FLOOR_INSERT"
|
||||
| "EVAPORATOR";
|
||||
| "EVAPORATOR"
|
||||
| "FRAME_FEEDER";
|
||||
|
||||
export type HiveBoxDepth = "FULL" | "SHALLOW";
|
||||
|
||||
@@ -61,6 +62,8 @@ export interface HivePart {
|
||||
state: Record<string, number | string>;
|
||||
inserts?: HivePart[];
|
||||
frames?: HiveFrame[];
|
||||
/** Only on FRAME_FEEDER inserts: first frame slot (1-based) the pocket covers. */
|
||||
slot?: number;
|
||||
}
|
||||
|
||||
export interface HiveState {
|
||||
@@ -112,6 +115,8 @@ export interface CatalogPartType {
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
frameSlots: number | null;
|
||||
/** Only FRAME_FEEDER: number of consecutive frame slots the pocket covers. */
|
||||
slotWidth: number | null;
|
||||
stateSchema: StateField[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
// STATE_OUT_OF_RANGE; extra root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per
|
||||
// stack part: id, unknown fields (UNKNOWN_FIELD), type, label, state, frames
|
||||
// (per frame: slot, unknown fields, type, name), inserts (each insert in the
|
||||
// same order), then FLOOR, then ROOF. Text lengths are UTF-16 code units
|
||||
// (`.length`).
|
||||
// same order; then per insert RULE_INSERT_HOST, nested inserts and `slot` /
|
||||
// RULE_FEEDER_SLOT; after all inserts of a box the FRAME_FEEDER overlap check,
|
||||
// also RULE_FEEDER_SLOT), then FLOOR, then ROOF. Text lengths are UTF-16 code
|
||||
// units (`.length`).
|
||||
const ROOT_KEYS = ["schemaVersion", "stack"];
|
||||
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames"];
|
||||
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames", "slot"];
|
||||
const FRAME_KEYS = ["slot", "type", "name"];
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
@@ -144,10 +146,70 @@ function validatePartCommon(
|
||||
return { part, type };
|
||||
}
|
||||
|
||||
function allowedInsertKind(hostType: CatalogPartType): string | null {
|
||||
if (hostType.kind === "FLOOR") return "FLOOR_INSERT";
|
||||
if (hostType.kind === "BOX") return "EVAPORATOR";
|
||||
return null;
|
||||
const INSERT_KINDS: Partial<Record<string, string[]>> = {
|
||||
FLOOR: ["FLOOR_INSERT"],
|
||||
BOX: ["EVAPORATOR", "FRAME_FEEDER"],
|
||||
};
|
||||
|
||||
const INSERT_ONLY_KINDS: string[] = ["FLOOR_INSERT", "EVAPORATOR", "FRAME_FEEDER"];
|
||||
|
||||
interface SlotRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RULE_FEEDER_SLOT checks for one insert: `slot` only on FRAME_FEEDER, an
|
||||
* integer inside 1..frameSlots for the covered width, and every covered frame
|
||||
* NONE. Returns the covered range for the overlap check, or null.
|
||||
*/
|
||||
function validateFeederSlot(
|
||||
insert: Obj,
|
||||
type: CatalogPartType,
|
||||
host: Obj,
|
||||
hostType: CatalogPartType,
|
||||
where: string,
|
||||
out: string[],
|
||||
): SlotRange | null {
|
||||
if (type.kind !== "FRAME_FEEDER") {
|
||||
if (insert.slot !== undefined) out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
|
||||
return null;
|
||||
}
|
||||
if (hostType.kind !== "BOX") return null;
|
||||
const slot = insert.slot;
|
||||
const width = type.slotWidth;
|
||||
const slots = hostType.frameSlots ?? 0;
|
||||
if (typeof slot !== "number" || !Number.isInteger(slot) || slot < 1) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} needs an integer slot of 1 or more`);
|
||||
return null;
|
||||
}
|
||||
if (width === null || width === undefined || !Number.isInteger(width) || width < 1) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} part type has no slot width`);
|
||||
return null;
|
||||
}
|
||||
const range = { from: slot, to: slot + width - 1 };
|
||||
if (range.to > slots) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} covers slots ${range.from}-${range.to} but the box has ${slots}`);
|
||||
return null;
|
||||
}
|
||||
const frames = Array.isArray(host.frames) ? (host.frames as Obj[]) : [];
|
||||
for (let at = range.from; at <= range.to; at++) {
|
||||
if (frames[at - 1]?.type !== "NONE") {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} covers frame ${at} which is not NONE`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
function validateFeederOverlap(ranges: SlotRange[], where: string, out: string[]): void {
|
||||
const sorted = [...ranges].sort((a, b) => a.from - b.from);
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i].from <= sorted[i - 1].to) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} has overlapping frame feeders`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ctx: WalkContext): void {
|
||||
@@ -158,25 +220,34 @@ function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ct
|
||||
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
|
||||
return;
|
||||
}
|
||||
const allowed = allowedInsertKind(hostType);
|
||||
const allowed = INSERT_KINDS[hostType.kind] ?? null;
|
||||
if (inserts.length > 0 && allowed === null) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
|
||||
}
|
||||
if (allowed !== null && inserts.length > 1) {
|
||||
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
|
||||
if (hostType.kind === "FLOOR" && inserts.length > 1) {
|
||||
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one FLOOR_INSERT`);
|
||||
}
|
||||
const ranges: SlotRange[] = [];
|
||||
let evaporators = 0;
|
||||
inserts.forEach((insert: unknown, index) => {
|
||||
const here = `${where}.inserts[${index}]`;
|
||||
const checked = validatePartCommon(insert, here, ctx);
|
||||
if (!checked) return;
|
||||
if (allowed !== null && checked.type.kind !== allowed) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
|
||||
if (checked.type.kind === "EVAPORATOR") {
|
||||
evaporators += 1;
|
||||
if (evaporators > 1) out.push(`RULE_INSERT_LIMIT: ${where} holds at most one EVAPORATOR`);
|
||||
}
|
||||
if (allowed !== null && !allowed.includes(checked.type.kind)) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed.join(" or ")}`);
|
||||
}
|
||||
const nested = checked.part.inserts;
|
||||
if (nested !== undefined && (!Array.isArray(nested) || nested.length > 0)) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
|
||||
}
|
||||
const range = validateFeederSlot(checked.part, checked.type, host, hostType, here, out);
|
||||
if (range) ranges.push(range);
|
||||
});
|
||||
validateFeederOverlap(ranges, where, out);
|
||||
}
|
||||
|
||||
function validateStackRules(kinds: (CatalogPartType | null)[], out: string[]): void {
|
||||
@@ -207,9 +278,12 @@ export function validateHiveState(state: unknown, catalog: HiveCatalog): string[
|
||||
const checked = validatePartCommon(entry, where, ctx);
|
||||
types.push(checked?.type ?? null);
|
||||
if (!checked) return;
|
||||
if (checked.type.kind === "FLOOR_INSERT" || checked.type.kind === "EVAPORATOR") {
|
||||
if (INSERT_ONLY_KINDS.includes(checked.type.kind)) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`);
|
||||
}
|
||||
if (checked.part.slot !== undefined) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
|
||||
}
|
||||
validateInserts(checked.part, checked.type, where, ctx);
|
||||
});
|
||||
validateStackRules(types, out);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { canUseWebGL, resetWebGLProbe, shouldUse3D } from "./webgl";
|
||||
|
||||
const base = { webgl: true, motion: "full", cores: 8, forcedColors: false } as const;
|
||||
|
||||
describe("shouldUse3D", () => {
|
||||
it("is true with WebGL and a normal setup", () => {
|
||||
expect(shouldUse3D(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false without WebGL", () => {
|
||||
expect(shouldUse3D({ ...base, webgl: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false with forced colors", () => {
|
||||
expect(shouldUse3D({ ...base, forcedColors: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false with motion off on a weak device (2 cores or fewer)", () => {
|
||||
expect(shouldUse3D({ ...base, motion: "off", cores: 2 })).toBe(false);
|
||||
expect(shouldUse3D({ ...base, motion: "off", cores: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps 3D with motion off on a stronger device", () => {
|
||||
expect(shouldUse3D({ ...base, motion: "off", cores: 4 })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps 3D on a weak device when motion is not off", () => {
|
||||
expect(shouldUse3D({ ...base, motion: "subtle", cores: 2 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canUseWebGL", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
it("is false when no context can be created (jsdom)", () => {
|
||||
expect(canUseWebGL()).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when a context is available, and probes only once", () => {
|
||||
const getContext = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||
.mockReturnValue({} as unknown as RenderingContext);
|
||||
expect(canUseWebGL()).toBe(true);
|
||||
expect(canUseWebGL()).toBe(true);
|
||||
expect(getContext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is false when creating the context throws", () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
expect(canUseWebGL()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||
|
||||
/** At or below this many cores, motion `off` keeps the card on the SVG (spec 9.7). */
|
||||
const WEAK_DEVICE_CORES = 2;
|
||||
|
||||
let probed: boolean | undefined;
|
||||
|
||||
/** Whether a WebGL context can be created here. Probed once, then cached. */
|
||||
export function canUseWebGL(): boolean {
|
||||
if (probed !== undefined) return probed;
|
||||
if (typeof document === "undefined") return false;
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
probed = Boolean(canvas.getContext("webgl2") ?? canvas.getContext("webgl"));
|
||||
} catch {
|
||||
probed = false;
|
||||
}
|
||||
return probed;
|
||||
}
|
||||
|
||||
/** Test hook: forget the cached probe result. */
|
||||
export function resetWebGLProbe(): void {
|
||||
probed = undefined;
|
||||
}
|
||||
|
||||
export interface Use3DInput {
|
||||
webgl: boolean;
|
||||
motion: MotionLevel;
|
||||
cores: number;
|
||||
forcedColors: boolean;
|
||||
}
|
||||
|
||||
/** The card shows the 3D scene only when all of this holds; otherwise it stays on the SVG. */
|
||||
export function shouldUse3D({ webgl, motion, cores, forcedColors }: Use3DInput): boolean {
|
||||
if (!webgl || forcedColors) return false;
|
||||
return !(motion === "off" && cores <= WEAK_DEVICE_CORES);
|
||||
}
|
||||
Reference in new issue
Block a user