diff --git a/apps/viewer/package-lock.json b/apps/viewer/package-lock.json index c71ee53..8ad73b2 100644 --- a/apps/viewer/package-lock.json +++ b/apps/viewer/package-lock.json @@ -8,12 +8,60 @@ "name": "space-game-viewer", "version": "0.1.0", "dependencies": { - "three": "^0.179.1" + "pinia": "^3.0.3", + "three": "^0.179.1", + "vue": "^3.5.21" }, "devDependencies": { + "@tailwindcss/vite": "^4.2.2", "@types/three": "^0.183.1", + "@vitejs/plugin-vue": "^6.0.1", + "tailwindcss": "^4.2.2", "typescript": "^5.9.2", - "vite": "^7.1.3" + "vite": "^7.1.3", + "vue-tsc": "^3.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@dimforge/rapier3d-compat": { @@ -438,6 +486,56 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -763,6 +861,263 @@ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -802,12 +1157,256 @@ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "dev": true }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz", + "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==" + }, "node_modules/@webgpu/types": { "version": "0.1.69", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", "dev": true }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -849,6 +1448,11 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -886,17 +1490,315 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/meshoptimizer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", "dev": true }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -910,11 +1812,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "4.0.3", @@ -928,11 +1840,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -956,6 +1887,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1004,11 +1940,48 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/three": { "version": "0.179.1", "resolved": "https://registry.npmjs.org/three/-/three-0.179.1.tgz", @@ -1034,7 +2007,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1116,6 +2089,48 @@ "optional": true } } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz", + "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } } } } diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 549cae3..dda30c0 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -5,15 +5,21 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.json && vite build", + "build": "vue-tsc -p tsconfig.json --noEmit && vite build", "preview": "vite preview" }, "dependencies": { - "three": "^0.179.1" + "pinia": "^3.0.3", + "three": "^0.179.1", + "vue": "^3.5.21" }, "devDependencies": { + "@tailwindcss/vite": "^4.2.2", "@types/three": "^0.183.1", + "@vitejs/plugin-vue": "^6.0.1", + "tailwindcss": "^4.2.2", "typescript": "^5.9.2", - "vite": "^7.1.3" + "vite": "^7.1.3", + "vue-tsc": "^3.0.7" } } diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue new file mode 100644 index 0000000..b4e1878 --- /dev/null +++ b/apps/viewer/src/App.vue @@ -0,0 +1,197 @@ + + + diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index a46cb03..022e59c 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -1,13 +1,27 @@ +import type { ViewerHudBindings } from "./viewerHudState"; +import type { Selectable, CameraMode } from "./viewerTypes"; import { ViewerAppController } from "./ViewerAppController"; export class GameViewer { private readonly controller: ViewerAppController; - constructor(container: HTMLElement) { - this.controller = new ViewerAppController(container); + constructor(container: HTMLElement, hud: ViewerHudBindings) { + this.controller = new ViewerAppController(container, hud); } async start() { await this.controller.start(); } + + focusSelection(selection: Selectable, cameraMode?: CameraMode) { + this.controller.focusSelection(selection, cameraMode); + } + + openHistoryWindow(selection: Selectable) { + this.controller.openHistoryWindow(selection); + } + + dispose() { + this.controller.dispose(); + } } diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts index 44d2bac..4f5088d 100644 --- a/apps/viewer/src/ViewerAppController.ts +++ b/apps/viewer/src/ViewerAppController.ts @@ -4,7 +4,6 @@ import { MIN_CAMERA_DISTANCE, NAV_DISTANCE, } from "./viewerConstants"; -import { createViewerHud } from "./viewerHud"; import { updatePanFromKeyboard } from "./viewerCamera"; import { setShellReticleOpacity } from "./viewerControls"; import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop"; @@ -21,16 +20,21 @@ import { ViewerNavigationController } from "./viewerNavigationController"; import { ViewerSceneDataController } from "./viewerSceneDataController"; import { ViewerPresentationController } from "./viewerPresentationController"; import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory"; +import { createViewerRenderer } from "./runtime/rendering/createViewerRenderer"; +import { disposeSceneResources } from "./runtime/rendering/disposeThreeResources"; +import { ViewerRenderSurface } from "./runtime/rendering/ViewerRenderSurface"; import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera"; import { UniverseLayer } from "./viewerUniverseLayer"; import { GalaxyLayer } from "./viewerGalaxyLayer"; import { SystemLayer } from "./viewerSystemLayer"; import { LocalLayer } from "./viewerLocalLayer"; +import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState"; +import { describeSelectable } from "./viewerSelection"; +import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection"; import type { FactionSnapshot } from "./contracts"; import type { CameraMode, DragMode, - HistoryWindowState, NetworkStats, PerformanceStats, Selectable, @@ -41,7 +45,8 @@ import type { export class ViewerAppController { private readonly container: HTMLElement; - private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); + private readonly renderer = createViewerRenderer(); + private readonly renderSurface: ViewerRenderSurface; // ── Three independent rendering layers ─────────────────────────────────── readonly universeLayer = new UniverseLayer(); @@ -61,23 +66,9 @@ export class ViewerAppController { private readonly cameraOffset = new THREE.Vector3(); private readonly keyState = new Set(); - private readonly gamePanelEl: HTMLDivElement; - - private readonly statusEl: HTMLDivElement; - private readonly gameSummaryEl: HTMLSpanElement; - private readonly systemPanelEl: HTMLDivElement; - private readonly systemTitleEl: HTMLHeadingElement; - private readonly systemBodyEl: HTMLDivElement; - private readonly detailTitleEl: HTMLHeadingElement; - private readonly detailBodyEl: HTMLDivElement; + readonly hudState: ViewerHudState; + readonly selectionStore: ViewerSelectionStore; private readonly opsStripEl: HTMLDivElement; - private readonly networkSectionEl: HTMLDivElement; - private readonly networkSummaryEl: HTMLSpanElement; - private readonly networkPanelEl: HTMLDivElement; - private readonly performanceSectionEl: HTMLDivElement; - private readonly performanceSummaryEl: HTMLSpanElement; - private readonly performancePanelEl: HTMLDivElement; - private readonly errorEl: HTMLDivElement; private readonly historyLayerEl: HTMLDivElement; private readonly marqueeEl: HTMLDivElement; private readonly hoverLabelEl: HTMLDivElement; @@ -124,30 +115,14 @@ export class ViewerAppController { private readonly navigationController: ViewerNavigationController; private readonly sceneDataController: ViewerSceneDataController; private readonly presentationController: ViewerPresentationController; + private readonly disposeEventBindings: () => void; + private readonly unsubscribeSelectionStore: () => void; - constructor(container: HTMLElement) { + constructor(container: HTMLElement, hud: ViewerHudBindings) { this.container = container; - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.renderer.outputColorSpace = THREE.SRGBColorSpace; - - - const hud = createViewerHud(document); - this.gamePanelEl = hud.gamePanelEl; - this.statusEl = hud.statusEl; - this.gameSummaryEl = hud.gameSummaryEl; - this.networkSectionEl = hud.networkSectionEl; - this.systemPanelEl = hud.systemPanelEl; - this.systemTitleEl = hud.systemTitleEl; - this.systemBodyEl = hud.systemBodyEl; - this.detailTitleEl = hud.detailTitleEl; - this.detailBodyEl = hud.detailBodyEl; + this.hudState = hud.state; + this.selectionStore = hud.selectionStore; this.opsStripEl = hud.opsStripEl; - this.networkSummaryEl = hud.networkSummaryEl; - this.networkPanelEl = hud.networkPanelEl; - this.performanceSectionEl = hud.performanceSectionEl; - this.performanceSummaryEl = hud.performanceSummaryEl; - this.performancePanelEl = hud.performancePanelEl; - this.errorEl = hud.errorEl; this.historyLayerEl = hud.historyLayerEl; this.marqueeEl = hud.marqueeEl; this.hoverLabelEl = hud.hoverLabelEl; @@ -160,33 +135,51 @@ export class ViewerAppController { interactionController: this.interactionController, } = createViewerControllers(this)); this.presentationController.initializeAmbience(); - - this.container.append(this.renderer.domElement, hud.root); - this.initializePanelToggles(); - wireViewerEvents(this); - this.onResize(); + this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => { + this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId); + }); + this.renderSurface = new ViewerRenderSurface({ + container: this.container, + renderer: this.renderer, + onFrame: () => this.render(), + onResize: (width, height) => this.onResize(width, height), + }); + this.disposeEventBindings = wireViewerEvents(this); this.updateCamera(0); } - private initializePanelToggles() { - for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) { - const toggle = panel.querySelector(".panel-toggle"); - if (!(toggle instanceof HTMLButtonElement)) { - continue; - } - - toggle.addEventListener("click", () => { - const collapsed = panel.classList.toggle("is-collapsed"); - toggle.textContent = collapsed ? "+" : "-"; - toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`); - }); - } + async start() { + this.selectionStore.clearSelection(); + await this.worldLifecycle.bootstrapWorld(); + this.renderSurface.start(); } - async start() { - await this.worldLifecycle.bootstrapWorld(); - this.renderer.setAnimationLoop(() => this.render()); + dispose() { + this.disposeEventBindings(); + this.unsubscribeSelectionStore(); + this.stream?.close(); + this.renderSurface.dispose(); + disposeSceneResources(this.universeLayer.scene); + disposeSceneResources(this.galaxyLayer.scene); + disposeSceneResources(this.systemLayer.scene); + disposeSceneResources(this.localLayer.scene); + } + + focusSelection(selection: Selectable, cameraMode?: CameraMode) { + this.applySelectedItems([selection], "ui"); + this.navigationController.focusOnSelection(selection); + if (cameraMode) { + this.interactionController.toggleCameraMode(cameraMode); + if (selection.kind === "ship" && cameraMode === "follow") { + this.desiredDistance = 0.00018; + } + } + this.updatePanels(); + this.updateGamePanel("Live"); + } + + openHistoryWindow(selection: Selectable) { + this.interactionController.openHistoryWindow(selection); } private refreshStreamScopeIfNeeded() { @@ -214,6 +207,32 @@ export class ViewerAppController { this.worldLifecycle.updatePanels(); } + private applySelectedItems(items: Selectable[], source: "viewer" | "ui") { + this.selectedItems = items; + if (items.length === 1) { + const selection = items[0]; + this.selectionStore.selectSelection({ + id: selectionToEntityId(selection), + kind: selection.kind, + label: describeSelectable(this.world, selection), + }, source); + return; + } + + this.selectionStore.clearSelection(source); + } + + private syncSelectionFromStore( + kind: Selectable["kind"] | null, + entityId: string | null, + ) { + const selection = entityIdToSelectable(kind, entityId); + this.selectedItems = selection ? [selection] : []; + this.navigationController.syncFollowStateFromSelection(); + this.updatePanels(); + this.updateGamePanel("Live"); + } + private render() { renderFrame({ clock: this.clock, @@ -324,14 +343,15 @@ export class ViewerAppController { return resolveFocusedCelestialId(this.world, this.selectedItems); } - private onResize = () => { + private onResize(width: number, height: number) { resizeViewer({ - renderer: this.renderer, galaxyLayer: this.galaxyLayer, systemLayer: this.systemLayer, localLayer: this.localLayer, + width, + height, }); - }; + } private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) { setShellReticleOpacity(sprite, opacity); diff --git a/apps/viewer/src/components/CollapsibleHudPanel.vue b/apps/viewer/src/components/CollapsibleHudPanel.vue new file mode 100644 index 0000000..3804a66 --- /dev/null +++ b/apps/viewer/src/components/CollapsibleHudPanel.vue @@ -0,0 +1,40 @@ + + + diff --git a/apps/viewer/src/components/HtmlInfoPanel.vue b/apps/viewer/src/components/HtmlInfoPanel.vue new file mode 100644 index 0000000..a667f9b --- /dev/null +++ b/apps/viewer/src/components/HtmlInfoPanel.vue @@ -0,0 +1,25 @@ + + + diff --git a/apps/viewer/src/components/ViewerHistoryLayer.vue b/apps/viewer/src/components/ViewerHistoryLayer.vue new file mode 100644 index 0000000..1349e47 --- /dev/null +++ b/apps/viewer/src/components/ViewerHistoryLayer.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/viewer/src/components/ViewerOpsStrip.vue b/apps/viewer/src/components/ViewerOpsStrip.vue new file mode 100644 index 0000000..85dfde7 --- /dev/null +++ b/apps/viewer/src/components/ViewerOpsStrip.vue @@ -0,0 +1,184 @@ + + + diff --git a/apps/viewer/src/env.d.ts b/apps/viewer/src/env.d.ts new file mode 100644 index 0000000..158d2b1 --- /dev/null +++ b/apps/viewer/src/env.d.ts @@ -0,0 +1,8 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/apps/viewer/src/main.ts b/apps/viewer/src/main.ts index 3f84ab7..3143c0e 100644 --- a/apps/viewer/src/main.ts +++ b/apps/viewer/src/main.ts @@ -1,5 +1,7 @@ -import "./style.css"; -import { GameViewer } from "./GameViewer"; +import "./styles/index.css"; +import { createApp } from "vue"; +import App from "./App.vue"; +import { viewerPinia } from "./ui/stores/pinia"; const root = document.querySelector("#app"); @@ -7,5 +9,6 @@ if (!root) { throw new Error("Missing #app root element"); } -const viewer = new GameViewer(root); -void viewer.start(); +createApp(App) + .use(viewerPinia) + .mount(root); diff --git a/apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts b/apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts new file mode 100644 index 0000000..f3d06ae --- /dev/null +++ b/apps/viewer/src/runtime/rendering/ViewerRenderSurface.ts @@ -0,0 +1,52 @@ +import * as THREE from "three"; + +interface ViewerRenderSurfaceOptions { + container: HTMLElement; + renderer: THREE.WebGLRenderer; + onFrame: () => void; + onResize: (width: number, height: number) => void; +} + +export class ViewerRenderSurface { + private readonly container: HTMLElement; + readonly renderer: THREE.WebGLRenderer; + private readonly onFrame: () => void; + private readonly onResizeCallback: (width: number, height: number) => void; + private readonly resizeListener = () => this.resize(); + + constructor(options: ViewerRenderSurfaceOptions) { + this.container = options.container; + this.renderer = options.renderer; + this.onFrame = options.onFrame; + this.onResizeCallback = options.onResize; + this.container.append(this.renderer.domElement); + window.addEventListener("resize", this.resizeListener); + this.resize(); + } + + get domElement() { + return this.renderer.domElement; + } + + start() { + this.renderer.setAnimationLoop(this.onFrame); + } + + stop() { + this.renderer.setAnimationLoop(null); + } + + resize() { + const width = this.container.clientWidth || window.innerWidth; + const height = this.container.clientHeight || window.innerHeight; + this.renderer.setSize(width, height, false); + this.onResizeCallback(width, height); + } + + dispose() { + this.stop(); + window.removeEventListener("resize", this.resizeListener); + this.renderer.dispose(); + this.renderer.domElement.remove(); + } +} diff --git a/apps/viewer/src/runtime/rendering/createViewerRenderer.ts b/apps/viewer/src/runtime/rendering/createViewerRenderer.ts new file mode 100644 index 0000000..0e92c8b --- /dev/null +++ b/apps/viewer/src/runtime/rendering/createViewerRenderer.ts @@ -0,0 +1,9 @@ +import * as THREE from "three"; + +export function createViewerRenderer() { + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setClearColor(0x040912, 1); + return renderer; +} diff --git a/apps/viewer/src/runtime/rendering/disposeThreeResources.ts b/apps/viewer/src/runtime/rendering/disposeThreeResources.ts new file mode 100644 index 0000000..d1839c9 --- /dev/null +++ b/apps/viewer/src/runtime/rendering/disposeThreeResources.ts @@ -0,0 +1,35 @@ +import * as THREE from "three"; + +function disposeMaterialTextures(material: THREE.Material, disposedTextures: Set) { + for (const value of Object.values(material)) { + if (value instanceof THREE.Texture && !disposedTextures.has(value)) { + disposedTextures.add(value); + value.dispose(); + } + } +} + +export function disposeSceneResources(root: THREE.Object3D) { + const disposedGeometries = new Set(); + const disposedMaterials = new Set(); + const disposedTextures = new Set(); + + root.traverse((object) => { + const mesh = object as THREE.Mesh; + const geometry = mesh.geometry; + if (geometry instanceof THREE.BufferGeometry && !disposedGeometries.has(geometry)) { + disposedGeometries.add(geometry); + geometry.dispose(); + } + + const material = mesh.material; + const materials = Array.isArray(material) ? material : material ? [material] : []; + for (const candidate of materials) { + if (candidate instanceof THREE.Material && !disposedMaterials.has(candidate)) { + disposedMaterials.add(candidate); + disposeMaterialTextures(candidate, disposedTextures); + candidate.dispose(); + } + } + }); +} diff --git a/apps/viewer/src/styles/index.css b/apps/viewer/src/styles/index.css new file mode 100644 index 0000000..32ae610 --- /dev/null +++ b/apps/viewer/src/styles/index.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "./viewer.css"; diff --git a/apps/viewer/src/style.css b/apps/viewer/src/styles/viewer.css similarity index 55% rename from apps/viewer/src/style.css rename to apps/viewer/src/styles/viewer.css index 9294150..959a8d2 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/styles/viewer.css @@ -1,13 +1,12 @@ :root { color-scheme: dark; font-family: "Space Grotesk", "Segoe UI", sans-serif; - --bg: #050812; - --panel: rgba(9, 18, 34, 0.78); - --panel-border: rgba(132, 196, 255, 0.18); - --text: #eaf4ff; - --muted: #98adc4; - --accent: #7fd6ff; - --warning: #ffbf69; + --viewer-panel: rgba(9, 18, 34, 0.78); + --viewer-panel-border: rgba(132, 196, 255, 0.18); + --viewer-text: #eaf4ff; + --viewer-muted: #98adc4; + --viewer-accent: #7fd6ff; + --viewer-warning: #ffbf69; } * { @@ -27,39 +26,95 @@ body, linear-gradient(180deg, #03060d 0%, #060c18 100%); } +body { + color: var(--viewer-text); +} + canvas { display: block; } -.viewer-shell { - position: fixed; - inset: 0; - pointer-events: none; +.viewer-app, +.viewer-canvas-host { + width: 100%; + height: 100%; } -.left-panel-stack { - position: absolute; - top: 20px; - left: 20px; - width: min(360px, calc(100vw - 40px)); - display: flex; - flex-direction: column; - gap: 16px; +.panel-summary, +.hud-mono, +.system-body, +.detail-body, +.ship-card p, +.history, +.history-window-body, +.hover-label { + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; } -.right-panel-stack { - position: absolute; - top: 20px; - right: 20px; - width: min(380px, calc(100vw - 40px)); +.collapsible-panel.is-collapsed .game-body, +.collapsible-panel.is-collapsed .network-body, +.collapsible-panel.is-collapsed .performance-body { + display: none; +} + +.collapsible-panel.is-collapsed .panel-summary { + display: inline-block; +} + +.system-title, +.detail-title { + margin: 12px 0 0; + font-size: 1.05rem; +} + +.system-body, +.detail-body { + margin-top: 12px; + color: var(--viewer-muted); + line-height: 1.55; +} + +.system-body p, +.detail-body p { + margin: 0 0 12px; +} + +.detail-progress, +.ship-action-progress { + margin: 0 0 3px; +} + +.detail-progress-label, +.ship-action-progress-label { display: flex; - flex-direction: column; - gap: 16px; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; + color: var(--viewer-muted); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.72rem; + line-height: 1; +} + +.detail-progress-track, +.ship-action-progress-track { + height: 6px; + border-radius: 999px; + overflow: hidden; + background: rgba(127, 214, 255, 0.12); + border: 1px solid rgba(127, 214, 255, 0.14); +} + +.detail-progress-fill, +.ship-action-progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9)); } .marquee-box { position: absolute; - display: none; border: 1px solid rgba(127, 214, 255, 0.72); background: rgba(127, 214, 255, 0.14); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); @@ -91,7 +146,6 @@ canvas { background: rgba(7, 15, 28, 0.88); border: 1px solid rgba(255, 88, 72, 0.5); color: #fff2ef; - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-size: 0.75rem; line-height: 1.35; white-space: pre-line; @@ -102,235 +156,6 @@ canvas { display: none; } -.topbar, -.info-panel, -.network-panel, -.performance-panel, -.ops-strip { - backdrop-filter: blur(18px); - background: var(--panel); - border: 1px solid var(--panel-border); - box-shadow: 0 18px 54px rgba(0, 0, 0, 0.35); -} - -.topbar { - border-radius: 22px; - padding: 14px 16px; - pointer-events: auto; -} - -.eyebrow { - margin: 0 0 6px; - color: var(--accent); - letter-spacing: 0.18em; - font-size: 0.72rem; - text-transform: uppercase; -} - -.topbar h1, -.topbar h2, -.info-panel h2, -.info-panel h3, -.ship-card h3 { - margin: 0; -} - -.topbar { - display: block; - align-items: start; -} - -.topbar h2 { - color: var(--accent); - letter-spacing: 0.16em; - font-size: 0.64rem; - text-transform: uppercase; - line-height: 1; -} - -.panel-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.panel-heading-meta { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.panel-summary { - display: none; - color: var(--muted); - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 0.72rem; - line-height: 1; - text-align: right; - white-space: nowrap; -} - -.panel-toggle { - border: 1px solid rgba(127, 214, 255, 0.2); - background: rgba(127, 214, 255, 0.08); - color: var(--text); - border-radius: 999px; - width: 28px; - height: 28px; - cursor: pointer; - font: inherit; -} - -.panel-toggle:hover { - background: rgba(127, 214, 255, 0.16); -} - -.topbar-body { - margin-top: 14px; - color: var(--muted); - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 0.8rem; - line-height: 1.6; - white-space: pre-wrap; -} - -.info-panel { - border-radius: 24px; - padding: 16px; - color: var(--text); - pointer-events: auto; - overflow: auto; -} - -.network-panel { - border-radius: 24px; - padding: 14px 16px; - color: var(--text); - pointer-events: auto; -} - -.performance-panel { - width: min(360px, calc(100vw - 40px)); - border-radius: 24px; - padding: 14px 16px; - color: var(--text); - pointer-events: auto; -} - -.info-panel h2 { - color: var(--accent); - letter-spacing: 0.16em; - font-size: 0.72rem; - text-transform: uppercase; -} - -.network-panel h2, -.performance-panel h2 { - margin: 0; - color: var(--accent); - letter-spacing: 0.16em; - font-size: 0.64rem; - line-height: 1; - text-transform: uppercase; -} - -.network-body, -.performance-body { - margin-top: 14px; - color: var(--muted); - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 0.8rem; - line-height: 1.6; - white-space: pre-wrap; -} - -.collapsible-panel.is-collapsed .topbar-body, -.collapsible-panel.is-collapsed .network-body, -.collapsible-panel.is-collapsed .performance-body { - display: none; -} - -.collapsible-panel.is-collapsed .panel-summary { - display: inline-block; -} - -.collapsible-panel.is-collapsed { - padding-bottom: 12px; -} - -.detail-title { - margin-top: 12px; - font-size: 1.05rem; -} - -.system-title { - margin-top: 12px; - font-size: 1.05rem; -} - -.system-body, -.detail-body { - margin-top: 12px; - color: var(--muted); - line-height: 1.55; -} - -.system-body p, -.detail-body p { - margin: 0 0 12px; -} - -.detail-progress, -.ship-action-progress { - margin: 0 0 3px; -} - -.detail-progress-label, -.ship-action-progress-label { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 6px; - color: var(--muted); - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 0.72rem; - line-height: 1; -} - -.module-item { - display: block; - padding-left: 1em; - color: var(--muted); - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 0.72rem; - line-height: 1.5; -} - -.detail-progress-track, -.ship-action-progress-track { - height: 6px; - border-radius: 999px; - overflow: hidden; - background: rgba(127, 214, 255, 0.12); - border: 1px solid rgba(127, 214, 255, 0.14); -} - -.detail-progress-fill, -.ship-action-progress-fill { - height: 100%; - border-radius: 999px; - background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9)); -} - -.history { - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 0.78rem; - line-height: 1.6; -} - .history-window { position: absolute; right: auto; @@ -353,10 +178,6 @@ canvas { resize: both; } -.history-window[hidden] { - display: none; -} - .history-window-header { display: flex; justify-content: space-between; @@ -369,41 +190,16 @@ canvas { .history-window-title { margin: 0; - color: var(--accent); + color: var(--viewer-accent); font-size: 0.8rem; letter-spacing: 0.16em; text-transform: uppercase; } -.history-window-close, -.ship-card-history-button { - border: 1px solid rgba(127, 214, 255, 0.22); - border-radius: 999px; - background: rgba(127, 214, 255, 0.08); - color: var(--text); - font: inherit; - cursor: pointer; -} - -.history-window-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.history-window-close { - padding: 8px 12px; -} - -.history-window-copy { - padding: 8px 12px; -} - .history-window-body { overflow: auto; padding: 16px; - color: var(--text); - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + color: var(--viewer-text); font-size: 0.78rem; line-height: 1.6; white-space: pre-wrap; @@ -411,34 +207,10 @@ canvas { cursor: text; } -.error-strip { - border-radius: 14px; - padding: 12px 14px; - background: rgba(255, 116, 88, 0.14); - color: #ffd8cf; - pointer-events: auto; -} - -.right-panel-stack .error-strip { - margin-top: -4px; -} - -.system-panel-section[hidden] { - display: none; -} - -.detail-panel-section[hidden] { - display: none; -} - -.error-strip[hidden] { - display: none; -} - -.history-layer { - position: absolute; - inset: 0; - pointer-events: none; +.history-window-actions { + display: flex; + align-items: center; + gap: 8px; } .ops-strip { @@ -448,8 +220,6 @@ canvas { bottom: 0; width: 50vw; min-height: 128px; - border-radius: 0; - padding: 0; display: flex; align-items: stretch; gap: 0; @@ -461,7 +231,6 @@ canvas { } .ship-card { - border-radius: 0; border-top: 1px solid rgba(127, 214, 255, 0.14); border-right: 1px solid rgba(127, 214, 255, 0.1); background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98)); @@ -471,7 +240,7 @@ canvas { display: flex; flex-direction: column; gap: 6px; - color: var(--text); + color: var(--viewer-text); cursor: pointer; transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; } @@ -499,11 +268,26 @@ canvas { } .ship-card h3 { + margin: 0; font-size: 0.82rem; line-height: 1.15; letter-spacing: 0.04em; } +.ship-card p { + margin: 2px 0 0; + color: var(--viewer-muted); + line-height: 1.35; + font-size: 0.72rem; + white-space: pre-line; +} + +.ship-card-header + p { + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .ship-card-meta { display: flex; flex-direction: column; @@ -515,30 +299,12 @@ canvas { padding: 3px 8px; border-radius: 999px; background: rgba(127, 214, 255, 0.12); - color: var(--accent); + color: var(--viewer-accent); font-size: 0.64rem; letter-spacing: 0.12em; text-transform: uppercase; } -.ship-card p { - margin: 2px 0 0; - color: var(--muted); - line-height: 1.35; - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; - font-size: 0.72rem; -} - -.ship-card-header+p { - font-size: 0.62rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.ship-action-progress { - margin-top: 2px; -} - .ship-card-ai { margin-top: 2px; padding-top: 6px; @@ -547,11 +313,22 @@ canvas { .ship-card-section-title { margin: 0; - color: var(--accent); + color: var(--viewer-accent); letter-spacing: 0.14em; text-transform: uppercase; } +.ship-card-history-button, +.history-window-copy, +.history-window-close { + border: 1px solid rgba(127, 214, 255, 0.22); + border-radius: 999px; + background: rgba(127, 214, 255, 0.08); + color: var(--viewer-text); + font: inherit; + cursor: pointer; +} + .ship-card-history-button { width: 24px; height: 24px; @@ -564,6 +341,11 @@ canvas { line-height: 1; } +.history-window-copy, +.history-window-close { + padding: 8px 12px; +} + .faction-card { border-top-color: rgba(180, 130, 255, 0.3); cursor: default; @@ -582,11 +364,14 @@ canvas { border-color: rgba(127, 255, 180, 0.5); } -.swatch { - width: 14px; - height: 48px; - border-radius: 999px; - flex: none; +.ship-card-split-line { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.selection-action-button { + pointer-events: auto; } @media (max-width: 1080px) { @@ -596,53 +381,8 @@ canvas { } @media (max-width: 760px) { - .left-panel-stack { - right: 20px; - width: auto; - } - - .right-panel-stack { - left: 20px; - right: 20px; - top: auto; - width: auto; - bottom: 148px; - max-height: 38vh; - overflow: auto; - } - - .info-panel { - max-height: none; - overflow: visible; - } - - .system-panel-section, - .detail-panel-section, - .error-strip { - width: auto; - } - - .network-panel { - width: auto; - } - - .performance-panel { - width: auto; - } - .ops-strip { - left: 0; - right: 0; - bottom: 0; - width: 50vw; + width: 100vw; min-height: 120px; } - - .history-window { - left: 20px; - right: 20px; - width: auto; - max-width: calc(100vw - 40px); - max-height: calc(100vh - 40px); - } } diff --git a/apps/viewer/src/ui/stores/pinia.ts b/apps/viewer/src/ui/stores/pinia.ts new file mode 100644 index 0000000..989550e --- /dev/null +++ b/apps/viewer/src/ui/stores/pinia.ts @@ -0,0 +1,3 @@ +import { createPinia } from "pinia"; + +export const viewerPinia = createPinia(); diff --git a/apps/viewer/src/ui/stores/viewerSelection.ts b/apps/viewer/src/ui/stores/viewerSelection.ts new file mode 100644 index 0000000..d22fd73 --- /dev/null +++ b/apps/viewer/src/ui/stores/viewerSelection.ts @@ -0,0 +1,108 @@ +import { defineStore } from "pinia"; +import type { Selectable } from "../../viewerTypes"; + +export type ViewerSelectionSource = "viewer" | "ui" | null; + +export interface ViewerSelectionSummary { + id: string; + kind: Selectable["kind"]; + label?: string | null; +} + +export function selectionToEntityId(selection: Selectable): string { + switch (selection.kind) { + case "planet": + return `${selection.systemId}:${selection.planetIndex}`; + case "moon": + return `${selection.systemId}:${selection.planetIndex}:${selection.moonIndex}`; + default: + return selection.id; + } +} + +export function entityIdToSelectable( + kind: Selectable["kind"] | null, + entityId: string | null, +): Selectable | null { + if (!kind || !entityId) { + return null; + } + + if (kind === "planet") { + const [systemId, planetIndex] = entityId.split(":"); + if (!systemId || planetIndex == null) { + return null; + } + + return { + kind, + systemId, + planetIndex: Number(planetIndex), + }; + } + + if (kind === "moon") { + const [systemId, planetIndex, moonIndex] = entityId.split(":"); + if (!systemId || planetIndex == null || moonIndex == null) { + return null; + } + + return { + kind, + systemId, + planetIndex: Number(planetIndex), + moonIndex: Number(moonIndex), + }; + } + + return { + kind, + id: entityId, + } as Selectable; +} + +export const useViewerSelectionStore = defineStore("viewerSelection", { + state: () => ({ + selectedEntityId: null as string | null, + selectedEntityKind: null as Selectable["kind"] | null, + selectedEntityLabel: null as string | null, + hoveredEntityId: null as string | null, + inspectedEntityId: null as string | null, + selectionSource: null as ViewerSelectionSource, + }), + actions: { + selectEntity(id: string | null, source: ViewerSelectionSource = null) { + this.selectedEntityId = id; + this.selectionSource = source; + if (id == null) { + this.selectedEntityKind = null; + this.selectedEntityLabel = null; + } + }, + selectSelection(selection: ViewerSelectionSummary | null, source: ViewerSelectionSource = null) { + if (!selection) { + this.clearSelection(source); + return; + } + + this.selectedEntityId = selection.id; + this.selectedEntityKind = selection.kind; + this.selectedEntityLabel = selection.label ?? null; + this.selectionSource = source; + }, + clearSelection(source: ViewerSelectionSource = null) { + this.selectedEntityId = null; + this.selectedEntityKind = null; + this.selectedEntityLabel = null; + this.selectionSource = source; + }, + hoverEntity(id: string | null) { + this.hoveredEntityId = id; + }, + inspectEntity(id: string | null) { + this.inspectedEntityId = id; + }, + }, +}); + +export type ViewerSelectionStore = ReturnType; diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts index 96cc906..3a00083 100644 --- a/apps/viewer/src/viewerControllerFactory.ts +++ b/apps/viewer/src/viewerControllerFactory.ts @@ -79,21 +79,13 @@ export function createViewerControllers(host: any) { const presentationController = new ViewerPresentationController({ renderer: host.renderer, + hudState: host.hudState, galaxyScene: host.galaxyLayer.scene, galaxyCamera: host.galaxyLayer.camera, systemCamera: host.systemLayer.camera, galaxyAnchor: host.galaxyAnchor, systemAnchor: host.systemAnchor, ambienceGroup: host.universeLayer.ambienceGroup, - gameSummaryEl: host.gameSummaryEl, - networkSummaryEl: host.networkSummaryEl, - performanceSummaryEl: host.performanceSummaryEl, - statusEl: host.statusEl, - networkPanelEl: host.networkPanelEl, - performancePanelEl: host.performancePanelEl, - systemPanelEl: host.systemPanelEl, - systemTitleEl: host.systemTitleEl, - systemBodyEl: host.systemBodyEl, networkStats: host.networkStats, performanceStats: host.performanceStats, getWorld: () => host.world, @@ -137,10 +129,7 @@ export function createViewerControllers(host: any) { getCameraTargetShipId: () => host.cameraTargetShipId, getNetworkStats: () => host.networkStats, getSystemSummaryVisuals: () => new Map(), - errorEl: host.errorEl, - opsStripEl: host.opsStripEl, - detailTitleEl: host.detailTitleEl, - detailBodyEl: host.detailBodyEl, + hudState: host.hudState, worldLabel: () => host.world?.label ?? "", rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems), syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials), @@ -166,7 +155,6 @@ export function createViewerControllers(host: any) { }); const historyController = new ViewerHistoryWindowController({ - historyLayerEl: host.historyLayerEl, historyWindows: host.historyWindows, getWorld: () => host.world, getHistoryWindowCounter: () => host.historyWindowCounter, @@ -200,13 +188,14 @@ export function createViewerControllers(host: any) { hoverLabelEl: host.hoverLabelEl, hoverConnectorLineEl: host.hoverConnectorLineEl, marqueeEl: host.marqueeEl, + hudState: host.hudState, keyState: host.keyState, getWorld: () => host.world, getActiveSystemId: () => host.activeSystemId, getPovLevel: () => host.povLevel, getSelectedItems: () => host.selectedItems, setSelectedItems: (items) => { - host.selectedItems = items; + host.applySelectedItems(items, "viewer"); }, getDragMode: () => host.dragMode, setDragMode: (mode) => { @@ -268,20 +257,33 @@ export function createViewerControllers(host: any) { } export function wireViewerEvents(host: any) { - host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown); - host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove); - host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp); - host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp); - host.renderer.domElement.addEventListener("click", host.interactionController.onClick); - host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick); - host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false }); - host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick); - host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick); + const canvas = host.renderer.domElement; + canvas.addEventListener("pointerdown", host.interactionController.onPointerDown); + canvas.addEventListener("pointermove", host.interactionController.onPointerMove); + canvas.addEventListener("pointerup", host.interactionController.onPointerUp); + canvas.addEventListener("pointerleave", host.interactionController.onPointerUp); + canvas.addEventListener("click", host.interactionController.onClick); + canvas.addEventListener("dblclick", host.interactionController.onDoubleClick); + canvas.addEventListener("wheel", host.interactionController.onWheel, { passive: false }); host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick); host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown); window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove); window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp); window.addEventListener("keydown", host.interactionController.onKeyDown); window.addEventListener("keyup", host.interactionController.onKeyUp); - window.addEventListener("resize", host.onResize); + return () => { + canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown); + canvas.removeEventListener("pointermove", host.interactionController.onPointerMove); + canvas.removeEventListener("pointerup", host.interactionController.onPointerUp); + canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp); + canvas.removeEventListener("click", host.interactionController.onClick); + canvas.removeEventListener("dblclick", host.interactionController.onDoubleClick); + canvas.removeEventListener("wheel", host.interactionController.onWheel); + host.historyLayerEl.removeEventListener("click", host.interactionController.onHistoryLayerClick); + host.historyLayerEl.removeEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown); + window.removeEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove); + window.removeEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp); + window.removeEventListener("keydown", host.interactionController.onKeyDown); + window.removeEventListener("keyup", host.interactionController.onKeyUp); + }; } diff --git a/apps/viewer/src/viewerHistory.ts b/apps/viewer/src/viewerHistory.ts index 3d3557a..7e763fd 100644 --- a/apps/viewer/src/viewerHistory.ts +++ b/apps/viewer/src/viewerHistory.ts @@ -1,39 +1,23 @@ -import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes"; +import type { HistoryWindowState } from "./viewerHudState"; +import type { Selectable, WorldState } from "./viewerTypes"; export function createHistoryWindowState( - documentRef: Document, target: Selectable, historyWindowsCount: number, historyWindowCounter: number, ): HistoryWindowState { - const id = `history-${historyWindowCounter}`; - const root = documentRef.createElement("aside"); - root.className = "history-window"; - root.dataset.historyWindowId = id; - root.innerHTML = ` -
-

History

-
- - -
-
-
No history selected.
- `; - - root.style.width = `${Math.min(520, window.innerWidth - 40)}px`; - root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`; - root.style.left = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580)))}px`; - root.style.top = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420)))}px`; - return { - id, + id: `history-${historyWindowCounter}`, target, - root, - titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement, - bodyEl: root.querySelector(".history-window-body") as HTMLDivElement, - copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement, + title: "History", + bodyHtml: "No history selected.", text: "", + copyLabel: "Copy", + width: Math.min(520, window.innerWidth - 40), + height: Math.min(360, Math.max(240, window.innerHeight * 0.42)), + x: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580))), + y: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420))), + zIndex: 1, }; } @@ -48,9 +32,9 @@ export function refreshHistoryWindow( return false; } - windowState.titleEl.textContent = `${ship.label} History`; + windowState.title = `${ship.label} History`; windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet."; - windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "
"); + windowState.bodyHtml = windowState.text.replaceAll("\n", "
"); return true; } @@ -60,9 +44,9 @@ export function refreshHistoryWindow( return false; } - windowState.titleEl.textContent = `${station.label} History`; + windowState.title = `${station.label} History`; windowState.text = renderRecentEvents("station", station.id).replaceAll("
", "\n") || "No history yet."; - windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "
"); + windowState.bodyHtml = windowState.text.replaceAll("\n", "
"); return true; } diff --git a/apps/viewer/src/viewerHistoryManager.ts b/apps/viewer/src/viewerHistoryManager.ts index c9895e5..bf775ae 100644 --- a/apps/viewer/src/viewerHistoryManager.ts +++ b/apps/viewer/src/viewerHistoryManager.ts @@ -1,10 +1,10 @@ import * as THREE from "three"; import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory"; -import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes"; +import type { HistoryWindowState } from "./viewerHudState"; +import type { Selectable, WorldState } from "./viewerTypes"; export function openHistoryWindow( historyWindows: HistoryWindowState[], - historyLayerEl: HTMLDivElement, target: Selectable, nextCounter: number, bringToFront: (windowState: HistoryWindowState) => void, @@ -17,9 +17,8 @@ export function openHistoryWindow( return nextCounter; } - const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter); + const windowState = createHistoryWindowState(target, historyWindows.length, nextCounter); historyWindows.push(windowState); - historyLayerEl.append(windowState.root); bringToFront(windowState); refreshWindows(); return nextCounter; @@ -56,8 +55,7 @@ export function destroyHistoryWindow( }; } - const [removed] = historyWindows.splice(index, 1); - removed.root.remove(); + historyWindows.splice(index, 1); if (historyWindowDragId === id) { return { historyWindowDragId: undefined, @@ -72,7 +70,7 @@ export function destroyHistoryWindow( } export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) { - windowState.root.style.zIndex = `${nextZIndex}`; + windowState.zIndex = nextZIndex; } export function beginHistoryWindowDrag( @@ -91,9 +89,7 @@ export function beginHistoryWindowDrag( }; } - const bounds = windowState.root.getBoundingClientRect(); - historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top); - windowState.root.setPointerCapture?.(pointerId); + historyWindowDragOffset.set(clientX - windowState.x, clientY - windowState.y); return { historyWindowDragId: windowId, historyWindowDragPointerId: pointerId, @@ -118,16 +114,12 @@ export function updateHistoryWindowDrag( return; } - const width = windowState.root.offsetWidth; - const height = windowState.root.offsetHeight; - const left = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - width - 20); - const top = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - height - 20); - windowState.root.style.left = `${left}px`; - windowState.root.style.top = `${top}px`; + windowState.x = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - windowState.width - 20); + windowState.y = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - windowState.height - 20); } export function endHistoryWindowDrag( - historyWindows: HistoryWindowState[], + _historyWindows: HistoryWindowState[], historyWindowDragId: string | undefined, historyWindowDragPointerId: number | undefined, pointerId: number, @@ -139,8 +131,6 @@ export function endHistoryWindowDrag( }; } - const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId); - windowState?.root.releasePointerCapture?.(pointerId); return { historyWindowDragId: undefined, historyWindowDragPointerId: undefined, diff --git a/apps/viewer/src/viewerHistoryWindowController.ts b/apps/viewer/src/viewerHistoryWindowController.ts index 42b47cd..ffa6838 100644 --- a/apps/viewer/src/viewerHistoryWindowController.ts +++ b/apps/viewer/src/viewerHistoryWindowController.ts @@ -9,10 +9,10 @@ import { refreshHistoryWindows, updateHistoryWindowDrag, } from "./viewerHistoryManager"; -import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes"; +import type { HistoryWindowState } from "./viewerHudState"; +import type { Selectable, WorldState } from "./viewerTypes"; export interface ViewerHistoryWindowContext { - historyLayerEl: HTMLDivElement; historyWindows: HistoryWindowState[]; getWorld: () => WorldState | undefined; getHistoryWindowCounter: () => number; @@ -33,7 +33,6 @@ export class ViewerHistoryWindowController { openHistoryWindow(target: Selectable) { const nextCounter = openHistoryWindow( this.context.historyWindows, - this.context.historyLayerEl, target, this.context.getHistoryWindowCounter() + 1, (windowState) => this.bringHistoryWindowToFront(windowState), @@ -155,14 +154,14 @@ export class ViewerHistoryWindowController { try { await copyTextToClipboard(windowState.text); - windowState.copyButtonEl.textContent = "Copied"; + windowState.copyLabel = "Copied"; window.setTimeout(() => { - windowState.copyButtonEl.textContent = "Copy"; + windowState.copyLabel = "Copy"; }, 1200); } catch { - windowState.copyButtonEl.textContent = "Failed"; + windowState.copyLabel = "Failed"; window.setTimeout(() => { - windowState.copyButtonEl.textContent = "Copy"; + windowState.copyLabel = "Copy"; }, 1200); } } diff --git a/apps/viewer/src/viewerHud.ts b/apps/viewer/src/viewerHud.ts deleted file mode 100644 index 4165350..0000000 --- a/apps/viewer/src/viewerHud.ts +++ /dev/null @@ -1,106 +0,0 @@ -export interface ViewerHudElements { - root: HTMLDivElement; - gamePanelEl: HTMLDivElement; - statusEl: HTMLDivElement; - gameSummaryEl: HTMLSpanElement; - networkSectionEl: HTMLDivElement; - systemPanelEl: HTMLDivElement; - systemTitleEl: HTMLHeadingElement; - systemBodyEl: HTMLDivElement; - detailTitleEl: HTMLHeadingElement; - detailBodyEl: HTMLDivElement; - opsStripEl: HTMLDivElement; - networkSummaryEl: HTMLSpanElement; - networkPanelEl: HTMLDivElement; - performanceSectionEl: HTMLDivElement; - performanceSummaryEl: HTMLSpanElement; - performancePanelEl: HTMLDivElement; - errorEl: HTMLDivElement; - historyLayerEl: HTMLDivElement; - marqueeEl: HTMLDivElement; - hoverLabelEl: HTMLDivElement; - hoverConnectorLineEl: SVGLineElement; -} - -export function createViewerHud(documentRef: Document): ViewerHudElements { - const root = documentRef.createElement("div"); - root.className = "viewer-shell"; - root.innerHTML = ` -
- - - -
-
- - - -
-
-
-
- - - `; - - return { - root, - gamePanelEl: root.querySelector(".topbar") as HTMLDivElement, - statusEl: root.querySelector(".topbar-body") as HTMLDivElement, - gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement, - networkSectionEl: root.querySelector(".network-panel") as HTMLDivElement, - systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement, - systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement, - systemBodyEl: root.querySelector(".system-body") as HTMLDivElement, - detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement, - detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement, - opsStripEl: root.querySelector(".ops-strip") as HTMLDivElement, - networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement, - networkPanelEl: root.querySelector(".network-body") as HTMLDivElement, - performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement, - performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement, - performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement, - errorEl: root.querySelector(".error-strip") as HTMLDivElement, - historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement, - marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement, - hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement, - hoverConnectorLineEl: root.querySelector(".hover-connector-line") as unknown as SVGLineElement, - }; -} diff --git a/apps/viewer/src/viewerHudState.ts b/apps/viewer/src/viewerHudState.ts new file mode 100644 index 0000000..9746b69 --- /dev/null +++ b/apps/viewer/src/viewerHudState.ts @@ -0,0 +1,178 @@ +import { reactive } from "vue"; +import type { ViewerSelectionStore } from "./ui/stores/viewerSelection"; +import type { Selectable } from "./viewerTypes"; + +export interface HudPanelState { + collapsed: boolean; + summary: string; + bodyText: string; +} + +export interface HudHtmlPanelState { + hidden: boolean; + title: string; + bodyHtml: string; +} + +export interface HudErrorState { + hidden: boolean; + message: string; +} + +export interface HudProgressBar { + label: string; + valueLabel: string; + progress: number; +} + +export interface OpsFactionCardState { + kind: "faction"; + id: string; + label: string; + stateLines: string[]; + priorities: { label: string; value: string }[]; +} + +export interface OpsStationCardState { + kind: "station"; + id: string; + label: string; + badge: string; + selected: boolean; + lines: string[]; + processes: HudProgressBar[]; +} + +export interface OpsShipCardState { + kind: "ship"; + id: string; + label: string; + badge: string; + selected: boolean; + followed: boolean; + locationLines: string[]; + lines: string[]; + action?: HudProgressBar; + aiLines: string[]; +} + +export interface OpsStripState { + factions: OpsFactionCardState[]; + stations: OpsStationCardState[]; + ships: OpsShipCardState[]; +} + +export interface HistoryWindowState { + id: string; + target: Selectable; + title: string; + bodyHtml: string; + text: string; + copyLabel: string; + x: number; + y: number; + width: number; + height: number; + zIndex: number; +} + +export interface HoverLabelState { + hidden: boolean; + text: string; + x: number; + y: number; + connectorHidden: boolean; + x1: number; + y1: number; + x2: number; + y2: number; +} + +export interface MarqueeState { + visible: boolean; + x: number; + y: number; + width: number; + height: number; +} + +export interface ViewerHudState { + gamePanel: HudPanelState; + networkPanel: HudPanelState; + performancePanel: HudPanelState; + systemPanel: HudHtmlPanelState; + detailPanel: HudHtmlPanelState; + error: HudErrorState; + opsStrip: OpsStripState; + historyWindows: HistoryWindowState[]; + hoverLabel: HoverLabelState; + marquee: MarqueeState; +} + +export interface ViewerHudBindings { + state: ViewerHudState; + selectionStore: ViewerSelectionStore; + opsStripEl: HTMLDivElement; + historyLayerEl: HTMLDivElement; + marqueeEl: HTMLDivElement; + hoverLabelEl: HTMLDivElement; + hoverConnectorLineEl: SVGLineElement; +} + +export function createViewerHudState(): ViewerHudState { + return reactive({ + gamePanel: { + collapsed: true, + summary: "Bootstrapping", + bodyText: "Bootstrapping", + }, + networkPanel: { + collapsed: true, + summary: "Waiting", + bodyText: "Waiting for snapshot.", + }, + performancePanel: { + collapsed: true, + summary: "Waiting", + bodyText: "Waiting for frame samples.", + }, + systemPanel: { + hidden: false, + title: "Deep Space", + bodyHtml: "Waiting for the authoritative snapshot.", + }, + detailPanel: { + hidden: false, + title: "Nothing selected", + bodyHtml: "Waiting for the authoritative snapshot.", + }, + error: { + hidden: true, + message: "", + }, + opsStrip: { + factions: [], + stations: [], + ships: [], + }, + historyWindows: [], + hoverLabel: { + hidden: true, + text: "", + x: 0, + y: 0, + connectorHidden: true, + x1: 0, + y1: 0, + x2: 0, + y2: 0, + }, + marquee: { + visible: false, + x: 0, + y: 0, + width: 0, + height: 0, + }, + }); +} diff --git a/apps/viewer/src/viewerInteraction.ts b/apps/viewer/src/viewerInteraction.ts index c0090a6..72cbc6d 100644 --- a/apps/viewer/src/viewerInteraction.ts +++ b/apps/viewer/src/viewerInteraction.ts @@ -2,6 +2,7 @@ import * as THREE from "three"; import { describeHoverLabel, getSelectionGroup } from "./viewerSelection"; import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath"; +import type { HoverLabelState, MarqueeState } from "./viewerHudState"; import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes"; export interface HoverPickResult { @@ -67,6 +68,7 @@ export function pickSelectableHitAtClientPosition( export function updateHoverLabel(params: { dragMode?: string; + hoverState: HoverLabelState; hoverLabelEl: HTMLDivElement; hoverConnectorLineEl: SVGLineElement; hoverPick: HoverPickResult | undefined; @@ -77,6 +79,7 @@ export function updateHoverLabel(params: { }) { const { dragMode, + hoverState, hoverLabelEl, hoverConnectorLineEl, hoverPick, @@ -87,6 +90,8 @@ export function updateHoverLabel(params: { } = params; if (dragMode || !hoverPick) { + hoverState.hidden = true; + hoverState.connectorHidden = true; hoverLabelEl.hidden = true; hoverConnectorLineEl.setAttribute("hidden", ""); return; @@ -95,6 +100,8 @@ export function updateHoverLabel(params: { const { selection, object, camera } = hoverPick; const label = describeHoverLabel(world, selection); if (!label) { + hoverState.hidden = true; + hoverState.connectorHidden = true; hoverLabelEl.hidden = true; hoverConnectorLineEl.setAttribute("hidden", ""); return; @@ -102,18 +109,27 @@ export function updateHoverLabel(params: { const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId); + hoverState.hidden = false; + hoverState.text = `${label}\n${distance}`; + hoverState.x = point.x + 44; + hoverState.y = point.y - 90; hoverLabelEl.hidden = false; - hoverLabelEl.textContent = `${label}\n${distance}`; - hoverLabelEl.style.left = `${point.x + 44}px`; - hoverLabelEl.style.top = `${point.y - 90}px`; + hoverLabelEl.textContent = hoverState.text; + hoverLabelEl.style.left = `${hoverState.x}px`; + hoverLabelEl.style.top = `${hoverState.y}px`; const rect = hoverLabelEl.getBoundingClientRect(); const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).getBoundingClientRect(); + hoverState.connectorHidden = false; + hoverState.x1 = point.x; + hoverState.y1 = point.y; + hoverState.x2 = rect.left - svgRect.left; + hoverState.y2 = rect.top - svgRect.top + rect.height / 2; hoverConnectorLineEl.removeAttribute("hidden"); - hoverConnectorLineEl.setAttribute("x1", String(point.x)); - hoverConnectorLineEl.setAttribute("y1", String(point.y)); - hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left)); - hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2)); + hoverConnectorLineEl.setAttribute("x1", String(hoverState.x1)); + hoverConnectorLineEl.setAttribute("y1", String(hoverState.y1)); + hoverConnectorLineEl.setAttribute("x2", String(hoverState.x2)); + hoverConnectorLineEl.setAttribute("y2", String(hoverState.y2)); } function formatHoverDistance( @@ -150,6 +166,7 @@ function formatHoverDistance( } export function updateMarqueeBox( + marqueeState: MarqueeState, marqueeEl: HTMLDivElement, dragStart: THREE.Vector2, dragLast: THREE.Vector2, @@ -158,13 +175,21 @@ export function updateMarqueeBox( const minY = Math.min(dragStart.y, dragLast.y); const maxX = Math.max(dragStart.x, dragLast.x); const maxY = Math.max(dragStart.y, dragLast.y); + marqueeState.visible = true; + marqueeState.x = minX; + marqueeState.y = minY; + marqueeState.width = maxX - minX; + marqueeState.height = maxY - minY; marqueeEl.style.left = `${minX}px`; marqueeEl.style.top = `${minY}px`; marqueeEl.style.width = `${maxX - minX}px`; marqueeEl.style.height = `${maxY - minY}px`; } -export function hideMarqueeBox(marqueeEl: HTMLDivElement) { +export function hideMarqueeBox(marqueeState: MarqueeState, marqueeEl: HTMLDivElement) { + marqueeState.visible = false; + marqueeState.width = 0; + marqueeState.height = 0; marqueeEl.style.display = "none"; marqueeEl.style.width = "0"; marqueeEl.style.height = "0"; diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts index b4c7191..4d8b435 100644 --- a/apps/viewer/src/viewerInteractionController.ts +++ b/apps/viewer/src/viewerInteractionController.ts @@ -14,6 +14,7 @@ import { } from "./viewerControls"; import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; +import type { ViewerHudState } from "./viewerHudState"; import type { CameraMode, DragMode, @@ -33,6 +34,7 @@ export interface ViewerInteractionContext { hoverLabelEl: HTMLDivElement; hoverConnectorLineEl: SVGLineElement; marqueeEl: HTMLDivElement; + hudState: ViewerHudState; keyState: Set; getWorld: () => WorldState | undefined; getActiveSystemId: () => string | undefined; @@ -109,6 +111,7 @@ export class ViewerInteractionController { if (!this.context.getMarqueeActive() && dragDistance > 8) { this.context.setMarqueeActive(true); this.context.setSuppressClickSelection(true); + this.context.hudState.marquee.visible = true; this.context.marqueeEl.style.display = "block"; } @@ -117,7 +120,7 @@ export class ViewerInteractionController { } this.context.dragLast.copy(point); - updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast); + updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast); }; readonly onPointerUp = (event: PointerEvent) => { @@ -131,7 +134,7 @@ export class ViewerInteractionController { if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) { this.completeMarqueeSelection(); - hideMarqueeBox(this.context.marqueeEl); + hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl); } this.context.setDragMode(undefined); @@ -285,6 +288,7 @@ export class ViewerInteractionController { updateHoverLabel(event: PointerEvent) { updateHoverLabel({ dragMode: this.context.getDragMode(), + hoverState: this.context.hudState.hoverLabel, hoverLabelEl: this.context.hoverLabelEl, hoverConnectorLineEl: this.context.hoverConnectorLineEl, hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY), @@ -299,6 +303,10 @@ export class ViewerInteractionController { this.context.historyController.refreshHistoryWindows(); } + openHistoryWindow(selection: Selectable) { + this.context.historyController.openHistoryWindow(selection); + } + toggleCameraMode(forceMode?: CameraMode) { const nextState = toggleCameraMode({ cameraMode: this.context.getCameraMode(), diff --git a/apps/viewer/src/viewerOpsStrip.ts b/apps/viewer/src/viewerOpsStrip.ts index 53d6248..82782bc 100644 --- a/apps/viewer/src/viewerOpsStrip.ts +++ b/apps/viewer/src/viewerOpsStrip.ts @@ -1,154 +1,131 @@ import type { StationSnapshot } from "./contractsInfrastructure"; import type { FactionSnapshot } from "./contractsFactions"; -import { inventoryAmount } from "./viewerMath"; +import type { + HudProgressBar, + OpsFactionCardState, + OpsShipCardState, + OpsStationCardState, + OpsStripState, +} from "./viewerHudState"; import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection"; import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes"; -function renderFactionCard(faction: FactionSnapshot): string { +function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState { const state = faction.goapState; - const priorities = faction.goapPriorities; - - return ` -
-
-

${faction.label}

- faction -
- ${state ? ` -
-

GOAP State

-

Military ${state.militaryShipCount} · Miners ${state.minerShipCount}

-

Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}

-

Systems ${state.controlledSystemCount} / ${state.targetSystemCount}

-

Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}

-
- ` : ""} - ${priorities && priorities.length > 0 ? ` -
-

Priorities

- ${priorities.map(p => `

${p.goalName} ${p.priority.toFixed(0)}

`).join("")} -
- ` : ""} -
- `; + return { + kind: "faction", + id: faction.id, + label: faction.label, + stateLines: state ? [ + `Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`, + `Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`, + `Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`, + `Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}`, + ] : [], + priorities: (faction.goapPriorities ?? []).map((entry) => ({ + label: entry.goalName, + value: entry.priority.toFixed(0), + })), + }; } -function renderStationCard(station: StationSnapshot, isSelected: boolean): string { - const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0); - const processes = station.currentProcesses; - - return ` -
-
-

${station.label}

- ${station.category} -
-

${station.systemId}

-

Docked ${station.dockedShips} / ${station.dockingPads}

-

Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}

-

Modules ${station.installedModules.length}

- ${processes.length > 0 ? ` -
- ${processes.map(p => ` -
-
- ${p.label} - ${Math.round(p.progress * 100)}% -
-
-
-
-
- `).join("")} -
- ` : ""} -
- `; +function buildProgressBar(label: string, progress: number): HudProgressBar { + return { + label, + valueLabel: `${Math.round(progress * 100)}%`, + progress: Number((progress * 100).toFixed(1)), + }; } -export function renderOpsStrip( +function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState { + const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0); + return { + kind: "station", + id: station.id, + label: station.label, + badge: station.category, + selected: isSelected, + lines: [ + station.systemId, + `Docked ${station.dockedShips} / ${station.dockingPads}`, + `Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`, + `Modules ${station.installedModules.length}`, + ], + processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)), + }; +} + +function buildShipCard( + world: WorldState, + ship: WorldState["ships"] extends Map ? Ship : never, + isSelected: boolean, + isFollowed: boolean, +): OpsShipCardState { + const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); + const shipLocation = describeShipLocation(world, ship); + const shipState = describeShipState(world, ship); + const shipAction = describeShipCurrentAction(ship); + + return { + kind: "ship", + id: ship.id, + label: ship.label, + badge: ship.class, + selected: isSelected, + followed: isFollowed, + locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])], + lines: [ + `Cargo ${cargo.toFixed(0)}`, + `State ${shipState}`, + ], + action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined, + aiLines: [ + ...(ship.commanderObjective ? [`Objective ${describeShipObjective(ship.commanderObjective)}`] : []), + `Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}`, + `Task ${ship.controllerTaskKind}`, + ], + }; +} + +export function buildOpsStripState( world: WorldState | undefined, selectedItems: Selectable[], cameraMode: CameraMode, cameraTargetShipId?: string, povLevel?: PovLevel, activeSystemId?: string, -) { +): OpsStripState { if (!world) { - return ""; + return { + factions: [], + stations: [], + ships: [], + }; } const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null; - const factionCards = [...world.factions.values()] - .sort((a, b) => a.label.localeCompare(b.label)) - .map(renderFactionCard) - .join(""); + const factions = [...world.factions.values()] + .sort((left, right) => left.label.localeCompare(right.label)) + .map(buildFactionCard); - const stationCards = [...world.stations.values()] + const stations = [...world.stations.values()] .filter((station) => !isSystemFiltered || station.systemId === activeSystemId) - .sort((a, b) => a.label.localeCompare(b.label)) - .map((station) => { - const isSelected = selectedItems.length === 1 - && selectedItems[0].kind === "station" - && selectedItems[0].id === station.id; - return renderStationCard(station, isSelected); - }) - .join(""); + .sort((left, right) => left.label.localeCompare(right.label)) + .map((station) => buildStationCard( + station, + selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id, + )); const ships = [...world.ships.values()] .filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId) - .sort((a, b) => a.label.localeCompare(b.label)); + .sort((left, right) => left.label.localeCompare(right.label)) + .map((ship) => buildShipCard( + world, + ship, + selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id, + cameraMode === "follow" && cameraTargetShipId === ship.id, + )); - const shipCards = ships - .map((ship) => { - const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0); - const shipLocation = describeShipLocation(world, ship); - const shipState = describeShipState(world, ship); - const shipAction = describeShipCurrentAction(ship); - const isSelected = selectedItems.length === 1 - && selectedItems[0].kind === "ship" - && selectedItems[0].id === ship.id; - const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id; - - return ` -
-
-

${ship.label}

-
- ${ship.class} - -
-
-

${shipLocation.system}${shipLocation.local ? `
${shipLocation.local}` : ""}

-

Cargo ${cargo.toFixed(0)}

-

State ${shipState}

- ${shipAction ? ` -
-
- ${shipAction.label} - ${Math.round(shipAction.progress * 100)}% -
-
-
-
-
- ` : ""} -
- ${ship.commanderObjective ? `

Objective ${describeShipObjective(ship.commanderObjective)}

` : ""} -

Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}

-

Task ${ship.controllerTaskKind}

-
-
- `; - }) - .join(""); - - return factionCards + stationCards + shipCards; + return { factions, stations, ships }; } diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index cbc59e9..d8e0f9c 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -17,7 +17,6 @@ const itemTransportById = new Map( import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import type { CameraMode, - HistoryWindowState, NodeVisual, OrbitalAnchor, Selectable, @@ -39,9 +38,6 @@ interface DetailPanelParams { interface SystemPanelParams { world: WorldState; activeSystemId?: string; - systemTitleEl: HTMLHeadingElement; - systemBodyEl: HTMLDivElement; - systemPanelEl: HTMLDivElement; cameraMode: CameraMode; cameraTargetShipId?: string; } @@ -70,7 +66,15 @@ function formatModuleListWithConstruction( world: WorldState, stationId: string, installedModules: string[], - currentProcesses: { lane: string; label: string; progress: number; timeRemainingSeconds: number }[], + currentProcesses: { + lane: string; + label: string; + progress: number; + timeRemainingSeconds: number; + cycleSeconds: number; + inputs: { itemId: string; amount: number }[]; + outputs: { itemId: string; amount: number }[]; + }[], ): string { const processByModule = new Map(); for (const process of currentProcesses) { @@ -175,11 +179,7 @@ function renderSystemOwnership(world: WorldState, systemId: string): string { .join("
"); } -export function updateDetailPanel( - detailTitleEl: HTMLHeadingElement, - detailBodyEl: HTMLDivElement, - params: DetailPanelParams, -) { +export function buildDetailPanelState(params: DetailPanelParams) { const { world, selectedItems, @@ -191,35 +191,37 @@ export function updateDetailPanel( } = params; if (selectedItems.length === 0) { - detailTitleEl.textContent = worldLabel; - detailBodyEl.innerHTML = ` - Zoom ${povLevel}
- Systems ${world.systems.size}
- Celestials ${world.celestials.size}
- Stations ${world.stations.size}
- Claims ${world.claims.size}
- Construction ${world.constructionSites.size}
- Ships ${world.ships.size}
- Recent events ${world.recentEvents.length} - `; - return; + return { + title: worldLabel, + bodyHtml: ` + Zoom ${povLevel}
+ Systems ${world.systems.size}
+ Celestials ${world.celestials.size}
+ Stations ${world.stations.size}
+ Claims ${world.claims.size}
+ Construction ${world.constructionSites.size}
+ Ships ${world.ships.size}
+ Recent events ${world.recentEvents.length} + `, + }; } if (selectedItems.length > 1) { const group = getSelectionGroup(selectedItems[0]); - detailTitleEl.textContent = `${selectedItems.length} selected`; - detailBodyEl.innerHTML = ` - Type ${group}
- ${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("
")} - `; - return; + return { + title: `${selectedItems.length} selected`, + bodyHtml: ` + Type ${group}
+ ${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("
")} + `, + }; } const selected = selectedItems[0]; if (selected.kind === "ship") { const ship = world.ships.get(selected.id); if (!ship) { - return; + return { title: "Missing ship", bodyHtml: "" }; } const parent = describeSelectionParent(selected); const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0); @@ -227,36 +229,37 @@ export function updateDetailPanel( const shipBehavior = describeShipBehavior(ship); const shipOrder = describeShipOrder(ship); const shipAction = describeShipCurrentAction(ship); - detailTitleEl.textContent = ship.label; - detailBodyEl.innerHTML = ` -

Parent ${parent}

-

Behavior ${shipBehavior}

-

State ${shipState}

-

Order ${shipOrder}

-

Task ${ship.controllerTaskKind}

- ${shipAction ? ` -
-
- ${shipAction.label} - ${Math.round(shipAction.progress * 100)}% + return { + title: ship.label, + bodyHtml: ` +

Parent ${parent}

+

Behavior ${shipBehavior}

+

State ${shipState}

+

Order ${shipOrder}

+

Task ${ship.controllerTaskKind}

+ ${shipAction ? ` +
+
+ ${shipAction.label} + ${Math.round(shipAction.progress * 100)}% +
+
+
+
-
-
-
-
- ` : ""} -

Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}

-

Inventory ${formatInventory(ship.inventory)}

-

Speed ${formatShipSpeed(ship)}

-

Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}
Press C to toggle follow

- `; - return; + ` : ""} +

Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}

+

Inventory ${formatInventory(ship.inventory)}

+

Speed ${formatShipSpeed(ship)}

+

Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}
Press C to toggle follow

+ `, + }; } if (selected.kind === "station") { const station = world.stations.get(selected.id); if (!station) { - return; + return { title: "Missing station", bodyHtml: "" }; } const parent = describeSelectionParent(selected); const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses); @@ -264,110 +267,116 @@ export function updateDetailPanel( ? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("
") : "none"; const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory); - detailTitleEl.textContent = station.label; - detailBodyEl.innerHTML = ` -

${station.category} · ${station.systemId}

-

Parent ${parent}

-

Docked ${station.dockedShips} / ${station.dockingPads} -
- ${dockedShipLabels}

-

Modules ${moduleList}

-

Storage ${stationStorage}

- `; - return; + return { + title: station.label, + bodyHtml: ` +

${station.category} · ${station.systemId}

+

Parent ${parent}

+

Docked ${station.dockedShips} / ${station.dockingPads} +
+ ${dockedShipLabels}

+

Modules ${moduleList}

+

Storage ${stationStorage}

+ `, + }; } if (selected.kind === "node") { const node = world.nodes.get(selected.id); if (!node) { - return; + return { title: "Missing node", bodyHtml: "" }; } const parent = describeSelectionParent(selected); const nodeLevel = node.maxOre > 0 ? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1)) : 0; - detailTitleEl.textContent = `Node ${node.id}`; - detailBodyEl.innerHTML = ` -

${node.systemId}

-

Parent ${parent}

-

Source ${node.sourceKind}
Resource ${node.itemId}

-
-
- Level - ${Math.round(nodeLevel * 100)}% + return { + title: `Node ${node.id}`, + bodyHtml: ` +

${node.systemId}

+

Parent ${parent}

+

Source ${node.sourceKind}
Resource ${node.itemId}

+
+
+ Level + ${Math.round(nodeLevel * 100)}% +
+
+
+
-
-
-
-
-

Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

- `; - return; +

Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

+ `, + }; } if (selected.kind === "celestial") { const celestial = world.celestials.get(selected.id); if (!celestial) { - return; + return { title: "Missing celestial", bodyHtml: "" }; } - detailTitleEl.textContent = `${celestial.kind} celestial`; - detailBodyEl.innerHTML = ` -

${celestial.systemId}

-

Parent ${celestial.parentNodeId ?? "none"}
Orbit ref ${celestial.orbitReferenceId ?? "none"}

-

Occupying structure ${celestial.occupyingStructureId ?? "none"}

-

Local space radius ${celestial.localSpaceRadius.toFixed(0)} km

- `; - return; + return { + title: `${celestial.kind} celestial`, + bodyHtml: ` +

${celestial.systemId}

+

Parent ${celestial.parentNodeId ?? "none"}
Orbit ref ${celestial.orbitReferenceId ?? "none"}

+

Occupying structure ${celestial.occupyingStructureId ?? "none"}

+

Local space radius ${celestial.localSpaceRadius.toFixed(0)} km

+ `, + }; } if (selected.kind === "claim") { const claim = world.claims.get(selected.id); if (!claim) { - return; + return { title: "Missing claim", bodyHtml: "" }; } - detailTitleEl.textContent = `Claim ${claim.id}`; - detailBodyEl.innerHTML = ` -

${claim.systemId}

-

Celestial ${claim.celestialId}

-

State ${claim.state}
Health ${claim.health.toFixed(0)}

-

Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}

- `; - return; + return { + title: `Claim ${claim.id}`, + bodyHtml: ` +

${claim.systemId}

+

Celestial ${claim.celestialId}

+

State ${claim.state}
Health ${claim.health.toFixed(0)}

+

Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}

+ `, + }; } if (selected.kind === "construction-site") { const site = world.constructionSites.get(selected.id); if (!site) { - return; + return { title: "Missing construction", bodyHtml: "" }; } const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length; - detailTitleEl.textContent = `Construction ${site.id}`; - detailBodyEl.innerHTML = ` -

${site.systemId}

-

Celestial ${site.celestialId}

-

${site.targetKind} ${site.targetDefinitionId}

-

State ${site.state}
Progress ${(site.progress * 100).toFixed(0)}%

-

Orders ${orderCount}
Assigned constructors ${site.assignedConstructorShipIds.length}

- `; - return; + return { + title: `Construction ${site.id}`, + bodyHtml: ` +

${site.systemId}

+

Celestial ${site.celestialId}

+

${site.targetKind} ${site.targetDefinitionId}

+

State ${site.state}
Progress ${(site.progress * 100).toFixed(0)}%

+

Orders ${orderCount}
Assigned constructors ${site.assignedConstructorShipIds.length}

+ `, + }; } if (selected.kind === "planet") { const system = world.systems.get(selected.systemId); const planet = system?.planets[selected.planetIndex]; if (!system || !planet) { - return; + return { title: "Missing planet", bodyHtml: "" }; } const parent = describeSelectionParent(selected); - detailTitleEl.textContent = planet.label; - detailBodyEl.innerHTML = ` -

${system.label}

-

Parent ${parent}

-

${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}

-

Orbit ${formatSystemDistance(planet.orbitRadius)}
Speed ${planet.orbitSpeed.toFixed(3)}
Ecc ${planet.orbitEccentricity.toFixed(3)}
Inc ${planet.orbitInclination.toFixed(1)}°

-

Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°

- `; - return; + return { + title: planet.label, + bodyHtml: ` +

${system.label}

+

Parent ${parent}

+

${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}

+

Orbit ${formatSystemDistance(planet.orbitRadius)}
Speed ${planet.orbitSpeed.toFixed(3)}
Ecc ${planet.orbitEccentricity.toFixed(3)}
Inc ${planet.orbitInclination.toFixed(1)}°

+

Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°

+ `, + }; } if (selected.kind === "moon") { @@ -375,51 +384,57 @@ export function updateDetailPanel( const planet = system?.planets[selected.planetIndex]; const moon = planet?.moons[selected.moonIndex]; if (moon) { - detailTitleEl.textContent = moon.label; - detailBodyEl.innerHTML = ` -

${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}

-

Orbit ${formatSystemDistance(moon.orbitRadius)}
Inc ${moon.orbitInclination.toFixed(1)}°

- `; + return { + title: moon.label, + bodyHtml: ` +

${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}

+

Orbit ${formatSystemDistance(moon.orbitRadius)}
Inc ${moon.orbitInclination.toFixed(1)}°

+ `, + }; } - return; + return { title: "Moon", bodyHtml: "" }; } const system = world.systems.get(selected.id); if (!system) { - return; + return { + title: "Unknown selection", + bodyHtml: "", + }; } - detailTitleEl.textContent = system.label; - detailBodyEl.innerHTML = ` -

Parent galaxy

- ${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)} - `; + return { + title: system.label, + bodyHtml: ` +

Parent galaxy

+ ${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)} + `, + }; } -export function updateSystemPanel(params: SystemPanelParams) { +export function buildSystemPanelState(params: SystemPanelParams) { const { world, activeSystemId, - systemTitleEl, - systemBodyEl, - systemPanelEl, - cameraMode, - cameraTargetShipId, } = params; const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined; - systemPanelEl.hidden = !activeSystem; if (!activeSystem) { - systemTitleEl.textContent = "Deep Space"; - systemBodyEl.innerHTML = ""; - return; + return { + hidden: true, + title: "Deep Space", + bodyHtml: "", + }; } - systemTitleEl.textContent = activeSystem.label; - systemBodyEl.innerHTML = ` -

${renderSystemOwnership(world, activeSystem.id)}

- `; + return { + hidden: false, + title: activeSystem.label, + bodyHtml: ` +

${renderSystemOwnership(world, activeSystem.id)}

+ `, + }; } export function describeSelectionParent( diff --git a/apps/viewer/src/viewerPresentationController.ts b/apps/viewer/src/viewerPresentationController.ts index 6b42dcd..4c6740b 100644 --- a/apps/viewer/src/viewerPresentationController.ts +++ b/apps/viewer/src/viewerPresentationController.ts @@ -1,34 +1,27 @@ import * as THREE from "three"; import { - updateNetworkPanel as renderNetworkPanel, + describeNetworkPanel, + describePerformancePanel, recordPerformanceStats, summarizeNetworkStats, summarizePerformanceStats, - updatePerformancePanel as renderPerformancePanel, } from "./viewerTelemetry"; import { updatePlanetPresentation } from "./viewerPresentation"; -import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation"; -import { updateSystemPanel } from "./viewerPanels"; +import { buildSystemPanelState } from "./viewerPanels"; +import { describeGameStatus, renderRecentEvents, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation"; import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory"; +import type { ViewerHudState } from "./viewerHudState"; import type { Selectable } from "./viewerTypes"; export interface ViewerPresentationContext { renderer: THREE.WebGLRenderer; + hudState: ViewerHudState; galaxyScene: THREE.Scene; galaxyCamera: THREE.PerspectiveCamera; systemCamera: THREE.PerspectiveCamera; galaxyAnchor: THREE.Vector3; systemAnchor: THREE.Vector3; ambienceGroup: THREE.Group; - gameSummaryEl: HTMLSpanElement; - networkSummaryEl: HTMLSpanElement; - performanceSummaryEl: HTMLSpanElement; - statusEl: HTMLDivElement; - networkPanelEl: HTMLDivElement; - performancePanelEl: HTMLDivElement; - systemPanelEl: HTMLDivElement; - systemTitleEl: HTMLHeadingElement; - systemBodyEl: HTMLDivElement; networkStats: any; performanceStats: any; getWorld: () => any; @@ -61,7 +54,6 @@ export class ViewerPresentationController { } applyZoomPresentation() { - const activeSystemId = this.context.getActiveSystemId(); const povLevel = this.context.getPovLevel(); this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035); @@ -73,8 +65,8 @@ export class ViewerPresentationController { } updateNetworkPanel() { - renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats); - this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats); + this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats); + this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats); } recordPerformanceStats(frameMs: number) { @@ -82,8 +74,11 @@ export class ViewerPresentationController { } updatePerformancePanel() { - renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer); - this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats); + const bodyText = describePerformancePanel(this.context.performanceStats, this.context.renderer); + if (bodyText) { + this.context.hudState.performancePanel.bodyText = bodyText; + } + this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats); } updateShipPresentation() { @@ -109,9 +104,7 @@ export class ViewerPresentationController { } updateGamePanel(mode: string) { - updateGameStatus({ - statusEl: this.context.statusEl, - summaryEl: this.context.gameSummaryEl, + const state = describeGameStatus({ world: this.context.getWorld(), activeSystemId: this.context.getActiveSystemId(), cameraMode: this.context.getCameraMode(), @@ -121,6 +114,8 @@ export class ViewerPresentationController { galaxyAnchor: this.context.galaxyAnchor, systemAnchor: this.context.systemAnchor, }); + this.context.hudState.gamePanel.bodyText = state.bodyText; + this.context.hudState.gamePanel.summary = state.summaryText; } updateSystemPanel() { @@ -129,15 +124,15 @@ export class ViewerPresentationController { return; } - updateSystemPanel({ + const state = buildSystemPanelState({ world, activeSystemId: this.context.getActiveSystemId(), - systemTitleEl: this.context.systemTitleEl, - systemBodyEl: this.context.systemBodyEl, - systemPanelEl: this.context.systemPanelEl, cameraMode: this.context.getCameraMode(), cameraTargetShipId: this.context.getCameraTargetShipId(), }); + this.context.hudState.systemPanel.hidden = state.hidden; + this.context.hudState.systemPanel.title = state.title; + this.context.hudState.systemPanel.bodyHtml = state.bodyHtml; } screenPointFromClient(clientX: number, clientY: number) { diff --git a/apps/viewer/src/viewerRenderLoop.ts b/apps/viewer/src/viewerRenderLoop.ts index 261d6eb..4a261b3 100644 --- a/apps/viewer/src/viewerRenderLoop.ts +++ b/apps/viewer/src/viewerRenderLoop.ts @@ -25,10 +25,11 @@ export interface RenderFrameParams { } export interface ResizeParams { - renderer: THREE.WebGLRenderer; galaxyLayer: GalaxyLayer; systemLayer: SystemLayer; localLayer: LocalLayer; + width: number; + height: number; } export interface CameraStepParams { @@ -72,12 +73,10 @@ export function renderFrame(params: RenderFrameParams) { } export function resizeViewer(params: ResizeParams) { - const width = window.innerWidth; - const height = window.innerHeight; - params.galaxyLayer.onResize(width / height); - params.systemLayer.onResize(width / height); - params.localLayer.onResize(width / height); - params.renderer.setSize(width, height); + const aspect = params.width / params.height; + params.galaxyLayer.onResize(aspect); + params.systemLayer.onResize(aspect); + params.localLayer.onResize(aspect); } export function stepCamera(params: CameraStepParams) { diff --git a/apps/viewer/src/viewerTelemetry.ts b/apps/viewer/src/viewerTelemetry.ts index 103c29d..4839e40 100644 --- a/apps/viewer/src/viewerTelemetry.ts +++ b/apps/viewer/src/viewerTelemetry.ts @@ -6,6 +6,10 @@ import type { } from "./viewerTypes"; export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) { + networkPanelEl.textContent = describeNetworkPanel(networkStats); +} + +export function describeNetworkPanel(networkStats: NetworkStats) { const now = performance.now(); const uptimeSeconds = networkStats.streamOpenedAtMs ? (now - networkStats.streamOpenedAtMs) / 1000 @@ -22,7 +26,7 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: ? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1) : "n/a"; - networkPanelEl.textContent = [ + return [ `snapshot: ${formatBytes(networkStats.snapshotBytes)}`, `stream: ${networkStats.streamConnected ? "live" : "offline"}`, `deltas: ${networkStats.deltasReceived}`, @@ -59,13 +63,23 @@ export function updatePerformancePanel( performancePanelEl: HTMLDivElement, performanceStats: PerformanceStats, renderer: THREE.WebGLRenderer, +) { + const text = describePerformancePanel(performanceStats, renderer); + if (text) { + performancePanelEl.textContent = text; + } +} + +export function describePerformancePanel( + performanceStats: PerformanceStats, + renderer: THREE.WebGLRenderer, ) { const now = performance.now(); if ( performanceStats.lastPanelUpdateAtMs > 0 && now - performanceStats.lastPanelUpdateAtMs < 250 ) { - return; + return undefined; } const samples = performanceStats.frameSamples; @@ -84,7 +98,8 @@ export function updatePerformancePanel( const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0; const renderInfo = renderer.info; - performancePanelEl.textContent = [ + performanceStats.lastPanelUpdateAtMs = now; + return [ `fps: ${fps.toFixed(1)}`, `frame avg: ${averageFrameMs.toFixed(2)} ms`, `frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`, @@ -98,7 +113,6 @@ export function updatePerformancePanel( `textures: ${renderInfo.memory.textures}`, `pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`, ].join("\n"); - performanceStats.lastPanelUpdateAtMs = now; } export function summarizePerformanceStats(performanceStats: PerformanceStats): string { diff --git a/apps/viewer/src/viewerTypes.ts b/apps/viewer/src/viewerTypes.ts index e85e9c7..19fcdca 100644 --- a/apps/viewer/src/viewerTypes.ts +++ b/apps/viewer/src/viewerTypes.ts @@ -183,13 +183,3 @@ export interface PerformanceStats { lastFrameMs: number; lastPanelUpdateAtMs: number; } - -export interface HistoryWindowState { - id: string; - target: Selectable; - root: HTMLElement; - titleEl: HTMLHeadingElement; - bodyEl: HTMLDivElement; - copyButtonEl: HTMLButtonElement; - text: string; -} diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts index 74d754e..9ea306e 100644 --- a/apps/viewer/src/viewerWorldLifecycle.ts +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -1,6 +1,7 @@ import { fetchWorldSnapshot, openWorldStream } from "./api"; -import { renderOpsStrip } from "./viewerOpsStrip"; -import { updateDetailPanel } from "./viewerPanels"; +import type { ViewerHudState } from "./viewerHudState"; +import { buildOpsStripState } from "./viewerOpsStrip"; +import { buildDetailPanelState } from "./viewerPanels"; import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState"; import type { CelestialDelta, @@ -46,10 +47,7 @@ export interface ViewerWorldLifecycleContext { getCameraTargetShipId: () => string | undefined; getNetworkStats: () => NetworkStats; getSystemSummaryVisuals: () => Map; - errorEl: HTMLDivElement; - opsStripEl: HTMLDivElement; - detailTitleEl: HTMLHeadingElement; - detailBodyEl: HTMLDivElement; + hudState: ViewerHudState; worldLabel: () => string; rebuildSystems: (systems: SystemSnapshot[]) => void; syncCelestials: (celestials: CelestialSnapshot[]) => void; @@ -83,14 +81,15 @@ export class ViewerWorldLifecycle { this.context.setWorld(createWorldState(snapshot)); this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size; this.context.updateGamePanel("Bootstrapped"); - this.context.errorEl.hidden = true; + this.context.hudState.error.hidden = true; + this.context.hudState.error.message = ""; this.applySnapshot(snapshot); this.openDeltaStream(snapshot.sequence); this.updatePanels(); } catch (error) { this.context.updateGamePanel("Backend offline"); - this.context.errorEl.hidden = false; - this.context.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot."; + this.context.hudState.error.hidden = false; + this.context.hudState.error.message = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot."; } } @@ -188,7 +187,7 @@ export class ViewerWorldLifecycle { } rebuildFactions(_factions: FactionSnapshot[]) { - this.context.opsStripEl.innerHTML = renderOpsStrip( + this.context.hudState.opsStrip = buildOpsStripState( this.context.getWorld(), this.context.getSelectedItems(), this.context.getCameraMode(), @@ -207,7 +206,7 @@ export class ViewerWorldLifecycle { this.context.refreshHistoryWindows(); this.context.updateSystemPanel(); this.refreshStreamScopeIfNeeded(); - updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, { + const detailState = buildDetailPanelState({ world, selectedItems: this.context.getSelectedItems(), povLevel: this.context.getPovLevel(), @@ -216,6 +215,9 @@ export class ViewerWorldLifecycle { worldLabel: this.context.worldLabel(), describeSelectionParent: this.context.describeSelectionParent, }); + this.context.hudState.detailPanel.hidden = false; + this.context.hudState.detailPanel.title = detailState.title; + this.context.hudState.detailPanel.bodyHtml = detailState.bodyHtml; } private getPreferredStreamScope() { diff --git a/apps/viewer/src/viewerWorldPresentation.ts b/apps/viewer/src/viewerWorldPresentation.ts index 437bf39..a49a4ce 100644 --- a/apps/viewer/src/viewerWorldPresentation.ts +++ b/apps/viewer/src/viewerWorldPresentation.ts @@ -68,8 +68,6 @@ export interface WorldPresentationContext extends WorldOrbitalContext { } export interface GameStatusParams { - statusEl: HTMLDivElement; - summaryEl?: HTMLSpanElement; world?: WorldState; activeSystemId?: string; cameraMode: CameraMode; @@ -269,8 +267,8 @@ function fmtVec(v: THREE.Vector3, digits: number) { return `${v.x.toFixed(digits)} ${v.y.toFixed(digits)} ${v.z.toFixed(digits)}`; } -export function updateGameStatus(params: GameStatusParams) { - const { statusEl, summaryEl, world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params; +export function describeGameStatus(params: GameStatusParams) { + const { world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params; const sequence = world?.sequence ?? 0; const generatedAt = world?.generatedAtUtc ? new Date(world.generatedAtUtc).toLocaleTimeString() @@ -296,19 +294,27 @@ export function updateGameStatus(params: GameStatusParams) { ? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km` : ""; - statusEl.textContent = [ - `mode: ${mode}`, - `camera: ${cameraModeLabel}`, - `zoom: ${displayPovLevel}`, - `space: ${activeSpace}`, - galPos, - sysPos, - locPos, - `sequence: ${sequence}`, - `snapshot: ${generatedAt}`, - ].filter(Boolean).join("\n"); - if (summaryEl) { - summaryEl.textContent = `${mode} | ${displayPovLevel} | ${activeSpace}`; + return { + bodyText: [ + `mode: ${mode}`, + `camera: ${cameraModeLabel}`, + `zoom: ${displayPovLevel}`, + `space: ${activeSpace}`, + galPos, + sysPos, + locPos, + `sequence: ${sequence}`, + `snapshot: ${generatedAt}`, + ].filter(Boolean).join("\n"), + summaryText: `${mode} | ${displayPovLevel} | ${activeSpace}`, + }; +} + +export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivElement; summaryEl?: HTMLSpanElement }) { + const state = describeGameStatus(params); + params.statusEl.textContent = state.bodyText; + if (params.summaryEl) { + params.summaryEl.textContent = state.summaryText; } } diff --git a/apps/viewer/tsconfig.json b/apps/viewer/tsconfig.json index 99eed22..4e13b19 100644 --- a/apps/viewer/tsconfig.json +++ b/apps/viewer/tsconfig.json @@ -9,7 +9,8 @@ "strict": true, "noEmit": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["vite/client"] }, "include": ["src", "vite.config.ts"] } diff --git a/apps/viewer/vite.config.ts b/apps/viewer/vite.config.ts index 4243777..80e0f97 100644 --- a/apps/viewer/vite.config.ts +++ b/apps/viewer/vite.config.ts @@ -1,8 +1,11 @@ +import tailwindcss from "@tailwindcss/vite"; +import vue from "@vitejs/plugin-vue"; import { defineConfig } from "vite"; const root = new URL(".", import.meta.url).pathname; export default defineConfig({ + plugins: [tailwindcss(), vue()], root, server: { host: true,