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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent c608f5d405
commit 6e13236d49
100 files changed
+9432 -209

No files matched your search

+710 -3
View File
@@ -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
}
}
}
}
}
+8
View File
@@ -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>
+4 -4
View File
@@ -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;
+1
View File
@@ -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" },
]);
});
});
});
+5 -2
View File
@@ -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));
+1
View File
@@ -27,4 +27,5 @@ export const PART_KIND_LABELS: Record<HivePartKind, string> = {
ROOF: "Dach",
FLOOR_INSERT: "Bausperre",
EVAPORATOR: "Verdunster",
FRAME_FEEDER: "Futtertasche",
};
+28 -3
View File
@@ -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);
});
});
+45 -17
View File
@@ -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 }],
}),
+6 -1
View File
@@ -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;
}
+87 -13
View File
@@ -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);
});
});
+37
View File
@@ -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);
}