diff --git a/.gitignore b/.gitignore index 6f3092c..be30dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,10 @@ dist/ .vscode/* !.vscode/extensions.json .idea + +# FlatBuffers +bin/ +src/generated/ + +# Telemetry data +public/data/ diff --git a/package.json b/package.json index dccea88..efed656 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,20 @@ "check": "biome check --write", "dev": "rsbuild dev --open", "format": "biome format --write", + "convert:csv": "tsx tools/csv-to-bin.ts", + "generate:fbs": "bin/flatc --ts -o src/generated/ telemetry.fbs", + "generate:mock-bin": "tsx tools/generate-mock-bin.ts", + "generate:mock-csv": "tsx tools/generate-mock-csv.ts", "preview": "rsbuild preview", "test": "rstest", "test:watch": "rstest --watch" }, "dependencies": { - "preact": "^10.28.1" + "chart.js": "^4.5.1", + "flatbuffers": "^25.9.23", + "preact": "^10.28.1", + "react-chartjs-2": "^5.3.1", + "react-split-pane": "^3.2.0" }, "devDependencies": { "@biomejs/biome": "2.3.8", @@ -23,6 +31,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "happy-dom": "^20.0.11", + "tsx": "^4.21.0", "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..38c75a7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1229 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + chart.js: + specifier: ^4.5.1 + version: 4.5.1 + flatbuffers: + specifier: ^25.9.23 + version: 25.9.23 + preact: + specifier: ^10.28.1 + version: 10.28.4 + react-chartjs-2: + specifier: ^5.3.1 + version: 5.3.1(chart.js@4.5.1)(react@19.2.4) + react-split-pane: + specifier: ^3.2.0 + version: 3.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devDependencies: + '@biomejs/biome': + specifier: 2.3.8 + version: 2.3.8 + '@rsbuild/core': + specifier: ^1.7.1 + version: 1.7.3 + '@rsbuild/plugin-preact': + specifier: ^1.7.0 + version: 1.7.1(@rsbuild/core@1.7.3)(preact@10.28.4) + '@rstest/core': + specifier: ^0.7.8 + version: 0.7.9(happy-dom@20.8.3) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + happy-dom: + specifier: ^20.0.11 + version: 20.8.3 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@module-federation/error-codes@0.22.0': + resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==} + + '@module-federation/runtime-core@0.22.0': + resolution: {integrity: sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==} + + '@module-federation/runtime-tools@0.22.0': + resolution: {integrity: sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==} + + '@module-federation/runtime@0.22.0': + resolution: {integrity: sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==} + + '@module-federation/sdk@0.22.0': + resolution: {integrity: sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==} + + '@module-federation/webpack-bundler-runtime@0.22.0': + resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==} + + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + + '@prefresh/core@1.5.9': + resolution: {integrity: sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==} + peerDependencies: + preact: ^10.0.0 || ^11.0.0-0 + + '@prefresh/utils@1.2.1': + resolution: {integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==} + + '@rsbuild/core@1.7.1': + resolution: {integrity: sha512-ULIE/Qh+Ne80Pm/aUPbRHUvwvIzpap07jYNFB47azI8w5Q3sDEC4Gn574jsluT/42iNDsZTFADRBog9FEvtN9Q==} + engines: {node: '>=18.12.0'} + hasBin: true + + '@rsbuild/core@1.7.3': + resolution: {integrity: sha512-kI1oQvCXbQYxUvQPnDLdjSX4gFsbrFNpuUj6jXEJ7IcJ74Q+n4oeFj74/8tKerhxhe0L90m/ZQfzLeN5ORGA9w==} + engines: {node: '>=18.12.0'} + hasBin: true + + '@rsbuild/plugin-preact@1.7.1': + resolution: {integrity: sha512-FRBO4kQ72s6s4F2D7MMMljSD4tjkMg9cCfJg/UBnX+rySLiWFDSG5zsM7G6ALcLWEriYCMupmD7LJL+eDA+nGA==} + peerDependencies: + '@rsbuild/core': ^1.0.0 || ^2.0.0-0 + + '@rspack/binding-darwin-arm64@1.7.0': + resolution: {integrity: sha512-HMYrhvVh3sMRBXl6cSI2JqsvlHJKQ42qX+Sw4qbj7LeZBN6Gv4GjfL3cXRLUTdO37FOC0uLEUYgxVXetx/Y4sA==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-arm64@1.7.7': + resolution: {integrity: sha512-eL14fHy2JqfQ0YA5YMN2hktXhbafDSZt5kthvlBCbpQZLnYB7RP7TjHManIW/xFpnzrabvxkrLUOHhuIbWixIw==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@1.7.0': + resolution: {integrity: sha512-R/SoR04ySmHPqoIBGC+SjP9zRGjL1fS908mdwBvQ1RfFinKu7a/o/5rxH/vxUUsVQrHCyX+o7YXpfWq9xpvyQA==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-darwin-x64@1.7.7': + resolution: {integrity: sha512-Zt+whHag/cTw1pZfRwkv11tu5LaAHy2VkvRVCsHClwrfp81PRcNJ2oRMurOUmRt1YL0mRdpRbZTh7XjGSc6gGw==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@1.7.0': + resolution: {integrity: sha512-jDCcso++qshu58+Iuo6oiL0XKuX04lDugL0qwrWHW8SS/EjZ2rc1J3yQx+XDW0PCQsfI2c9ji0IOW56PzW1hXQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-arm64-gnu@1.7.7': + resolution: {integrity: sha512-uSq4qkvmAzSDUTKE2v4yUgHIBdTily1k3BcK5wBCGFm9OPODj5lQZpAdOHHIwu+Jxyjoa7Mb64tghhj9hZcXcA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-arm64-musl@1.7.0': + resolution: {integrity: sha512-0W49s0SQQhr3hZ8Zd7Auyf2pv4OTBr6wQhgWUQ6XeeMEjB16KpAVypSK5Jpn1ON0v9jAPLdod+a255rz8/f3kg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-arm64-musl@1.7.7': + resolution: {integrity: sha512-NhWCBfiu6plpmLRP6c6D5lBUaVrBr1nvjSEc7VyQF8TGh8URo2btH0wngEiX0nWvidsSlERt1l6Y5QPGuiCl1g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-x64-gnu@1.7.0': + resolution: {integrity: sha512-oFjzjTD1MmG0ucAaP0Wyg9eobrsnFwZjEHa7LwyzWDRBeC3GWAF9T04Bqd6Ba6DgASGzU0BjEJcUpjvtXxO95Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-gnu@1.7.7': + resolution: {integrity: sha512-aRvf8gCI7jDeEN9i4u9fY5coa3ZAyHzGVA4ZhTJCgZ5wWA5A9SQewMSq7khS1WAAFE1USlk1tUuPujnrGoYrGg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-musl@1.7.0': + resolution: {integrity: sha512-MNGslPLOsurdwOcoo6r0u8mLpw1ADar3hkx67WzwwMqYnem/Ky0aANJC2JvQHPC22mu01gCOukHYyEaUFTxcuw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-x64-musl@1.7.7': + resolution: {integrity: sha512-ALPto4OT7snzXbYDyqkLfh1BvwDTTH1hPYXGUXBzQ0wEV7sXeyvxCC4yjH6B5MhR7W3tFuF4IfDy5Z4BxmOoGQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-wasm32-wasi@1.7.0': + resolution: {integrity: sha512-eaZzkGpxzVESmaX/UALMiQO+eNppe/i1VWQksGRfdoUu0rILqr/YDjsWFTcpbI9Dt3fg2kshHawBHxfwtxHcZQ==} + cpu: [wasm32] + + '@rspack/binding-wasm32-wasi@1.7.7': + resolution: {integrity: sha512-7DZvUp0v75n451qfZw1ppbPakL6NAc2gjb5e9AJcOb7KUMBHNyOxqpPo/jRYKxH7isPpLfpoId79WQGGNTTMAw==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@1.7.0': + resolution: {integrity: sha512-XFg4l7sOhupnpG0soOfzYLeF2cgpSJMenmjmdzd9y06CotTyVId0hNoS7y+A7hEP8XGf3YPbdiUL5UDp6+DRBA==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-arm64-msvc@1.7.7': + resolution: {integrity: sha512-oI08KqyVDKhq1Qi/YPMdrSLDOib0DQes9Cg67NJLZISe5UXwzvgBj7zyyKpaj8TLWnIlKSq4ITr3haRnd4lOfA==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@1.7.0': + resolution: {integrity: sha512-eWt2XV6la/c0IlU/18RlhQsqwHGShSypwA3kt4s/dpfOK0YB1h4f0fYeUZuvj2X0MIoJQGhMofMrgA35/IcAcw==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@1.7.7': + resolution: {integrity: sha512-nZ/t7XpO/+tRjK6m85an27j8FwJqpYXVSBGReZbB6dVHZiS7l6psjWkIf6A3E2umn/RjA7qvHaPH9czWkH+Fhw==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@1.7.0': + resolution: {integrity: sha512-LOL5G8rfbAwlmusx+t98r9QzuGRz+L9Bg+8s5s6K/Qe64iemcNIuxGr5QLVq1jLa0SGNTeog4N21pAzlkWh4jw==} + cpu: [x64] + os: [win32] + + '@rspack/binding-win32-x64-msvc@1.7.7': + resolution: {integrity: sha512-+XnPOC1MoeF5Qa24Z8+DCsytQP0Q9Ifdkh+XzTWgvjpFQmGAkDynHUVfscmJL/8k/nd1l/6TyXCL1EGoqa0huQ==} + cpu: [x64] + os: [win32] + + '@rspack/binding@1.7.0': + resolution: {integrity: sha512-xO+pZKG2dvU9CuRTTi+DcCc4p+CZhBJlvuYikBja/0a62cTntQV2PWV+/xU1a6Vbo89yNz158LR05nvjtKVwTw==} + + '@rspack/binding@1.7.7': + resolution: {integrity: sha512-9FqHG2Bl70Bd4gUmwA+3xUx4pYphdLO9ToIm9iMWbBINyArME0XboZg4FoEdU13LqndkWqaamkE613BR0lRF3g==} + + '@rspack/core@1.7.0': + resolution: {integrity: sha512-uDxPQsPh/+2DnOISuKnUiXZ9M0y2G1BOsI0IesxPJGp42ME2QW7axbJfUqD3bwp4bi3RN2zqh56NgxU/XETQvA==} + engines: {node: '>=18.12.0'} + peerDependencies: + '@swc/helpers': '>=0.5.1' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@rspack/core@1.7.7': + resolution: {integrity: sha512-efwVXxAA9eYgLtYX53zcuuex6Wr8DnOXeIw3JFoA8EuyN7TINGqnvkuGDuE+F9XQxQ3KBzVueiYdMK42sVTyUw==} + engines: {node: '>=18.12.0'} + peerDependencies: + '@swc/helpers': '>=0.5.1' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@rspack/lite-tapable@1.1.0': + resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} + + '@rspack/plugin-preact-refresh@1.1.5': + resolution: {integrity: sha512-1oXF8ovAF+XoMcZ1qjeJ+l28qXAnKmVulV8Q5/Q1bXDkfMBmvC10O94wpxojcIY/kdMrHIFTfWbUhm0qBcYeGA==} + peerDependencies: + '@prefresh/core': ^1.5.0 + '@prefresh/utils': ^1.2.0 + + '@rstest/core@0.7.9': + resolution: {integrity: sha512-RHNPS1MDUxtf+1Z0YZi+vIQ13SdvCbcbpzDA7XHcPziTRy2mAPg8nfcms+XzbIp95KXH75ucAhgAKNFO0QgARA==} + engines: {node: '>=18.12.0'} + hasBin: true + peerDependencies: + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@swc/plugin-prefresh@12.7.0': + resolution: {integrity: sha512-1d+YWDPdeHcxbK6WXwm+TYMDwKSsSzf6OoqFrBpUujr3XvE2ydK+egocGJS7Z8bhwIUD4bHst6CqZVO+v32o/Q==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/node@25.3.5': + resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + happy-dom@20.8.3: + resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} + engines: {node: '>=20.0.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + preact@10.28.4: + resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + react-chartjs-2@5.3.1: + resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==} + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-split-pane@3.2.0: + resolution: {integrity: sha512-tH2SaMqBZ7Ypcmj6I/2fIR0gdeE0Q5ve66T51UyXcSqIgPGJh40bOMTVCAFjB0Q3OB5+Aw/4ECVWU+YU9SL88Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/runtime@7.28.6': {} + + '@biomejs/biome@2.3.8': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 + + '@biomejs/cli-darwin-arm64@2.3.8': + optional: true + + '@biomejs/cli-darwin-x64@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64@2.3.8': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-x64@2.3.8': + optional: true + + '@biomejs/cli-win32-arm64@2.3.8': + optional: true + + '@biomejs/cli-win32-x64@2.3.8': + optional: true + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@kurkle/color@0.3.4': {} + + '@module-federation/error-codes@0.22.0': {} + + '@module-federation/runtime-core@0.22.0': + dependencies: + '@module-federation/error-codes': 0.22.0 + '@module-federation/sdk': 0.22.0 + + '@module-federation/runtime-tools@0.22.0': + dependencies: + '@module-federation/runtime': 0.22.0 + '@module-federation/webpack-bundler-runtime': 0.22.0 + + '@module-federation/runtime@0.22.0': + dependencies: + '@module-federation/error-codes': 0.22.0 + '@module-federation/runtime-core': 0.22.0 + '@module-federation/sdk': 0.22.0 + + '@module-federation/sdk@0.22.0': {} + + '@module-federation/webpack-bundler-runtime@0.22.0': + dependencies: + '@module-federation/runtime': 0.22.0 + '@module-federation/sdk': 0.22.0 + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@prefresh/core@1.5.9(preact@10.28.4)': + dependencies: + preact: 10.28.4 + + '@prefresh/utils@1.2.1': {} + + '@rsbuild/core@1.7.1': + dependencies: + '@rspack/core': 1.7.0(@swc/helpers@0.5.19) + '@rspack/lite-tapable': 1.1.0 + '@swc/helpers': 0.5.19 + core-js: 3.47.0 + jiti: 2.6.1 + + '@rsbuild/core@1.7.3': + dependencies: + '@rspack/core': 1.7.7(@swc/helpers@0.5.19) + '@rspack/lite-tapable': 1.1.0 + '@swc/helpers': 0.5.19 + core-js: 3.47.0 + jiti: 2.6.1 + + '@rsbuild/plugin-preact@1.7.1(@rsbuild/core@1.7.3)(preact@10.28.4)': + dependencies: + '@prefresh/core': 1.5.9(preact@10.28.4) + '@prefresh/utils': 1.2.1 + '@rsbuild/core': 1.7.3 + '@rspack/plugin-preact-refresh': 1.1.5(@prefresh/core@1.5.9(preact@10.28.4))(@prefresh/utils@1.2.1) + '@swc/plugin-prefresh': 12.7.0 + transitivePeerDependencies: + - preact + + '@rspack/binding-darwin-arm64@1.7.0': + optional: true + + '@rspack/binding-darwin-arm64@1.7.7': + optional: true + + '@rspack/binding-darwin-x64@1.7.0': + optional: true + + '@rspack/binding-darwin-x64@1.7.7': + optional: true + + '@rspack/binding-linux-arm64-gnu@1.7.0': + optional: true + + '@rspack/binding-linux-arm64-gnu@1.7.7': + optional: true + + '@rspack/binding-linux-arm64-musl@1.7.0': + optional: true + + '@rspack/binding-linux-arm64-musl@1.7.7': + optional: true + + '@rspack/binding-linux-x64-gnu@1.7.0': + optional: true + + '@rspack/binding-linux-x64-gnu@1.7.7': + optional: true + + '@rspack/binding-linux-x64-musl@1.7.0': + optional: true + + '@rspack/binding-linux-x64-musl@1.7.7': + optional: true + + '@rspack/binding-wasm32-wasi@1.7.0': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@rspack/binding-wasm32-wasi@1.7.7': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@rspack/binding-win32-arm64-msvc@1.7.0': + optional: true + + '@rspack/binding-win32-arm64-msvc@1.7.7': + optional: true + + '@rspack/binding-win32-ia32-msvc@1.7.0': + optional: true + + '@rspack/binding-win32-ia32-msvc@1.7.7': + optional: true + + '@rspack/binding-win32-x64-msvc@1.7.0': + optional: true + + '@rspack/binding-win32-x64-msvc@1.7.7': + optional: true + + '@rspack/binding@1.7.0': + optionalDependencies: + '@rspack/binding-darwin-arm64': 1.7.0 + '@rspack/binding-darwin-x64': 1.7.0 + '@rspack/binding-linux-arm64-gnu': 1.7.0 + '@rspack/binding-linux-arm64-musl': 1.7.0 + '@rspack/binding-linux-x64-gnu': 1.7.0 + '@rspack/binding-linux-x64-musl': 1.7.0 + '@rspack/binding-wasm32-wasi': 1.7.0 + '@rspack/binding-win32-arm64-msvc': 1.7.0 + '@rspack/binding-win32-ia32-msvc': 1.7.0 + '@rspack/binding-win32-x64-msvc': 1.7.0 + + '@rspack/binding@1.7.7': + optionalDependencies: + '@rspack/binding-darwin-arm64': 1.7.7 + '@rspack/binding-darwin-x64': 1.7.7 + '@rspack/binding-linux-arm64-gnu': 1.7.7 + '@rspack/binding-linux-arm64-musl': 1.7.7 + '@rspack/binding-linux-x64-gnu': 1.7.7 + '@rspack/binding-linux-x64-musl': 1.7.7 + '@rspack/binding-wasm32-wasi': 1.7.7 + '@rspack/binding-win32-arm64-msvc': 1.7.7 + '@rspack/binding-win32-ia32-msvc': 1.7.7 + '@rspack/binding-win32-x64-msvc': 1.7.7 + + '@rspack/core@1.7.0(@swc/helpers@0.5.19)': + dependencies: + '@module-federation/runtime-tools': 0.22.0 + '@rspack/binding': 1.7.0 + '@rspack/lite-tapable': 1.1.0 + optionalDependencies: + '@swc/helpers': 0.5.19 + + '@rspack/core@1.7.7(@swc/helpers@0.5.19)': + dependencies: + '@module-federation/runtime-tools': 0.22.0 + '@rspack/binding': 1.7.7 + '@rspack/lite-tapable': 1.1.0 + optionalDependencies: + '@swc/helpers': 0.5.19 + + '@rspack/lite-tapable@1.1.0': {} + + '@rspack/plugin-preact-refresh@1.1.5(@prefresh/core@1.5.9(preact@10.28.4))(@prefresh/utils@1.2.1)': + dependencies: + '@prefresh/core': 1.5.9(preact@10.28.4) + '@prefresh/utils': 1.2.1 + + '@rstest/core@0.7.9(happy-dom@20.8.3)': + dependencies: + '@rsbuild/core': 1.7.1 + '@types/chai': 5.2.3 + tinypool: 1.1.1 + optionalDependencies: + happy-dom: 20.8.3 + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@swc/plugin-prefresh@12.7.0': + dependencies: + '@swc/counter': 0.1.3 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/node@25.3.5': + dependencies: + undici-types: 7.18.2 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.3.5 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + core-js@3.47.0: {} + + css.escape@1.5.1: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + entities@7.0.1: {} + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + flatbuffers@25.9.23: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + happy-dom@20.8.3: + dependencies: + '@types/node': 25.3.5 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + indent-string@4.0.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + lz-string@1.5.0: {} + + min-indent@1.0.1: {} + + picocolors@1.1.1: {} + + preact@10.28.4: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + react-chartjs-2@5.3.1(chart.js@4.5.1)(react@19.2.4): + dependencies: + chart.js: 4.5.1 + react: 19.2.4 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-split-pane@3.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + react@19.2.4: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + resolve-pkg-maps@1.0.0: {} + + scheduler@0.27.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + tinypool@1.1.1: {} + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + whatwg-mimetype@3.0.0: {} + + ws@8.19.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/App.css b/src/App.css index 164c0a6..28e58c4 100644 --- a/src/App.css +++ b/src/App.css @@ -1,26 +1,65 @@ -body { - margin: 0; - color: #fff; - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - background-image: linear-gradient(to bottom, #020917, #101725); +.app-shell { + display: flex; + flex-direction: column; + height: 100%; } -.content { +.nav-bar { display: flex; - min-height: 100vh; - line-height: 1.1; - text-align: center; - flex-direction: column; - justify-content: center; + align-items: center; + justify-content: space-between; + padding: 1rem 2rem; + background: #fff; + border-bottom: 1px solid #ddd; +} + +.nav-title { + font-size: 1.25rem; + font-weight: 600; +} + +.nav-buttons { + display: flex; + gap: 0.5rem; } -.content h1 { - font-size: 3.6rem; - font-weight: 700; +.nav-btn { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + cursor: pointer; +} + +.nav-btn:hover { + background: #f5f5f5; +} + +.nav-btn.active { + background: #333; + border-color: #333; + color: #fff; +} + +@media (max-width: 768px) { + .nav-bar { + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .nav-buttons { + flex-wrap: wrap; + justify-content: center; + } + + .nav-btn { + padding: 0.4rem 0.75rem; + font-size: 0.875rem; + } } -.content p { - font-size: 1.2rem; - font-weight: 400; - opacity: 0.5; +.main-content { + flex: 1; + overflow: hidden; } diff --git a/src/App.tsx b/src/App.tsx index 49accce..3ea4e69 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,40 @@ +import { useState } from 'preact/hooks'; import './App.css'; +import { DeserialisationTab, GpsTab, GraphsTab } from './tabs'; + +type Tab = 'graphs' | 'gps' | 'deserialisation'; + +const tabs: { id: Tab; label: string }[] = [ + { id: 'graphs', label: 'Graphs' }, + { id: 'gps', label: 'GPS' }, + { id: 'deserialisation', label: 'Deserialisation' }, +]; const App = () => { + const [activeTab, setActiveTab] = useState('graphs'); + return ( -
-

Rsbuild with Preact

-

Start building amazing things with Rsbuild.

+
+ + +
+ {activeTab === 'graphs' && } + {activeTab === 'gps' && } + {activeTab === 'deserialisation' && } +
); }; diff --git a/src/charts/ChartTabs.tsx b/src/charts/ChartTabs.tsx new file mode 100644 index 0000000..01abd64 --- /dev/null +++ b/src/charts/ChartTabs.tsx @@ -0,0 +1,69 @@ +import { Line } from 'react-chartjs-2'; +import type { TelemetryStore } from '../data/types'; +import type { SeriesSelector } from './adapter'; +import { useChartData } from '../hooks/use-chart-data'; + +export interface ChartTabConfig { + readonly id: string; + readonly label: string; + readonly preset: readonly SeriesSelector[]; + readonly title: string; +} + +function ChartPanel({ + store, + preset, + title, +}: { + store: TelemetryStore; + preset: readonly SeriesSelector[]; + title: string; +}) { + const data = useChartData(store, preset); + if (!data) return null; + return ( +
+ +
+ ); +} + +export function ChartPanels({ + tabs, + store, + activeTab, +}: { + tabs: readonly ChartTabConfig[]; + store: TelemetryStore; + activeTab: string; +}) { + return ( +
+ {activeTab === 'all' + ? tabs.map((tab) => ( + + )) + : tabs + .filter((tab) => tab.id === activeTab) + .map((tab) => ( + + ))} +
+ ); +} diff --git a/src/charts/adapter.ts b/src/charts/adapter.ts new file mode 100644 index 0000000..1539872 --- /dev/null +++ b/src/charts/adapter.ts @@ -0,0 +1,54 @@ +/** + * Transforms TelemetryStore data into Chart.js-compatible datasets. + * Pure functions — no side effects, no Chart.js registration. + */ + +import type { ChartData } from 'chart.js'; +import type { TelemetryFrame, TelemetryStore } from '../data/types'; + +/** + * Describes one line on a chart. + * Each selector extracts a single numeric value from a frame. + */ +export interface SeriesSelector { + readonly label: string; + readonly color: string; + readonly extract: (frame: TelemetryFrame) => number | undefined; +} + +/** + * Build a Chart.js line chart data object from a telemetry store. + * + * X-axis: time in seconds relative to the first frame (from clockNanos). + * Each selector becomes one dataset (line) on the chart. + * Frames where extract returns undefined produce null (Chart.js gap). + */ +export function buildLineChartData( + store: TelemetryStore, + selectors: readonly SeriesSelector[], +): ChartData<'line'> { + const { frames } = store; + + if (frames.length === 0) { + return { labels: [], datasets: [] }; + } + + const firstClock = frames[0].clockNanos; + + // X-axis: seconds relative to first frame + const labels = frames.map( + (f) => Number(((f.clockNanos - firstClock) / 1_000_000_000).toFixed(3)), + ); + + const datasets = selectors.map((sel) => ({ + label: sel.label, + data: frames.map((f) => sel.extract(f) ?? null), + borderColor: sel.color, + backgroundColor: sel.color, + pointRadius: 0, + borderWidth: 1.5, + tension: 0, + })); + + return { labels, datasets }; +} diff --git a/src/charts/colors.ts b/src/charts/colors.ts new file mode 100644 index 0000000..924e118 --- /dev/null +++ b/src/charts/colors.ts @@ -0,0 +1,10 @@ +export const COLORS = { + blue: '#3b82f6', + red: '#ef4444', + green: '#22c55e', + orange: '#f97316', + purple: '#8b5cf6', + cyan: '#06b6d4', + pink: '#ec4899', + yellow: '#eab308', +} as const; diff --git a/src/charts/index.ts b/src/charts/index.ts new file mode 100644 index 0000000..00d2c45 --- /dev/null +++ b/src/charts/index.ts @@ -0,0 +1,21 @@ +export { COLORS } from './colors'; +export { buildLineChartData } from './adapter'; +export type { SeriesSelector } from './adapter'; +export { + WHEEL_SPEED_ALL, + WHEEL_SPEED_FRONT, + WHEEL_SPEED_BACK, + WHEEL_SPEED_FL, + WHEEL_SPEED_FR, + WHEEL_SPEED_BL, + WHEEL_SPEED_BR, + DAMPER_POSITION_ALL, + DAMPER_POSITION_FRONT, + DAMPER_POSITION_BACK, + DAMPER_POSITION_FL, + DAMPER_POSITION_FR, + DAMPER_POSITION_BL, + DAMPER_POSITION_BR, + ACCELERATION, + GPS_SPEED, +} from './presets'; diff --git a/src/charts/presets.ts b/src/charts/presets.ts new file mode 100644 index 0000000..e4cdc3c --- /dev/null +++ b/src/charts/presets.ts @@ -0,0 +1,91 @@ +/** + * Pre-built SeriesSelector arrays for each chart type + * listed in the README TODOs. + */ + +import type { SeriesSelector } from './adapter'; +import { COLORS } from './colors'; + +// ---- Wheel Speed ---- + +export const WHEEL_SPEED_ALL: readonly SeriesSelector[] = [ + { label: 'Front Left', color: COLORS.blue, extract: (f) => f.wheelSpeed?.frontLeft }, + { label: 'Front Right', color: COLORS.red, extract: (f) => f.wheelSpeed?.frontRight }, + { label: 'Back Left', color: COLORS.green, extract: (f) => f.wheelSpeed?.backLeft }, + { label: 'Back Right', color: COLORS.orange, extract: (f) => f.wheelSpeed?.backRight }, +]; + +export const WHEEL_SPEED_FRONT: readonly SeriesSelector[] = [ + { label: 'Front Left', color: COLORS.blue, extract: (f) => f.wheelSpeed?.frontLeft }, + { label: 'Front Right', color: COLORS.red, extract: (f) => f.wheelSpeed?.frontRight }, +]; + +export const WHEEL_SPEED_BACK: readonly SeriesSelector[] = [ + { label: 'Back Left', color: COLORS.green, extract: (f) => f.wheelSpeed?.backLeft }, + { label: 'Back Right', color: COLORS.orange, extract: (f) => f.wheelSpeed?.backRight }, +]; + +export const WHEEL_SPEED_FL: readonly SeriesSelector[] = [ + { label: 'Front Left', color: COLORS.blue, extract: (f) => f.wheelSpeed?.frontLeft }, +]; + +export const WHEEL_SPEED_FR: readonly SeriesSelector[] = [ + { label: 'Front Right', color: COLORS.red, extract: (f) => f.wheelSpeed?.frontRight }, +]; + +export const WHEEL_SPEED_BL: readonly SeriesSelector[] = [ + { label: 'Back Left', color: COLORS.green, extract: (f) => f.wheelSpeed?.backLeft }, +]; + +export const WHEEL_SPEED_BR: readonly SeriesSelector[] = [ + { label: 'Back Right', color: COLORS.orange, extract: (f) => f.wheelSpeed?.backRight }, +]; + +// ---- Damper Position (Linear Position Sensors) ---- + +export const DAMPER_POSITION_ALL: readonly SeriesSelector[] = [ + { label: 'Front Left', color: COLORS.blue, extract: (f) => f.damperPosition?.frontLeft }, + { label: 'Front Right', color: COLORS.red, extract: (f) => f.damperPosition?.frontRight }, + { label: 'Back Left', color: COLORS.green, extract: (f) => f.damperPosition?.backLeft }, + { label: 'Back Right', color: COLORS.orange, extract: (f) => f.damperPosition?.backRight }, +]; + +export const DAMPER_POSITION_FRONT: readonly SeriesSelector[] = [ + { label: 'Front Left', color: COLORS.blue, extract: (f) => f.damperPosition?.frontLeft }, + { label: 'Front Right', color: COLORS.red, extract: (f) => f.damperPosition?.frontRight }, +]; + +export const DAMPER_POSITION_BACK: readonly SeriesSelector[] = [ + { label: 'Back Left', color: COLORS.green, extract: (f) => f.damperPosition?.backLeft }, + { label: 'Back Right', color: COLORS.orange, extract: (f) => f.damperPosition?.backRight }, +]; + +export const DAMPER_POSITION_FL: readonly SeriesSelector[] = [ + { label: 'Front Left', color: COLORS.blue, extract: (f) => f.damperPosition?.frontLeft }, +]; + +export const DAMPER_POSITION_FR: readonly SeriesSelector[] = [ + { label: 'Front Right', color: COLORS.red, extract: (f) => f.damperPosition?.frontRight }, +]; + +export const DAMPER_POSITION_BL: readonly SeriesSelector[] = [ + { label: 'Back Left', color: COLORS.green, extract: (f) => f.damperPosition?.backLeft }, +]; + +export const DAMPER_POSITION_BR: readonly SeriesSelector[] = [ + { label: 'Back Right', color: COLORS.orange, extract: (f) => f.damperPosition?.backRight }, +]; + +// ---- Acceleration ---- + +export const ACCELERATION: readonly SeriesSelector[] = [ + { label: 'Accel X', color: COLORS.blue, extract: (f) => f.imu?.acceleration.x }, + { label: 'Accel Y', color: COLORS.red, extract: (f) => f.imu?.acceleration.y }, + { label: 'Accel Z', color: COLORS.green, extract: (f) => f.imu?.acceleration.z }, +]; + +// ---- Speed ---- + +export const GPS_SPEED: readonly SeriesSelector[] = [ + { label: 'GPS Speed', color: COLORS.blue, extract: (f) => f.gps?.speed }, +]; diff --git a/src/data/flatbuffer/fb-provider.ts b/src/data/flatbuffer/fb-provider.ts new file mode 100644 index 0000000..631ab4d --- /dev/null +++ b/src/data/flatbuffer/fb-provider.ts @@ -0,0 +1,81 @@ +import type { DataProvider, DataProviderResult } from '../provider'; +import type { TelemetryChannel, TelemetryFrame } from '../types'; +import { readFrames } from './fb-reader'; + +export class FlatBufferDataProvider implements DataProvider { + readonly name = 'FlatBuffer Binary'; + + async load(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + return { + status: 'error', + error: `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + }; + } + + const buffer = await response.arrayBuffer(); + if (buffer.byteLength === 0) { + return { status: 'error', error: 'Empty file' }; + } + + const frames = readFrames(buffer); + if (frames.length === 0) { + return { status: 'error', error: 'No frames found in file' }; + } + + const availableChannels = detectChannels(frames); + const durationSeconds = computeDuration(frames); + + return { + status: 'success', + store: { + frames, + metadata: { + sourceName: url, + frameCount: frames.length, + durationSeconds, + availableChannels, + }, + }, + }; + } catch (err) { + return { + status: 'error', + error: err instanceof Error ? err.message : String(err), + }; + } + } +} + +/** + * Scan frames to determine which sensor channels have data. + * Checks only the first frame for performance — assumes all frames + * have the same channels populated. + */ +function detectChannels(frames: readonly TelemetryFrame[]): TelemetryChannel[] { + const channels: TelemetryChannel[] = []; + const first = frames[0]; + + if (first.gps) channels.push('gps'); + if (first.imu) channels.push('imu'); + if (first.wheelSpeed) channels.push('wheelSpeed'); + if (first.damperPosition) channels.push('damperPosition'); + if (first.canBus) channels.push('canBus'); + + return channels; +} + +/** + * Compute session duration in seconds from the first and last frame. + * Uses clockNanos (monotonic) for accuracy. + */ +function computeDuration(frames: readonly TelemetryFrame[]): number { + if (frames.length < 2) return 0; + + const first = frames[0].clockNanos; + const last = frames[frames.length - 1].clockNanos; + + return (last - first) / 1_000_000_000; +} diff --git a/src/data/flatbuffer/fb-reader.ts b/src/data/flatbuffer/fb-reader.ts new file mode 100644 index 0000000..bbbdaae --- /dev/null +++ b/src/data/flatbuffer/fb-reader.ts @@ -0,0 +1,192 @@ +/** + * Reads length-prefixed FlatBuffer binary data and converts + * each Frame into our internal TelemetryFrame type. + * + * Binary format: [4-byte LE size][frame data][4-byte LE size][frame data]... + * Each frame is a size-prefixed FlatBuffer as produced by + * Frame.finishSizePrefixedFrameBuffer(). + */ + +import * as flatbuffers from 'flatbuffers'; +import { Frame } from '../../generated/telemetry/frame'; +import type { Vec3 as FbVec3 } from '../../generated/telemetry/vec3'; +import type { + CanBusMessage, + DamperPositionData, + GpsData, + ImuData, + TelemetryFrame, + Timestamp, + Vec3, + WheelSpeedData, +} from '../types'; + +/** Size of the length prefix in bytes (uint32 little-endian). */ +const SIZE_PREFIX_BYTES = 4; + +/** + * Parse a length-prefixed FlatBuffer binary buffer into TelemetryFrame[]. + * + * @param buffer - Raw binary data containing concatenated size-prefixed Frames + * @returns Array of parsed telemetry frames, in order + * @throws Error if the binary data is malformed + */ +export function readFrames(buffer: ArrayBuffer): TelemetryFrame[] { + const frames: TelemetryFrame[] = []; + const view = new DataView(buffer); + let offset = 0; + + while (offset < buffer.byteLength) { + // Ensure we can read the size prefix + if (offset + SIZE_PREFIX_BYTES > buffer.byteLength) { + throw new Error( + `Truncated size prefix at offset ${offset}: ` + + `need ${SIZE_PREFIX_BYTES} bytes, ` + + `have ${buffer.byteLength - offset}`, + ); + } + + // Read 4-byte little-endian frame size + const frameSize = view.getUint32(offset, true); + + // Validate frame size + if (frameSize === 0) { + throw new Error(`Zero-length frame at offset ${offset}`); + } + if (offset + SIZE_PREFIX_BYTES + frameSize > buffer.byteLength) { + throw new Error( + `Truncated frame at offset ${offset}: ` + + `declared size ${frameSize}, ` + + `available ${buffer.byteLength - offset - SIZE_PREFIX_BYTES}`, + ); + } + + // Extract frame bytes (copy to ensure safe, isolated ByteBuffer) + const frameBytes = new Uint8Array( + buffer.slice(offset + SIZE_PREFIX_BYTES, offset + SIZE_PREFIX_BYTES + frameSize), + ); + const bb = new flatbuffers.ByteBuffer(frameBytes); + const fbFrame = Frame.getRootAsFrame(bb); + + frames.push(convertFrame(fbFrame)); + + offset += SIZE_PREFIX_BYTES + frameSize; + } + + return frames; +} + +/** + * Convert a FlatBuffer Frame to our internal TelemetryFrame. + */ +function convertFrame(fb: Frame): TelemetryFrame { + const frame: TelemetryFrame = { + timestamp: convertTimestamp(fb), + clockNanos: convertClockNanos(fb), + gps: convertGps(fb), + imu: convertImu(fb), + wheelSpeed: convertWheelSpeed(fb), + damperPosition: convertDamperPosition(fb), + canBus: convertCanBus(fb), + }; + + return frame; +} + +function convertTimestamp(fb: Frame): Timestamp | undefined { + const ts = fb.time(); + if (!ts) return undefined; + return { + seconds: Number(ts.seconds()), + nanoseconds: Number(ts.nanoseconds()), + }; +} + +function convertClockNanos(fb: Frame): number { + const clocks = fb.clocks(); + if (!clocks) { + // clocks is required in the schema, so this should not happen + // with valid data. Return 0 as a safe fallback. + return 0; + } + return Number(clocks.time()); +} + +function convertGps(fb: Frame): GpsData | undefined { + const gps = fb.gpsFrame(); + if (!gps) return undefined; + return { + lat: gps.lat(), + lng: gps.lng(), + speed: gps.speed(), + altitude: gps.altitude(), + heading: gps.heading(), + }; +} + +function convertVec3(v: FbVec3 | null): Vec3 | undefined { + if (!v) return undefined; + return { x: v.x(), y: v.y(), z: v.z() }; +} + +function convertImu(fb: Frame): ImuData | undefined { + const imu = fb.imuFrame(); + if (!imu) return undefined; + + const acceleration = convertVec3(imu.acceleration()); + const angularVelocity = convertVec3(imu.angularVelocity()); + // Note: schema has typo "magnemometer", our type uses "magnetometer" + const magnetometer = convertVec3(imu.magnemometer()); + + if (!acceleration || !angularVelocity || !magnetometer) return undefined; + + return { acceleration, angularVelocity, magnetometer }; +} + +function convertWheelSpeed(fb: Frame): WheelSpeedData | undefined { + const ws = fb.wheelFrame(); + if (!ws) return undefined; + return { + frontLeft: ws.frontLeft(), + frontRight: ws.frontRight(), + backLeft: ws.backLeft(), + backRight: ws.backRight(), + }; +} + +function convertDamperPosition(fb: Frame): DamperPositionData | undefined { + const dp = fb.damperFrame(); + if (!dp) return undefined; + return { + frontLeft: dp.frontLeft(), + frontRight: dp.frontRight(), + backLeft: dp.backLeft(), + backRight: dp.backRight(), + }; +} + +function convertCanBus(fb: Frame): readonly CanBusMessage[] | undefined { + const len = fb.canBusLength(); + if (len === 0) return undefined; + + const messages: CanBusMessage[] = []; + for (let i = 0; i < len; i++) { + const msg = fb.canBus(i); + if (!msg) continue; + + const data: number[] = []; + const dataLen = msg.dataLen(); + for (let j = 0; j < dataLen && j < 8; j++) { + data.push(msg.data(j) ?? 0); + } + + messages.push({ + id: msg.id(), + rtr: msg.rtr(), + dataLen, + data, + }); + } + + return messages; +} diff --git a/src/data/flatbuffer/index.ts b/src/data/flatbuffer/index.ts new file mode 100644 index 0000000..d105e5a --- /dev/null +++ b/src/data/flatbuffer/index.ts @@ -0,0 +1,2 @@ +export { readFrames } from './fb-reader'; +export { FlatBufferDataProvider } from './fb-provider'; diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 0000000..1bf5cb1 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,19 @@ +export type { + Vec3, + Timestamp, + GpsData, + ImuData, + WheelSpeedData, + DamperPositionData, + CanBusMessage, + TelemetryFrame, + TelemetryChannel, + TelemetryMetadata, + TelemetryStore, +} from './types'; + +export type { + DataProvider, + DataProviderResult, + DataProviderState, +} from './provider'; diff --git a/src/data/provider.ts b/src/data/provider.ts new file mode 100644 index 0000000..4643790 --- /dev/null +++ b/src/data/provider.ts @@ -0,0 +1,28 @@ +import type { TelemetryStore } from './types'; + +// ---- Load result ---- + +export type DataProviderResult = + | { readonly status: 'success'; readonly store: TelemetryStore } + | { readonly status: 'error'; readonly error: string }; + +// ---- The universal interface ---- + +export interface DataProvider { + /** Human-readable name for UI display, e.g. "FlatBuffer Binary" */ + readonly name: string; + + /** + * Load telemetry data from the given URL. + * Returns the fully loaded TelemetryStore or an error. + */ + load(url: string): Promise; +} + +// ---- State for the hook layer ---- + +export type DataProviderState = + | { readonly status: 'idle' } + | { readonly status: 'loading' } + | { readonly status: 'ready'; readonly store: TelemetryStore } + | { readonly status: 'error'; readonly error: string }; diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 0000000..48618bf --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,95 @@ +/** + * Core telemetry data model. + * + * These types are the app's internal representation, decoupled from + * FlatBuffer generated code. The fb-reader layer converts between them. + * + * Field naming follows the FlatBuffer schema (telemetry.fbs) with + * camelCase convention. The schema typo "magnemometer" is corrected + * to "magnetometer" here; the conversion layer handles the mapping. + */ + +// ---- Primitives ---- + +export interface Vec3 { + readonly x: number; + readonly y: number; + readonly z: number; +} + +export interface Timestamp { + readonly seconds: number; + readonly nanoseconds: number; +} + +// ---- Sensor data per frame ---- + +export interface GpsData { + readonly lat: number; + readonly lng: number; + readonly speed: number; + readonly altitude: number; + readonly heading: number; +} + +export interface ImuData { + readonly acceleration: Vec3; + readonly angularVelocity: Vec3; + readonly magnetometer: Vec3; +} + +export interface WheelSpeedData { + readonly frontLeft: number; + readonly frontRight: number; + readonly backLeft: number; + readonly backRight: number; +} + +export interface DamperPositionData { + readonly frontLeft: number; + readonly frontRight: number; + readonly backLeft: number; + readonly backRight: number; +} + +export interface CanBusMessage { + readonly id: number; + readonly rtr: boolean; + readonly dataLen: number; + readonly data: readonly number[]; +} + +// ---- Single telemetry frame ---- + +export interface TelemetryFrame { + /** Wall-clock time. Optional because the schema does not mark it required. */ + readonly timestamp?: Timestamp; + /** Monotonic clock in nanoseconds. Always present (required in schema). */ + readonly clockNanos: number; + readonly gps?: GpsData; + readonly imu?: ImuData; + readonly wheelSpeed?: WheelSpeedData; + readonly damperPosition?: DamperPositionData; + readonly canBus?: readonly CanBusMessage[]; +} + +// ---- Store: collection of frames + metadata ---- + +export type TelemetryChannel = + | 'gps' + | 'imu' + | 'wheelSpeed' + | 'damperPosition' + | 'canBus'; + +export interface TelemetryMetadata { + readonly sourceName: string; + readonly frameCount: number; + readonly durationSeconds: number; + readonly availableChannels: readonly TelemetryChannel[]; +} + +export interface TelemetryStore { + readonly frames: readonly TelemetryFrame[]; + readonly metadata: TelemetryMetadata; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..ad857d2 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { useDataProvider } from './use-data-provider'; +export { useChartData } from './use-chart-data'; diff --git a/src/hooks/use-chart-data.ts b/src/hooks/use-chart-data.ts new file mode 100644 index 0000000..6a32501 --- /dev/null +++ b/src/hooks/use-chart-data.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'preact/hooks'; +import type { ChartData } from 'chart.js'; +import type { TelemetryStore } from '../data/types'; +import type { SeriesSelector } from '../charts/adapter'; +import { buildLineChartData } from '../charts/adapter'; + +/** + * Memoized transformation from TelemetryStore + selectors to ChartData. + * Only recomputes when store or selectors change. + */ +export function useChartData( + store: TelemetryStore | undefined, + selectors: readonly SeriesSelector[], +): ChartData<'line'> | undefined { + return useMemo(() => { + if (!store) return undefined; + return buildLineChartData(store, selectors); + }, [store, selectors]); +} diff --git a/src/hooks/use-data-provider.ts b/src/hooks/use-data-provider.ts new file mode 100644 index 0000000..e6b4ccf --- /dev/null +++ b/src/hooks/use-data-provider.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'preact/hooks'; +import type { DataProvider, DataProviderState } from '../data/provider'; + +/** + * Manages the lifecycle of loading data from a DataProvider. + * Automatically triggers load on mount (or when url changes). + */ +export function useDataProvider( + provider: DataProvider, + url: string, +): DataProviderState { + const [state, setState] = useState({ status: 'idle' }); + + useEffect(() => { + let cancelled = false; + + setState({ status: 'loading' }); + + provider.load(url).then((result) => { + if (cancelled) return; + + if (result.status === 'success') { + setState({ status: 'ready', store: result.store }); + } else { + setState({ status: 'error', error: result.error }); + } + }); + + return () => { + cancelled = true; + }; + }, [provider, url]); + + return state; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..e387d65 --- /dev/null +++ b/src/index.css @@ -0,0 +1,104 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body, +#root { + height: 100%; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #fff; + color: #333; +} + +/* Split pane divider - 1px line with expanded hit area */ +.split-pane-divider { + background: #ddd; + position: relative; +} + +.split-pane-divider::before { + content: ""; + position: absolute; + background: transparent; +} + +.split-pane-divider:hover::before, +.split-pane-divider.dragging::before { + background: rgba(0, 0, 0, 0.08); +} + +.split-pane-divider:focus { + outline: 2px solid #0066cc; + outline-offset: -1px; +} + +.split-pane-divider.horizontal { + width: 1px; + cursor: col-resize; +} + +.split-pane-divider.horizontal::before { + top: 0; + bottom: 0; + left: -4px; + right: -4px; +} + +.split-pane-divider.vertical { + height: 1px; + cursor: row-resize; +} + +.split-pane-divider.vertical::before { + left: 0; + right: 0; + top: -4px; + bottom: -4px; +} + +/* Pane content */ +.pane { + height: 100%; + padding: 1rem; + display: flex; + flex-direction: column; +} + +.pane h3 { + font-size: 0.875rem; + margin-bottom: 0.5rem; + color: #333; +} + +.pane p { + font-size: 0.875rem; + color: #666; +} + +.pane.gray { + background: #f5f5f5; +} + +/* Button */ +.btn { + padding: 0.25rem 0.5rem; + background: #333; + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + font-size: 0.8rem; + margin-top: 0.5rem; + align-self: flex-start; +} + +.btn:hover { + background: #555; +} diff --git a/src/index.tsx b/src/index.tsx index c2c9df7..20d0594 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,6 @@ import { render } from 'preact'; +import 'react-split-pane/styles.css'; +import './index.css'; import App from './App'; const root = document.getElementById('root'); diff --git a/src/tabs/DeserialisationTab.tsx b/src/tabs/DeserialisationTab.tsx new file mode 100644 index 0000000..fcccc4d --- /dev/null +++ b/src/tabs/DeserialisationTab.tsx @@ -0,0 +1,6 @@ +export const DeserialisationTab = () => ( +
+

Deserialisation

+

Placeholder

+
+); diff --git a/src/tabs/GpsTab.tsx b/src/tabs/GpsTab.tsx new file mode 100644 index 0000000..5fd335f --- /dev/null +++ b/src/tabs/GpsTab.tsx @@ -0,0 +1,6 @@ +export const GpsTab = () => ( +
+

GPS

+

Placeholder

+
+); diff --git a/src/tabs/GraphsTab.tsx b/src/tabs/GraphsTab.tsx new file mode 100644 index 0000000..80e3a65 --- /dev/null +++ b/src/tabs/GraphsTab.tsx @@ -0,0 +1,123 @@ +import { useMemo, useState } from 'preact/hooks'; +import { Pane, SplitPane } from 'react-split-pane'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; +import { FlatBufferDataProvider } from '../data/flatbuffer'; +import { useDataProvider } from '../hooks/use-data-provider'; +import { + WHEEL_SPEED_ALL, + DAMPER_POSITION_ALL, + ACCELERATION, + GPS_SPEED, +} from '../charts/presets'; +import type { ChartTabConfig } from '../charts/ChartTabs'; +import { ChartPanels } from '../charts/ChartTabs'; +import { GpsTab } from './GpsTab'; + +// Register Chart.js components (once at module level) +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +); + +const DATA_URL = '/data/telemetry.bin'; + +const CHART_TABS: ChartTabConfig[] = [ + { id: 'wheelSpeed', label: 'Wheel Speed', preset: WHEEL_SPEED_ALL, title: 'Wheel Speed - All Wheels' }, + { id: 'damper', label: 'Damper Position', preset: DAMPER_POSITION_ALL, title: 'Damper Position - All' }, + { id: 'accel', label: 'Acceleration', preset: ACCELERATION, title: 'Acceleration (X, Y, Z)' }, + { id: 'gpsSpeed', label: 'GPS Speed', preset: GPS_SPEED, title: 'GPS Speed' }, +]; + +export const GraphsTab = () => { + const [showBottom, setShowBottom] = useState(false); + const [activeTab, setActiveTab] = useState('all'); + + const provider = useMemo(() => new FlatBufferDataProvider(), []); + const state = useDataProvider(provider, DATA_URL); + const store = state.status === 'ready' ? state.store : undefined; + + const chartContent = store && ( + + ); + + return ( + + +
+

Data

+ {state.status === 'idle' &&

Idle

} + {state.status === 'loading' &&

Loading telemetry...

} + {state.status === 'error' &&

Error: {state.error}

} + {state.status === 'ready' && ( +
+

Source: {state.store.metadata.sourceName}

+

Frames: {state.store.metadata.frameCount}

+

Duration: {state.store.metadata.durationSeconds.toFixed(1)}s

+

Channels: {state.store.metadata.availableChannels.join(', ')}

+
+ )} +

Charts

+
+ + {CHART_TABS.map((tab) => ( + + ))} +
+

Panels

+ +
+
+ + {showBottom ? ( + + +
+ {chartContent} +
+
+ + + +
+ ) : ( +
+ {chartContent} +
+ )} +
+
+ ); +}; diff --git a/src/tabs/index.ts b/src/tabs/index.ts new file mode 100644 index 0000000..54d3242 --- /dev/null +++ b/src/tabs/index.ts @@ -0,0 +1,3 @@ +export { DeserialisationTab } from './DeserialisationTab'; +export { GpsTab } from './GpsTab'; +export { GraphsTab } from './GraphsTab'; diff --git a/tools/csv-to-bin.ts b/tools/csv-to-bin.ts new file mode 100644 index 0000000..6d666a4 --- /dev/null +++ b/tools/csv-to-bin.ts @@ -0,0 +1,250 @@ +/** + * Converts a CSV file to length-prefixed FlatBuffer binary. + * Output: public/data/telemetry.bin + * + * Usage: pnpm run convert:csv + * + * Column auto-detection: matches common header patterns to telemetry fields. + * Unrecognized columns are skipped with a warning. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as flatbuffers from 'flatbuffers'; +import { Frame } from '../src/generated/telemetry/frame.js'; +import { Timestamp } from '../src/generated/telemetry/timestamp.js'; +import { Nanos } from '../src/generated/telemetry/nanos.js'; +import { GPSFrame } from '../src/generated/telemetry/gpsframe.js'; +import { IMUFrame } from '../src/generated/telemetry/imuframe.js'; +import { WheelSpeedFrame } from '../src/generated/telemetry/wheel-speed-frame.js'; +import { DamperPositionFrame } from '../src/generated/telemetry/damper-position-frame.js'; + +const OUTPUT_PATH = path.join('public', 'data', 'telemetry.bin'); + +// ---- Column mapping patterns ---- + +interface ColumnMatcher { + pattern: RegExp; + field: string; +} + +const COLUMN_MATCHERS: ColumnMatcher[] = [ + // Timestamp + { pattern: /^(time_?)?seconds?$/i, field: 'time_seconds' }, + { pattern: /^(time_?)?nano(second)?s?$/i, field: 'time_nanoseconds' }, + { pattern: /^clock(_?nanos)?|^nanos$/i, field: 'clock_nanos' }, + { pattern: /^time(stamp)?$/i, field: 'time_seconds' }, + + // GPS + { pattern: /^(gps_?)?lat(itude)?$/i, field: 'gps_lat' }, + { pattern: /^(gps_?)?l(o)?ng(itude)?$/i, field: 'gps_lng' }, + { pattern: /^(gps_?)?speed$/i, field: 'gps_speed' }, + { pattern: /^(gps_?)?alt(itude)?$/i, field: 'gps_altitude' }, + { pattern: /^(gps_?)?heading$/i, field: 'gps_heading' }, + + // IMU - Acceleration + { pattern: /^(imu_?)?(accel(eration)?_?)?x$/i, field: 'imu_accel_x' }, + { pattern: /^(imu_?)?(accel(eration)?_?)?y$/i, field: 'imu_accel_y' }, + { pattern: /^(imu_?)?(accel(eration)?_?)?z$/i, field: 'imu_accel_z' }, + + // IMU - Angular velocity + { pattern: /^(imu_?)?ang(ular)?_?vel(ocity)?_?x$/i, field: 'imu_angvel_x' }, + { pattern: /^(imu_?)?ang(ular)?_?vel(ocity)?_?y$/i, field: 'imu_angvel_y' }, + { pattern: /^(imu_?)?ang(ular)?_?vel(ocity)?_?z$/i, field: 'imu_angvel_z' }, + + // IMU - Magnetometer + { pattern: /^(imu_?)?mag(net(o?meter)?)?_?x$/i, field: 'imu_mag_x' }, + { pattern: /^(imu_?)?mag(net(o?meter)?)?_?y$/i, field: 'imu_mag_y' }, + { pattern: /^(imu_?)?mag(net(o?meter)?)?_?z$/i, field: 'imu_mag_z' }, + + // Wheel speed + { pattern: /^(wheel_?speed_?|ws_?)?f(ront)?_?l(eft)?$/i, field: 'ws_fl' }, + { pattern: /^(wheel_?speed_?|ws_?)?f(ront)?_?r(ight)?$/i, field: 'ws_fr' }, + { pattern: /^(wheel_?speed_?|ws_?)?b(ack)?_?l(eft)?$/i, field: 'ws_bl' }, + { pattern: /^(wheel_?speed_?|ws_?)?b(ack)?_?r(ight)?$/i, field: 'ws_br' }, + + // Damper position + { pattern: /^(damper_?|dp_?|lin_?pos_?)?f(ront)?_?l(eft)?_?(damper|pos)?$/i, field: 'dp_fl' }, + { pattern: /^(damper_?|dp_?|lin_?pos_?)?f(ront)?_?r(ight)?_?(damper|pos)?$/i, field: 'dp_fr' }, + { pattern: /^(damper_?|dp_?|lin_?pos_?)?b(ack)?_?l(eft)?_?(damper|pos)?$/i, field: 'dp_bl' }, + { pattern: /^(damper_?|dp_?|lin_?pos_?)?b(ack)?_?r(ight)?_?(damper|pos)?$/i, field: 'dp_br' }, +]; + +// ---- Parsed row ---- + +interface ParsedRow { + time_seconds: number; + time_nanoseconds: number; + clock_nanos: number; + gps_lat: number; + gps_lng: number; + gps_speed: number; + gps_altitude: number; + gps_heading: number; + imu_accel_x: number; + imu_accel_y: number; + imu_accel_z: number; + imu_angvel_x: number; + imu_angvel_y: number; + imu_angvel_z: number; + imu_mag_x: number; + imu_mag_y: number; + imu_mag_z: number; + ws_fl: number; + ws_fr: number; + ws_bl: number; + ws_br: number; + dp_fl: number; + dp_fr: number; + dp_bl: number; + dp_br: number; +} + +function emptyRow(): ParsedRow { + return { + time_seconds: 0, time_nanoseconds: 0, clock_nanos: 0, + gps_lat: 0, gps_lng: 0, gps_speed: 0, gps_altitude: 0, gps_heading: 0, + imu_accel_x: 0, imu_accel_y: 0, imu_accel_z: 0, + imu_angvel_x: 0, imu_angvel_y: 0, imu_angvel_z: 0, + imu_mag_x: 0, imu_mag_y: 0, imu_mag_z: 0, + ws_fl: 0, ws_fr: 0, ws_bl: 0, ws_br: 0, + dp_fl: 0, dp_fr: 0, dp_bl: 0, dp_br: 0, + }; +} + +// ---- Column detection ---- + +function detectColumns(headers: string[]): Map { + const mapping = new Map(); + + for (let i = 0; i < headers.length; i++) { + const header = headers[i].trim(); + const matcher = COLUMN_MATCHERS.find((m) => m.pattern.test(header)); + + if (matcher) { + mapping.set(i, matcher.field); + console.log(` Column ${i} "${header}" -> ${matcher.field}`); + } else { + console.warn(` Column ${i} "${header}" -> (unmapped, skipped)`); + } + } + + return mapping; +} + +// ---- Build FlatBuffer frame ---- + +function buildFrameFromRow(row: ParsedRow): Uint8Array { + const builder = new flatbuffers.Builder(256); + + // Structs must be created inline (immediately before their addField call) + Frame.startFrame(builder); + + Frame.addDamperFrame(builder, DamperPositionFrame.createDamperPositionFrame( + builder, + row.dp_fl, row.dp_fr, row.dp_bl, row.dp_br, + )); + + Frame.addWheelFrame(builder, WheelSpeedFrame.createWheelSpeedFrame( + builder, + row.ws_fl, row.ws_fr, row.ws_bl, row.ws_br, + )); + + Frame.addImuFrame(builder, IMUFrame.createIMUFrame( + builder, + row.imu_accel_x, row.imu_accel_y, row.imu_accel_z, + row.imu_angvel_x, row.imu_angvel_y, row.imu_angvel_z, + row.imu_mag_x, row.imu_mag_y, row.imu_mag_z, + )); + + Frame.addGpsFrame(builder, GPSFrame.createGPSFrame( + builder, + row.gps_lat, row.gps_lng, row.gps_speed, + row.gps_altitude, row.gps_heading, + )); + + Frame.addClocks(builder, Nanos.createNanos( + builder, + BigInt(Math.floor(row.clock_nanos)), + )); + + Frame.addTime(builder, Timestamp.createTimestamp( + builder, + BigInt(Math.floor(row.time_seconds)), + BigInt(Math.floor(row.time_nanoseconds)), + )); + + const frameOffset = Frame.endFrame(builder); + + Frame.finishSizePrefixedFrameBuffer(builder, frameOffset); + + return builder.asUint8Array(); +} + +// ---- Main ---- + +function main() { + const inputFile = process.argv[2]; + if (!inputFile) { + console.error('Usage: pnpm run convert:csv '); + process.exit(1); + } + + if (!fs.existsSync(inputFile)) { + console.error(`File not found: ${inputFile}`); + process.exit(1); + } + + const content = fs.readFileSync(inputFile, 'utf-8'); + const lines = content.split(/\r?\n/).filter((line) => line.trim() !== ''); + + if (lines.length < 2) { + console.error('CSV must have at least a header row and one data row'); + process.exit(1); + } + + const headers = lines[0].split(','); + console.log(`Detected ${headers.length} columns:`); + const columnMapping = detectColumns(headers); + + if (columnMapping.size === 0) { + console.error('No columns could be mapped to telemetry fields'); + process.exit(1); + } + + const chunks: Uint8Array[] = []; + let totalSize = 0; + + for (let i = 1; i < lines.length; i++) { + const cells = lines[i].split(','); + const row = emptyRow(); + + for (const [colIndex, field] of columnMapping) { + const value = Number(cells[colIndex]); + if (!Number.isNaN(value)) { + row[field as keyof ParsedRow] = value; + } + } + + const frameBytes = buildFrameFromRow(row); + chunks.push(frameBytes); + totalSize += frameBytes.byteLength; + } + + // Concatenate + const output = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + + // Write + const dir = path.dirname(OUTPUT_PATH); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, output); + + console.log(`Converted ${chunks.length} rows (${totalSize} bytes) -> ${OUTPUT_PATH}`); +} + +main(); diff --git a/tools/generate-mock-bin.ts b/tools/generate-mock-bin.ts new file mode 100644 index 0000000..d068756 --- /dev/null +++ b/tools/generate-mock-bin.ts @@ -0,0 +1,89 @@ +/** + * Generates mock telemetry data as length-prefixed FlatBuffer binary. + * Output: public/data/telemetry.bin + * + * Usage: pnpm run generate:mock-bin + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as flatbuffers from 'flatbuffers'; +import { Frame } from '../src/generated/telemetry/frame.js'; +import { Timestamp } from '../src/generated/telemetry/timestamp.js'; +import { Nanos } from '../src/generated/telemetry/nanos.js'; +import { GPSFrame } from '../src/generated/telemetry/gpsframe.js'; +import { IMUFrame } from '../src/generated/telemetry/imuframe.js'; +import { WheelSpeedFrame } from '../src/generated/telemetry/wheel-speed-frame.js'; +import { DamperPositionFrame } from '../src/generated/telemetry/damper-position-frame.js'; +import { generateAllRows, FRAME_COUNT, SAMPLE_RATE_HZ } from './mock-data.js'; +import type { MockRow } from './mock-data.js'; + +const OUTPUT_PATH = path.join('public', 'data', 'telemetry.bin'); + +function rowToFlatBuffer(row: MockRow): Uint8Array { + const builder = new flatbuffers.Builder(256); + + Frame.startFrame(builder); + + Frame.addDamperFrame(builder, DamperPositionFrame.createDamperPositionFrame( + builder, row.dp_fl, row.dp_fr, row.dp_bl, row.dp_br, + )); + + Frame.addWheelFrame(builder, WheelSpeedFrame.createWheelSpeedFrame( + builder, row.ws_fl, row.ws_fr, row.ws_bl, row.ws_br, + )); + + Frame.addImuFrame(builder, IMUFrame.createIMUFrame( + builder, + row.imu_accel_x, row.imu_accel_y, row.imu_accel_z, + row.imu_angvel_x, row.imu_angvel_y, row.imu_angvel_z, + row.imu_mag_x, row.imu_mag_y, row.imu_mag_z, + )); + + Frame.addGpsFrame(builder, GPSFrame.createGPSFrame( + builder, row.gps_lat, row.gps_lng, row.gps_speed, + row.gps_altitude, row.gps_heading, + )); + + Frame.addClocks(builder, Nanos.createNanos( + builder, BigInt(row.clock_nanos), + )); + + Frame.addTime(builder, Timestamp.createTimestamp( + builder, BigInt(row.time_seconds), BigInt(row.time_nanoseconds), + )); + + const frameOffset = Frame.endFrame(builder); + Frame.finishSizePrefixedFrameBuffer(builder, frameOffset); + + return builder.asUint8Array(); +} + +function main() { + const rows = generateAllRows(); + + const chunks: Uint8Array[] = []; + let totalSize = 0; + + for (const row of rows) { + const bytes = rowToFlatBuffer(row); + chunks.push(bytes); + totalSize += bytes.byteLength; + } + + const output = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + + const dir = path.dirname(OUTPUT_PATH); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, output); + + console.log(`Generated ${FRAME_COUNT} frames (${totalSize} bytes) -> ${OUTPUT_PATH}`); + console.log(`Duration: ${(FRAME_COUNT / SAMPLE_RATE_HZ).toFixed(1)}s at ${SAMPLE_RATE_HZ} Hz`); +} + +main(); diff --git a/tools/generate-mock-csv.ts b/tools/generate-mock-csv.ts new file mode 100644 index 0000000..f9fde1f --- /dev/null +++ b/tools/generate-mock-csv.ts @@ -0,0 +1,32 @@ +/** + * Generates mock telemetry data as CSV. + * Output: public/data/telemetry.csv + * + * Usage: pnpm run generate:mock-csv + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { generateAllRows, CSV_HEADERS, FRAME_COUNT, SAMPLE_RATE_HZ } from './mock-data.js'; + +const OUTPUT_PATH = path.join('public', 'data', 'telemetry.csv'); + +function main() { + const rows = generateAllRows(); + + const lines: string[] = [CSV_HEADERS.join(',')]; + + for (const row of rows) { + const values = CSV_HEADERS.map((key) => row[key]); + lines.push(values.join(',')); + } + + const dir = path.dirname(OUTPUT_PATH); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, lines.join('\n')); + + console.log(`Generated ${FRAME_COUNT} rows -> ${OUTPUT_PATH}`); + console.log(`Duration: ${(FRAME_COUNT / SAMPLE_RATE_HZ).toFixed(1)}s at ${SAMPLE_RATE_HZ} Hz`); +} + +main(); diff --git a/tools/mock-data.ts b/tools/mock-data.ts new file mode 100644 index 0000000..0e273bd --- /dev/null +++ b/tools/mock-data.ts @@ -0,0 +1,89 @@ +/** + * Shared mock telemetry data generation. + * Used by both generate-mock-bin.ts and generate-mock-csv.ts. + */ + +// Base GPS position (Silverstone Circuit, UK) +const BASE_LAT = 52.0786; +const BASE_LNG = -1.0169; + +export const FRAME_COUNT = 1000; +export const SAMPLE_RATE_HZ = 100; // 100 Hz => 10 seconds of data + +export interface MockRow { + time_seconds: number; + time_nanoseconds: number; + clock_nanos: number; + gps_lat: number; + gps_lng: number; + gps_speed: number; + gps_altitude: number; + gps_heading: number; + imu_accel_x: number; + imu_accel_y: number; + imu_accel_z: number; + imu_angvel_x: number; + imu_angvel_y: number; + imu_angvel_z: number; + imu_mag_x: number; + imu_mag_y: number; + imu_mag_z: number; + ws_fl: number; + ws_fr: number; + ws_bl: number; + ws_br: number; + dp_fl: number; + dp_fr: number; + dp_bl: number; + dp_br: number; +} + +export const CSV_HEADERS: (keyof MockRow)[] = [ + 'time_seconds', 'time_nanoseconds', 'clock_nanos', + 'gps_lat', 'gps_lng', 'gps_speed', 'gps_altitude', 'gps_heading', + 'imu_accel_x', 'imu_accel_y', 'imu_accel_z', + 'imu_angvel_x', 'imu_angvel_y', 'imu_angvel_z', + 'imu_mag_x', 'imu_mag_y', 'imu_mag_z', + 'ws_fl', 'ws_fr', 'ws_bl', 'ws_br', + 'dp_fl', 'dp_fr', 'dp_bl', 'dp_br', +]; + +export function generateRow(index: number): MockRow { + const t = index / SAMPLE_RATE_HZ; + + return { + time_seconds: Math.floor(1700000000 + t), + time_nanoseconds: Math.floor((t % 1) * 1_000_000_000), + clock_nanos: Math.floor(t * 1_000_000_000), + gps_lat: BASE_LAT + Math.sin(t * 0.1) * 0.001, + gps_lng: BASE_LNG + Math.cos(t * 0.1) * 0.001, + gps_speed: 60 + Math.sin(t * 0.5) * 20, + gps_altitude: 150 + Math.sin(t * 0.05) * 5, + gps_heading: (t * 36) % 360, + imu_accel_x: Math.sin(t * 5) * 0.5, + imu_accel_y: Math.cos(t * 3) * 0.3, + imu_accel_z: -9.81 + Math.sin(t * 10) * 0.1, + imu_angvel_x: Math.sin(t * 2) * 0.1, + imu_angvel_y: Math.cos(t * 2) * 0.1, + imu_angvel_z: Math.sin(t * 0.5) * 0.05, + imu_mag_x: 25 + Math.sin(t) * 2, + imu_mag_y: -10 + Math.cos(t) * 2, + imu_mag_z: -45 + Math.sin(t * 0.3) * 1, + ws_fl: 60 + Math.sin(t * 0.5) * 20 + Math.sin(t * 8) * 0.5, + ws_fr: 60 + Math.sin(t * 0.5) * 20 + Math.cos(t * 8) * 0.5, + ws_bl: 59 + Math.sin(t * 0.5) * 20 + Math.sin(t * 7) * 0.4, + ws_br: 59 + Math.sin(t * 0.5) * 20 + Math.cos(t * 7) * 0.4, + dp_fl: 50 + Math.sin(t * 3) * 10 + Math.sin(t * 15) * 2, + dp_fr: 50 + Math.sin(t * 3) * 10 + Math.cos(t * 15) * 2, + dp_bl: 50 + Math.sin(t * 2.5) * 8 + Math.sin(t * 12) * 1.5, + dp_br: 50 + Math.sin(t * 2.5) * 8 + Math.cos(t * 12) * 1.5, + }; +} + +export function generateAllRows(): MockRow[] { + const rows: MockRow[] = []; + for (let i = 0; i < FRAME_COUNT; i++) { + rows.push(generateRow(i)); + } + return rows; +}