diff --git a/eslint.config.mjs b/eslint.config.mjs index 9469a38..79f4e71 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,7 +35,11 @@ export default tseslint.config( parser: tseslint.parser, parserOptions: { projectService: { - allowDefaultProject: ["*.mjs", "src/d3Sankey/*.js"], + allowDefaultProject: [ + "*.mjs", + "src/d3Sankey/*.js", + "src/uplot/*.js", + ], defaultProject: "tsconfig.json", }, tsconfigRootDir: import.meta.dirname, @@ -106,5 +110,5 @@ export default tseslint.config( "@typescript-eslint/no-useless-constructor": "warn", "react/prop-types": "off", }, - }, + } ); diff --git a/index.html b/index.html index ba18997..dc8b5f6 100644 --- a/index.html +++ b/index.html @@ -1,30 +1,19 @@ - - - - - - - Firedancer - - -
- - - + + + + + + + Firedancer + + + +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2f47a14..bc0ad7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "@ag-grid-community/csv-export": "^32.2.0", "@ag-grid-community/react": "^32.2.0", "@ag-grid-community/styles": "^32.2.0", - "@fontsource/inter-tight": "^5.1.0", + "@fontsource/inter-tight": "^5.2.5", + "@fontsource/roboto-mono": "^5.1.0", "@nivo/core": "^0.88.0", "@nivo/pie": "^0.88.0", "@radix-ui/react-icons": "^1.3.0", @@ -44,6 +45,7 @@ "react-virtualized-auto-sizer": "^1.0.24", "recharts": "^2.15.1", "simplify-js": "^1.2.4", + "uplot": "^1.6.32", "use-debounce": "^10.0.3", "zod": "^3.23.8" }, @@ -267,11 +269,10 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -337,13 +338,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -353,13 +353,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", - "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -461,14 +460,13 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", - "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "aix" @@ -478,14 +476,13 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", - "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -495,14 +492,13 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", - "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -512,14 +508,13 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", - "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -529,14 +524,13 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", - "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -546,14 +540,13 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", - "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -563,14 +556,13 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", - "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -580,14 +572,13 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", - "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -597,14 +588,13 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", - "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -614,14 +604,13 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", - "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -631,14 +620,13 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", - "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -648,14 +636,13 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", - "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", "cpu": [ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -665,14 +652,13 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", - "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", "cpu": [ "mips64el" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -682,14 +668,13 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", - "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -699,14 +684,13 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", - "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -716,14 +700,13 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", - "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -733,14 +716,13 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", - "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -750,14 +732,13 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", - "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -767,14 +748,13 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", - "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -784,14 +764,13 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", - "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -801,14 +780,13 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", - "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -818,14 +796,13 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", - "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "sunos" @@ -835,14 +812,13 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", - "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -852,14 +828,13 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", - "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -869,14 +844,13 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", - "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -1078,7 +1052,14 @@ "version": "5.2.5", "resolved": "https://registry.npmjs.org/@fontsource/inter-tight/-/inter-tight-5.2.5.tgz", "integrity": "sha512-sVr0ssFA/A15tBcYVpa1imiPEPW9v6M0XPSw++hAQb1ZxLqFsIRCnRPBldL5k4ivfuoXkDHKLqq78oEhuxZ5IQ==", - "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/roboto-mono": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/roboto-mono/-/roboto-mono-5.2.5.tgz", + "integrity": "sha512-P1wrUBMFWfr+tuUIY10MzdRTBcl9f011CbZV1fXqcCk40Xmtn91l1V1daVuQ+JPctYEVYazabvaK+sUEThgnyw==", "funding": { "url": "https://github.com/sponsors/ayuhito" } @@ -3435,10 +3416,9 @@ ] }, "node_modules/@tanstack/history": { - "version": "1.114.22", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.114.22.tgz", - "integrity": "sha512-CNwKraj/Xa8H7DUyzrFBQC3wL96JzIxT4i9CW0hxqFNNmLDyUcMJr8264iqqfxC0u1lFSG96URad08T2Qhadpw==", - "license": "MIT", + "version": "1.115.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.115.0.tgz", + "integrity": "sha512-K7JJNrRVvyjAVnbXOH2XLRhFXDkeP54Kt2P4FR1Kl2KDGlIbkua5VqZQD2rot3qaDrpufyUa63nuLai1kOLTsQ==", "engines": { "node": ">=12" }, @@ -3448,14 +3428,13 @@ } }, "node_modules/@tanstack/react-router": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.114.25.tgz", - "integrity": "sha512-4vls9pz+AOLIeTLZWUKK5ZqENc1azuSQ/UATNzZChckZqkMqtEoUci0dgp1XVAX+ocPH9bU1WP+/eonuyaLvdA==", - "license": "MIT", + "version": "1.120.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.120.3.tgz", + "integrity": "sha512-+5Y5ORtjW/LJhIxOxxBrvhZzfMOP9B+LaJ1j1P5tM5YqpLYWHuImfzGNRpKtBgiRoSaJedPjwY7lj88EwNWVbg==", "dependencies": { - "@tanstack/history": "1.114.22", + "@tanstack/history": "1.115.0", "@tanstack/react-store": "^0.7.0", - "@tanstack/router-core": "1.114.25", + "@tanstack/router-core": "1.120.3", "jsesc": "^3.1.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" @@ -3473,13 +3452,12 @@ } }, "node_modules/@tanstack/react-router-devtools": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.114.25.tgz", - "integrity": "sha512-55W9Wde0D7ZW1FNa9aepk2xo0wPugWquF3fC6pVMALq4gVNP9QufNBc+TMX6TiErffGtGrJzPkkYJOkF6ZUGVg==", + "version": "1.120.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.120.3.tgz", + "integrity": "sha512-aeEodmbATZ81H1xJuiaWaadSW9iqG9YEvaBgmlS70bxepFNkeXONEXcw38IQMTsPNoEZqbtvqAjl2Pg08cZlxQ==", "dev": true, - "license": "MIT", "dependencies": { - "@tanstack/router-devtools-core": "^1.114.25", + "@tanstack/router-devtools-core": "^1.120.3", "solid-js": "^1.9.5" }, "engines": { @@ -3490,7 +3468,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.114.25", + "@tanstack/react-router": "^1.120.3", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } @@ -3514,12 +3492,11 @@ } }, "node_modules/@tanstack/router-core": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.114.25.tgz", - "integrity": "sha512-OyLCfs7r+0LEhmQGAdyJxfO+pqGBITlr4aUN0rdhXqDTpqBn0tyrO6Tu+U9B3LQF9Xnux3KqbjzRopTY9QZBog==", - "license": "MIT", + "version": "1.120.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.120.3.tgz", + "integrity": "sha512-/16Pp7yxUUIGkc+oPVnlWqvlGtvLoQeKfJPpKc1vcPIBvHFO/o3yg/CEzP5raWDAjyq3b+BVkej3lSzkNxgBSg==", "dependencies": { - "@tanstack/history": "1.114.22", + "@tanstack/history": "1.115.0", "@tanstack/store": "^0.7.0", "tiny-invariant": "^1.3.3" }, @@ -3532,13 +3509,12 @@ } }, "node_modules/@tanstack/router-devtools": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.114.25.tgz", - "integrity": "sha512-qtR2uggdel8+uJDUsJaH6tmcCh834OwvUDiKA4tFk+Ruo9dmwr0h3DNHrukSAxqPeCK52c9JToBvISWKqpsQHA==", + "version": "1.120.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.120.3.tgz", + "integrity": "sha512-mh750B6yqilyHDoh08ud7oq23ZE2Shp8Xfcn0ioyJM9lvx6aKRLQScDGI8LQUdJIOSLUTRr1m8GVO3POR2886Q==", "dev": true, - "license": "MIT", "dependencies": { - "@tanstack/react-router-devtools": "^1.114.25", + "@tanstack/react-router-devtools": "^1.120.3", "clsx": "^2.1.1", "goober": "^2.1.16" }, @@ -3550,7 +3526,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.114.25", + "@tanstack/react-router": "^1.120.3", "csstype": "^3.0.10", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" @@ -3562,11 +3538,10 @@ } }, "node_modules/@tanstack/router-devtools-core": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.114.25.tgz", - "integrity": "sha512-3KFAAytAV6nWcXLTe3nWNaiRPV8AyM3jx5aa2UpB+RLDgDbO+GkVMnv3C7fnGCM6j2nw2/1boAvTvHcoKKO5UA==", + "version": "1.120.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.120.3.tgz", + "integrity": "sha512-cUY1GFq8qNfIfivhozaG2NOt05Jran1yoZrBajVK0qLO0nIXJ673XeCPjzQkDctN5FU6xfmF6aXgO/xJd0igrA==", "dev": true, - "license": "MIT", "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" @@ -3579,7 +3554,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/router-core": "^1.114.25", + "@tanstack/router-core": "^1.120.3", "csstype": "^3.0.10", "solid-js": ">=1.9.5", "tiny-invariant": "^1.3.3" @@ -3590,14 +3565,49 @@ } } }, - "node_modules/@tanstack/router-generator": { + "node_modules/@tanstack/router-utils": { + "version": "1.115.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.115.0.tgz", + "integrity": "sha512-Dng4y+uLR9b5zPGg7dHReHOTHQa6x+G6nCoZshsDtWrYsrdCcJEtLyhwZ5wG8OyYS6dVr/Cn+E5Bd2b6BhJ89w==", + "dev": true, + "dependencies": { + "@babel/generator": "^7.26.8", + "@babel/parser": "^7.26.8", + "ansis": "^3.11.0", + "diff": "^7.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-vite-plugin": { "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.114.25.tgz", - "integrity": "sha512-KfPdXm9+zGPrEjcdDkkSbZpDvx8rOSD9sS0cQn6y82jqoSeHlzC0K3bSVElsAmS1uh7WXR+PNDJra+nHUdPhaQ==", + "resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.114.25.tgz", + "integrity": "sha512-9PGvbBEndtsGsZpkIf8aVlot3T3Rz7PLvuHknhYsLEOBIILj4xTxsYPg/pDrp2ozNP+6r5fVLBaxsT0UQL1Vxg==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/virtual-file-routes": "^1.114.12", + "@tanstack/router-plugin": "^1.114.25" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-vite-plugin/node_modules/@tanstack/router-generator": { + "version": "1.120.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.120.3.tgz", + "integrity": "sha512-Lz0nIwGNM+vlLGGiSBTQvcD2gW5WhoIeZN8IlTBssUb33m21QLpoj9ozpXFDrlzk36rTn5NcijHEStpYqrvQbA==", + "dev": true, + "dependencies": { + "@tanstack/virtual-file-routes": "^1.115.0", "prettier": "^3.5.0", "tsx": "^4.19.2", "zod": "^3.24.2" @@ -3610,7 +3620,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-router": "^1.114.25" + "@tanstack/react-router": "^1.120.3" }, "peerDependenciesMeta": { "@tanstack/react-router": { @@ -3618,12 +3628,11 @@ } } }, - "node_modules/@tanstack/router-plugin": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.114.25.tgz", - "integrity": "sha512-4SIvBzgX6TzwgW5OO6Knx4/vX8AocXnfQhXW7dzsNBgzt5WnI4dzoPvp6p9p+Hqo0AjJ2WndpEYq7fMl5BhA4Q==", + "node_modules/@tanstack/router-vite-plugin/node_modules/@tanstack/router-plugin": { + "version": "1.120.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.120.3.tgz", + "integrity": "sha512-iTW402GLCxexMn42OSN8Md7A0vYm5q5+vBKDp3FcjnLgmD+31AI7H//RnGI6nxRWo/xMN8ZjESy/PVg1ouvDxA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.26.8", "@babel/plugin-syntax-jsx": "^7.25.9", @@ -3631,14 +3640,14 @@ "@babel/template": "^7.26.8", "@babel/traverse": "^7.26.8", "@babel/types": "^7.26.8", - "@tanstack/router-core": "^1.114.25", - "@tanstack/router-generator": "^1.114.25", - "@tanstack/router-utils": "^1.114.12", - "@tanstack/virtual-file-routes": "^1.114.12", + "@tanstack/router-core": "^1.120.3", + "@tanstack/router-generator": "^1.120.3", + "@tanstack/router-utils": "^1.115.0", + "@tanstack/virtual-file-routes": "^1.115.0", "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.20.6", - "babel-dead-code-elimination": "^1.0.9", + "babel-dead-code-elimination": "^1.0.10", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" @@ -3652,7 +3661,7 @@ }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", - "@tanstack/react-router": "^1.114.25", + "@tanstack/react-router": "^1.120.3", "vite": ">=5.0.0 || >=6.0.0", "vite-plugin-solid": "^2.11.2", "webpack": ">=5.92.0" @@ -3675,43 +3684,6 @@ } } }, - "node_modules/@tanstack/router-utils": { - "version": "1.114.12", - "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.114.12.tgz", - "integrity": "sha512-W4tltvM9FQuDEJejz/JJD3q/pVHBXBb8VmA77pZlj4IBW97RnUNy8CUwZUgSYcb9OReoO4i/VjjQCUq9ZdiDmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.26.8", - "@babel/parser": "^7.26.8", - "ansis": "^3.11.0", - "diff": "^7.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/router-vite-plugin": { - "version": "1.114.25", - "resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.114.25.tgz", - "integrity": "sha512-9PGvbBEndtsGsZpkIf8aVlot3T3Rz7PLvuHknhYsLEOBIILj4xTxsYPg/pDrp2ozNP+6r5fVLBaxsT0UQL1Vxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/router-plugin": "^1.114.25" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/store": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.0.tgz", @@ -3723,11 +3695,10 @@ } }, "node_modules/@tanstack/virtual-file-routes": { - "version": "1.114.12", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.114.12.tgz", - "integrity": "sha512-aR13V1kSE/kUkP4a8snmqvj82OUlR5Q/rzxICmObLCsERGfzikUc4wquOy1d/RzJgsLb8o+FiOjSWynt4T7Jhg==", + "version": "1.115.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.115.0.tgz", + "integrity": "sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -4412,7 +4383,6 @@ "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", "dev": true, - "license": "ISC", "engines": { "node": ">=14" } @@ -4632,11 +4602,10 @@ } }, "node_modules/babel-dead-code-elimination": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.9.tgz", - "integrity": "sha512-JLIhax/xullfInZjtu13UJjaLHDeTzt3vOeomaSUdO/nAMEL/pWC/laKrSvWylXMnVWyL5bpmG9njqBZlUQOdg==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.10.tgz", + "integrity": "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", @@ -5383,7 +5352,6 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -5668,12 +5636,11 @@ } }, "node_modules/esbuild": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", - "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -5681,31 +5648,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.1", - "@esbuild/android-arm": "0.25.1", - "@esbuild/android-arm64": "0.25.1", - "@esbuild/android-x64": "0.25.1", - "@esbuild/darwin-arm64": "0.25.1", - "@esbuild/darwin-x64": "0.25.1", - "@esbuild/freebsd-arm64": "0.25.1", - "@esbuild/freebsd-x64": "0.25.1", - "@esbuild/linux-arm": "0.25.1", - "@esbuild/linux-arm64": "0.25.1", - "@esbuild/linux-ia32": "0.25.1", - "@esbuild/linux-loong64": "0.25.1", - "@esbuild/linux-mips64el": "0.25.1", - "@esbuild/linux-ppc64": "0.25.1", - "@esbuild/linux-riscv64": "0.25.1", - "@esbuild/linux-s390x": "0.25.1", - "@esbuild/linux-x64": "0.25.1", - "@esbuild/netbsd-arm64": "0.25.1", - "@esbuild/netbsd-x64": "0.25.1", - "@esbuild/openbsd-arm64": "0.25.1", - "@esbuild/openbsd-x64": "0.25.1", - "@esbuild/sunos-x64": "0.25.1", - "@esbuild/win32-arm64": "0.25.1", - "@esbuild/win32-ia32": "0.25.1", - "@esbuild/win32-x64": "0.25.1" + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" } }, "node_modules/escalade": { @@ -6381,7 +6348,6 @@ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", "dev": true, - "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -6456,7 +6422,6 @@ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", "dev": true, - "license": "MIT", "peerDependencies": { "csstype": "^3.0.10" } @@ -8943,7 +8908,6 @@ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -9275,21 +9239,19 @@ } }, "node_modules/seroval": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.2.1.tgz", - "integrity": "sha512-yBxFFs3zmkvKNmR0pFSU//rIsYjuX418TnlDmc2weaq5XFDqDIV/NOMPBoLrbxjLH42p4UzRuXHryXh9dYcKcw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.1.tgz", + "integrity": "sha512-F+T9EQPdLzgdewgxnBh4mSc+vde+EOkU6dC9BDuu/bfGb+UyUlqM6t8znFCTPQSuai/ZcfFg0gu79h+bVW2O0w==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/seroval-plugins": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.2.1.tgz", - "integrity": "sha512-H5vs53+39+x4Udwp4J5rNZfgFuA+Lt+uU+09w1gYBVWomtAl98B+E9w7yC05Xc81/HgLvJdlyqJbU0fJCKCmdw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.1.tgz", + "integrity": "sha512-dOlUoiI3fgZbQIcj6By+l865pzeWdP3XCSLdI3xlKnjCk5983yLWPsXytFOUI0BUZKG9qwqbj78n9yVcVwUqaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -9507,11 +9469,10 @@ } }, "node_modules/solid-js": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.5.tgz", - "integrity": "sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==", + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.6.tgz", + "integrity": "sha512-PoasAJvLk60hRtOTe9ulvALOdLjjqxuxcGZRolBQqxOnXrBXHGzqMT4ijNhGsDAYdOgEa8ZYaAE94PSldrFSkA==", "dev": true, - "license": "MIT", "dependencies": { "csstype": "^3.1.0", "seroval": "^1.1.0", @@ -10051,11 +10012,10 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.19.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", - "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", + "version": "4.19.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", + "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", "dev": true, - "license": "MIT", "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -10276,19 +10236,31 @@ } }, "node_modules/unplugin": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.2.1.tgz", - "integrity": "sha512-Q0YDhwViJaSnHf1cxLf+/VKhmfdr/ZAS/RL2GQVO0cAbAfJAVUef2bvNu+veyWcEPNwsTlFmMiFLjf8Xeqog8g==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.4.tgz", + "integrity": "sha512-m4PjxTurwpWfpMomp8AptjD5yj8qEZN5uQjjGM3TAs9MWWD2tXSSNNj6jGR2FoVGod4293ytyV6SwBbertfyJg==", "dev": true, - "license": "MIT", "dependencies": { "acorn": "^8.14.1", + "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" }, "engines": { "node": ">=18.12.0" } }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -10320,6 +10292,11 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -11158,8 +11135,7 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/which": { "version": "2.0.2", diff --git a/package.json b/package.json index 5bc3517..d4f80cf 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "@ag-grid-community/csv-export": "^32.2.0", "@ag-grid-community/react": "^32.2.0", "@ag-grid-community/styles": "^32.2.0", - "@fontsource/inter-tight": "^5.1.0", + "@fontsource/inter-tight": "^5.2.5", + "@fontsource/roboto-mono": "^5.1.0", "@nivo/core": "^0.88.0", "@nivo/pie": "^0.88.0", "@radix-ui/react-icons": "^1.3.0", @@ -54,6 +55,7 @@ "react-virtualized-auto-sizer": "^1.0.24", "recharts": "^2.15.1", "simplify-js": "^1.2.4", + "uplot": "^1.6.32", "use-debounce": "^10.0.3", "zod": "^3.23.8" }, diff --git a/src/api/types.ts b/src/api/types.ts index e7e936f..97ae609 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -114,7 +114,7 @@ export interface Peer extends z.infer { export type PeerRemove = z.infer; -export type ComputeUnits = z.infer; +export type SlotTransactions = z.infer; export type SlotPublish = z.infer; diff --git a/src/app.css b/src/app.css index 4b5c317..ebb1bf2 100644 --- a/src/app.css +++ b/src/app.css @@ -31,3 +31,7 @@ .rt-TooltipArrow { fill: #131923; } + +.rt-Separator { + background: #65677a; +} diff --git a/src/features/LeaderSchedule/Slots/SlotCardGrid.tsx b/src/features/LeaderSchedule/Slots/SlotCardGrid.tsx index a88bfb2..e0d41bd 100644 --- a/src/features/LeaderSchedule/Slots/SlotCardGrid.tsx +++ b/src/features/LeaderSchedule/Slots/SlotCardGrid.tsx @@ -26,6 +26,10 @@ import { scrollAllFuncsAtom, } from "./atoms"; import clsx from "clsx"; +import { Link } from "@tanstack/react-router"; +import { identityKeyAtom } from "../../../api/atoms"; +import { usePubKey } from "../../../hooks/usePubKey"; +import { ExternalLinkIcon } from "@radix-ui/react-icons"; interface SlotCardGridProps { slot: number; @@ -133,6 +137,9 @@ function SlotText({ isWideScreen, }: SlotTextProps & { isWideScreen: boolean }) { const queryPublish = useSlotQueryPublish(slot); + const pubkey = usePubKey(slot); + const myPubkey = useAtomValue(identityKeyAtom); + const isLeader = myPubkey === pubkey; return ( {isWideScreen ? ( - {slot} + <> + {slot} + {isLeader && ( + + + + + + )} + ) : (   )} diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/Actions.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/Actions.tsx index 030e321..664aea9 100644 --- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/Actions.tsx +++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/Actions.tsx @@ -22,7 +22,7 @@ export default function Actions() { useUnmount(() => setFitYToData(false)); return ( - + zoom("in")} disabled={isMaxZoomRange} + className={styles.button} > zoom("out")} disabled={!canZoomOut} + className={styles.button} > diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/Chart.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/Chart.tsx index 76d1fca..b197f77 100644 --- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/Chart.tsx +++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/Chart.tsx @@ -1,6 +1,5 @@ import AutoSizer from "react-virtualized-auto-sizer"; -import { ComputeUnits } from "../../../../api/types"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Line, XAxis, @@ -40,9 +39,11 @@ import { withinDomain, } from "./chartUtils"; import { AxisDomain, Coordinate } from "recharts/types/util/types"; +import { SlotTransactions } from "../../../../api/types"; +import { uplotActionAtom } from "../../../../uplotReact/atoms"; interface ChartProps { - computeUnits: ComputeUnits; + computeUnits: SlotTransactions; maxComputeUnits: number; bankTileCount: number; } @@ -79,7 +80,7 @@ const cusPerNs = 1 / 9; const tickLabelWidth = 110; const minTickCount = 3; -function getChartData(computeUnits: ComputeUnits): ChartData[] { +function getChartData(computeUnits: SlotTransactions): ChartData[] { const events = [ ...computeUnits.txn_start_timestamps_nanos.map((timestamp, i) => ({ timestampNanos: timestamp, @@ -259,7 +260,7 @@ function getBankCount({ } function getSegments( - computeUnits: ComputeUnits, + computeUnits: SlotTransactions, maxComputeUnits: number, bankTileCount: number, xDomain: Domain, @@ -362,6 +363,23 @@ export default function Chart({ setIsMaxZoomRange(false); }); + const uplotAction = useSetAtom(uplotActionAtom); + const setUplotZoomRange = useCallback( + (range?: ZoomRange) => { + uplotAction((u) => { + if (range && range[0] !== range[1] && range[1] !== undefined) { + u.setScale("x", { + min: range[0], + max: range[1] ?? range[0], + }); + } else { + u.setScale("x", { min: u.data[0][0], max: u.data[0].at(-1) ?? 0 }); + } + }); + }, + [uplotAction], + ); + useEffect(() => { if (zoomRange === undefined) { setFitYToData(false); @@ -535,8 +553,10 @@ export default function Chart({ if (newStartTs !== visStartTs || newEndTs !== visEndTs) { if (newStartTs === dataStartTs && newEndTs === dataEndTs) { // Disable zoom when zooming out to full range + setUplotZoomRange(undefined); setZoomRange(undefined); } else { + setUplotZoomRange([newStartTs, newEndTs]); setZoomRange([newStartTs, newEndTs]); if (!isPanning) { setIsMaxZoomRange(false); @@ -724,11 +744,12 @@ export default function Chart({ ); return; case "reset": + setUplotZoomRange(undefined); setZoomRange(undefined); return; } }); - }, [setTriggerZoom, setZoomRange]); + }, [setTriggerZoom, setUplotZoomRange, setZoomRange]); useEffect(() => { if (!containerElRef.current) return; @@ -824,7 +845,10 @@ export default function Chart({ onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} - onDoubleClick={() => setZoomRange(undefined)} + onDoubleClick={() => { + setZoomRange(undefined); + setUplotZoomRange(undefined); + }} > void; + bankIdx: number; +} + +export default function BarChartFloatingAction({ + setSelected, + bankIdx, + isSelected, +}: BarChartFloatingActionProps) { + return ( +
+ Bank {bankIdx} + +
+ ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/BarsChart.tsx b/src/features/Overview/SlotPerformance/TransactionBarsCard/BarsChart.tsx new file mode 100644 index 0000000..26ebcb8 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/BarsChart.tsx @@ -0,0 +1,176 @@ +import styles from "./barsChart.module.css"; +import { useCallback, useMemo, useRef } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; +import { timelinePlugin } from "./txnBarsPlugin"; +import { getDefaultStore, useAtomValue, useSetAtom } from "jotai"; +import UplotReact from "../../../../uplotReact/uplot-react"; +import AutoSizer from "react-virtualized-auto-sizer"; +import { SlotTransactions } from "../../../../api/types"; +import { tooltipPlugin } from "./tooltipPlugin"; +import { tooltipTxnIdxAtom, tooltipTxnStateAtom } from "./chartTooltipAtoms"; +import { timeScaleDragPlugin } from "./scaleDragPlugin"; +import { getChartData } from "./chartUtils"; +import { addPrevSeries, barCountAtom, chartFiltersAtom } from "./atoms"; +import { wheelZoomPlugin } from "./wheelZoomPlugin"; +import { uplotActionAtom } from "../../../../uplotReact/atoms"; + +interface BarsChartProps { + bankIdx: number; + transactions: SlotTransactions; + maxTs: number; + isLastChart?: boolean; + hide?: boolean; + isSelected?: boolean; +} + +export default function BarsChart({ + bankIdx, + transactions, + maxTs, + isLastChart, + hide, + isSelected, +}: BarsChartProps) { + const uplotAction = useSetAtom(uplotActionAtom); + + const containerRef = useRef(null); + const transactionsRef = useRef(transactions); + transactionsRef.current = transactions; + + const chartData = useMemo(() => { + return getChartData( + transactions, + bankIdx, + maxTs, + Object.values(getDefaultStore().get(chartFiltersAtom)), + ); + }, [bankIdx, maxTs, transactions]); + + const handleCreate = useCallback( + (u: uPlot) => { + u.setData( + getChartData( + transactions, + bankIdx, + maxTs, + Object.values(getDefaultStore().get(chartFiltersAtom)), + ), + false, + ); + requestAnimationFrame(() => addPrevSeries(u, bankIdx)); + }, + [bankIdx, maxTs, transactions], + ); + + const chartDataRef = useRef(chartData); + chartDataRef.current = chartData; + + const setTxnIdx = useSetAtom(tooltipTxnIdxAtom); + const setTxnState = useSetAtom(tooltipTxnStateAtom); + + const options = useMemo(() => { + if (!chartData?.length) return; + + const rechartsRect = document + .querySelector(".recharts-cartesian-grid") + ?.getBoundingClientRect(); + + const xOffsetLeft = Math.max( + (rechartsRect?.x ?? 0) - + (containerRef.current?.getBoundingClientRect().x ?? 20), + 66.5, + ); + + const rechartsRight = (rechartsRect?.x ?? 0) + (rechartsRect?.width ?? 0); + const containerRight = + (containerRef.current?.getBoundingClientRect().x ?? 0) + + (containerRef.current?.getBoundingClientRect().width ?? 0); + const xOffsetRight = Math.max(containerRight - rechartsRight, 73.5); + + return { + width: 0, + height: 0, + class: styles.chart, + drawOrder: ["series", "axes"] as uPlot.DrawOrderKey[], + scales: { x: { time: false } }, + axes: [ + { + stroke: "gray", + values: (self, ticks) => { + return isLastChart + ? ticks.map((rawValue) => rawValue / 1_000_000 + "ms") + : []; + }, + size: isLastChart ? 40 : 0, + space: 100, + grid: { stroke: "rgba(250, 250, 250, 0.05)" }, + }, + { + stroke: "#777b84", + grid: { show: false }, + show: false, + }, + ], + legend: { markers: { width: 0 }, show: false }, + padding: [null, xOffsetRight, null, xOffsetLeft], + series: [{ label: "Time" }, { label: `Bank ${bankIdx}` }, {}], + plugins: [ + timelinePlugin(chartDataRef, transactionsRef), + tooltipPlugin({ transactionsRef, setTxnIdx, setTxnState }), + timeScaleDragPlugin(), + wheelZoomPlugin({ factor: 0.75, uplotAction }), + ], + }; + }, [ + bankIdx, + chartData?.length, + isLastChart, + setTxnIdx, + setTxnState, + uplotAction, + ]); + + const barCount = useAtomValue(barCountAtom); + + if (!chartData || !options || hide) return null; + + return ( +
+ + {({ height, width }) => { + options.width = width; + options.height = height; + return ( + <> + + + ); + }} + +
+ ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/BarsChartContainer.tsx b/src/features/Overview/SlotPerformance/TransactionBarsCard/BarsChartContainer.tsx new file mode 100644 index 0000000..8512356 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/BarsChartContainer.tsx @@ -0,0 +1,104 @@ +import { useMemo, useRef } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { selectedSlotAtom, tileCountAtom } from "../atoms"; +import { SlotTransactions } from "../../../../api/types"; +import ChartControls from "./ChartControls"; +import { Flex } from "@radix-ui/themes"; +import BarsChart from "./BarsChart"; +import { max } from "lodash"; +import { useSlotQueryResponseTransactions } from "../../../../hooks/useSlotQuery"; +import { baseChartDataAtom, selectedBankAtom } from "./atoms"; +import { getChartData } from "./chartUtils"; +import BarChartFloatingAction from "./BarChartFloatingAction"; +import CardHeader from "../../../../components/CardHeader"; + +export default function BarsChartContainer() { + const slot = useAtomValue(selectedSlotAtom); + + const query = useSlotQueryResponseTransactions(slot); + const queryTxsRef = useRef( + query.response?.transactions, + ); + queryTxsRef.current = query.response?.transactions; + + const tileCount = useAtomValue(tileCountAtom); + const bankTileCount = tileCount["bank"]; + + const setBaseChartDataAtom = useSetAtom(baseChartDataAtom); + + const maxTs = useMemo(() => { + if (!query.response?.transactions) return 0; + return ( + max( + query.response.transactions.txn_mb_end_timestamps_nanos.map((ts) => + Number( + ts - (query.response?.transactions?.start_timestamp_nanos ?? 0n), + ), + ), + ) ?? 0 + ); + }, [query.response?.transactions]); + + useMemo(() => { + if (!query.response?.transactions) return; + const chartData: uPlot.AlignedData[] = []; + for (let i = 0; i < bankTileCount; i++) { + chartData.push(getChartData(query.response.transactions, i, maxTs)); + } + setBaseChartDataAtom(chartData); + }, [ + bankTileCount, + maxTs, + query.response?.transactions, + setBaseChartDataAtom, + ]); + + const [selected, setSelected] = useAtom(selectedBankAtom); + + if (!query.response?.transactions) return null; + + return ( + + + + + + {new Array(bankTileCount).fill(0).map((_, bankIdx) => { + if (!query.response?.transactions) return; + if (selected !== undefined && selected !== bankIdx) return; + + return ( +
+ {(selected === undefined || selected === bankIdx) && ( + + setSelected((prev) => + prev === undefined ? bankIdx : undefined, + ) + } + isSelected={selected === bankIdx} + /> + )} + +
+ ); + })} +
+ ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/ChartControls.tsx b/src/features/Overview/SlotPerformance/TransactionBarsCard/ChartControls.tsx new file mode 100644 index 0000000..283147f --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/ChartControls.tsx @@ -0,0 +1,380 @@ +import { Flex, Select, Separator, Text } from "@radix-ui/themes"; +import { focusedErrorCode, highlightErrorCode } from "./txnBarsPlugin"; +import { SlotTransactions } from "../../../../api/types"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + addCuRequestedSeriesAtom, + addIncomeCuSeriesAtom, + addMinCuSeriesAtom, + addFeeSeriesAtom, + addMinTipsSeriesAtom, + filterBundleDataAtom, + filterLandedDataAtom, + filterSimpleDataAtom, + removeSeriesAtom, + defaultChartFilters, + chartFiltersAtom, + filterErrorDataAtom, + clearAddPrevSeries, + barCountAtom, + selectedBankAtom, +} from "./atoms"; +import { uplotActionAtom } from "../../../../uplotReact/atoms"; +import { groupBy } from "lodash"; +import { useEffect, useMemo, useState } from "react"; +import ToggleGroupControl from "./ToggleGroupControl"; +import uPlot from "uplot"; +import { useUnmount } from "react-use"; +import { errorCodeMap, FilterEnum } from "./consts"; +import ToggleControl from "./ToggleControl"; +import styles from "./toggleControl.module.css"; + +interface ChartControlsProps { + transactions: SlotTransactions; + maxTs: number; +} + +export default function ChartControls({ + transactions, + maxTs, +}: ChartControlsProps) { + const setChartFilters = useSetAtom(chartFiltersAtom); + const setBarCount = useSetAtom(barCountAtom); + const setSelectedBank = useSetAtom(selectedBankAtom); + + useUnmount(() => { + setChartFilters(defaultChartFilters); + clearAddPrevSeries(); + setBarCount(1); + setSelectedBank(undefined); + highlightErrorCode(0); + }); + + return ( + + + + + + + + + + + + + + + + CU + + + + + ); +} + +interface ToggleGroupControlProps { + transactions: SlotTransactions; + maxTs: number; +} + +function ErrorControl({ transactions, maxTs }: ToggleGroupControlProps) { + const uplotAction = useSetAtom(uplotActionAtom); + const filterError = useSetAtom(filterErrorDataAtom); + const [value, setValue] = useState<"All" | "Success" | "Errors">("All"); + + return ( + <> + { + if (!value) return; + + setValue(value); + const filterValue = + value === "Success" ? "No" : value === "Errors" ? "Yes" : "All"; + uplotAction((u, bankIdx) => + filterError(u, transactions, Number(bankIdx), maxTs, filterValue), + ); + }} + /> + + + ); +} + +interface HighlightErrorControlProps { + transactions: SlotTransactions; + isDisabled: boolean; +} + +function HighlightErrorControl({ + transactions, + isDisabled, +}: HighlightErrorControlProps) { + const chartFilters = useAtomValue(chartFiltersAtom); + const uplotAction = useSetAtom(uplotActionAtom); + const [value, setValue] = useState("0"); + + const errorCodeCount = useMemo(() => { + if (Object.keys(chartFilters).length) { + const filteredTxnIds = new Set(); + uplotAction((u) => { + if (!u.data[1]?.length) return; + + for (let i = 0; i < u.data[1].length; i++) { + const txnId = u.data[1][i]; + if (txnId != null) { + filteredTxnIds.add(txnId); + } + } + }); + const filteredErrorCodes = transactions.txn_error_code.filter((_, i) => + filteredTxnIds.has(i), + ); + return groupBy(filteredErrorCodes); + } + + return groupBy(transactions.txn_error_code); + }, [chartFilters, transactions, uplotAction]); + + useEffect(() => { + if (!errorCodeCount[focusedErrorCode]) { + setValue("0"); + highlightErrorCode(0); + } + }, [errorCodeCount]); + + return ( + // TODO: replace with comobobox + { + setValue(value); + highlightErrorCode(Number(value)); + uplotAction((u) => u.redraw()); + }} + size="1" + value={value} + disabled={isDisabled} + > + + + + None + {Object.keys(errorCodeCount).map((err) => { + if (err === "0") return null; + return ( + + {errorCodeMap[err]} ({errorCodeCount[err].length}) + + ); + })} + + + + ); +} + +function BundleControl({ transactions, maxTs }: ToggleGroupControlProps) { + const uplotAction = useSetAtom(uplotActionAtom); + const filterBundle = useSetAtom(filterBundleDataAtom); + + return ( + + value && + uplotAction((u, bankIdx) => + filterBundle(u, transactions, Number(bankIdx), maxTs, value), + ) + } + /> + ); +} + +function LandedControl({ transactions, maxTs }: ToggleGroupControlProps) { + const uplotAction = useSetAtom(uplotActionAtom); + const filterLanded = useSetAtom(filterLandedDataAtom); + + return ( + + value && + uplotAction((u, bankIdx) => + filterLanded(u, transactions, Number(bankIdx), maxTs, value), + ) + } + /> + ); +} + +function SimpleControl({ transactions, maxTs }: ToggleGroupControlProps) { + const uplotAction = useSetAtom(uplotActionAtom); + const filterSimple = useSetAtom(filterSimpleDataAtom); + + return ( + + value && + uplotAction((u, bankIdx) => + filterSimple(u, transactions, Number(bankIdx), maxTs, value), + ) + } + /> + ); +} + +interface ToggleControlProps { + transactions: SlotTransactions; +} + +function FeeControl({ transactions }: ToggleControlProps) { + const [isEnabled, setIsEnabled] = useState(false); + const uplotAction = useSetAtom(uplotActionAtom); + const addMinFeeSeries = useSetAtom(addFeeSeriesAtom); + const removeSeries = useSetAtom(removeSeriesAtom); + + const handleCheckedChange = (checked: boolean) => { + setIsEnabled(checked); + + const action = (uOverride?: uPlot | null, bankIdxx?: string) => + uplotAction( + (u, bankIdx) => { + checked + ? addMinFeeSeries(uOverride ?? u, transactions, Number(bankIdx)) + : removeSeries(uOverride ?? u, FilterEnum.FEES); + }, + bankIdxx != null ? bankIdxx : undefined, + ); + action(); + }; + + return ( + + ); +} + +function TipsControl({ transactions }: ToggleControlProps) { + const [isEnabled, setIsEnabled] = useState(false); + const uplotAction = useSetAtom(uplotActionAtom); + const addMinTipsSeries = useSetAtom(addMinTipsSeriesAtom); + const removeSeries = useSetAtom(removeSeriesAtom); + + const handleCheckedChange = (checked: boolean) => { + setIsEnabled(checked); + uplotAction((u, bankIdx) => { + checked + ? addMinTipsSeries(u, transactions, Number(bankIdx)) + : removeSeries(u, FilterEnum.TIPS); + }); + }; + + return ( + + ); +} + +function CuControl({ transactions }: ToggleControlProps) { + const [isEnabled, setIsEnabled] = useState(false); + const uplotAction = useSetAtom(uplotActionAtom); + const addMinCusSeries = useSetAtom(addMinCuSeriesAtom); + const removeSeries = useSetAtom(removeSeriesAtom); + + const handleCheckedChange = (checked: boolean) => { + setIsEnabled(checked); + + uplotAction((u, bankIdx) => { + checked + ? addMinCusSeries(u, transactions, Number(bankIdx)) + : removeSeries(u, FilterEnum.CUS_CONSUMED); + }); + }; + + return ( + + ); +} + +function CuRequested({ transactions }: ToggleControlProps) { + const [isEnabled, setIsEnabled] = useState(false); + const uplotAction = useSetAtom(uplotActionAtom); + const addCuRequestedSeries = useSetAtom(addCuRequestedSeriesAtom); + const removeSeries = useSetAtom(removeSeriesAtom); + + const handleCheckedChange = (checked: boolean) => { + setIsEnabled(checked); + + uplotAction((u, bankIdx) => { + checked + ? addCuRequestedSeries(u, transactions, Number(bankIdx)) + : removeSeries(u, FilterEnum.CUS_REQUESTED); + }); + }; + + return ( + + ); +} + +function IncomeControl({ transactions }: ToggleControlProps) { + const [isEnabled, setIsEnabled] = useState(false); + const uplotAction = useSetAtom(uplotActionAtom); + const addMinCusSeries = useSetAtom(addIncomeCuSeriesAtom); + const removeSeries = useSetAtom(removeSeriesAtom); + + const handleCheckedChange = (checked: boolean) => { + setIsEnabled(checked); + + uplotAction((u, bankIdx) => { + checked + ? addMinCusSeries(u, transactions, Number(bankIdx)) + : removeSeries(u, FilterEnum.INCOME_CUS); + }); + }; + + return ( + + ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/ChartTooltip.tsx b/src/features/Overview/SlotPerformance/TransactionBarsCard/ChartTooltip.tsx new file mode 100644 index 0000000..e1a9f8b --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/ChartTooltip.tsx @@ -0,0 +1,439 @@ +import { useAtomValue } from "jotai"; +import styles from "./chartTooltip.module.css"; +import { tooltipTxnIdxAtom, tooltipTxnStateAtom } from "./chartTooltipAtoms"; +import { SlotTransactions } from "../../../../api/types"; +import { Flex, Separator, Text } from "@radix-ui/themes"; +import { errorCodeMap, stateTextColors, TxnState } from "./consts"; +import { CSSProperties, useMemo } from "react"; +import { useSlotQueryResponseTransactions } from "../../../../hooks/useSlotQuery"; +import { selectedSlotAtom } from "../atoms"; +import { chartFiltersAtom } from "./atoms"; + +export default function ChartTooltip() { + const slot = useAtomValue(selectedSlotAtom); + const query = useSlotQueryResponseTransactions(slot); + const txnIdx = useAtomValue(tooltipTxnIdxAtom); + const txnState = useAtomValue(tooltipTxnStateAtom); + const transactions = query.response?.transactions; + + const bundleStats = useMemo(() => { + if (!transactions) return; + if (txnIdx < 0) return; + if (!transactions.txn_from_bundle[txnIdx]) return; + + const mbId = transactions.txn_microblock_id[txnIdx]; + const bundleTxnIdx: number[] = []; + + for (let i = 0; i < transactions.txn_microblock_id.length; i++) { + if (transactions.txn_microblock_id[i] !== mbId) continue; + bundleTxnIdx.push(i); + } + + bundleTxnIdx.sort((a, b) => { + const diff = + transactions.txn_start_timestamps_nanos[a] - + transactions.txn_start_timestamps_nanos[b]; + if (diff > 0) return 1; + if (diff < 0) return -1; + return 0; + }); + + return { + totalCount: bundleTxnIdx.length, + order: bundleTxnIdx.indexOf(txnIdx) + 1, + bundleTxnIdx, + }; + }, [transactions, txnIdx]); + + return ( +
+ {txnIdx > -1 && transactions && ( + + + {txnState} + + + + + + + + + + + + + + + + + + + + + + + )} +
+ ); +} + +interface IncomeDisplayProps { + transactions: SlotTransactions; + txnIdx: number; +} + +function IncomeDisplay({ transactions, txnIdx }: IncomeDisplayProps) { + const filters = useAtomValue(chartFiltersAtom); + + const incomeRank = useMemo(() => { + const cuIncome = transactions.txn_priority_fee.reduce< + { txnIdx: number; income: number }[] + >((incomeTxnId, _, txnIdx) => { + if ( + Object.values(filters).some( + (func) => transactions && !func(transactions, txnIdx), + ) + ) + return incomeTxnId; + + if (!transactions?.txn_compute_units_consumed[txnIdx]) return incomeTxnId; + + if ( + !( + (transactions?.txn_priority_fee[txnIdx] ?? 0n) + + (transactions?.txn_tips[txnIdx] ?? 0n) + ) + ) + return incomeTxnId; + + const income = + Number( + (transactions?.txn_priority_fee[txnIdx] ?? 0n) + + (transactions?.txn_tips[txnIdx] ?? 0n), + ) / (transactions?.txn_compute_units_consumed[txnIdx] ?? 0); + + incomeTxnId.push({ + txnIdx, + income, + }); + + return incomeTxnId; + }, []); + + cuIncome.sort((a, b) => b.income - a.income); + + return cuIncome.reduce>( + (incomeMap, { txnIdx }, rank) => { + incomeMap[txnIdx] = rank + 1; + return incomeMap; + }, + {}, + ); + }, [filters, transactions]); + + const totalRanks = Object.keys(incomeRank).length; + const rankText = incomeRank[txnIdx] + ? ` (${incomeRank[txnIdx]} of ${totalRanks})` + : ""; + + return ( + + ); +} + +function getDurationUnits(value: bigint) { + let formatted = Number(value); + + if (formatted < 1_000) { + return { value: Math.round(formatted), unit: "ns" }; + } + + formatted /= 1_000; + if (formatted < 1_000) { + return { value: Math.round(formatted), unit: "µs" }; + } + + formatted /= 1_000; + return { value: Math.round(formatted), unit: "ms" }; +} + +interface StateDurationDisplayProps { + transactions: SlotTransactions; + txnIdx: number; + bundleTxnIdx?: number[]; +} + +function StateDurationDisplay({ + transactions, + txnIdx, + bundleTxnIdx, +}: StateDurationDisplayProps) { + const durations = useMemo(() => { + if (txnIdx < 0) return; + + let startTs = transactions.txn_mb_start_timestamps_nanos[txnIdx]; + let endTs = transactions.txn_mb_end_timestamps_nanos[txnIdx]; + let bundleTotal = null; + + if (transactions.txn_from_bundle[txnIdx] && bundleTxnIdx?.length) { + const bundleIdx = bundleTxnIdx.indexOf(txnIdx) ?? -1; + const prevTxnIdx = bundleTxnIdx[bundleIdx - 1]; + if (prevTxnIdx > 0) { + startTs = transactions.txn_end_timestamps_nanos[prevTxnIdx]; + } + + const nextTxnIdx = bundleTxnIdx[bundleIdx + 1]; + if (nextTxnIdx > 0) { + endTs = transactions.txn_start_timestamps_nanos[nextTxnIdx]; + } + + bundleTotal = + transactions.txn_mb_end_timestamps_nanos[ + bundleTxnIdx[bundleTxnIdx.length - 1] + ] - transactions.txn_mb_start_timestamps_nanos[bundleTxnIdx[0]]; + } + + const preLoading = + transactions.txn_start_timestamps_nanos[txnIdx] - startTs; + const loading = + transactions.txn_load_end_timestamps_nanos[txnIdx] - + transactions.txn_start_timestamps_nanos[txnIdx]; + const execute = + transactions.txn_end_timestamps_nanos[txnIdx] - + transactions.txn_load_end_timestamps_nanos[txnIdx]; + const postExecute = endTs - transactions.txn_end_timestamps_nanos[txnIdx]; + const total = endTs - startTs; + + return { preLoading, loading, execute, postExecute, total, bundleTotal }; + }, [bundleTxnIdx, transactions, txnIdx]); + + const durationRatios = useMemo(() => { + if (!durations) return; + + const total = Number(durations.total); + const preLoading = (Number(durations.preLoading) / total) * 100; + const loading = (Number(durations.loading) / total) * 100; + const execute = (Number(durations.execute) / total) * 100; + const postExecute = (Number(durations.postExecute) / total) * 100; + + return { preLoading, loading, execute, postExecute }; + }, [durations]); + + const durationUnits = useMemo(() => { + if (!durations) return; + + const preLoading = getDurationUnits(durations.preLoading); + const loading = getDurationUnits(durations.loading); + const execute = getDurationUnits(durations.execute); + const postExecute = getDurationUnits(durations.postExecute); + const total = getDurationUnits(durations.total); + const bundleTotal = + durations.bundleTotal != null && getDurationUnits(durations.bundleTotal); + + return { preLoading, loading, execute, postExecute, total, bundleTotal }; + }, [durations]); + + if (!durations || !durationRatios || !durationUnits) return; + + return ( + <> + + + + + + + + + + + + + + + {durations.bundleTotal && durationUnits.bundleTotal && ( + + )} + + ); +} + +interface SuccessErrorDisplayProps { + txnIdx: number; + transactions: SlotTransactions; +} + +function SuccessErrorDisplay({ + txnIdx, + transactions, +}: SuccessErrorDisplayProps) { + const errorCode = transactions.txn_error_code[txnIdx]; + const isError = errorCode !== 0; + + return ( + + ); +} + +interface LabelValueDisplayProps { + label: string; + value: string | number; + color?: string; + unit?: string; +} + +function LabelValueDisplay({ + label, + value, + color, + unit, +}: LabelValueDisplayProps) { + const formattedValue = + typeof value === "number" ? value.toLocaleString() : value; + + return ( + + {label} + + {formattedValue} + {unit && {unit}} + + + ); +} + +function BooleanLabelValueDisplay({ + value, + append, + ...props +}: Omit & { + value: boolean; + append?: string; +}) { + let formattedValue = value ? "Yes" : "No"; + if (append) { + formattedValue += ` ${append}`; + } + return ; +} + +function RowSeparator() { + return ; +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/ToggleControl.tsx b/src/features/Overview/SlotPerformance/TransactionBarsCard/ToggleControl.tsx new file mode 100644 index 0000000..bd6659a --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/ToggleControl.tsx @@ -0,0 +1,31 @@ +import { Flex, Switch, Text } from "@radix-ui/themes"; +import styles from "./toggleControl.module.css"; + +interface ToggleControlProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + label: string; + color?: string; +} + +export default function ToggleControl({ + checked, + onCheckedChange, + label, + color, +}: ToggleControlProps) { + return ( + + + + + {label} + + + + ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/ToggleGroupControl.tsx b/src/features/Overview/SlotPerformance/TransactionBarsCard/ToggleGroupControl.tsx new file mode 100644 index 0000000..7866bcb --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/ToggleGroupControl.tsx @@ -0,0 +1,57 @@ +import { ToggleGroup } from "radix-ui"; +import { Flex, Text } from "@radix-ui/themes"; +import styles from "./toggleGroupControl.module.css"; +import { useState } from "react"; + +interface ToggleGroupControlProps { + label?: string; + options: T[]; + defaultValue?: T; + onChange: (value: T) => void; + optionColors?: Partial>; +} + +export default function ToggleGroupControl({ + label, + options, + defaultValue, + onChange, + optionColors, +}: ToggleGroupControlProps) { + const [value, setValue] = useState(defaultValue); + + return ( + + {label && {label}} + { + if (value) { + setValue(value as T); + onChange(value as T); + } + }} + > + {options.map((option) => ( + + {optionColors?.[option] && ( +
+ )} + {option} + + ))} + + + ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/atoms.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/atoms.ts new file mode 100644 index 0000000..97343df --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/atoms.ts @@ -0,0 +1,255 @@ +import { atom } from "jotai"; +import uPlot, { AlignedData } from "uplot"; +import { SlotTransactions } from "../../../../api/types"; +import { setBarCount } from "./txnBarsPlugin"; +import { getChartData } from "./chartUtils"; +import { FilterEnum } from "./consts"; + +const landedKey = "landed"; +function landedFilter(transactions: SlotTransactions, txnIdx: number) { + return transactions.txn_landed[txnIdx]; +} + +// indexed by bank id +export const baseChartDataAtom = atom([]); + +export const defaultChartFilters = {}; +// export const defaultChartFilters = { [landedKey]: landedFilter }; + +export const chartFiltersAtom = atom< + Record boolean> +>({ ...defaultChartFilters }); + +function getNewDataSeries({ + baseChartData, + transactions, + value, + filterEnum, + filterFunc, + mergeMatchingPoints, +}: { + baseChartData: AlignedData; + transactions: SlotTransactions; + value?: number; + filterEnum: FilterEnum; + filterFunc: ( + transactions: SlotTransactions, + idx: number, + value?: number, + ) => boolean; + mergeMatchingPoints?: boolean; +}) { + const newData: (number | null | undefined)[] = [null]; + let lastTxnIdx: number | null = null; + + for (let i = 1; i < baseChartData[0].length - 1; i++) { + const txnIdx = baseChartData[1][i]; + if (txnIdx != null) { + // to not have the filter span all states of txn + // if (txnIdx === u.data[1][i - 1]) { + // newData.push(null); + // continue; + // } + if (filterFunc(transactions, txnIdx, value)) { + if (lastTxnIdx === txnIdx) { + newData.push(undefined); + } else { + newData.push(filterEnum); + } + } else { + newData.push(null); + } + + lastTxnIdx = txnIdx; + } else { + newData.push(txnIdx); + + if (txnIdx === null) lastTxnIdx = null; + } + } + newData.push(null); + + if (mergeMatchingPoints) { + for (let i = 1; i < newData.length - 1; i++) { + if (newData[i] === null && newData[i + 1] != null) { + newData[i] = undefined; + } + } + } + + return newData; +} + +function filterChartDataAtom( + key: string, + filterFunc: (transactions: SlotTransactions, idx: number) => boolean, +) { + return atom( + null, + ( + get, + set, + u: uPlot, + transactions: SlotTransactions, + bankIdx: number, + maxTs: number, + filter: "All" | "Yes" | "No", + ) => { + const filters = { ...get(chartFiltersAtom) }; + + if (filter === "All") { + delete filters[key]; + } else { + filters[key] = (transactions, txnIdx) => { + switch (filter) { + case "Yes": + return filterFunc(transactions, txnIdx); + case "No": + return !filterFunc(transactions, txnIdx); + } + }; + } + + const filteredData = getChartData( + transactions, + bankIdx, + maxTs, + Object.values(filters), + ); + + set(chartFiltersAtom, filters); + + u.data.splice(1, 1, filteredData[1]); + u.data.splice(2, 1, filteredData[2]); + u.setData(u.data, false); + u.redraw(true, true); + }, + ); +} + +export const filterErrorDataAtom = filterChartDataAtom( + "error", + (transactions, idx) => transactions.txn_error_code[idx] !== 0, +); + +export const filterBundleDataAtom = filterChartDataAtom( + "bundle", + (transactions, idx) => transactions.txn_from_bundle[idx], +); + +export const filterLandedDataAtom = filterChartDataAtom( + landedKey, + landedFilter, +); + +export const filterSimpleDataAtom = filterChartDataAtom( + "simple", + (transactions, idx) => transactions.txn_is_simple_vote[idx], +); + +let addSeriesFunc: Partial< + Record void> +> = {}; +export function addPrevSeries(u: uPlot, bankIdx: number) { + Object.values(addSeriesFunc).forEach((addSeries) => addSeries(u, bankIdx)); +} + +export function clearAddPrevSeries() { + addSeriesFunc = {}; +} + +function addSeriesAtom( + filterEnum: FilterEnum, + filterFunc: ( + transactions: SlotTransactions, + idx: number, + value?: number, + ) => boolean, + mergeMatchingPoints?: boolean, +) { + return atom( + null, + ( + get, + set, + _u: uPlot, + transactions: SlotTransactions, + _bankIdx: number, + value?: number, + ) => { + function addSeries(u: uPlot, bankIdx: number) { + const sidx = u.data.length; + const baseChartData = get(baseChartDataAtom); + + const newData = getNewDataSeries({ + filterEnum, + filterFunc, + transactions, + baseChartData: baseChartData[bankIdx], + value, + mergeMatchingPoints, + }); + u.data.splice(sidx, 0, newData); + u.addSeries({ ...u.series[1], label: `${filterEnum}` }, sidx); + setBarCount(u.series.filter((s) => s.show).length - 1); + u.setData(u.data, false); + if (get(selectedBankAtom) === undefined) u.redraw(true, true); + } + + addSeriesFunc[filterEnum] = addSeries; + addSeries(_u, _bankIdx); + }, + ); +} + +export const addFeeSeriesAtom = addSeriesAtom( + FilterEnum.FEES, + (transactions, idx) => !!Number(transactions.txn_priority_fee[idx]), +); + +export const addMinTipsSeriesAtom = addSeriesAtom( + FilterEnum.TIPS, + (transactions, idx) => !!Number(transactions.txn_tips[idx]), +); + +export const addMinCuSeriesAtom = addSeriesAtom( + FilterEnum.CUS_CONSUMED, + (transactions, idx) => !!transactions.txn_compute_units_consumed[idx], +); + +export const addCuRequestedSeriesAtom = addSeriesAtom( + FilterEnum.CUS_REQUESTED, + (transactions, idx, value) => !!transactions.txn_compute_units_requested[idx], +); + +export const addIncomeCuSeriesAtom = addSeriesAtom( + FilterEnum.INCOME_CUS, + (transactions, idx) => { + return ( + transactions.txn_compute_units_consumed[idx] > 0 && + Number(transactions.txn_priority_fee[idx] + transactions.txn_tips[idx]) / + transactions.txn_compute_units_consumed[idx] > + 0 + ); + }, +); + +export const removeSeriesAtom = atom( + null, + (get, set, u: uPlot, filterEnum: FilterEnum) => { + const sidx = u.series.findIndex((s) => s.label === `${filterEnum}`); + if (sidx === -1) return; + + u.delSeries(sidx); + u.data.splice(sidx, 1); + setBarCount(u.data.length - 1); + u.setData(u.data, false); + if (get(selectedBankAtom) === undefined) u.redraw(true, true); + + delete addSeriesFunc[filterEnum]; + }, +); + +export const barCountAtom = atom(1); + +export const selectedBankAtom = atom(); diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/barChartFloatingAction.module.css b/src/features/Overview/SlotPerformance/TransactionBarsCard/barChartFloatingAction.module.css new file mode 100644 index 0000000..215f6f7 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/barChartFloatingAction.module.css @@ -0,0 +1,14 @@ +.container { + position: absolute; + z-index: 100; + right: 5px; + top: 5px; + display: flex; + align-items: center; + gap: 8px; + + .label { + color: #60646c; + font-size: 12px; + } +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/barsChart.module.css b/src/features/Overview/SlotPerformance/TransactionBarsCard/barsChart.module.css new file mode 100644 index 0000000..4408ce6 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/barsChart.module.css @@ -0,0 +1,32 @@ +.chart { + /* background: green; */ + + :global(.uplot) :global(.legend) :global(.series):first-child, + :global(.uplot) :global(.legend) :global(.series) th::after, + :global(.uplot) :global(.legend) :global(.series) td { + display: none; + } + + :global(.lib-toggles) { + margin-top: 20px; + text-align: center; + } + + :global(.u-select) { + background: rgba(255, 255, 255, 0.1); + } + + :global(.hidden) { + color: silver; + } + + :global(.u-cursor-pt) { + border-radius: 0; + } + + :global(.uplot) { + margin-bottom: 20px; + padding: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + } +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/chartTooltip.module.css b/src/features/Overview/SlotPerformance/TransactionBarsCard/chartTooltip.module.css new file mode 100644 index 0000000..da785fd --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/chartTooltip.module.css @@ -0,0 +1,41 @@ +:global(#uplot-tooltip) { + z-index: 1; + position: absolute; + background: var(--Colors-Gray-1, #111); + padding: 6px 8px; + border-radius: 8px; + max-height: none !important; + max-width: none !important; + pointer-events: none; + + .state { + font-weight: 700; + color: var(--Colors-Neutral-Neutral-10, #777b84); + } + + span { + user-select: none; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + color: var(--color-override, #777b84); + } + + .duration-container { + flex: 1; + background: rgba(54, 58, 63, 0.5); + padding: 4px 0; + margin: 4px 0; + width: 0; + } + + .unit { + font-family: "Roboto Mono", monospace; + margin-left: 2px; + } + + .separator { + background: #333333; + } +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/chartTooltipAtoms.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/chartTooltipAtoms.ts new file mode 100644 index 0000000..6c796fa --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/chartTooltipAtoms.ts @@ -0,0 +1,6 @@ +import { atom } from "jotai"; +import { TxnState } from "./consts"; + +export const tooltipTxnIdxAtom = atom(-1); + +export const tooltipTxnStateAtom = atom(TxnState.DEFAULT); diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/chartUtils.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/chartUtils.ts new file mode 100644 index 0000000..796175e --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/chartUtils.ts @@ -0,0 +1,104 @@ +import { SlotTransactions } from "../../../../api/types"; +import uPlot from "uplot"; + +export function getSlotStateTs(transactions: SlotTransactions, txnIdx: number) { + if (txnIdx < 0) return; + if (transactions.txn_mb_start_timestamps_nanos[txnIdx] === undefined) return; + + const startTs = transactions.start_timestamp_nanos; + const mbStartTs = Number( + transactions.txn_mb_start_timestamps_nanos[txnIdx] - startTs, + ); + const txnStartTs = Number( + transactions.txn_start_timestamps_nanos[txnIdx] - startTs, + ); + const loadEndTs = Number( + transactions.txn_load_end_timestamps_nanos[txnIdx] - startTs, + ); + const txnEndTs = Number( + transactions.txn_end_timestamps_nanos[txnIdx] - startTs, + ); + const mbEndTs = Number( + transactions.txn_mb_end_timestamps_nanos[txnIdx] - startTs, + ); + + return { mbStartTs, txnStartTs, loadEndTs, txnEndTs, mbEndTs }; +} + +export function getChartData( + transactions: SlotTransactions, + bankIdx: number, + maxTs: number, + filterFunctions?: (( + transactions: SlotTransactions, + txnIdx: number, + ) => boolean)[], +) { + const tsToTxnIdx: Record = {}; + + for (let txnIdx = 0; txnIdx < transactions.txn_bank_idx.length; txnIdx++) { + if (transactions.txn_bank_idx[txnIdx] !== bankIdx) continue; + const stateTs = getSlotStateTs(transactions, txnIdx); + if (!stateTs) continue; + + const { mbStartTs, txnStartTs, loadEndTs, txnEndTs, mbEndTs } = stateTs; + if (tsToTxnIdx[mbStartTs] != null) { + if ( + transactions.txn_start_timestamps_nanos[txnIdx] < + transactions.txn_start_timestamps_nanos[tsToTxnIdx[mbStartTs]] + ) { + tsToTxnIdx[mbStartTs] = txnIdx; + } + } else { + tsToTxnIdx[mbStartTs] = txnIdx; + } + tsToTxnIdx[txnStartTs] = txnIdx; + tsToTxnIdx[loadEndTs] = txnIdx; + tsToTxnIdx[txnEndTs] = txnIdx; + tsToTxnIdx[mbEndTs] = null; + } + + const sortedTs = Object.keys(tsToTxnIdx) + .map((ts) => +ts) + .sort((a, b) => a - b); + + const res: (number | null | undefined)[][] = [[0], [null], [null]]; + + for (let i = 0; i < sortedTs.length; i++) { + const ts = sortedTs[i]; + const txnIdx = tsToTxnIdx[ts]; + + res[0].push(ts); + + if ( + txnIdx != null && + filterFunctions?.length && + filterFunctions.some((func) => !func(transactions, txnIdx)) + ) { + res[1].push(null); + res[2].push(null); + } else { + res[1].push(txnIdx); + if (txnIdx === null) { + res[2].push(null); + } else { + const mbId = transactions.txn_microblock_id[txnIdx]; + if ( + res[2][res[2].length - 1] === mbId || + res[2][res[2].length - 1] === undefined + ) { + res[2].push(undefined); + } else { + res[2].push(mbId); + } + } + } + } + + res[0].push(maxTs); + for (let i = 1; i < res.length; i++) { + res[i].push(null); + } + + return res as uPlot.AlignedData; +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/consts.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/consts.ts new file mode 100644 index 0000000..bca9b16 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/consts.ts @@ -0,0 +1,79 @@ +export const enum TxnState { + DEFAULT = "All", + PRELOADING = "Pre-Loading", + LOADING = "Loading", + EXECUTE = "Execute", + POST_EXECUTE = "Post-Execute", +} + +export const stateColors = { + [TxnState.DEFAULT]: "rgba(0, 0, 0, .5)", + [TxnState.PRELOADING]: "rgba(125, 94, 84, .5)", + [TxnState.LOADING]: "rgba(158, 108, 0, .5)", + [TxnState.EXECUTE]: "rgba(96, 100, 108, .5)", + [TxnState.POST_EXECUTE]: "rgba(17, 50, 100, .5)", +}; + +export const stateTextColors = { + [TxnState.DEFAULT]: "rgba(0, 0, 0, .5)", + [TxnState.PRELOADING]: "#A18072", + [TxnState.LOADING]: "#836A21", + [TxnState.EXECUTE]: "#B5B2BC", + [TxnState.POST_EXECUTE]: "#0090FF", +}; + +export const enum FilterEnum { + ERROR, + MICROBLOCK, + BUNDLE, + LANDED, + SIMPLE, + FEES, + TIPS, + CUS_CONSUMED, + CUS_REQUESTED, + INCOME_CUS, +} + +export const errorCodeMap: Record = { + 0: "Success", // The transaction successfully executed + 1: "Account In Use", // Includes a writable account that was already in use at the time this transaction was executed + 2: "Account Loaded Twice", // Lists at least one account pubkey more than once + 3: "Account Not Found", // Lists at least one account pubkey that was not found in the accounts database + 4: "Program Account Not Found", // Could not find or parse a listed program account + 5: "Insufficient Funds For Fee", // Lists a fee payer that does not have enough SOL to fund this transaction + 6: "Invalid Account For Fee", // Lists a fee payer that may not be used to pay transaction fees + 7: "Already Processed", // This transaction has been processed before (e.g. the transaction was sent twice) + 8: "Blockhash Not Found", // Provides a block hash of a recent block in the chain, b, that this validator has not seen yet, or that is so old it has been discarded + 9: "Instruction Error", // Includes an instruction that failed to process + 10: "Call Chain Too Deep", // Includes a cross program invocation (CPI) chain that exceeds the maximum depth allowed + 11: "Missing Signature For Fee", // Requires a fee but has no signature present + 12: "Invalid Account Index", // Contains an invalid account reference in one of its instructions + 13: "Signature Failure", // Includes a signature that did not pass verification + 14: "Invalid Program For Execution", // Includes a program that may not be used for executing transactions + 15: "Sanitize Failure", // Failed to parse a portion of the transaction payload + 16: "Cluster Maintenance", // Cluster is undergoing an active maintenance window + 17: "Account Borrow Outstanding", // Transaction processing left an account with an outstanding borrowed reference + 18: "Would Exceed Max Block Cost Limit", // Exceeded the maximum compute unit cost allowed for this slot + 19: "Unsupported Version", // Includes a transaction version that is not supported by this validator + 20: "Invalid Writable Account", // Includes an account marked as writable that is not in fact writable + 21: "Would Exceed Max Account Cost Limit", // Exceeded the maximum per-account compute unit cost allowed for this slot + 22: "Would Exceed Account Data Block Limit", // Retrieved accounts data size exceeds the limit imposed for this slot + 23: "Too Many Account Locks", // Locked too many accounts + 24: "Address Lookup Table Not Found", // Loads an address table account that doesn't exist + 25: "Invalid Address Lookup Table Owner", // Loads an address table account with an invalid owner + 26: "Invalid Address Lookup Table Data", // Loads an address table account with invalid data + 27: "Invalid Address Lookup Table Index", // Address table lookup uses an invalid index + 28: "Invalid Rent Paying Account", // Deprecated + 29: "Would Exceed Max Vote Cost Limit", // Exceeded the maximum vote compute unit cost allowed for this slot + 30: "Would Exceed Account Data Total Limit", // Deprecated + 31: "Duplicate Instruction", // Contains duplicate instructions + 32: "Insufficient Funds For Rent", // Deprecated + 33: "Max Loaded Accounts Data Size Exceeded", // Retrieved accounts data size exceeds the limit imposed for this transaction + 34: "Invalid Loaded Accounts Data Size Limit", // Requested an invalid data size (i.e. 0) + 35: "Resanitization Needed", // Sanitized transaction differed before/after feature activation. Needs to be resanitized + 36: "Program Execution Temporarily Restricted", // Execution of a program referenced by this transaciton is restricted + 37: "Unbalanced Transaction", // The total accounts balance before the transaction does not equal the total balance after + 38: "Program Cache Hit Max Limit", // The program cache allocated for transaction batch for this transaction hit its load limit + 39: "Commit Cancelled", // This transaction was part of a bundle that failed +}; diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/distr.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/distr.ts new file mode 100644 index 0000000..46a1a36 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/distr.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +function roundDec(val: number, dec: number) { + return Math.round(val * (dec = 10 ** dec)) / dec; +} + +export const SPACE_BETWEEN = 1; +export const SPACE_AROUND = 2; +export const SPACE_EVENLY = 3; + +const coord = ( + i: number, + offs: number, + iwid: number, + scaledIwid: number, + gap: number, +) => { + // if (i > 2) { + // return roundDec(offs + (i-2) * (scaledIwid + gap) + (iwid + gap), 6); + // } + return roundDec(offs + i * (iwid + gap), 6); +}; + +export function distr( + numItems: number, + sizeFactor: number, + justify: number, + onlyIdx: number | null, + each: { + (groupIdx: number, groupOffPct: number, groupDimPct: number): void; + (barIdx: number, barOffPct: number, barDimPct: any): void; + (di: any, lftPct: number, widPct: number): void; + (arg0: number, arg1: number, arg2: number): void; + }, +) { + const space = 1 - sizeFactor; + + let gap = + justify == SPACE_BETWEEN + ? space / (numItems - 1) + : justify == SPACE_AROUND + ? space / numItems + : justify == SPACE_EVENLY + ? space / (numItems + 1) + : 0; + + if (isNaN(gap) || gap == Infinity) gap = 0; + + const offs = + justify == SPACE_BETWEEN + ? 0 + : justify == SPACE_AROUND + ? gap / 2 + : justify == SPACE_EVENLY + ? gap + : 0; + + const iwid = sizeFactor / numItems; + const _iwid = roundDec(iwid, 6); + + const firstItemWeight = 3; // First item is 1.5x larger than others + const firstItemWidth = + (sizeFactor / (numItems - 1 + firstItemWeight)) * firstItemWeight; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const otherItemsWidth = (sizeFactor - firstItemWidth) / (numItems - 1); + const _firstItemWidth = _iwid; // roundDec(firstItemWidth, 6); + const _otherItemsWidth = _iwid; // roundDec(otherItemsWidth, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) { + const currentWidth = i < 2 ? _firstItemWidth : _otherItemsWidth; // Halve size for onlyIdx if it > 2 + each( + i, + coord(i, offs, _firstItemWidth, _otherItemsWidth, gap), + currentWidth, + ); + } + // for (let i = 0; i < numItems; i++) + // each(i, coord(i, offs, iwid, gap), _iwid); + } else { + const currentWidth = onlyIdx < 2 ? _firstItemWidth : _otherItemsWidth; // Halve size for onlyIdx if it > 2 + + each( + onlyIdx, + coord(onlyIdx, offs, _firstItemWidth, _otherItemsWidth, gap), + currentWidth, + ); + } +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/index.tsx b/src/features/Overview/SlotPerformance/TransactionBarsCard/index.tsx new file mode 100644 index 0000000..bd5de60 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/index.tsx @@ -0,0 +1,29 @@ +import BarsChartContainer from "./BarsChartContainer"; +import { Card } from "@radix-ui/themes"; +import ChartTooltip from "./ChartTooltip"; +import { useAtomValue } from "jotai"; +import { useSlotQueryResponseTransactions } from "../../../../hooks/useSlotQuery"; +import { selectedSlotAtom } from "../atoms"; + +export default function TransactionsBarsCard() { + const slot = useAtomValue(selectedSlotAtom); + const query = useSlotQueryResponseTransactions(slot); + if (!slot || !query.response?.transactions) return null; + + return ( + <> + + + + + + ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/quadTree.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/quadTree.ts new file mode 100644 index 0000000..80aa41b --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/quadTree.ts @@ -0,0 +1,120 @@ +export function pointWithin( + px: number, + py: number, + rlft: number, + rtop: number, + rrgt: number, + rbtm: number, +) { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} + +export class Quadtree { + static MAX_OBJECTS: number = 10; // Maximum objects per node + static MAX_LEVELS: number = 4; // Maximum levels of the tree + + x: number; // X-coordinate of the node's boundary + y: number; // Y-coordinate of the node's boundary + w: number; // Width of the node's boundary + h: number; // Height of the node's boundary + l: number; // Current level of the node + o: Array<{ x: number; y: number; w: number; h: number }>; // Objects stored in this node + q: Quadtree[] | null; // Children nodes (quadrants) + + constructor(x: number, y: number, w: number, h: number, l: number = 0) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.l = l; + this.o = []; + this.q = null; + } + + // Splits the current node into four child nodes (quadrants) + private split(): void { + const w = this.w / 2; + const h = this.h / 2; + const l = this.l + 1; + + this.q = [ + new Quadtree(this.x + w, this.y, w, h, l), // Top-right + new Quadtree(this.x, this.y, w, h, l), // Top-left + new Quadtree(this.x, this.y + h, w, h, l), // Bottom-left + new Quadtree(this.x + w, this.y + h, w, h, l), // Bottom-right + ]; + } + + // Determines which quadrants overlap with a rectangular area + private quads( + x: number, + y: number, + w: number, + h: number, + cb: (quad: Quadtree) => void, + ): void { + if (!this.q) return; + + const hzMid = this.x + this.w / 2; + const vtMid = this.y + this.h / 2; + + const startIsNorth = y < vtMid; + const startIsWest = x < hzMid; + const endIsEast = x + w > hzMid; + const endIsSouth = y + h > vtMid; + + // Callbacks for overlapping quadrants + startIsNorth && endIsEast && cb(this.q[0]); // Top-right + startIsWest && startIsNorth && cb(this.q[1]); // Top-left + startIsWest && endIsSouth && cb(this.q[2]); // Bottom-left + endIsEast && endIsSouth && cb(this.q[3]); // Bottom-right + } + + // Adds an object to this node or its children + add(o: { x: number; y: number; w: number; h: number }): void { + if (this.q) { + this.quads(o.x, o.y, o.w, o.h, (quad) => quad.add(o)); + } else { + this.o.push(o); + + // Split node if capacity is exceeded and below max level + if ( + this.o.length > Quadtree.MAX_OBJECTS && + this.l < Quadtree.MAX_LEVELS + ) { + this.split(); + + // Redistribute objects in the new child quadrants + for (const obj of this.o) { + this.quads(obj.x, obj.y, obj.w, obj.h, (quad) => quad.add(obj)); + } + + // Clear objects in the current node + this.o.length = 0; + } + } + } + + // Retrieves objects from the given rectangular area + get( + x: number, + y: number, + w: number, + h: number, + cb: (obj: { x: number; y: number; w: number; h: number }) => void, + ): void { + for (const obj of this.o) { + cb(obj); + } + + if (this.q) { + this.quads(x, y, w, h, (quad) => quad.get(x, y, w, h, cb)); + } + } + + // Clears the node and its children + clear(): void { + this.o.length = 0; + this.q = null; + } +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/scaleDragPlugin.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/scaleDragPlugin.ts new file mode 100644 index 0000000..c1a16a5 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/scaleDragPlugin.ts @@ -0,0 +1,75 @@ +import { getDefaultStore } from "jotai"; +import uPlot from "uplot"; +import { uplotActionAtom } from "../../../../uplotReact/atoms"; + +const store = getDefaultStore(); + +export function timeScaleDragPlugin(): uPlot.Plugin { + return { + hooks: { + init: [ + (u) => { + const axisEls = u.root.querySelectorAll(".u-axis"); + + // x axis + const el = axisEls[0]; + if (!el) return; + + el.addEventListener("mousedown", (e) => { + const x0 = (e as MouseEvent).clientX; + const scaleKey = u.axes[0].scale; + if (scaleKey === undefined) return; + + const scale = u.scales[scaleKey]; + const { min, max } = scale; + const unitsPerPx = + ((max ?? 0) - (min ?? 0)) / (u.bbox.width / uPlot.pxRatio); + + const mousemove = (e: MouseEvent) => { + const dx = e.clientX - x0; + const shiftXBy = dx * unitsPerPx; + + // setPauseDrawing(true); + + store.set(uplotActionAtom, (u) => { + if (!u.data[0].length) return; + + const scaleMin = u.data[0][0] ?? 0; + const scaleMax = u.data[0][u.data[0].length - 1] ?? 0; + + u.setScale(scaleKey, { + min: Math.max( + scaleMin, + e.shiftKey ? (min ?? 0) - shiftXBy : (min ?? 0) + shiftXBy, + ), + max: Math.min(scaleMax, (max ?? 0) + shiftXBy), + }); + }); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let rafId: number = 0; + + const mouseup = () => { + document.removeEventListener("mousemove", mousemove); + document.removeEventListener("mousemove", mouseup); + + rafId++; + // To avoid expensive drawPath we + // setPauseDrawing(false); + store.set(uplotActionAtom, (u) => { + // if(rafId) + requestAnimationFrame(() => { + u.redraw(); + }); + }); + }; + + document.addEventListener("mousemove", mousemove); + document.addEventListener("mouseup", mouseup); + }); + }, + ], + }, + }; +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/toggleControl.module.css b/src/features/Overview/SlotPerformance/TransactionBarsCard/toggleControl.module.css new file mode 100644 index 0000000..e991ad9 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/toggleControl.module.css @@ -0,0 +1,5 @@ +.label { + color: #0bd8b6; + font-size: 14px; + line-height: 16px; +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/toggleGroupControl.module.css b/src/features/Overview/SlotPerformance/TransactionBarsCard/toggleGroupControl.module.css new file mode 100644 index 0000000..1633e42 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/toggleGroupControl.module.css @@ -0,0 +1,56 @@ +.group { + display: inline-flex; + background-color: var(--mauve-6); + border-radius: 4px; + box-shadow: 0 2px 10px var(--black-a7); + margin-left: 8px; +} + +.item { + all: unset; + background-color: #292929; + color: #9e9e9e; + height: 23px; + padding: 0 8px; + display: flex; + font-size: 12px; + line-height: 16px; + align-items: center; + justify-content: center; + margin-left: 1px; + user-select: none; + font-weight: 400; + cursor: pointer; + + &:first-child { + margin-left: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + &:last-child { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + &:hover { + background: rgba(62, 98, 189, 0.32); + } + + &[data-state="on"] { + background-color: #4a4a4a; + color: #f0f0f0; + font-weight: 510; + } + + &:focus { + position: relative; + box-shadow: 0 0 0 2px black; + } + + .item-color { + width: 4px; + height: 16px; + margin-right: 4px; + } +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/tooltipPlugin.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/tooltipPlugin.ts new file mode 100644 index 0000000..bc3ff0f --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/tooltipPlugin.ts @@ -0,0 +1,104 @@ +import uPlot from "uplot"; +import placement from "../../../../uplot/placement.js"; +import { SlotTransactions } from "../../../../api/types.js"; +import { MutableRefObject } from "react"; +import { TxnState } from "./consts.js"; +import { getTxnState } from "./txnBarsPluginUtils.js"; + +export function tooltipPlugin({ + transactionsRef, + setTxnIdx, + setTxnState, +}: { + transactionsRef: MutableRefObject; + setTxnIdx: (txnIdx: number) => void; + setTxnState: (state: TxnState) => void; +}): uPlot.Plugin { + let over: HTMLDivElement; + let bound: HTMLElement; + let bLeft: number; + let bTop: number; + + function syncBounds() { + const bbox = over.getBoundingClientRect(); + bLeft = bbox.left; + bTop = bbox.top; + } + + let overlay: HTMLElement; + + // setTimeout(() => syncBounds(), 10_000); + + return { + hooks: { + init: (u) => { + const el = document.getElementById("uplot-tooltip"); + if (el) { + overlay = el; + } else { + overlay = document.createElement("div"); + overlay.id = "uplot-tooltip"; + document.body.appendChild(overlay); + } + if (!overlay) return; + + overlay.style.display = "none"; + + // overlay.style.zIndex = "10000" + + over = u.over; + + // bound = over; + bound = document.body; + + over.onmouseenter = () => { + overlay.style.display = "block"; + }; + + over.onmouseleave = () => { + overlay.style.display = "none"; + }; + }, + destroy: () => { + over.onmouseenter = null; + over.onmouseleave = null; + // document.getElementById("overlay")?.remove(); + }, + setSize: (u) => { + syncBounds(); + }, + syncRect: () => { + syncBounds(); + }, + setCursor: (u) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { left, top, idx, dataIdx, idxs } = u.cursor; + if (left === undefined || top === undefined || idx == null) return; + const val = u.posToVal(left ?? 0, "x"); + const anchor = { left: left + bLeft + 10, top: top + bTop + 10 }; + + let txnIdx = u.data[1][idx]; + // To catch second half of bar where end point is undefined indicating end of state + if (txnIdx == null && u.data[1][idx - 1] != null) { + txnIdx = u.data[1][idx - 1]; + } + + if ( + txnIdx == null || + !transactionsRef.current || + (idxs?.length && idxs[1] === undefined) + ) { + overlay.style.display = "none"; + return; + } else { + overlay.style.display = "block"; + } + + setTxnState(getTxnState(val, transactionsRef.current, txnIdx)); + setTxnIdx(txnIdx); + + placement(overlay, anchor, "bottom", "start", { bound }); + }, + }, + }; +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/txnBarsPlugin.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/txnBarsPlugin.ts new file mode 100644 index 0000000..1d4d0cd --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/txnBarsPlugin.ts @@ -0,0 +1,798 @@ +import { AlignedData, RectH } from "uplot"; +import { distr, SPACE_BETWEEN } from "./distr"; +import uPlot from "uplot"; +import { ceil, round } from "lodash"; +import { pointWithin, Quadtree } from "./quadTree"; +import { MutableRefObject } from "react"; +import { SlotTransactions } from "../../../../api/types"; +import { getDefaultStore } from "jotai"; +import { logRatio } from "../../../../mathUtils"; +import { barCountAtom } from "./atoms"; +import { FilterEnum, stateColors } from "./consts"; +import { + getMaxFees, + getMaxTips, + getMaxCuConsumed, + getMaxCuRequested, + getMaxCuIncome, + isTimeSeries, + isTxnIdxSeries, + getChartTxnState, + isMicroblockSeries, + bigIntRatio, + isAdditionalSeries, + syncZoom, + txnIdxSidx, +} from "./txnBarsPluginUtils"; + +const laneWidth = 1; +const laneDistr = SPACE_BETWEEN; + +let stateSeriesHgt = 0; + +export let focusedErrorCode: number; +export function highlightErrorCode(errorCode: number) { + focusedErrorCode = errorCode; +} + +let barCount = 0; +export const setBarCount = (count: number) => { + barCount = count - 1; + getDefaultStore().set(barCountAtom, barCount); +}; + +let pauseDrawing = false; +export const setPauseDrawing = (pause: boolean) => (pauseDrawing = pause); + +export function timelinePlugin( + chartDataRef: MutableRefObject, + transactionsRef: MutableRefObject, +): uPlot.Plugin { + let maxFees = 0n; + let maxTips = 0n; + let maxCuConsumed = 0; + let maxCuRequested = 0; + let maxCuIncome: Record = {}; + + function setMaxFees() { + maxFees = getMaxFees(transactionsRef); + } + function setMaxTips() { + maxTips = getMaxTips(transactionsRef); + } + function setMaxCuConsumed() { + maxCuConsumed = getMaxCuConsumed(transactionsRef); + } + function setMaxCuRequested() { + maxCuRequested = getMaxCuRequested(transactionsRef); + } + function setMaxCuIncome() { + maxCuIncome = getMaxCuIncome(transactionsRef); + } + + barCount = 1; + + const opts = { + mode: 1, + fill: ( + data: AlignedData, + seriesIdx: number, + dataIdx: number, + value: number, + ) => { + if (!transactionsRef.current) return ""; + + if (isTimeSeries(seriesIdx)) return ""; + if (isTxnIdxSeries(seriesIdx)) { + return stateColors[ + getChartTxnState(data, dataIdx, transactionsRef.current, value) + ]; + } + + if (isMicroblockSeries(seriesIdx)) { + return ""; + } + + if (value === FilterEnum.FEES) { + const txnIdx = data[txnIdxSidx][dataIdx] ?? -1; + const fees = transactionsRef.current?.txn_priority_fee[txnIdx]; + if (!fees) return ""; + + const ratio = bigIntRatio(fees, maxFees, 4); + const ratioScaled = Math.max(Math.min(0.8, ratio * 4), 0.3); + return `rgba(76, 204, 230, ${ratioScaled})`; + } + + if (value === FilterEnum.TIPS) { + const txnIdx = data[txnIdxSidx][dataIdx] ?? -1; + const tips = transactionsRef.current?.txn_tips[txnIdx]; + if (!tips) return ""; + + const ratio = bigIntRatio(tips, maxTips, 4); + const ratioScaled = Math.max(Math.min(0.8, ratio * 4), 0.3); + return `rgba(31, 216, 164, ${ratioScaled})`; + } + + if (value === FilterEnum.CUS_REQUESTED) { + const txnIdx = data[txnIdxSidx][dataIdx] ?? -1; + const cuRequested = + transactionsRef.current?.txn_compute_units_requested[txnIdx]; + if (!cuRequested) return ""; + + const ratio = cuRequested / maxCuRequested; + const ratioScaled = Math.max(Math.min(0.85, ratio), 0.2); + return `rgba(255, 141, 204, ${ratioScaled})`; + } + + if (value === FilterEnum.CUS_CONSUMED) { + const txnIdx = data[txnIdxSidx][dataIdx] ?? -1; + const cuConsumed = + transactionsRef.current?.txn_compute_units_consumed[txnIdx]; + if (!cuConsumed) return ""; + + const ratio = cuConsumed / maxCuConsumed; + const ratioScaled = Math.max(Math.min(0.85, ratio), 0.2); + return `rgba(209, 157, 255, ${ratioScaled})`; + } + + if (value === FilterEnum.INCOME_CUS) { + const txnIdx = data[txnIdxSidx][dataIdx] ?? -1; + const ratio = maxCuIncome[txnIdx] ?? 0; + const ratioScaled = Math.max(Math.min(0.8, ratio), 0.3); + + return `rgba(158, 177, 255, ${ratioScaled})`; + } + + return ""; + }, + stroke: ( + data: AlignedData, + seriesIdx: number, + dataIdx: number, + value: number, + ) => { + if (isTimeSeries(seriesIdx)) return ""; + + if (isTxnIdxSeries(seriesIdx)) { + return ""; + } + + if (isMicroblockSeries(seriesIdx)) { + const txnIdx = data[txnIdxSidx][dataIdx] ?? -1; + const errorCode = transactionsRef.current?.txn_error_code[txnIdx]; + if (focusedErrorCode) { + if (errorCode === focusedErrorCode) { + return "rgba(162,5,8, .8)"; + } + + return errorCode ? "rgba(162,5,8, .1)" : "rgba(19,173,79, .1)"; + } + return errorCode ? "rgba(162,5,8, .5)" : "rgba(19,173,79, .5)"; + } + + return ""; + }, + align: undefined, + size: undefined, + }; + const { mode, fill, stroke } = opts; + + function walk( + yIdx: number | null, + count: number, + dim: number, + draw: { + (iy: number, y0: number, hgt: number): void; + (iy: number, y0: number, hgt: number): void; + (arg0: number, arg1: number, arg2: number): void; + }, + ) { + distr( + count, + laneWidth, + laneDistr, + yIdx, + (i: number, offPct: number, dimPct: number) => { + const laneOffPx = dim * offPct; + const laneWidPx = dim * dimPct; + draw(i, laneOffPx, laneWidPx); + }, + ); + } + + const size = opts.size ?? [0.6, Infinity]; + const align: number = opts.align ?? 0; + + const gapFactor = 1 - size[0]; + + let font = ""; + let maxWidth = 0; + + function recalcDppxVars() { + font = round(14 * uPlot.pxRatio) + "px Arial"; + maxWidth = (size[1] ?? Infinity) * uPlot.pxRatio; + } + + recalcDppxVars(); + + const fillPaths = new Map(); + const strokePaths = new Map(); + + function drawBoxes(ctx: CanvasRenderingContext2D) { + fillPaths.forEach((fillPath, fillStyle) => { + if (fillStyle) { + ctx.fillStyle = fillStyle; + ctx.fill(fillPath); + } + }); + + strokePaths.forEach((strokePath, strokeStyle) => { + if (strokeStyle) { + ctx.strokeStyle = strokeStyle; + ctx.stroke(strokePath); + } + }); + + fillPaths.clear(); + strokePaths.clear(); + } + + function putBox( + data: AlignedData, + ctx: CanvasRenderingContext2D, + rect: RectH, + xOff: number, + yOff: number, + lft: number, + top: number, + wid: number, + hgt: number, + strokeWidth: number, + iy: number, + ix: number, + value: number, + ) { + if (!transactionsRef.current) return; + + const sidx = iy + 1; + const fillStyle = fill(data, sidx, ix, value); + let fillPath = fillPaths.get(fillStyle); + + if (isAdditionalSeries(sidx)) { + if (value === FilterEnum.FEES) { + const txnIdx = data[txnIdxSidx][ix] ?? -1; + const fees = transactionsRef.current?.txn_priority_fee[txnIdx]; + if (fees !== undefined) { + let ratio = 1 / logRatio(Number(maxFees), Number(fees), 1.7); + if (ratio > 0.9) ratio = 0.9; + if (ratio < 0.1) ratio = 0.1; + const ratioHgt = hgt * ratio; + const diff = hgt - ratioHgt; + hgt -= diff; + top += diff; + } + } + + if (value === FilterEnum.TIPS) { + const txnIdx = chartDataRef.current?.[txnIdxSidx][ix] ?? -1; + const tips = transactionsRef.current?.txn_tips[txnIdx]; + if (tips !== undefined) { + let ratio = 1 / logRatio(Number(maxTips), Number(tips), 1.7); + + if (ratio > 0.9) ratio = 0.9; + if (ratio < 0.1) ratio = 0.1; + const ratioHgt = hgt * ratio; + const diff = hgt - ratioHgt; + hgt -= diff; + top += diff; + } + } + + if (value === FilterEnum.CUS_CONSUMED) { + const txnIdx = chartDataRef.current?.[txnIdxSidx][ix] ?? -1; + const cuConsumed = + transactionsRef.current?.txn_compute_units_consumed[txnIdx]; + if (cuConsumed !== undefined) { + let ratio = cuConsumed / maxCuConsumed; + if (ratio > 0.9) ratio = 0.9; + if (ratio < 0.1) ratio = 0.1; + const ratioHgt = hgt * ratio; + const diff = hgt - ratioHgt; + hgt -= diff; + top += diff; + } + } + + if (value === FilterEnum.CUS_REQUESTED) { + const txnIdx = chartDataRef.current?.[txnIdxSidx][ix] ?? -1; + const cuRequested = + transactionsRef.current?.txn_compute_units_requested[txnIdx]; + if (cuRequested !== undefined) { + let ratio = cuRequested / maxCuRequested; + if (ratio > 0.9) ratio = 0.9; + if (ratio < 0.1) ratio = 0.1; + const ratioHgt = hgt * ratio; + const diff = hgt - ratioHgt; + hgt -= diff; + top += diff; + } + } + + if (value === FilterEnum.INCOME_CUS) { + const txnIdx = chartDataRef.current?.[txnIdxSidx][ix] ?? -1; + let ratio = maxCuIncome[txnIdx] ?? 0; + if (ratio > 0.9) ratio = 0.95; + if (ratio < 0.1) ratio = 0.1; + const ratioHgt = hgt * ratio; + const diff = hgt - ratioHgt; + hgt -= diff; + top += diff; + } + } + + if (fillPath == null) fillPaths.set(fillStyle, (fillPath = new Path2D())); + rect(fillPath, lft, top, wid, hgt); + + if (strokeWidth) { + const strokeStyle = stroke(data, sidx, ix, value); + let strokePath = strokePaths.get(strokeStyle); + + if (strokePath == null) + strokePaths.set(strokeStyle, (strokePath = new Path2D())); + + rect( + strokePath, + lft + strokeWidth / 2, + top + strokeWidth / 2, + wid - strokeWidth, + hgt - strokeWidth, + ); + } + + if (isMicroblockSeries(sidx)) return; + + qt.add({ + x: round(lft - xOff), + y: round(top - yOff), + w: wid, + h: hgt, + sidx: iy + (iy > 1 ? 2 : 1), + didx: ix, + }); + } + + function drawPaths( + u: import("uplot"), + sidx: number, + idx0: number, + idx1: number, + ) { + if (pauseDrawing) return; + + setMaxFees(); + setMaxTips(); + setMaxCuConsumed(); + setMaxCuRequested(); + setMaxCuIncome(); + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + ) => { + const strokeWidth = round((series.width || 0) * uPlot.pxRatio); + + u.ctx.save(); + rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + walk( + sidx - 1, + barCount, + yDim - 10, + (iy: number, y0: number, hgt: number) => { + if ((isTimeSeries(sidx) || isTxnIdxSeries(sidx)) && hgt) { + stateSeriesHgt = hgt; + } + // to collapse the microblock series on top of the txn state series + if (!(isTimeSeries(sidx) || isTxnIdxSeries(sidx))) { + y0 -= stateSeriesHgt; + } + // draw spans + if (mode == 1) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const lft = round(valToPosX(dataX[ix], scaleX, xDim, xOff)); + let nextIx = ix; + while ( + dataY[++nextIx] === undefined && + nextIx < dataY.length + ); + + // to now (not to end of chart) + const rgt = + nextIx == dataY.length + ? xOff + xDim + strokeWidth + : round(valToPosX(dataX[nextIx], scaleX, xDim, xOff)); + + // To avoid excess drawing when zoomed, avoid drawing boxes past bbox + if (rgt >= u.bbox.left && lft <= u.bbox.left + u.bbox.width) { + const scaleDiff = + u.scales.x.min != null && u.scales.x.max != null + ? 400_000_000 / (u.scales.x.max - u.scales.x.min) + : undefined; + + putBox( + u.data, + u.ctx, + rect, + xOff, + yOff, + lft, + round(yOff + y0), + iy > 1 + ? Math.max(3, Math.min(rgt - lft, scaleDiff ?? 1)) + : rgt - lft, + // rgt - lft, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix] ?? 0, + ); + } + + ix = nextIx - 1; + } + } + } + // draw matrix + else { + const colWid = + valToPosX(dataX[1], scaleX, xDim, xOff) - + valToPosX(dataX[0], scaleX, xDim, xOff); + const gapWid = colWid * gapFactor; + const barWid = round( + Math.min(maxWidth, colWid - gapWid) - strokeWidth, + ); + const xShift = align == 1 ? 0 : align == -1 ? barWid : barWid / 2; + + for (let ix = idx0; ix <= idx1; ix++) { + if (dataY[ix] != null) { + // TODO: all xPos can be pre-computed once for all series in aligned set + const lft = valToPosX(dataX[ix], scaleX, xDim, xOff); + + putBox( + u.data, + u.ctx, + rect, + xOff, + yOff, + round(lft - xShift), + round(yOff + y0), + barWid, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix] ?? 0, + ); + } + } + } + }, + ); + + u.ctx.lineWidth = strokeWidth; + drawBoxes(u.ctx); + + u.ctx.restore(); + }, + ); + + return null; + } + + // TODO: remove + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function drawPoints( + u: import("uplot"), + sidx: number, + i0: number, + i1: number, + ) { + u.ctx.save(); + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + u.ctx.font = font; + u.ctx.fillStyle = "black"; + u.ctx.textAlign = mode == 1 ? "left" : "center"; + u.ctx.textBaseline = "middle"; + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + ) => { + const strokeWidth = round((series.width || 0) * uPlot.pxRatio); + const textOffset = mode == 1 ? strokeWidth + 2 : 0; + + const y = round(yOff + yMids[sidx - 1]); + + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const x = valToPosX(dataX[ix], scaleX, xDim, xOff) + textOffset; + u.ctx.fillText(`${dataY[ix]}`, x, y); + } + } + }, + ); + + u.ctx.restore(); + + return false; + } + + let qt: { + add: (arg0: { + x: number; + y: number; + w: number; + h: number; + sidx: number; + didx: number; + }) => void; + clear: () => void; + get: ( + arg0: number, + arg1: number, + arg2: number, + arg3: number, + arg4: (o: { x: number; y: number; w: number; h: number }) => void, + ) => void; + }; + const hovered = Array<{ + x: number; + y: number; + w: number; + h: number; + didx?: number; + } | null>(barCount).fill(null); + + const yMids = Array(barCount).fill(0); + const ySplits = Array(barCount).fill(0); + + const fmtDate = uPlot.fmtDate("{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}"); + let legendTimeValueEl: { textContent: string } | null = null; + + return { + hooks: { + setScale: (u, scaleKey) => { + if (u.scales.x.min == null || u.scales.x.max == null) return; + syncZoom([Math.trunc(u.scales.x.min), Math.trunc(u.scales.x.max)]); + }, + init: (u: { + root: { querySelector: (arg0: string) => { textContent: string } }; + }) => { + legendTimeValueEl = u.root.querySelector( + ".u-series:first-child .u-value", + ); + window.addEventListener("dppxchange", recalcDppxVars); + }, + destroy: (u) => { + window.removeEventListener("dppxchange", recalcDppxVars); + }, + drawClear: (u) => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (u.series as any).forEach((s: { _paths: null }) => { + s._paths = null; + }); + }, + setCursor: (u: uPlot) => { + if (mode == 1) { + const val = u.posToVal(u.cursor.left ?? 0, "x"); + if (legendTimeValueEl) + legendTimeValueEl.textContent = u.scales.x.time + ? fmtDate(new Date(val * 1e3)) + : val.toFixed(2); + } + }, + }, + opts: (u: uPlot, opts: uPlot.Options) => { + uPlot.assign(opts, { + cursor: { + sync: { + key: "x", + // setSeries: true, + // scales: ["x"], + // match: [matchScaleKeys, matchScaleKeys, matchScaleKeys], + }, + y: false, + + dataIdx: ( + u: { cursor: { left: number } }, + seriesIdx: number, + closestIdx: number, + xValue: number, + ) => { + if (isTimeSeries(seriesIdx)) return closestIdx; + // Don't hover for microblock series + if (isMicroblockSeries(seriesIdx)) return closestIdx; + + const cx = round(u.cursor.left * uPlot.pxRatio); + + if (cx >= 0) { + const cy = yMids[seriesIdx - 1]; + + hovered[seriesIdx - 1] = null; + qt.get(cx, cy, 1, 1, (o) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { + hovered[seriesIdx - 1] = o; + } + }); + } + + return hovered[seriesIdx - 1]?.didx; + }, + points: { + fill: "rgba(255,255,255,0.2)", + bbox: (u: uPlot, seriesIdx: number) => { + const hRect = hovered[seriesIdx - 1]; + + const res = { + left: hRect ? round(hRect.x / devicePixelRatio) : -10, + top: hRect ? round(hRect.y / devicePixelRatio) : -10, + width: hRect ? round(hRect.w / devicePixelRatio) : 0, + height: hRect ? round(hRect.h / devicePixelRatio) : 0, + }; + + const maxWidth = + round((u.bbox.left + u.bbox.width) / devicePixelRatio) - + res.left - + 10; + + // To clip hover overlay going past bbox + if (res.width > maxWidth) { + res.width = maxWidth; + } + + return res; + }, + }, + }, + scales: { + x: { + range(u: { data: number[][] }, min: number, max: number) { + if (mode == 2) { + const colWid = u.data[0][1] - u.data[0][0]; + const scalePad = colWid / 2; + + if (min <= u.data[0][0]) min = u.data[0][0] - scalePad; + + const lastIdx = u.data[0].length - 1; + + if (max >= u.data[0][lastIdx]) + max = u.data[0][lastIdx] + scalePad; + } + + return [min, max]; + }, + }, + y: { + range: [0, 1], + }, + }, + }); + + if (opts.axes) + uPlot.assign(opts.axes[0], { + splits: + mode == 2 + ? ( + u: { data: number[][] }, + axisIdx: number, + scaleMin: number, + scaleMax: number, + foundIncr: number, + foundSpace: number, + ) => { + const splits = []; + + const dataIncr = u.data[0][1] - u.data[0][0]; + const skipFactor = ceil(foundIncr / dataIncr); + + for (let i = 0; i < u.data[0].length; i += skipFactor) { + const v = u.data[0][i]; + + if (v >= scaleMin && v <= scaleMax) splits.push(v); + } + + return splits; + } + : null, + grid: { + show: mode != 2, + }, + }); + + if (opts.axes) + uPlot.assign(opts.axes[1], { + splits: ( + u: { + bbox: { height: number }; + posToVal: (arg0: number, arg1: string) => number; + }, + axisIdx: number, + ) => { + walk( + null, + barCount, + u.bbox.height, + (iy: number, y0: number, hgt: number) => { + // vertical midpoints of each series' timeline (stored relative to .u-over) + yMids[iy] = round(y0 + hgt / 2); + ySplits[iy] = u.posToVal(yMids[iy] / uPlot.pxRatio, "y"); + }, + ); + + return ySplits; + }, + values: () => + Array(barCount) + .fill(null) + .map((v, i) => u.series[i + 1].label as string), + gap: 5, + // to hide the axis labels + size: 0, + grid: { show: false }, + ticks: { show: false }, + side: 3, + }); + + opts.series.forEach((s: object, i: number) => { + if (i > 0) { + uPlot.assign(s, { + paths: drawPaths, + // points: { + // show: drawPoints, + // }, + }); + } + }); + }, + }; +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/txnBarsPluginUtils.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/txnBarsPluginUtils.ts new file mode 100644 index 0000000..b0887f3 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/txnBarsPluginUtils.ts @@ -0,0 +1,187 @@ +import uPlot from "uplot"; +import { debounce } from "lodash"; +import { MutableRefObject } from "react"; +import { SlotTransactions } from "../../../../api/types"; +import { ZoomRange } from "../ComputeUnitsCard/types"; +import { getDefaultStore } from "jotai"; +import { zoomRangeAtom } from "../ComputeUnitsCard/atoms"; +import { chartFiltersAtom } from "./atoms"; +import { TxnState } from "./consts"; + +const store = getDefaultStore(); + +export function getTxnState( + ts: number, + txns: SlotTransactions, + txnIdx: number, +) { + if ( + ts < + Number(txns.txn_start_timestamps_nanos[txnIdx] - txns.start_timestamp_nanos) + ) { + return TxnState.PRELOADING; + } + + if ( + ts < + Number( + txns.txn_load_end_timestamps_nanos[txnIdx] - txns.start_timestamp_nanos, + ) + ) { + return TxnState.LOADING; + } + + if ( + ts < + Number(txns.txn_end_timestamps_nanos[txnIdx] - txns.start_timestamp_nanos) + ) { + return TxnState.EXECUTE; + } + + return TxnState.POST_EXECUTE; +} + +export function getChartTxnState( + chartData: uPlot.AlignedData | undefined, + dataIdx: number, + txns: SlotTransactions | undefined | null, + txnIdx: number, +) { + if (!chartData || !txns) return TxnState.DEFAULT; + + const ts = chartData[0][dataIdx]; + + return getTxnState(ts, txns, txnIdx); +} + +export const timeSidx = 0; +export const txnIdxSidx = 1; +export const microblockSidx = 2; + +export function isTimeSeries(sidx: number) { + return sidx === timeSidx; +} + +export function isTxnIdxSeries(sidx: number) { + return sidx === txnIdxSidx; +} + +export function isMicroblockSeries(sidx: number) { + return sidx === microblockSidx; +} + +export function isAdditionalSeries(sidx: number) { + return sidx > microblockSidx; +} + +export function bigIntRatio( + numerator: bigint, + denominator: bigint, + precision = 2, +) { + const scale = 10n ** BigInt(precision); + const scaledNumerator = numerator * scale; + const ratio = scaledNumerator / denominator; + const formattedRatio = Number(ratio) / Number(scale); + return formattedRatio; +} + +const _setZoomRange = (zoomRange: ZoomRange) => + store.set(zoomRangeAtom, zoomRange); +export const syncZoom = debounce(_setZoomRange, 50); + +function getMax( + getSearchArr: (transactions: SlotTransactions) => T[], + initValue: T, +) { + return function ( + transactionsRef: MutableRefObject, + ) { + if (!transactionsRef.current) return initValue; + + const searchArr = getSearchArr(transactionsRef.current); + + const filterFunctions = Object.values(store.get(chartFiltersAtom)); + + return searchArr.reduce((total, val, txnIdx) => { + if ( + filterFunctions.some( + (func) => + transactionsRef.current && !func(transactionsRef.current, txnIdx), + ) + ) + return total; + + if (val > total) return val; + return total; + }, initValue); + }; +} + +export const getMaxFees = getMax( + (transactions) => transactions.txn_priority_fee, + 0n, +); +export const getMaxTips = getMax((transactions) => transactions.txn_tips, 0n); +export const getMaxCuConsumed = getMax( + (transactions) => transactions.txn_compute_units_consumed, + 0, +); +export const getMaxCuRequested = getMax( + (transactions) => transactions.txn_compute_units_requested, + 0, +); + +export function getMaxCuIncome( + transactionsRef: MutableRefObject, +) { + if (!transactionsRef.current) return {}; + + const filterFunctions = Object.values(store.get(chartFiltersAtom)); + + const cuIncome = transactionsRef.current.txn_priority_fee.reduce< + { txnIdx: number; income: number }[] + >((incomeTxnId, _, txnIdx) => { + if ( + filterFunctions.some( + (func) => + transactionsRef.current && !func(transactionsRef.current, txnIdx), + ) + ) + return incomeTxnId; + + if (!transactionsRef.current?.txn_compute_units_consumed[txnIdx]) + return incomeTxnId; + + if ( + !( + (transactionsRef.current?.txn_priority_fee[txnIdx] ?? 0n) + + (transactionsRef.current?.txn_tips[txnIdx] ?? 0n) + ) + ) + return incomeTxnId; + + const income = + Number( + (transactionsRef.current?.txn_priority_fee[txnIdx] ?? 0n) + + (transactionsRef.current?.txn_tips[txnIdx] ?? 0n), + ) / (transactionsRef.current?.txn_compute_units_consumed[txnIdx] ?? 0); + + incomeTxnId.push({ + txnIdx, + income, + }); + + return incomeTxnId; + }, []); + + cuIncome.sort((a, b) => a.income - b.income); + + return cuIncome.reduce>( + (incomeMap, { txnIdx }, rank) => { + incomeMap[txnIdx] = rank / cuIncome.length; + return incomeMap; + }, + {}, + ); +} diff --git a/src/features/Overview/SlotPerformance/TransactionBarsCard/wheelZoomPlugin.ts b/src/features/Overview/SlotPerformance/TransactionBarsCard/wheelZoomPlugin.ts new file mode 100644 index 0000000..7c6a520 --- /dev/null +++ b/src/features/Overview/SlotPerformance/TransactionBarsCard/wheelZoomPlugin.ts @@ -0,0 +1,156 @@ +import uPlot from "uplot"; + +export function wheelZoomPlugin(opts: { + factor: number; + uplotAction?: (action: (u: uPlot, id: string) => void) => void; +}): uPlot.Plugin { + const factor = opts.factor || 0.75; + + let xMin: number | undefined, + xMax: number | undefined, + yMin: number | undefined, + yMax: number | undefined, + xRange: number, + yRange: number; + + function clamp( + nRange: number, + nMin: number, + nMax: number, + fRange: number, + fMin: number, + fMax: number, + ) { + if (nRange > fRange) { + nMin = fMin; + nMax = fMax; + } else if (nMin < fMin) { + nMin = fMin; + nMax = fMin + nRange; + } else if (nMax > fMax) { + nMax = fMax; + nMin = fMax - nRange; + } + + return [nMin, nMax]; + } + + return { + hooks: { + ready: (u) => { + xMin = u.scales.x.min ?? 0; + xMax = u.scales.x.max ?? 0; + yMin = u.scales.y.min ?? 0; + yMax = u.scales.y.max ?? 0; + + xRange = xMax - xMin; + yRange = yMax - yMin; + + const over = u.over; + const rect = over.getBoundingClientRect(); + + // wheel drag pan + // over.addEventListener("mousedown", (e) => { + // if (e.button == 1) { + // // plot.style.cursor = "move"; + // e.preventDefault(); + + // let left0 = e.clientX; + // // let top0 = e.clientY; + + // let scXMin0 = u.scales.x.min; + // let scXMax0 = u.scales.x.max; + + // let xUnitsPerPx = u.posToVal(1, "x") - u.posToVal(0, "x"); + + // function onmove(e: { preventDefault: () => void; clientX: any }) { + // e.preventDefault(); + + // let left1 = e.clientX; + // // let top1 = e.clientY; + + // let dx = xUnitsPerPx * (left1 - left0); + + // u.setScale("x", { + // min: scXMin0 ?? 0 - dx, + // max: scXMax0 ?? 0 - dx, + // }); + // } + + // function onup(e: any) { + // document.removeEventListener("mousemove", onmove); + // document.removeEventListener("mouseup", onup); + // } + + // document.addEventListener("mousemove", onmove); + // document.addEventListener("mouseup", onup); + // } + // }); + + // wheel scroll zoom + over.addEventListener("wheel", (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + + e.preventDefault(); + + let { left, top } = u.cursor; + left ??= 0; + top ??= 0; + + const leftPct = left / rect.width; + const btmPct = 1 - top / rect.height; + const xVal = u.posToVal(left, "x"); + const yVal = u.posToVal(top, "y"); + const oxRange = (u.scales.x.max ?? 0) - (u.scales.x.min ?? 0); + const oyRange = (u.scales.y.max ?? 0) - (u.scales.y.min ?? 0); + + const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor; + let nxMin = xVal - leftPct * nxRange; + let nxMax = nxMin + nxRange; + [nxMin, nxMax] = clamp( + nxRange, + nxMin, + nxMax, + xRange, + xMin ?? 0, + xMax ?? 0, + ); + + const nyRange = e.deltaY < 0 ? oyRange * factor : oyRange / factor; + let nyMin = yVal - btmPct * nyRange; + let nyMax = nyMin + nyRange; + [nyMin, nyMax] = clamp( + nyRange, + nyMin, + nyMax, + yRange, + yMin ?? 0, + yMax ?? 0, + ); + + if (opts.uplotAction) { + requestAnimationFrame(() => { + opts.uplotAction?.((u) => + u.setScale("x", { + min: nxMin, + max: nxMax, + }), + ); + }); + } else { + u.batch(() => { + u.setScale("x", { + min: nxMin, + max: nxMax, + }); + // u.setScale("y", { + // min: nyMin, + // max: nyMax, + // }); + }); + } + }); + }, + }, + }; +} diff --git a/src/features/Overview/TransactionsCard/Chart.tsx b/src/features/Overview/TransactionsCard/Chart.tsx index d13fc8f..81ca0cd 100644 --- a/src/features/Overview/TransactionsCard/Chart.tsx +++ b/src/features/Overview/TransactionsCard/Chart.tsx @@ -106,7 +106,7 @@ export default function Chart() { y={scaledPaths.totalTpsY - 3} fill="#8E909D" fontSize="8" - fontFamily="Inter-Tight" + fontFamily="Inter Tight" > {maxTotalTps.toLocaleString()} diff --git a/src/features/Overview/index.tsx b/src/features/Overview/index.tsx index 23597e9..7a4d336 100644 --- a/src/features/Overview/index.tsx +++ b/src/features/Overview/index.tsx @@ -10,6 +10,7 @@ import ComputeUnitsCard from "./SlotPerformance/ComputeUnitsCard"; import { useSlotSearchParam } from "./useSearchParams"; import { useEffect } from "react"; import { selectedSlotAtom } from "./SlotPerformance/atoms"; +import TransactionBarsCard from "./SlotPerformance/TransactionBarsCard"; export default function Overview() { return ( @@ -27,11 +28,12 @@ export default function Overview() {
+
); } -function Setup() { +export function Setup() { const { selectedSlot } = useSlotSearchParam(); const setSelectedSlotAtom = useSetAtom(selectedSlotAtom); const setSlotOverride = useSetAtom(slotOverrideAtom); diff --git a/src/features/Overview/useSearchParams.ts b/src/features/Overview/useSearchParams.ts index ea4a06b..53457d8 100644 --- a/src/features/Overview/useSearchParams.ts +++ b/src/features/Overview/useSearchParams.ts @@ -1,10 +1,17 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; import { useCallback } from "react"; -import { Route } from "../../routes"; +import { Route as OverviewRoute } from "../../routes"; +import { Route as SlotRoute } from "../../routes/slotDetails"; export function useSlotSearchParam() { - const { slot } = Route.useSearch(); - const navigate = useNavigate({ from: Route.fullPath }); + const location = useLocation(); + // TODO: fix assumption that it has to be these 2 routes. This PR doesn't work: https://github.com/TanStack/router/pull/3642 + const search = useSearch({ from: location.pathname as "/" | "/slotDetails" }); + const navigate = useNavigate({ + from: location.pathname.startsWith("/slot") + ? SlotRoute.fullPath + : OverviewRoute.fullPath, + }); const setSelectedSlot = useCallback( (slot?: number) => { @@ -13,5 +20,5 @@ export function useSlotSearchParam() { [navigate], ); - return { selectedSlot: slot, setSelectedSlot }; + return { selectedSlot: search?.slot, setSelectedSlot }; } diff --git a/src/features/SlotDetails/index.tsx b/src/features/SlotDetails/index.tsx new file mode 100644 index 0000000..c173528 --- /dev/null +++ b/src/features/SlotDetails/index.tsx @@ -0,0 +1,16 @@ +import { Flex } from "@radix-ui/themes"; +import SlotPerformance from "../Overview/SlotPerformance"; +import ComputeUnitsCard from "../Overview/SlotPerformance/ComputeUnitsCard"; +import TransactionBarsCard from "../Overview/SlotPerformance/TransactionBarsCard"; +import { Setup } from "../Overview"; + +export default function SlotDetails() { + return ( + + + + + + + ); +} diff --git a/src/main.tsx b/src/main.tsx index 99896a3..454749e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,5 @@ import "@fontsource/inter-tight/latin-400.css"; +import "@fontsource/roboto-mono/latin-400.css"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; diff --git a/src/mathUtils.ts b/src/mathUtils.ts new file mode 100644 index 0000000..44adfb6 --- /dev/null +++ b/src/mathUtils.ts @@ -0,0 +1,32 @@ +export function logRatio(a: number, b: number, base = Math.E) { + if (a === 0 || b === 0) { + return 0; + } + if (a <= 0 || b <= 0) { + console.error(a, b); + console.error("Logarithms are only defined for positive numbers."); + } + + if (base === Math.E) { + return Math.log(a) - Math.log(b); + } else { + return Math.log(a) / Math.log(base) - Math.log(b) / Math.log(base); + } +} + +export function logBase(value: number, base = Math.E) { + if (base === Math.E) return Math.log(value); + return Math.log(value) / Math.log(base); +} + +export function logRatio2(a: number, b: number, base = Math.E) { + if (a === 0 || b === 0) { + return 0; + } + if (a <= 0 || b <= 0) { + console.error(a, b); + console.error("Logarithms are only defined for positive numbers."); + } + + return logBase(a, base) / logBase(b, base); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 6ff56d8..a83259c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ // Import Routes import { Route as rootRoute } from "./routes/__root"; +import { Route as SlotDetailsImport } from "./routes/slotDetails"; import { Route as LeaderScheduleImport } from "./routes/leaderSchedule"; import { Route as GossipImport } from "./routes/gossip"; import { Route as AboutImport } from "./routes/about"; @@ -18,6 +19,12 @@ import { Route as IndexImport } from "./routes/index"; // Create/Update Routes +const SlotDetailsRoute = SlotDetailsImport.update({ + id: "/slotDetails", + path: "/slotDetails", + getParentRoute: () => rootRoute, +} as any); + const LeaderScheduleRoute = LeaderScheduleImport.update({ id: "/leaderSchedule", path: "/leaderSchedule", @@ -74,6 +81,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof LeaderScheduleImport; parentRoute: typeof rootRoute; }; + "/slotDetails": { + id: "/slotDetails"; + path: "/slotDetails"; + fullPath: "/slotDetails"; + preLoaderRoute: typeof SlotDetailsImport; + parentRoute: typeof rootRoute; + }; } } @@ -84,6 +98,7 @@ export interface FileRoutesByFullPath { "/about": typeof AboutRoute; "/gossip": typeof GossipRoute; "/leaderSchedule": typeof LeaderScheduleRoute; + "/slotDetails": typeof SlotDetailsRoute; } export interface FileRoutesByTo { @@ -91,6 +106,7 @@ export interface FileRoutesByTo { "/about": typeof AboutRoute; "/gossip": typeof GossipRoute; "/leaderSchedule": typeof LeaderScheduleRoute; + "/slotDetails": typeof SlotDetailsRoute; } export interface FileRoutesById { @@ -99,14 +115,21 @@ export interface FileRoutesById { "/about": typeof AboutRoute; "/gossip": typeof GossipRoute; "/leaderSchedule": typeof LeaderScheduleRoute; + "/slotDetails": typeof SlotDetailsRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/" | "/about" | "/gossip" | "/leaderSchedule"; + fullPaths: "/" | "/about" | "/gossip" | "/leaderSchedule" | "/slotDetails"; fileRoutesByTo: FileRoutesByTo; - to: "/" | "/about" | "/gossip" | "/leaderSchedule"; - id: "__root__" | "/" | "/about" | "/gossip" | "/leaderSchedule"; + to: "/" | "/about" | "/gossip" | "/leaderSchedule" | "/slotDetails"; + id: + | "__root__" + | "/" + | "/about" + | "/gossip" + | "/leaderSchedule" + | "/slotDetails"; fileRoutesById: FileRoutesById; } @@ -115,6 +138,7 @@ export interface RootRouteChildren { AboutRoute: typeof AboutRoute; GossipRoute: typeof GossipRoute; LeaderScheduleRoute: typeof LeaderScheduleRoute; + SlotDetailsRoute: typeof SlotDetailsRoute; } const rootRouteChildren: RootRouteChildren = { @@ -122,6 +146,7 @@ const rootRouteChildren: RootRouteChildren = { AboutRoute: AboutRoute, GossipRoute: GossipRoute, LeaderScheduleRoute: LeaderScheduleRoute, + SlotDetailsRoute: SlotDetailsRoute, }; export const routeTree = rootRoute @@ -137,7 +162,8 @@ export const routeTree = rootRoute "/", "/about", "/gossip", - "/leaderSchedule" + "/leaderSchedule", + "/slotDetails" ] }, "/": { @@ -151,6 +177,9 @@ export const routeTree = rootRoute }, "/leaderSchedule": { "filePath": "leaderSchedule.tsx" + }, + "/slotDetails": { + "filePath": "slotDetails.tsx" } } } diff --git a/src/routes/slotDetails.tsx b/src/routes/slotDetails.tsx new file mode 100644 index 0000000..3c8369c --- /dev/null +++ b/src/routes/slotDetails.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { fallback, zodValidator } from "@tanstack/zod-adapter"; +import SlotDetails from "../features/SlotDetails"; + +const searchParamsSchema = z.object({ + slot: fallback(z.number().optional(), undefined), +}); + +export const Route = createFileRoute("/slotDetails")({ + validateSearch: zodValidator(searchParamsSchema), + component: SlotDetails, +}); diff --git a/src/sankey/SankeyLabels.tsx b/src/sankey/SankeyLabels.tsx index eeec306..7e0c889 100644 --- a/src/sankey/SankeyLabels.tsx +++ b/src/sankey/SankeyLabels.tsx @@ -173,6 +173,7 @@ export const SankeyLabels = ({ pointerEvents: "none", whiteSpace: "pre-line", fontSize: "14px", + fontFamily: "Inter Tight", }} > {getLabelParts(labelText).map((part, i) => { diff --git a/src/uplot/placement.d.ts b/src/uplot/placement.d.ts new file mode 100644 index 0000000..b21af78 --- /dev/null +++ b/src/uplot/placement.d.ts @@ -0,0 +1,7 @@ +export default function placement( + element: HTMLElement, + anchor: { left: number; top: number }, + side: string, + align: string, + options?: { bound?: HTMLElement }, +): void; diff --git a/src/uplot/placement.js b/src/uplot/placement.js new file mode 100644 index 0000000..9eca8e4 --- /dev/null +++ b/src/uplot/placement.js @@ -0,0 +1,103 @@ +/* eslint-disable */ +// https://tobyzerner.github.io/placement.js/dist/index.js +var placement = (function () { + "use strict"; + var e = { + size: ["height", "width"], + clientSize: ["clientHeight", "clientWidth"], + offsetSize: ["offsetHeight", "offsetWidth"], + maxSize: ["maxHeight", "maxWidth"], + before: ["top", "left"], + marginBefore: ["marginTop", "marginLeft"], + after: ["bottom", "right"], + marginAfter: ["marginBottom", "marginRight"], + scrollOffset: ["pageYOffset", "pageXOffset"], + }; + function t(e) { + return { top: e.top, bottom: e.bottom, left: e.left, right: e.right }; + } + return function (o, r, f, a, i) { + void 0 === f && (f = "bottom"), + void 0 === a && (a = "center"), + void 0 === i && (i = {}), + (r instanceof Element || r instanceof Range) && + (r = t(r.getBoundingClientRect())); + var n = Object.assign( + { top: r.bottom, bottom: r.top, left: r.right, right: r.left }, + r, + ), + s = { + top: 0, + left: 0, + bottom: window.innerHeight, + right: window.innerWidth, + }; + i.bound && + ((i.bound instanceof Element || i.bound instanceof Range) && + (i.bound = t(i.bound.getBoundingClientRect())), + Object.assign(s, i.bound)); + var l = getComputedStyle(o), + m = {}, + b = {}; + for (var g in e) + (m[g] = e[g]["top" === f || "bottom" === f ? 0 : 1]), + (b[g] = e[g]["top" === f || "bottom" === f ? 1 : 0]); + (o.style.position = "absolute"), + (o.style.maxWidth = ""), + (o.style.maxHeight = ""); + var d = parseInt(l[b.marginBefore]), + c = parseInt(l[b.marginAfter]), + u = d + c, + p = s[b.after] - s[b.before] - u, + h = parseInt(l[b.maxSize]); + (!h || p < h) && (o.style[b.maxSize] = p + "px"); + var x = parseInt(l[m.marginBefore]) + parseInt(l[m.marginAfter]), + y = n[m.before] - s[m.before] - x, + z = s[m.after] - n[m.after] - x; + ((f === m.before && o[m.offsetSize] > y) || + (f === m.after && o[m.offsetSize] > z)) && + (f = y > z ? m.before : m.after); + var S = f === m.before ? y : z, + v = parseInt(l[m.maxSize]); + (!v || S < v) && (o.style[m.maxSize] = S + "px"); + var w = window[m.scrollOffset], + O = function (e) { + return Math.max( + s[m.before], + Math.min(e, s[m.after] - o[m.offsetSize] - x), + ); + }; + f === m.before + ? ((o.style[m.before] = w + O(n[m.before] - o[m.offsetSize] - x) + "px"), + (o.style[m.after] = "auto")) + : ((o.style[m.before] = w + O(n[m.after]) + "px"), + (o.style[m.after] = "auto")); + var B = window[b.scrollOffset], + I = function (e) { + return Math.max( + s[b.before], + Math.min(e, s[b.after] - o[b.offsetSize] - u), + ); + }; + switch (a) { + case "start": + (o.style[b.before] = B + I(n[b.before] - d) + "px"), + (o.style[b.after] = "auto"); + break; + case "end": + (o.style[b.before] = "auto"), + (o.style[b.after] = + B + + I(document.documentElement[b.clientSize] - n[b.after] - c) + + "px"); + break; + default: + var H = n[b.after] - n[b.before]; + (o.style[b.before] = + B + I(n[b.before] + H / 2 - o[b.offsetSize] / 2 - d) + "px"), + (o.style[b.after] = "auto"); + } + (o.dataset.side = f), (o.dataset.align = a); + }; +})(); +export default placement; diff --git a/src/uplotReact/atoms.ts b/src/uplotReact/atoms.ts new file mode 100644 index 0000000..da2ca65 --- /dev/null +++ b/src/uplotReact/atoms.ts @@ -0,0 +1,26 @@ +import { atom } from "jotai"; +import uPlot from "uplot"; + +// export const uplotRefsAtom = atom( +// {} as Record>, +// ); + +export const uplotRefsAtom = atom({} as Record uPlot | null>); + +export const uplotActionAtom = atom( + null, + (get, set, action: (u: uPlot, id: string) => void, id?: string) => { + const uplotMap = get(uplotRefsAtom); + if (id && uplotMap[id]) { + const uplot = uplotMap[id]?.(); + uplot && action(uplot, id); + } else { + for (const [bankIdx, ref] of Object.entries(uplotMap)) { + if (ref) { + const uplot = ref?.(); + uplot && action(uplot, bankIdx); + } + } + } + }, +); diff --git a/src/uplotReact/uplot-react.tsx b/src/uplotReact/uplot-react.tsx new file mode 100644 index 0000000..d1ec0e5 --- /dev/null +++ b/src/uplotReact/uplot-react.tsx @@ -0,0 +1,147 @@ +// Pulled from https://github.com/skalinichev/uplot-wrappers +import { useCallback, useEffect, useRef } from "react"; +import uPlot from "uplot"; +import { dataMatch, optionsUpdateState } from "./utils"; +import { useSetAtom } from "jotai"; +import { uplotRefsAtom } from "./atoms"; +import { useDebouncedCallback } from "use-debounce"; + +interface UplotReactProps { + id: string; + options: uPlot.Options; + data: uPlot.AlignedData; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + target?: HTMLElement | ((self: uPlot, init: Function) => void); + onDelete?: (chart: uPlot) => void; + onCreate?: (chart: uPlot) => void; + resetScales?: boolean; + className?: string; +} + +export interface UplotChartRef { + getChart: () => uPlot | null; +} + +export default function UplotReact({ + id, + options, + data, + target, + onDelete, + onCreate, + resetScales = true, + className, +}: UplotReactProps): JSX.Element | null { + const chartRef = useRef(null); + const targetRef = useRef(null); + const propOptionsRef = useRef(options); + const propTargetRef = useRef(target); + const propDataRef = useRef(data); + const onCreateRef = useRef(onCreate); + const onDeleteRef = useRef(onDelete); + + const setUplotRef = useSetAtom(uplotRefsAtom); + + useEffect(() => { + onCreateRef.current = onCreate; + onDeleteRef.current = onDelete; + }); + + const destroy = useCallback( + (chart: uPlot | null) => { + if (chart) { + onDeleteRef.current?.(chart); + chart.destroy(); + chartRef.current = null; + setUplotRef((prev) => { + const state = { ...prev }; + delete state[id]; + return state; + }); + } + }, + [id, setUplotRef], + ); + + const create = useCallback(() => { + const newChart = new uPlot( + propOptionsRef.current, + propDataRef.current, + propTargetRef.current || (targetRef.current as HTMLDivElement), + ); + chartRef.current = newChart; + setUplotRef((prev) => { + return { ...prev, [id]: () => chartRef.current }; + }); + onCreateRef.current?.(newChart); + }, [id, setUplotRef]); + + useEffect(() => { + create(); + return () => { + destroy(chartRef.current); + }; + }, [create, destroy]); + + useEffect(() => { + if (propOptionsRef.current !== options) { + const optionsState = optionsUpdateState(propOptionsRef.current, options); + propOptionsRef.current = options; + if (!chartRef.current || optionsState === "create") { + destroy(chartRef.current); + create(); + } else if (optionsState === "update") { + console.log("update chart"); + + chartRef.current.setSize({ + width: options.width, + height: options.height, + }); + } + } + }, [options, create, destroy]); + + useEffect(() => { + if (propDataRef.current !== data) { + if (!chartRef.current) { + propDataRef.current = data; + create(); + } else if (!dataMatch(propDataRef.current, data)) { + if (resetScales) { + chartRef.current.setData(data, true); + } else { + chartRef.current.setData(data, false); + chartRef.current.redraw(); + } + } + propDataRef.current = data; + } + }, [data, resetScales, create]); + + useEffect(() => { + if (propTargetRef.current !== target) { + propTargetRef.current = target; + create(); + } + + return () => destroy(chartRef.current); + }, [target, create, destroy]); + + const setDbSize = useDebouncedCallback(() => { + requestAnimationFrame(() => + chartRef.current?.setSize({ + width: options.width, + height: options.height, + }), + ); + }, 100); + + if ( + options.height !== chartRef.current?.height || + options.width !== chartRef.current?.width + ) { + setDbSize(); + } + + return target ? null :
; +} diff --git a/src/uplotReact/utils.ts b/src/uplotReact/utils.ts new file mode 100644 index 0000000..6915fe6 --- /dev/null +++ b/src/uplotReact/utils.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import uPlot from "uplot"; + +type OptionsUpdateState = "keep" | "update" | "create"; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is +if (!Object.is) { + Object.defineProperty(Object, "is", { + value: (x: any, y: any) => + (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y), + }); +} + +export const optionsUpdateState = ( + _lhs: uPlot.Options, + _rhs: uPlot.Options, +): OptionsUpdateState => { + const { width: lhsWidth, height: lhsHeight, ...lhs } = _lhs; + const { width: rhsWidth, height: rhsHeight, ...rhs } = _rhs; + + let state: OptionsUpdateState = "keep"; + if (lhsHeight !== rhsHeight || lhsWidth !== rhsWidth) { + state = "update"; + } + if (Object.keys(lhs).length !== Object.keys(rhs).length) { + return "create"; + } + for (const k of Object.keys(lhs)) { + if (!Object.is(lhs[k as keyof typeof lhs], rhs[k as keyof typeof rhs])) { + state = "create"; + break; + } + } + return state; +}; + +export const dataMatch = ( + lhs: uPlot.AlignedData, + rhs: uPlot.AlignedData, +): boolean => { + if (lhs.length !== rhs.length) { + return false; + } + return lhs.every((lhsOneSeries, seriesIdx) => { + const rhsOneSeries = rhs[seriesIdx]; + if (lhsOneSeries.length !== rhsOneSeries.length) { + return false; + } + + if (Array.isArray(lhsOneSeries)) { + return lhsOneSeries.every( + (value, valueIdx) => value === rhsOneSeries[valueIdx], + ); + } else return; + }); +}; diff --git a/tsconfig.json b/tsconfig.json index d9f7829..a6ece48 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, - "isolatedModules": true, + // "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", @@ -27,7 +27,7 @@ } ] }, - "include": ["src"], + "include": ["src", "src/uplot/placement.js"], "references": [{ "path": "./tsconfig.node.json" }], "types": ["unplugin-fonts/client"]