diff --git a/docs/INDEX.md b/docs/INDEX.md index d53fab15..ca279448 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -26,7 +26,7 @@ AdvantageScope supports many ways to view and analyze data, organized into tabs. - 🎮 [Joysticks](/docs/tabs/JOYSTICKS.md) - 🦀 [Swerve](/docs/tabs/SWERVE.md) - ⚙️ [Mechanism](/docs/tabs/MECHANISM.md) -- 🔵 [Points](/docs/tabs/POINTS.md) +- 📍 [Points](/docs/tabs/POINTS.md) - 🔍 [Metadata](/docs/tabs/METADATA.md) ## More Features diff --git a/docs/tabs/POINTS.md b/docs/tabs/POINTS.md index 227af998..fe78e4fb 100644 --- a/docs/tabs/POINTS.md +++ b/docs/tabs/POINTS.md @@ -1,4 +1,4 @@ -# 🔵 Points +# 📍 Points _[< Return to homepage](/docs/INDEX.md)_ diff --git a/ffmpegDownload.mjs b/ffmpegDownload.mjs index 65d7476f..b78aeb8a 100644 --- a/ffmpegDownload.mjs +++ b/ffmpegDownload.mjs @@ -1,6 +1,6 @@ -import path from "path"; import download from "download"; import fs from "fs"; +import path from "path"; // Exit if disabled if (process.env.ASCOPE_NO_FFMPEG === "true") { diff --git a/getLicenses.mjs b/getLicenses.mjs index 41e4882f..7eb8446e 100644 --- a/getLicenses.mjs +++ b/getLicenses.mjs @@ -1,6 +1,6 @@ import fs from "fs"; -import path from "path"; import fetch from "node-fetch"; +import path from "path"; let licenses = []; let packageLock = JSON.parse(fs.readFileSync("package-lock.json")); diff --git a/package-lock.json b/package-lock.json index 7a182cd1..782390f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,10 @@ "download": "^8.0.0", "electron-fetch": "^1.9.1", "jsonfile": "^6.1.0", + "pngjs": "^7.0.0", "ssh2": "^1.14.0", - "tesseract.js": "^5.0.3" + "tesseract.js": "https://github.com/jwbonner/tesseract.js.git", + "ytdl-core": "https://github.com/khlevon/node-ytdl-core.git#v4.11.5-patch.2" }, "devDependencies": { "@electron/notarize": "^2.1.0", @@ -28,28 +30,33 @@ "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-typescript": "11.1.3", "@types/chart.js": "^2.9.38", + "@types/color-convert": "^2.0.3", "@types/download": "^8.0.2", "@types/heatmap.js": "2.0.38", "@types/jsonfile": "^6.1.2", + "@types/pngjs": "^6.0.5", "@types/remarkable": "^2.0.3", "@types/ssh2": "^1.11.13", - "@types/three": "^0.156.0", + "@types/three": "^0.168.0", "chart.js": "^4.4.0", - "electron": "^26.2.1", + "color-convert": "^2.0.1", + "electron": "^32.0.2", "electron-builder": "^24.6.4", "fuse.js": "^7.0.0", + "gunzip-file": "^0.1.1", "heatmap.js": "https://github.com/jwbonner/heatmap.js.git", "highlight.js": "^11.9.0", "mathjs": "11.3.0", "node-fetch": "^3.3.2", "prettier": "3.0.3", + "prettier-plugin-organize-imports": "^4.0.0", "protobufjs": "^7.2.5", "remarkable": "^2.0.1", "rollup": "^3.29.2", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-re": "^1.0.7", "simple-statistics": "^7.8.3", - "three": "^0.156.1", + "three": "^0.168.0", "tslib": "^2.6.2", "typescript": "5.2.2" } @@ -791,6 +798,12 @@ "node": ">= 10" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -812,6 +825,21 @@ "moment": "^2.10.2" } }, + "node_modules/@types/color-convert": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.3.tgz", + "integrity": "sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==", + "dev": true, + "dependencies": { + "@types/color-name": "*" + } + }, + "node_modules/@types/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-hulKeREDdLFesGQjl96+4aoJSHY5b2GRjagzzcqCfIrWhe5vkCqIvrLbqzBaI1q94Vg8DNJZZqTR5ocdWmWclg==", + "dev": true + }, "node_modules/@types/debug": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", @@ -928,10 +956,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.5.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", - "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", - "dev": true + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } }, "node_modules/@types/plist": { "version": "3.0.2", @@ -944,6 +975,15 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/remarkable": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/remarkable/-/remarkable-2.0.3.tgz", @@ -1002,14 +1042,16 @@ "dev": true }, "node_modules/@types/three": { - "version": "0.156.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.156.0.tgz", - "integrity": "sha512-733bXDSRdlrxqOmQuOmfC1UBRuJ2pREPk8sWnx9MtIJEVDQMx8U0NQO5MVVaOrjzDPyLI+cFPim2X/ss9v0+LQ==", + "version": "0.168.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.168.0.tgz", + "integrity": "sha512-qAGLGzbaYgkkonOBfwOr+TZpOskPfFjrDAj801WQSVkUz0/D9zwir4vhruJ/CC/GteywzR9pqeVVfs5th/2oKw==", "dev": true, "dependencies": { + "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", - "fflate": "~0.6.10", + "@webgpu/types": "*", + "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, @@ -1042,6 +1084,12 @@ "@types/node": "*" } }, + "node_modules/@webgpu/types": { + "version": "0.1.44", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.44.tgz", + "integrity": "sha512-JDpYJN5E/asw84LTYhKyvPpxGnD+bAKPtpW9Ilurf7cZpxaTbxkQcGwOd7jgB9BPBrTYQ+32ufo4HiuomTjHNQ==", + "dev": true + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -2251,14 +2299,14 @@ } }, "node_modules/electron": { - "version": "26.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-26.2.1.tgz", - "integrity": "sha512-SNT24Cf/wRvfcFZQoERXjzswUlg5ouqhIuA2t9x2L7VdTn+2Jbs0QXRtOfzcnOV/raVMz3e8ICyaU2GGeciKLg==", + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.0.2.tgz", + "integrity": "sha512-nmZblq8wW3HZ17MAyaUuiMI9Mb0Cgc7UR3To85h/rVopbfyF5s34NxtK4gvyRfYPxpDGP4k+HoQIPniPPrdE3w==", "dev": true, "hasInstallScript": true, "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^18.11.18", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -2348,12 +2396,6 @@ "node": ">=12" } }, - "node_modules/electron/node_modules/@types/node": { - "version": "18.17.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.12.tgz", - "integrity": "sha512-d6xjC9fJ/nSnfDeU0AMDsaJyb1iHsqCSOdi84w4u+SlN/UgQdY5tRhpMzaFYsI4mnpvgTivEaQd0yOUhAtOnEQ==", - "dev": true - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2544,9 +2586,9 @@ } }, "node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "dev": true }, "node_modules/file-type": { @@ -2867,6 +2909,12 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/gunzip-file": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/gunzip-file/-/gunzip-file-0.1.1.tgz", + "integrity": "sha512-DjDExBKHuhyhHOs8HeGV5dw6wY6eYTl4pAHz27oy8XppgBK60vmG/G3bY09zyT9PXX4TjmnEF4hYoRn61Fctlg==", + "dev": true + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -3901,6 +3949,14 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", @@ -3924,6 +3980,26 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-organize-imports": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", + "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "dev": true, + "peerDependencies": { + "@vue/language-plugin-pug": "^2.0.24", + "prettier": ">=2.0", + "typescript": ">=2.9", + "vue-tsc": "^2.0.24" + }, + "peerDependenciesMeta": { + "@vue/language-plugin-pug": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -4741,10 +4817,10 @@ } }, "node_modules/tesseract.js": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.0.3.tgz", - "integrity": "sha512-UcEaIRQ+KSjxl57SS2WQgnac8hON0uJgqR6418MYLBeFPGqrAooNwcKVIoSVCzflO3CxTKPg5Ic7z7JuW7wOGA==", + "version": "5.1.1", + "resolved": "git+ssh://git@github.com/jwbonner/tesseract.js.git#89141d24c18ab571b20ae2f38e699e319579df9d", "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { "bmp-js": "^0.1.0", "idb-keyval": "^6.2.0", @@ -4753,15 +4829,15 @@ "node-fetch": "^2.6.9", "opencollective-postinstall": "^2.0.3", "regenerator-runtime": "^0.13.3", - "tesseract.js-core": "^5.0.0", + "tesseract.js-core": "^5.1.1", "wasm-feature-detect": "^1.2.11", "zlibjs": "^0.3.1" } }, "node_modules/tesseract.js-core": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.0.0.tgz", - "integrity": "sha512-lJur5LzjinW5VYMKlVNnBU2JPLpO+A9VqAYBeuV+ZgH0hKvsnm+536Yyp+/zRTBdLe7D6Kok0FN9g+TE4J8qGA==" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz", + "integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==" }, "node_modules/tesseract.js/node_modules/node-fetch": { "version": "2.7.0", @@ -4788,9 +4864,9 @@ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/three": { - "version": "0.156.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.156.1.tgz", - "integrity": "sha512-kP7H0FK9d/k6t/XvQ9FO6i+QrePoDcNhwl0I02+wmUJRNSLCUIDMcfObnzQvxb37/0Uc9TDT0T1HgsRRrO6SYQ==", + "version": "0.168.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.168.0.tgz", + "integrity": "sha512-6m6jXtDwMJEK/GGMbAOTSAmxNdzKvvBzgd7q8bE/7Tr6m7PaBh5kKLrN7faWtlglXbzj7sVba48Idwx+NRsZXw==", "dev": true }, "node_modules/through": { @@ -4959,6 +5035,12 @@ "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -5176,6 +5258,19 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/ytdl-core": { + "version": "0.0.0-development", + "resolved": "git+ssh://git@github.com/khlevon/node-ytdl-core.git#917830302f2a9e232399ade91869849a7753a9b9", + "license": "MIT", + "dependencies": { + "m3u8stream": "^0.8.6", + "miniget": "^4.2.3", + "sax": "^1.1.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/zlibjs": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", @@ -5714,6 +5809,12 @@ "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true }, + "@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true + }, "@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -5735,6 +5836,21 @@ "moment": "^2.10.2" } }, + "@types/color-convert": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.3.tgz", + "integrity": "sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==", + "dev": true, + "requires": { + "@types/color-name": "*" + } + }, + "@types/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-hulKeREDdLFesGQjl96+4aoJSHY5b2GRjagzzcqCfIrWhe5vkCqIvrLbqzBaI1q94Vg8DNJZZqTR5ocdWmWclg==", + "dev": true + }, "@types/debug": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", @@ -5851,10 +5967,13 @@ "dev": true }, "@types/node": { - "version": "20.5.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", - "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", - "dev": true + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "dev": true, + "requires": { + "undici-types": "~6.19.2" + } }, "@types/plist": { "version": "3.0.2", @@ -5867,6 +5986,15 @@ "xmlbuilder": ">=11.0.1" } }, + "@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/remarkable": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/remarkable/-/remarkable-2.0.3.tgz", @@ -5924,14 +6052,16 @@ "dev": true }, "@types/three": { - "version": "0.156.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.156.0.tgz", - "integrity": "sha512-733bXDSRdlrxqOmQuOmfC1UBRuJ2pREPk8sWnx9MtIJEVDQMx8U0NQO5MVVaOrjzDPyLI+cFPim2X/ss9v0+LQ==", + "version": "0.168.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.168.0.tgz", + "integrity": "sha512-qAGLGzbaYgkkonOBfwOr+TZpOskPfFjrDAj801WQSVkUz0/D9zwir4vhruJ/CC/GteywzR9pqeVVfs5th/2oKw==", "dev": true, "requires": { + "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", - "fflate": "~0.6.10", + "@webgpu/types": "*", + "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, @@ -5964,6 +6094,12 @@ "@types/node": "*" } }, + "@webgpu/types": { + "version": "0.1.44", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.44.tgz", + "integrity": "sha512-JDpYJN5E/asw84LTYhKyvPpxGnD+bAKPtpW9Ilurf7cZpxaTbxkQcGwOd7jgB9BPBrTYQ+32ufo4HiuomTjHNQ==", + "dev": true + }, "@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -6903,22 +7039,14 @@ } }, "electron": { - "version": "26.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-26.2.1.tgz", - "integrity": "sha512-SNT24Cf/wRvfcFZQoERXjzswUlg5ouqhIuA2t9x2L7VdTn+2Jbs0QXRtOfzcnOV/raVMz3e8ICyaU2GGeciKLg==", + "version": "32.0.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.0.2.tgz", + "integrity": "sha512-nmZblq8wW3HZ17MAyaUuiMI9Mb0Cgc7UR3To85h/rVopbfyF5s34NxtK4gvyRfYPxpDGP4k+HoQIPniPPrdE3w==", "dev": true, "requires": { "@electron/get": "^2.0.0", - "@types/node": "^18.11.18", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" - }, - "dependencies": { - "@types/node": { - "version": "18.17.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.12.tgz", - "integrity": "sha512-d6xjC9fJ/nSnfDeU0AMDsaJyb1iHsqCSOdi84w4u+SlN/UgQdY5tRhpMzaFYsI4mnpvgTivEaQd0yOUhAtOnEQ==", - "dev": true - } } }, "electron-builder": { @@ -7133,9 +7261,9 @@ } }, "fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "dev": true }, "file-type": { @@ -7382,6 +7510,12 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "gunzip-file": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/gunzip-file/-/gunzip-file-0.1.1.tgz", + "integrity": "sha512-DjDExBKHuhyhHOs8HeGV5dw6wY6eYTl4pAHz27oy8XppgBK60vmG/G3bY09zyT9PXX4TjmnEF4hYoRn61Fctlg==", + "dev": true + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -8135,6 +8269,11 @@ "xmlbuilder": "^15.1.1" } }, + "pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==" + }, "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", @@ -8146,6 +8285,13 @@ "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", "dev": true }, + "prettier-plugin-organize-imports": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", + "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "dev": true, + "requires": {} + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8798,9 +8944,8 @@ } }, "tesseract.js": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.0.3.tgz", - "integrity": "sha512-UcEaIRQ+KSjxl57SS2WQgnac8hON0uJgqR6418MYLBeFPGqrAooNwcKVIoSVCzflO3CxTKPg5Ic7z7JuW7wOGA==", + "version": "git+ssh://git@github.com/jwbonner/tesseract.js.git#89141d24c18ab571b20ae2f38e699e319579df9d", + "from": "tesseract.js@https://github.com/jwbonner/tesseract.js.git", "requires": { "bmp-js": "^0.1.0", "idb-keyval": "^6.2.0", @@ -8809,7 +8954,7 @@ "node-fetch": "^2.6.9", "opencollective-postinstall": "^2.0.3", "regenerator-runtime": "^0.13.3", - "tesseract.js-core": "^5.0.0", + "tesseract.js-core": "^5.1.1", "wasm-feature-detect": "^1.2.11", "zlibjs": "^0.3.1" }, @@ -8830,14 +8975,14 @@ } }, "tesseract.js-core": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.0.0.tgz", - "integrity": "sha512-lJur5LzjinW5VYMKlVNnBU2JPLpO+A9VqAYBeuV+ZgH0hKvsnm+536Yyp+/zRTBdLe7D6Kok0FN9g+TE4J8qGA==" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz", + "integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==" }, "three": { - "version": "0.156.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.156.1.tgz", - "integrity": "sha512-kP7H0FK9d/k6t/XvQ9FO6i+QrePoDcNhwl0I02+wmUJRNSLCUIDMcfObnzQvxb37/0Uc9TDT0T1HgsRRrO6SYQ==", + "version": "0.168.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.168.0.tgz", + "integrity": "sha512-6m6jXtDwMJEK/GGMbAOTSAmxNdzKvvBzgd7q8bE/7Tr6m7PaBh5kKLrN7faWtlglXbzj7sVba48Idwx+NRsZXw==", "dev": true }, "through": { @@ -8973,6 +9118,12 @@ "@fastify/busboy": "^2.0.0" } }, + "undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -9150,6 +9301,15 @@ "fd-slicer": "~1.1.0" } }, + "ytdl-core": { + "version": "git+ssh://git@github.com/khlevon/node-ytdl-core.git#917830302f2a9e232399ade91869849a7753a9b9", + "from": "ytdl-core@https://github.com/khlevon/node-ytdl-core.git#v4.11.5-patch.2", + "requires": { + "m3u8stream": "^0.8.6", + "miniget": "^4.2.3", + "sax": "^1.1.3" + } + }, "zlibjs": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", diff --git a/package.json b/package.json index efabff75..183b2d98 100644 --- a/package.json +++ b/package.json @@ -31,28 +31,33 @@ "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-typescript": "11.1.3", "@types/chart.js": "^2.9.38", + "@types/color-convert": "^2.0.3", "@types/download": "^8.0.2", "@types/heatmap.js": "2.0.38", "@types/jsonfile": "^6.1.2", + "@types/pngjs": "^6.0.5", "@types/remarkable": "^2.0.3", "@types/ssh2": "^1.11.13", - "@types/three": "^0.156.0", + "@types/three": "^0.168.0", "chart.js": "^4.4.0", - "electron": "^26.2.1", + "color-convert": "^2.0.1", + "electron": "^32.0.2", "electron-builder": "^24.6.4", "fuse.js": "^7.0.0", + "gunzip-file": "^0.1.1", "heatmap.js": "https://github.com/jwbonner/heatmap.js.git", "highlight.js": "^11.9.0", "mathjs": "11.3.0", "node-fetch": "^3.3.2", "prettier": "3.0.3", + "prettier-plugin-organize-imports": "^4.0.0", "protobufjs": "^7.2.5", "remarkable": "^2.0.1", "rollup": "^3.29.2", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-re": "^1.0.7", "simple-statistics": "^7.8.3", - "three": "^0.156.1", + "three": "^0.168.0", "tslib": "^2.6.2", "typescript": "5.2.2" }, @@ -62,12 +67,17 @@ "download": "^8.0.0", "electron-fetch": "^1.9.1", "jsonfile": "^6.1.0", + "pngjs": "^7.0.0", "ssh2": "^1.14.0", - "tesseract.js": "^5.0.3" + "tesseract.js": "https://github.com/jwbonner/tesseract.js.git", + "ytdl-core": "https://github.com/khlevon/node-ytdl-core.git#v4.11.5-patch.2" }, "prettier": { "printWidth": 120, - "trailingComma": "none" + "trailingComma": "none", + "plugins": [ + "prettier-plugin-organize-imports" + ] }, "build": { "appId": "org.littletonrobotics.advantagescope", diff --git a/rollup.config.mjs b/rollup.config.mjs index 95f7e4f1..afef60c9 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -19,7 +19,9 @@ function bundle(input, output, isMain, external = []) { external: external, plugins: [ typescript(), - nodeResolve(), + nodeResolve({ + preferBuiltins: true + }), commonjs(), cleanup(), json(), @@ -87,6 +89,7 @@ const smallRendererBundles = [ bundle("unitConversion.ts", "unitConversion.js", false), bundle("renameTab.ts", "renameTab.js", false), bundle("editFov.ts", "editFov.js", false), + bundle("sourceListHelp.ts", "sourceListHelp.js", false), bundle("export.ts", "export.js", false), bundle("download.ts", "download.js", false), bundle("preferences.ts", "preferences.js", false), @@ -96,7 +99,9 @@ const workerBundles = [ bundle("hub/dataSources/rlog/rlogWorker.ts", "hub$rlogWorker.js", false), bundle("hub/dataSources/wpilog/wpilogWorker.ts", "hub$wpilogWorker.js", false), bundle("hub/dataSources/dslog/dsLogWorker.ts", "hub$dsLogWorker.js", false), - bundle("hub/exportWorker.ts", "hub$exportWorker.js", false) + bundle("hub/exportWorker.ts", "hub$exportWorker.js", false), + bundle("shared/renderers/threeDimension/workers/loadField.ts", "shared$loadField.js", false), + bundle("shared/renderers/threeDimension/workers/loadRobot.ts", "shared$loadRobot.js", false) ]; export default (cliArgs) => { diff --git a/src/hub/ScrollSensor.ts b/src/hub/ScrollSensor.ts index 8a8e8e5e..01848279 100644 --- a/src/hub/ScrollSensor.ts +++ b/src/hub/ScrollSensor.ts @@ -10,19 +10,46 @@ export default class ScrollSensor { private lastScrollLeft: number = 0; private lastScrollTop: number = 0; + private panActive = false; + private panLastCursorX = 0; + /** * Creates a new ScrollSensor. * @param container The container element. The overflow should be "scroll" and the scrollbar should be hidden. The child element should have the dimensions 1000000x1000000px. * @param callback A function to be called after each scroll event, with the relative change in x and y. */ - constructor(container: HTMLElement, callback: (dx: number, dy: number) => void) { + constructor(container: HTMLElement, callback: (dx: number, dy: number) => void, enableMouseControls = true) { this.container = container; this.callback = callback; + // Scroll events this.resetNext = true; this.container.addEventListener("scroll", () => { this.update(); }); + + // Mouse controls + if (enableMouseControls) { + container.addEventListener("mousedown", (event) => { + if (event.shiftKey) return; + this.panActive = true; + let x = event.clientX - container.getBoundingClientRect().x; + this.panLastCursorX = x; + }); + container.addEventListener("mouseleave", () => { + this.panActive = false; + }); + container.addEventListener("mouseup", () => { + this.panActive = false; + }); + container.addEventListener("mousemove", (event) => { + if (this.panActive) { + let cursorX = event.clientX - container.getBoundingClientRect().x; + callback(this.panLastCursorX - cursorX, 0); + this.panLastCursorX = cursorX; + } + }); + } } /** Should be called periodically to trigger resets. */ diff --git a/src/hub/Selection.ts b/src/hub/SelectionImpl.ts similarity index 54% rename from src/hub/Selection.ts rename to src/hub/SelectionImpl.ts index 5cbf4ae4..6177cdeb 100644 --- a/src/hub/Selection.ts +++ b/src/hub/SelectionImpl.ts @@ -1,7 +1,10 @@ +import Selection, { SelectionMode } from "../shared/Selection"; import { AKIT_TIMESTAMP_KEYS } from "../shared/log/LogUtil"; -export default class Selection { +export default class SelectionImpl implements Selection { private STEP_SIZE = 0.02; // When using left-right arrows keys on non-AdvantageKit logs + private TIMELINE_MIN_ZOOM_TIME = 0.05; + private TIMELINE_ZOOM_BASE = 1.001; private PLAY_BUTTON = document.getElementsByClassName("play")[0] as HTMLElement; private PAUSE_BUTTON = document.getElementsByClassName("pause")[0] as HTMLElement; @@ -11,6 +14,9 @@ export default class Selection { private mode: SelectionMode = SelectionMode.Idle; private hoveredTime: number | null = null; private staticTime: number = 0; + private timelineRange: [number, number] = [0, 10]; + private timelineMaxZoom = true; // When at maximum zoom, maintain it as the available range increases + private grabZoomRange: [number, number] | null = null; private playbackStartLog: number = 0; private playbackStartReal: number = 0; private playbackSpeed: number = 1; @@ -42,20 +48,12 @@ export default class Selection { switch (event.code) { case "Space": event.preventDefault(); - if (this.mode === SelectionMode.Playback || this.mode === SelectionMode.Locked) { - this.pause(); - } else { - this.play(); - } + this.togglePlayback(); break; case "KeyL": event.preventDefault(); - if (this.mode === SelectionMode.Locked) { - this.unlock(); - } else { - this.lock(); - } + this.toggleLock(); break; case "ArrowLeft": @@ -65,21 +63,8 @@ export default class Selection { return; } - const akitTimestampKey = window.log.getFieldKeys().find((key) => AKIT_TIMESTAMP_KEYS.includes(key)); - const isForward = event.code === "ArrowRight"; - if (akitTimestampKey !== undefined) { - const timestampData = window.log.getNumber(akitTimestampKey, -Infinity, Infinity); - if (timestampData === undefined) return; - if (isForward) { - let next = timestampData.timestamps.find((value) => value > this.staticTime); - if (next !== undefined) this.staticTime = next; - } else { - let prev = timestampData.timestamps.findLast((value) => value < this.staticTime); - if (prev !== undefined) this.staticTime = prev; - } - } else { - this.staticTime += isForward ? this.STEP_SIZE : -this.STEP_SIZE; - } + event.preventDefault(); + this.stepCycle(event.code === "ArrowRight"); break; } }); @@ -100,12 +85,12 @@ export default class Selection { this.UNLOCK_BUTTON.hidden = !this.liveConnected || this.mode !== SelectionMode.Locked; } - /** Updates the hovered time. */ + /** Gets the current the hovered time. */ getHoveredTime(): number | null { return this.hoveredTime; } - /** Returns the hovered time. */ + /** Updates the hovered time. */ setHoveredTime(value: number | null) { this.hoveredTime = value; } @@ -212,6 +197,15 @@ export default class Selection { } } + /** Switches between pausing and playback. */ + togglePlayback() { + if (this.mode === SelectionMode.Playback || this.mode === SelectionMode.Locked) { + this.pause(); + } else { + this.play(); + } + } + /** Switches to locked mode if possible. */ lock() { if (this.liveConnected) { @@ -228,6 +222,33 @@ export default class Selection { } } + /** Switches beteween locked and unlocked modes. */ + toggleLock() { + if (this.mode === SelectionMode.Locked) { + this.unlock(); + } else { + this.lock(); + } + } + + /** Steps forward or backward by one cycle. */ + stepCycle(isForward: boolean) { + const akitTimestampKey = window.log.getFieldKeys().find((key) => AKIT_TIMESTAMP_KEYS.includes(key)); + if (akitTimestampKey !== undefined) { + const timestampData = window.log.getNumber(akitTimestampKey, -Infinity, Infinity); + if (timestampData === undefined) return; + if (isForward) { + let next = timestampData.timestamps.find((value) => value > this.staticTime); + if (next !== undefined) this.staticTime = next; + } else { + let prev = timestampData.timestamps.findLast((value) => value < this.staticTime); + if (prev !== undefined) this.staticTime = prev; + } + } else { + this.staticTime += isForward ? this.STEP_SIZE : -this.STEP_SIZE; + } + } + /** Records that the live connection has started. */ setLiveConnected(timeSupplier: () => number) { let newConnection = !this.liveConnected; @@ -248,6 +269,7 @@ export default class Selection { this.setMode(this.mode); // Just update buttons } + /** Returns the latest live timestamp if available. */ getCurrentLiveTime(): number | null { if (this.liveTimeSupplier === null) { return null; @@ -256,6 +278,20 @@ export default class Selection { } } + /** Returns the time that should be displayed, for views that can only display a single sample. */ + getRenderTime(): number | null { + let selectedTime = this.getSelectedTime(); + if (this.mode === SelectionMode.Playback || this.mode === SelectionMode.Locked) { + return selectedTime as number; + } else if (this.hoveredTime !== null) { + return this.hoveredTime; + } else if (selectedTime !== null) { + return selectedTime; + } else { + return null; + } + } + /** Updates the playback speed. */ setPlaybackSpeed(speed: number) { if (this.mode === SelectionMode.Playback) { @@ -265,18 +301,103 @@ export default class Selection { } this.playbackSpeed = speed; } -} -export enum SelectionMode { - /** Nothing is selected and playback is inactive. */ - Idle, + /** Sets a new time range for an in-progress grab zoom. */ + setGrabZoomRange(range: [number, number] | null) { + if (range !== null) { + if (range[1] < range[0]) { + range.reverse(); + } + } + this.grabZoomRange = range; + } + + /** Gets the time range to display for an in-progress grab zoom. */ + getGrabZoomRange(): [number, number] | null { + return this.grabZoomRange; + } + + /** Ends an in-progress grab zoom, optionally applying the resulting zoom. */ + finishGrabZoom() { + if (this.grabZoomRange !== null) { + this.timelineMaxZoom = false; + this.timelineRange = [this.grabZoomRange[0], this.grabZoomRange[1]]; + if (this.timelineRange[1] - this.timelineRange[0] < this.TIMELINE_MIN_ZOOM_TIME) { + this.timelineRange[1] = this.timelineRange[0] + this.TIMELINE_MIN_ZOOM_TIME; + } + } + this.grabZoomRange = null; + } - /** A time is selected but playback is inactive. */ - Static, + /** Returns the visible range for the timeline. */ + getTimelineRange(): [number, number] { + this.applyTimelineScroll(0, 0, 0); + return this.timelineRange; + } - /** Historical playback is active. */ - Playback, + /** Updates the timeline range based on a scroll event. */ + applyTimelineScroll(dx: number, dy: number, widthPixels: number) { + // Find available timestamp range + let availableRange = window.log.getTimestampRange(); + availableRange = [availableRange[0], availableRange[1]]; + let liveTime = this.getCurrentLiveTime(); + if (liveTime !== null) { + availableRange[1] = liveTime; + } + if (availableRange[1] - availableRange[0] < this.TIMELINE_MIN_ZOOM_TIME) { + availableRange[1] = availableRange[0] + this.TIMELINE_ZOOM_BASE; + } - /** Playback is locked to the live data. */ - Locked + // Apply horizontal scroll + if (this.mode === SelectionMode.Locked) { + let zoom = this.timelineRange[1] - this.timelineRange[0]; + this.timelineRange[0] = availableRange[1] - zoom; + this.timelineRange[1] = availableRange[1]; + if (dx < 0) this.unlock(); // Unlock if attempting to scroll away + } else if (dx !== 0) { + let secsPerPixel = (this.timelineRange[1] - this.timelineRange[0]) / widthPixels; + this.timelineRange[0] += dx * secsPerPixel; + this.timelineRange[1] += dx * secsPerPixel; + } + + // Apply vertical scroll + if (dy !== 0 && (!this.timelineMaxZoom || dy < 0)) { + // If max zoom, ignore positive scroll (no effect, just apply the max zoom) + let zoomPercent = Math.pow(this.TIMELINE_ZOOM_BASE, dy); + let newZoom = (this.timelineRange[1] - this.timelineRange[0]) * zoomPercent; + if (newZoom < this.TIMELINE_MIN_ZOOM_TIME) newZoom = this.TIMELINE_MIN_ZOOM_TIME; + if (newZoom > availableRange[1] - availableRange[0]) newZoom = availableRange[1] - availableRange[0]; + + let hoveredTime = this.getHoveredTime(); + if (hoveredTime === null) { + hoveredTime = (this.timelineRange[0] + this.timelineRange[1]) / 2; + } + let hoveredPercent = (hoveredTime - this.timelineRange[0]) / (this.timelineRange[1] - this.timelineRange[0]); + this.timelineRange[0] = hoveredTime - newZoom * hoveredPercent; + this.timelineRange[1] = hoveredTime + newZoom * (1 - hoveredPercent); + } else if (this.timelineMaxZoom) { + this.timelineRange = availableRange; + } + + // Enforce max range + if (this.timelineRange[1] - this.timelineRange[0] > availableRange[1] - availableRange[0]) { + this.timelineRange = availableRange; + } + this.timelineMaxZoom = this.timelineRange[1] - this.timelineRange[0] === availableRange[1] - availableRange[0]; + + // Enforce left limit + if (this.timelineRange[0] < availableRange[0]) { + let shift = availableRange[0] - this.timelineRange[0]; + this.timelineRange[0] += shift; + this.timelineRange[1] += shift; + } + + // Enforce right limit + if (this.timelineRange[1] > availableRange[1]) { + let shift = availableRange[1] - this.timelineRange[1]; + this.timelineRange[0] += shift; + this.timelineRange[1] += shift; + if (dx > 0) this.lock(); // Lock if action is intentional + } + } } diff --git a/src/hub/Sidebar.ts b/src/hub/Sidebar.ts index 4c9ca92a..36463cd0 100644 --- a/src/hub/Sidebar.ts +++ b/src/hub/Sidebar.ts @@ -2,12 +2,14 @@ import { SidebarState } from "../shared/HubState"; import LogFieldTree from "../shared/log/LogFieldTree"; import LoggableType from "../shared/log/LoggableType"; import { getOrDefault, searchFields, TYPE_KEY } from "../shared/log/LogUtil"; +import { SelectionMode } from "../shared/Selection"; import { arraysEqual, setsEqual } from "../shared/util"; import { ZEBRA_LOG_KEY } from "./dataSources/LoadZebra"; import CustomSchemas from "./dataSources/schema/CustomSchemas"; -import { SelectionMode } from "./Selection"; export default class Sidebar { + private DEFAULT_SIDEBAR_WIDTH = 300; + private SIDEBAR = document.getElementsByClassName("side-bar")[0] as HTMLElement; private SIDEBAR_HANDLE = document.getElementsByClassName("side-bar-handle")[0] as HTMLElement; private SIDEBAR_SHADOW = document.getElementsByClassName("side-bar-shadow")[0] as HTMLElement; @@ -46,7 +48,7 @@ export default class Sidebar { private VALUE_WIDTH_MARGIN_PX = 12; private sidebarHandleActive = false; - private sidebarWidth = 300; + private sidebarWidth = this.DEFAULT_SIDEBAR_WIDTH; private fieldCount = 0; private isTuningMode = false; private lastFieldKeys: string[] = []; @@ -81,11 +83,27 @@ export default class Sidebar { let width = event.clientX; if (width > 500) width = 500; if (width >= 80 && width < 160) width = 160; - if (width < 80) width = 0; + if (width < 80) { + if (this.sidebarWidth > 0) { + width = 0; + } else { + width = this.sidebarWidth; + } + } this.sidebarWidth = width; this.updateWidth(); } }); + let lastClick = 0; + this.SIDEBAR_HANDLE.addEventListener("click", () => { + let now = new Date().getTime(); + if (now - lastClick < 400) { + this.toggleVisible(); + lastClick = 0; + } else { + lastClick = now; + } + }); this.updateWidth(); // Set up shadow when scrolling @@ -93,6 +111,30 @@ export default class Sidebar { this.SIDEBAR_SHADOW.style.opacity = this.SIDEBAR.scrollTop === 0 ? "0" : "1"; }); + // Menu bar + let menuBar = document.getElementsByClassName("title-bar-menu")[0] as HTMLElement; + Array.from(menuBar.getElementsByTagName("button")).forEach((button, index) => { + let active = false; + button.addEventListener("click", () => { + if (active) { + active = false; + window.sendMainMessage("close-app-menu", { + index: index + }); + } else { + active = true; + let rect = button.getBoundingClientRect(); + window.sendMainMessage("open-app-menu", { + index: index, + coordinates: [Math.round(rect.left), Math.round(rect.bottom)] + }); + } + }); + button.addEventListener("mouseleave", () => { + active = false; + }); + }); + // Search controls let searchInputFocused = false; this.SEARCH_INPUT.addEventListener("focus", () => (searchInputFocused = true)); @@ -187,6 +229,16 @@ export default class Sidebar { if (!expandedEqual) this.refresh(true); } + /** Toggles the visibility of the sidebar. */ + toggleVisible() { + if (this.sidebarWidth === 0) { + this.sidebarWidth = this.DEFAULT_SIDEBAR_WIDTH; + } else { + this.sidebarWidth *= -1; + } + this.updateWidth(); + } + /** Updates the hovering effect on the search results. */ private updateSearchHovered(scroll: boolean) { Array.from(this.SEARCH_RESULTS.children).forEach((element, index) => { @@ -248,8 +300,9 @@ export default class Sidebar { /** Updates the displayed width based on the current state. */ private updateWidth() { - document.documentElement.style.setProperty("--side-bar-width", this.sidebarWidth.toString() + "px"); - document.documentElement.style.setProperty("--show-side-bar", this.sidebarWidth > 0 ? "1" : "0"); + let appliedWidth = Math.max(this.sidebarWidth, 0); + document.documentElement.style.setProperty("--side-bar-width", appliedWidth.toString() + "px"); + document.documentElement.style.setProperty("--show-side-bar", appliedWidth === 0 ? "0" : "1"); } /** Updates the title with the duration and field count. */ @@ -523,8 +576,16 @@ export default class Sidebar { { let dragEvent = (x: number, y: number, offsetX: number, offsetY: number) => { let isGroup = this.selectGroup.includes(field.fullKey !== null ? field.fullKey : ""); - this.DRAG_ITEM.innerText = title + (isGroup ? "..." : ""); - this.DRAG_ITEM.style.fontWeight = isGroup ? "bolder" : "initial"; + while (this.DRAG_ITEM.firstChild) { + this.DRAG_ITEM.removeChild(this.DRAG_ITEM.firstChild); + } + + let text = document.createElement("span"); + text.classList.add("field-text"); + this.DRAG_ITEM.appendChild(text); + text.innerText = title + (isGroup ? "\u2026" : ""); + text.style.fontWeight = isGroup ? "bolder" : "initial"; + window.startDrag(x, y, offsetX, offsetY, { fields: isGroup ? this.selectGroup : [field.fullKey], children: isGroup diff --git a/src/hub/SourceList.ts b/src/hub/SourceList.ts new file mode 100644 index 00000000..2d72d38c --- /dev/null +++ b/src/hub/SourceList.ts @@ -0,0 +1,1181 @@ +import { ensureThemeContrast } from "../shared/Colors"; +import { + SourceListConfig, + SourceListItemState, + SourceListOptionValueConfig, + SourceListState, + SourceListTypeConfig, + SourceListTypeMemoryEntry +} from "../shared/SourceListConfig"; +import { + grabChassiSpeeds as grabChassisSpeeds, + grabPosesAuto, + grabSwerveStates, + rotation3dTo2d, + rotation3dToRPY +} from "../shared/geometry"; +import { getLogValueText, getMechanismState, getOrDefault } from "../shared/log/LogUtil"; +import LoggableType from "../shared/log/LoggableType"; +import { convert } from "../shared/units"; +import { createUUID, jsonCopy } from "../shared/util"; + +export default class SourceList { + private DRAG_THRESHOLD_PX = 5; + + static typePromptCallbacks: { [key: string]: (state: SourceListItemState) => void } = {}; + static clearPromptCallbacks: { [key: string]: () => void } = {}; + + private UUID = createUUID(); + private ITEM_TEMPLATE = document.getElementById("sourceListItemTemplate")?.firstElementChild as HTMLElement; + private ROOT: HTMLElement; + private TITLE: HTMLElement; + private EDIT_BUTTON: HTMLButtonElement; + private CLEAR_BUTTON: HTMLButtonElement; + private HELP_BUTTON: HTMLButtonElement; + private LIST: HTMLElement; + private HAND_ICON: HTMLImageElement; + private DRAG_HIGHLIGHT: HTMLElement; + private DRAG_ITEM = document.getElementById("dragItem") as HTMLElement; + + private stopped = false; + private config: SourceListConfig; + private configStr: string; + private state: SourceListState = []; + private independentAllowedTypes: Set = new Set(); // Types that are not only children + private parentKeys: Map = new Map(); // Map type key to parent key + private supplementalStateSuppliers: (() => SourceListState)[]; + private getNumberPreview: ((key: string, time: number) => number | null) | undefined; + + private refreshLastFields: Set = new Set(); + private refreshLastStructTypes: { [key: string]: string | null } = {}; + + /** + * Creates a new source list controller + * + * @param root The top-level HTML element of the source list + * @param config The configuration used for controlling the source list + * @param supplementalStateSuppliers Suppliers of additional states from other source lists, used when running auto advance logic + */ + constructor( + root: HTMLElement, + config: SourceListConfig, + supplementalStateSuppliers: (() => SourceListState)[], + editButtonCallback?: (coordinates: [number, number]) => void, + getNumberPreview?: (key: string, time: number) => number | null + ) { + this.config = jsonCopy(config); + this.configStr = JSON.stringify(config); + this.supplementalStateSuppliers = supplementalStateSuppliers; + this.getNumberPreview = getNumberPreview; + this.ROOT = root; + this.ROOT.classList.add("source-list"); + + this.TITLE = document.createElement("div"); + this.ROOT.appendChild(this.TITLE); + this.TITLE.classList.add("title"); + this.TITLE.innerText = config.title; + this.TITLE.style.left = editButtonCallback === undefined ? "55px" : "30px"; + this.TITLE.style.right = editButtonCallback === undefined ? "55px" : "30px"; + + this.EDIT_BUTTON = document.createElement("button"); + this.ROOT.appendChild(this.EDIT_BUTTON); + this.EDIT_BUTTON.classList.add("edit"); + let editIcon = document.createElement("img"); + this.EDIT_BUTTON.appendChild(editIcon); + editIcon.src = "symbols/ellipsis.svg"; + this.EDIT_BUTTON.hidden = editButtonCallback === undefined; + + this.CLEAR_BUTTON = document.createElement("button"); + this.ROOT.appendChild(this.CLEAR_BUTTON); + this.CLEAR_BUTTON.classList.add("clear"); + let clearButton = document.createElement("img"); + this.CLEAR_BUTTON.appendChild(clearButton); + clearButton.src = "symbols/trash.fill.svg"; + this.CLEAR_BUTTON.hidden = editButtonCallback !== undefined; + + this.HELP_BUTTON = document.createElement("button"); + this.ROOT.appendChild(this.HELP_BUTTON); + this.HELP_BUTTON.classList.add("help"); + let helpIcon = document.createElement("img"); + this.HELP_BUTTON.appendChild(helpIcon); + helpIcon.src = "symbols/questionmark.circle.svg"; + this.HELP_BUTTON.hidden = editButtonCallback !== undefined; + + this.LIST = document.createElement("div"); + this.ROOT.appendChild(this.LIST); + this.LIST.classList.add("list"); + + this.HAND_ICON = document.createElement("img"); + this.ROOT.appendChild(this.HAND_ICON); + this.HAND_ICON.classList.add("hand-icon"); + this.HAND_ICON.src = "symbols/rectangle.and.hand.point.up.left.filled.svg"; + this.updateHandIcon(); + + this.DRAG_HIGHLIGHT = document.createElement("div"); + this.ROOT.appendChild(this.DRAG_HIGHLIGHT); + this.DRAG_HIGHLIGHT.classList.add("drag-highlight"); + this.DRAG_HIGHLIGHT.hidden = true; + + // Summarize config + this.config.types.forEach((typeConfig) => { + if (typeConfig.childOf === undefined) { + typeConfig.sourceTypes.forEach((source) => { + this.independentAllowedTypes.add(source); + }); + } + if (typeConfig.parentKey !== undefined) { + this.parentKeys.set(typeConfig.key, typeConfig.parentKey); + } + }); + + // Edit button + this.EDIT_BUTTON.addEventListener("click", () => { + let rect = this.EDIT_BUTTON.getBoundingClientRect(); + let coordinates: [number, number] = [Math.round(rect.right), Math.round(rect.top)]; + if (editButtonCallback !== undefined) { + editButtonCallback(coordinates); + } + }); + this.CLEAR_BUTTON.addEventListener("click", () => { + let rect = this.CLEAR_BUTTON.getBoundingClientRect(); + window.sendMainMessage("source-list-clear-prompt", { + uuid: this.UUID, + coordinates: [Math.round(rect.right), Math.round(rect.top)] + }); + SourceList.clearPromptCallbacks[this.UUID] = () => { + delete SourceList.clearPromptCallbacks[this.UUID]; + this.clear(); + }; + }); + this.HELP_BUTTON.addEventListener("click", () => { + window.sendMainMessage("source-list-help", this.config); + }); + + // Incoming drag handling + window.addEventListener("drag-update", (event) => { + let dragData = (event as CustomEvent).detail; + if ("sourceListUUID" in dragData.data) { + this.handleItemDrag(dragData); + } else if ("fields" in dragData.data) { + this.handleFieldDrag(dragData); + } + }); + + // Entry dragging support + let mouseDownInfo: [number, number] | null = null; + this.LIST.addEventListener("mousedown", (event) => { + mouseDownInfo = [event.clientX, event.clientY]; + }); + this.LIST.addEventListener("mouseup", () => { + mouseDownInfo = null; + }); + this.LIST.addEventListener("mousemove", (event) => { + // Start drag + if ( + mouseDownInfo !== null && + (Math.abs(event.clientX - mouseDownInfo[0]) >= this.DRAG_THRESHOLD_PX || + Math.abs(event.clientY - mouseDownInfo[1]) >= this.DRAG_THRESHOLD_PX) + ) { + // Find item + let index = -1; + Array.from(this.LIST.children).forEach((element, i) => { + let rect = element.getBoundingClientRect(); + if ( + mouseDownInfo![0] >= rect.left && + mouseDownInfo![0] <= rect.right && + mouseDownInfo![1] >= rect.top && + mouseDownInfo![1] <= rect.bottom + ) { + index = i; + } + }); + mouseDownInfo = null; + if (index === -1) return; + + // Update drag item + while (this.DRAG_ITEM.firstChild) { + this.DRAG_ITEM.removeChild(this.DRAG_ITEM.firstChild); + } + let element = this.LIST.children[index]; + let dragContainer = document.createElement("div"); + dragContainer.style.position = "absolute"; + dragContainer.style.width = element.clientWidth.toString() + "px"; + dragContainer.style.height = "30px"; + dragContainer.style.left = "0px"; + dragContainer.style.top = "0px"; + dragContainer.style.pointerEvents = "none"; + dragContainer.style.margin = "none"; + dragContainer.style.padding = "none"; + dragContainer.classList.add("source-list"); + let elementClone = element.cloneNode(true) as HTMLElement; + { + // Apply icon color + Array.from(elementClone.getElementsByTagName("object")).forEach((objElement) => { + const color = objElement.getAttribute("type-color"); + objElement.addEventListener("load", () => { + if (color !== null && objElement.contentDocument !== null) { + let svgs = objElement.contentDocument.getElementsByTagName("svg"); + if (svgs.length > 0) { + svgs[0].style.color = color; + } + } + }); + }); + } + dragContainer.appendChild(elementClone); + this.DRAG_ITEM.appendChild(dragContainer); + + // Start drag + let itemRect = element.getBoundingClientRect(); + window.startDrag(event.clientX, event.clientY, event.clientX - itemRect.left, event.clientY - itemRect.top, { + sourceListUUID: this.UUID, + sourceListConfigStr: this.configStr, + sourceListIndex: index, + sourceListIsChild: this.isChild(index), + sourceListElement: this.LIST, + sourceListState: this.state + }); + } + }); + + // Periodic method + let lastIsDark: boolean | null = null; + let periodic = () => { + if (this.stopped) return; + + // Update items when theme changes (some icon colors will change) + let isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (isDark !== lastIsDark) { + lastIsDark = isDark; + this.updateAllItems(); + } + + // Update value previews + this.updateAllPreviews(); + + window.requestAnimationFrame(periodic); + }; + window.requestAnimationFrame(periodic); + } + + setTitle(title: string): void { + this.TITLE.innerText = title; + } + + /** + * Returns the set of item states displayed by the source list. + * @param onlyDisplayedFields Whether to only include fields that should be rendered + */ + getState(onlyDisplayedFields = false): SourceListState { + if (onlyDisplayedFields) { + let availableFields: boolean[] = []; + let parentAvailable = false; + this.state.forEach((item) => { + let available = this.isFieldAvailable(item); + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === item.type); + + if (typeConfig?.parentKey !== undefined) { + parentAvailable = available; + } + if (typeConfig?.childOf !== undefined && parentAvailable !== null) { + availableFields.push(available && parentAvailable); + } else { + availableFields.push(available); + } + }); + return this.state.filter((item, index) => item.visible && availableFields[index]); + } else { + return this.state; + } + } + + setState(state: SourceListState) { + this.clear(); + state.forEach((itemState) => { + this.addListItem(itemState); + }); + } + + getActiveFields(): string[] { + return this.state.map((item) => item.logKey); + } + + stop() { + this.stopped = true; + } + + /** Removes all items in the source list. */ + clear() { + this.state = []; + while (this.LIST.firstChild) { + this.LIST.removeChild(this.LIST.firstChild); + } + this.updateHandIcon(); + } + + /** Call when a new set of log fields may be available. */ + refresh() { + let displayedFields = this.state.map((item) => item.logKey); + let currentFields = new Set(window.log.getFieldKeys().filter((field) => displayedFields.includes(field))); + let structTypes: { [key: string]: string | null } = {}; + currentFields.forEach((field) => { + structTypes[field] = window.log.getStructuredType(field); + }); + + let shouldUpdate = false; + currentFields.forEach((field) => { + if (!this.refreshLastFields.has(field)) { + // New field was added + shouldUpdate = true; + } + }); + + if (!shouldUpdate) { + this.refreshLastFields.forEach((field) => { + if (!currentFields.has(field)) { + // Existing field was removed + shouldUpdate = true; + } + }); + + if (!shouldUpdate) { + Object.entries(structTypes).forEach(([key, type]) => { + if (this.refreshLastStructTypes[key] !== type) { + // Struct type changed + shouldUpdate = true; + } + }); + } + } + + this.refreshLastFields = currentFields; + this.refreshLastStructTypes = structTypes; + if (shouldUpdate) { + this.updateAllItems(); + } + } + + /** + * Updates a set of option values and validates that all items are valid. + */ + setOptionValues(type: string, option: string, values: SourceListOptionValueConfig[]) { + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === type); + if (typeConfig === undefined) return; + let optionConfig = typeConfig!.options.find((optionConfig) => optionConfig.key === option); + if (optionConfig === undefined) return; + optionConfig.values = values; + + // Verify that all items have a valid selection + let possibleValues = values.map((x) => x.key); + this.state.forEach((item, index) => { + if (item.type === type && !possibleValues.includes(item.options[option])) { + item.options[option] = values[0].key; + this.updateItem(this.LIST.children[index] as HTMLElement, item); + } + }); + } + + private updateHandIcon() { + let show = this.LIST.childElementCount === 0; + this.HAND_ICON.style.transition = show ? "opacity 1s ease-in 1s" : ""; + this.HAND_ICON.style.opacity = show ? "0.15" : "0"; + } + + /** + * Adds a new field to the list, if the type is valid. + * + * @param logKey The key for the field to add + * @param parentIndex The index of the parent item (optional) + */ + addField(logKey: string, parentIndex?: number) { + let logType = window.log.getType(logKey); + let logTypeString = logType === null ? null : LoggableType[logType]; + let structuredType = window.log.getStructuredType(logKey); + + // Get memory entry + let memory: SourceListTypeMemoryEntry | null = null; + if ( + this.config.typeMemoryId && + this.config.typeMemoryId in window.typeMemory && + logKey in window.typeMemory[this.config.typeMemoryId] + ) { + memory = window.typeMemory[this.config.typeMemoryId][logKey]; + } + + // Get all possible types + let stateWithSupplemental = this.state.concat(...this.supplementalStateSuppliers.map((func) => func())); + let possibleTypes: { typeConfig: SourceListTypeConfig; logType: string; uses: number }[] = []; + for (let i = 0; i < this.config.types.length; i++) { + let typeConfig = this.config.types[i]; + if ( + (parentIndex === undefined && typeConfig.childOf !== undefined) || // Not inserting as a child + (parentIndex !== undefined && typeConfig.childOf !== this.parentKeys.get(this.state[parentIndex!].type)) // Type is not the correct parent + ) { + continue; + } + let finalType = ""; + if (structuredType !== null && typeConfig.sourceTypes.includes(structuredType)) { + finalType = structuredType; + } else if (logTypeString !== null && typeConfig.sourceTypes.includes(logTypeString)) { + finalType = logTypeString; + } + if (finalType.length > 0) { + possibleTypes.push({ + typeConfig: typeConfig, + logType: finalType, + uses: stateWithSupplemental.filter((itemState) => itemState.type === typeConfig.key).length + }); + } + } + + // Find best type + if (possibleTypes.length === 0) return; + if (this.config.autoAdvance === true) { + possibleTypes.sort((a, b) => a.uses - b.uses); + } + let memoryTypeIndex = possibleTypes.findIndex((type) => memory !== null && type.typeConfig.key === memory.type); + if (memoryTypeIndex !== -1) { + let memoryType = possibleTypes.splice(memoryTypeIndex, 1)[0]; + possibleTypes.splice(0, 0, memoryType); + } + let bestType = possibleTypes[0]; + + // Add to list + let options: { [key: string]: string } = {}; + bestType.typeConfig.options.forEach((optionConfig) => { + if ( + memory !== null && + optionConfig.key in memory.options && + optionConfig.values.map((valueConfig) => valueConfig.key).includes(memory.options[optionConfig.key]) + ) { + // Select value from type memory + options[optionConfig.key] = memory.options[optionConfig.key]; + } else if (this.config.autoAdvance !== optionConfig.key) { + // Select first value + options[optionConfig.key] = optionConfig.values[0].key; + } else { + // Select least used value + let useCounts: { valueConfig: SourceListOptionValueConfig; uses: number }[] = optionConfig.values.map( + (valueConfig) => { + return { + valueConfig: valueConfig, + uses: stateWithSupplemental.filter( + (itemState) => + optionConfig.key in itemState.options && itemState.options[optionConfig.key] === valueConfig.key + ).length + }; + } + ); + useCounts.sort((a, b) => a.uses - b.uses); + options[optionConfig.key] = useCounts[0].valueConfig.key; + } + }); + let state: SourceListItemState = { + type: bestType.typeConfig.key, + logKey: logKey, + logType: bestType.logType, + visible: true, + options: options + }; + if (parentIndex !== undefined) { + let insertIndex = parentIndex! + 1; + while (insertIndex < this.state.length && this.isChild(insertIndex)) { + insertIndex++; + } + this.addListItem(state, insertIndex); + } else { + this.addListItem(state); + } + } + + /** Processes a item drag event, including rearranging fields if necessary. */ + private handleItemDrag(dragData: any) { + let uuid: string = dragData.data.sourceListUUID; + let configStr: string = dragData.data.sourceListConfigStr; + if (configStr !== this.configStr) return; + let startIndex: number = dragData.data.sourceListIndex; + let childSource: boolean = dragData.data.sourceListIsChild; + let sourceElement: HTMLElement = dragData.data.sourceListElement; + let sourceState: SourceListState = dragData.data.sourceListState; + let end: boolean = dragData.end; + let x: number = dragData.x; + let y: number = dragData.y; + + let rootRect = this.ROOT.getBoundingClientRect(); + if (x < rootRect.left || x > rootRect.right || y < rootRect.top || y > rootRect.bottom) { + this.DRAG_HIGHLIGHT.hidden = true; + return; + } + + let isValidTarget = (targetIndex: number) => { + if (childSource) { + if (uuid !== this.UUID) return false; + + let sourceParent = startIndex - 1; + while (sourceParent >= 0 && this.isChild(sourceParent)) { + sourceParent--; + } + + let targetParent = targetIndex - 1; + while (targetParent >= 0 && this.isChild(targetParent)) { + targetParent--; + } + + if (sourceParent !== targetParent) return false; + } else { + if (targetIndex < this.LIST.childElementCount && this.isChild(targetIndex)) return false; + } + return true; + }; + + let closestDist = Infinity; + let closestIndex = 0; + Array.from(this.LIST.children).forEach((element, index) => { + if (!isValidTarget(index)) return; + let dist = Math.abs(y - element.getBoundingClientRect().top); + if (dist < closestDist) { + closestDist = dist; + closestIndex = index; + } + }); + let lastElement = this.LIST.lastElementChild; + if (lastElement !== null && isValidTarget(this.LIST.childElementCount)) { + let dist = Math.abs(y - lastElement.getBoundingClientRect().bottom); + if (dist < closestDist) { + closestDist = dist; + closestIndex = this.LIST.childElementCount; + } + } + + if (end) { + this.DRAG_HIGHLIGHT.hidden = true; + let endIndex = closestIndex; + let itemCount = 0; + let i = startIndex; + if (childSource) { + itemCount = 1; + } else { + while (true) { + itemCount++; + if (i + itemCount >= this.state.length || !this.isChild(i + itemCount)) break; + } + } + + // Rearrange items + for (let i = 0; i < itemCount; i++) { + let state = sourceState[startIndex]; + if (uuid !== this.UUID) { + sourceElement.removeChild(sourceElement.children[startIndex]); + sourceState.splice(startIndex, 1); + this.addListItem(state, endIndex); + endIndex++; + } else if (endIndex < startIndex) { + this.LIST.removeChild(this.LIST.children[startIndex]); + this.state.splice(startIndex, 1); + this.addListItem(state, endIndex); + startIndex++; + endIndex++; + } else if (endIndex > startIndex) { + this.LIST.removeChild(this.LIST.children[startIndex]); + this.state.splice(startIndex, 1); + this.addListItem(state, endIndex - 1); + } + } + } else { + this.DRAG_HIGHLIGHT.hidden = false; + let highlightY = 0; + if (this.LIST.childElementCount === 0) { + highlightY = this.LIST.getBoundingClientRect().top; + } else if (closestIndex < this.LIST.childElementCount) { + highlightY = this.LIST.children[closestIndex].getBoundingClientRect().top; + } else { + highlightY = this.LIST.children[this.LIST.childElementCount - 1].getBoundingClientRect().bottom; + } + highlightY -= this.ROOT.getBoundingClientRect().top; + highlightY -= 6; + this.DRAG_HIGHLIGHT.style.left = "0%"; + this.DRAG_HIGHLIGHT.style.top = highlightY.toString() + "px"; + this.DRAG_HIGHLIGHT.style.width = "100%"; + this.DRAG_HIGHLIGHT.style.height = "12px"; + } + } + + /** Processes a field drag event, including adding a field if necessary. */ + private handleFieldDrag(dragData: any) { + let end = dragData.end; + let x = dragData.x; + let y = dragData.y; + let draggedFields: string[]; + if (this.config.allowChildrenFromDrag) { + draggedFields = dragData.data.fields.concat(dragData.data.children); + } else { + draggedFields = dragData.data.fields; + } + + // Exit if out of range + let listRect = this.ROOT.getBoundingClientRect(); + if ( + listRect.width === 0 || + listRect.height === 0 || + x < listRect.left || + x > listRect.right || + y < listRect.top || + y > listRect.bottom + ) { + this.DRAG_HIGHLIGHT.hidden = true; + return; + } + + // Check pixel ranges + let parentIndex: number | null = null; + for (let i = 0; i < this.LIST.childElementCount; i++) { + let itemRect = this.LIST.children[i].getBoundingClientRect(); + let withinItem = x > itemRect.left && x < itemRect.right && y > itemRect.top && y < itemRect.bottom; + if (withinItem && this.parentKeys.has(this.state[i].type)) { + parentIndex = i; + } + } + + // Check type validity + let isTypeValid = (sourceTypes: Set): boolean => { + return draggedFields.some((field) => { + let logType = window.log.getType(field); + let logTypeString = logType === null ? null : LoggableType[logType]; + let structuredType = window.log.getStructuredType(field); + return ( + (logTypeString !== null && sourceTypes.has(logTypeString)) || + (structuredType !== null && sourceTypes.has(structuredType)) + ); + }); + }; + let isTypeValidAsChild = (parentType: string): boolean => { + let parentKey = this.parentKeys.get(parentType); + if (parentKey === undefined) return false; + let childAllowedTypes: Set = new Set(); + this.config.types.forEach((typeConfig) => { + if (typeConfig.childOf === parentKey) { + typeConfig.sourceTypes.forEach((type) => childAllowedTypes.add(type)); + } + }); + return isTypeValid(childAllowedTypes); + }; + let typeValidAsRoot = isTypeValid(this.independentAllowedTypes); + let typeValidAsChild = false; + if (parentIndex !== null) { + typeValidAsChild = isTypeValidAsChild(this.state[parentIndex!].type); + } + + // Apply parent highlights + for (let i = 0; i < this.state.length; i++) { + if (isTypeValidAsChild(this.state[i].type)) { + this.LIST.children[i].classList.add("parent-highlight"); + } + } + + // Add fields and update highlight + if (end) { + this.DRAG_HIGHLIGHT.hidden = true; + if (!typeValidAsChild) parentIndex = null; + if (parentIndex !== null || typeValidAsRoot) { + draggedFields.forEach((field) => { + this.addField(field, parentIndex === null ? undefined : parentIndex); + }); + } + Array.from(this.LIST.children).forEach((element) => { + element.classList.remove("parent-highlight"); + }); + } else if (typeValidAsChild && parentIndex !== null) { + let top = Math.max(this.LIST.children[parentIndex!].getBoundingClientRect().top - listRect.top, 0); + let bottom = Math.min( + this.LIST.children[parentIndex!].getBoundingClientRect().bottom - listRect.top, + listRect.height + ); + this.DRAG_HIGHLIGHT.style.left = "0%"; + this.DRAG_HIGHLIGHT.style.top = top.toString() + "px"; + this.DRAG_HIGHLIGHT.style.width = "100%"; + this.DRAG_HIGHLIGHT.style.height = (bottom - top).toString() + "px"; + this.DRAG_HIGHLIGHT.hidden = false; + } else if (typeValidAsRoot) { + this.DRAG_HIGHLIGHT.style.left = "0%"; + this.DRAG_HIGHLIGHT.style.top = "0%"; + this.DRAG_HIGHLIGHT.style.width = "100%"; + this.DRAG_HIGHLIGHT.style.height = "100%"; + this.DRAG_HIGHLIGHT.hidden = false; + } else { + this.DRAG_HIGHLIGHT.hidden = true; + } + } + + /** Update all items to match the current state. */ + private updateAllItems() { + let count = Math.min(this.state.length, this.LIST.childElementCount); + for (let i = 0; i < count; i++) { + this.updateItem(this.LIST.children[i] as HTMLElement, this.state[i]); + } + } + + /** Update the preview values of all items. */ + private updateAllPreviews() { + let count = Math.min(this.state.length, this.LIST.childElementCount); + for (let i = 0; i < count; i++) { + this.updatePreview(this.LIST.children[i] as HTMLElement, this.state[i]); + } + } + + /** Make a list item element and inserts it into the list. */ + private addListItem(state: SourceListItemState, insertIndex?: number) { + let item = this.ITEM_TEMPLATE.cloneNode(true) as HTMLElement; + if (insertIndex === undefined) { + this.LIST.appendChild(item); + this.state.push(state); + } else { + this.LIST.insertBefore(item, this.LIST.children[insertIndex!]); + this.state.splice(insertIndex, 0, state); + } + this.updateItem(item, state); + this.updateHandIcon(); + + // Check if child type + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === state.type); + let isChild = typeConfig !== undefined && typeConfig.childOf !== undefined; + + // Type controls + let typeButton = item.getElementsByClassName("type")[0] as HTMLButtonElement; + let typeNameElement = item.getElementsByClassName("type-name")[0] as HTMLElement; + let promptType = (coordinates: [number, number]) => { + let index = Array.from(this.LIST.children).indexOf(item); + window.sendMainMessage("source-list-type-prompt", { + uuid: this.UUID, + config: this.config, + state: this.state[index], + coordinates: coordinates + }); + let originalType = this.state[index].type; + SourceList.typePromptCallbacks[this.UUID] = (newState) => { + delete SourceList.typePromptCallbacks[this.UUID]; + let index = Array.from(this.LIST.children).indexOf(item); + this.state[index] = newState; + this.updateItem(item, newState); + + if (!isChild) { + let originalParentKey = this.config.types.find((typeConfig) => typeConfig.key === originalType)?.parentKey; + let newParentKey = this.config.types.find((typeConfig) => typeConfig.key === newState.type)?.parentKey; + if (originalParentKey !== newParentKey) { + // Changed parent key, remove children + index++; + if (!this.isChild(index)) return; + let childCount = 0; + while (index + childCount < this.state.length) { + childCount++; + if (!this.isChild(index + childCount)) break; + } + this.state.splice(index, childCount); + for (let i = 0; i < childCount; i++) { + this.LIST.removeChild(this.LIST.children[index]); + this.updateHandIcon(); + } + } + } + }; + }; + typeButton.addEventListener("click", () => { + let rect = typeButton.getBoundingClientRect(); + promptType([Math.round(rect.right), Math.round(rect.top)]); + }); + item.addEventListener("contextmenu", (event) => { + promptType([event.clientX, event.clientY]); + }); + + // Warning button + let warningButton = item.getElementsByClassName("warning")[0] as HTMLButtonElement; + let enableWarning = typeConfig?.numberArrayDeprecated === true; + warningButton.hidden = !enableWarning; + let keyContainer = item.getElementsByClassName("key-container")[0] as HTMLElement; + keyContainer.style.setProperty("--has-warning", enableWarning ? "1" : "0"); + warningButton.addEventListener("click", () => { + window.sendMainMessage("numeric-array-deprecation-warning", { force: true }); + }); + if (enableWarning) { + window.sendMainMessage("numeric-array-deprecation-warning", { force: false }); + } + + // Hide button + let hideButton = item.getElementsByClassName("hide")[0] as HTMLButtonElement; + let toggleHidden = () => { + if (isChild) return; + let index = Array.from(this.LIST.children).indexOf(item); + let newVisible = !this.state[index].visible; + this.state[index].visible = newVisible; + this.updateItem(item, this.state[index]); + while (index < this.state.length) { + index++; + if (!this.isChild(index)) break; + this.state[index].visible = newVisible; + this.updateItem(this.LIST.children[index] as HTMLElement, this.state[index]); + } + }; + hideButton.addEventListener("click", (event) => { + event.preventDefault(); + toggleHidden(); + }); + let lastClick = 0; + [typeNameElement, keyContainer].forEach((element) => + element.addEventListener("click", () => { + let now = new Date().getTime(); + if (now - lastClick < 400) { + toggleHidden(); + lastClick = 0; + } else { + lastClick = now; + } + }) + ); + + // Child formatting + if (isChild) { + hideButton.hidden = true; + item.classList.add("child"); + } + + // Remove button + let removeButton = item.getElementsByClassName("remove")[0] as HTMLButtonElement; + removeButton.addEventListener("click", () => { + let index = Array.from(this.LIST.children).indexOf(item); + let removeCount = 0; + while (index + removeCount < this.state.length) { + removeCount++; + if (isChild || !this.isChild(index + removeCount)) break; + } + this.state.splice(index, removeCount); + for (let i = 0; i < removeCount; i++) { + this.LIST.removeChild(this.LIST.children[index]); + } + this.updateHandIcon(); + }); + return item; + } + + /** + * Updates a list item to match the item state. + * + * @param item The HTML element to update + * @param state The desired display state + */ + private updateItem(item: HTMLElement, state: SourceListItemState) { + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === state.type); + if (typeConfig === undefined) throw 'Unknown type "' + state.type + '"'; + + // Update type icon + let typeIconVisible = item.getElementsByTagName("object")[0] as HTMLObjectElement; + let typeIconHidden = item.getElementsByTagName("object")[1] as HTMLObjectElement; + if (typeIconVisible.classList.contains("hidden")) { + let temp = typeIconVisible; + typeIconVisible = typeIconHidden; + typeIconHidden = temp; + } + let color: string; + if (typeConfig.color.startsWith("#")) { + const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (isDark && typeConfig.darkColor !== undefined) { + color = typeConfig.darkColor; + } else { + color = typeConfig.color; + } + } else { + color = state.options[typeConfig.color]; + } + color = ensureThemeContrast(color); + let dataPath = "symbols/sourceList/" + typeConfig.symbol + ".svg"; + if (dataPath !== typeIconVisible.getAttribute("data")) { + // Load new image on hidden icon + typeIconHidden.data = dataPath; + typeIconHidden.addEventListener("load", () => { + if (typeIconHidden.contentDocument) { + typeIconHidden.contentDocument.getElementsByTagName("svg")[0].style.color = color; + typeIconHidden.setAttribute("type-color", color); + + typeIconHidden.classList.remove("hidden"); + typeIconVisible.classList.add("hidden"); + } + }); + } else if (typeIconVisible.contentDocument !== null) { + // Replace color on visible icon + let svgs = typeIconVisible.contentDocument.getElementsByTagName("svg"); + if (svgs.length > 0) { + svgs[0].style.color = color; + } + typeIconVisible.setAttribute("type-color", color); + } + + // Update type name + let typeNameComponents: string[] = []; + if (typeConfig.showInTypeName) { + typeNameComponents.push(typeConfig.display); + } + typeConfig.options.forEach((optionConfig) => { + if (optionConfig.showInTypeName) { + let valueKey = state.options[optionConfig.key]; + let valueConfig = optionConfig.values.find((value) => value.key === valueKey); + if (valueConfig === undefined) return; + typeNameComponents.push(valueConfig.display); + } + }); + let typeNameElement = item.getElementsByClassName("type-name")[0] as HTMLElement; + typeNameElement.innerText = typeNameComponents.join("/") + (typeNameComponents.length > 0 ? ":" : ""); + + // Update log key + let keyContainer = item.getElementsByClassName("key-container")[0] as HTMLElement; + let keySpan = keyContainer.firstElementChild as HTMLElement; + keySpan.innerText = state.logKey; + keySpan.style.textDecoration = this.isFieldAvailable(state) ? "" : "line-through"; + + // Update type width, cloning to a new node in case the controls aren't visible + let mockTypeName = typeNameElement.cloneNode(true) as HTMLElement; + let mockSourceContainer = document.createElement("div"); + mockSourceContainer.classList.add("source-list"); + mockSourceContainer.appendChild(mockTypeName); + document.body.appendChild(mockSourceContainer); + let typeNameWidth = mockTypeName.clientWidth; + if (typeNameWidth > 0) typeNameWidth += 3; //Add extra margin after colon + document.body.removeChild(mockSourceContainer); + keyContainer.style.setProperty("--type-width", typeNameWidth.toString() + "px"); + + // Update hide button + let hideButton = item.getElementsByClassName("hide")[0] as HTMLButtonElement; + let hideIcon = hideButton.firstElementChild as HTMLImageElement; + hideIcon.src = "symbols/" + (state.visible ? "eye.slash.svg" : "eye.svg"); + if (state.visible) { + item.classList.remove("hidden"); + } else { + item.classList.add("hidden"); + } + + // Save to memory + if (this.config.typeMemoryId !== undefined) { + if (!(this.config.typeMemoryId in window.typeMemory)) { + window.typeMemory[this.config.typeMemoryId] = {}; + } + window.typeMemory[this.config.typeMemoryId][state.logKey] = { type: state.type, options: state.options }; + } + } + + private isChild(index: number) { + if (index < 0 || index >= this.state.length) return false; + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === this.state[index].type); + return typeConfig !== undefined && typeConfig.childOf !== undefined; + } + + private isFieldAvailable(item: SourceListItemState): boolean { + let fieldType = window.log.getType(item.logKey); + let fieldStructuredType = window.log.getStructuredType(item.logKey); + return (fieldType !== null && LoggableType[fieldType] === item.logType) || fieldStructuredType === item.logType; + } + + /** + * Updates the preview value of an item. + * + * @param item The HTML element to update + * @param state The associated item state + */ + private updatePreview(item: HTMLElement, state: SourceListItemState) { + let time = window.selection.getRenderTime(); + let valueSymbol = item.getElementsByClassName("value-symbol")[0] as HTMLElement; + let valueText = item.getElementsByClassName("value")[0] as HTMLElement; + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === state.type); + + // Get text + let text: string | null = null; + if (this.isFieldAvailable(state) && time !== null) { + let logType = window.log.getType(state.logKey); + let structuredType = window.log.getStructuredType(state.logKey); + if (logType !== null) { + let value: any; + if (logType === LoggableType.Number && this.getNumberPreview !== undefined) { + value = this.getNumberPreview(state.logKey, time); + } else { + value = getOrDefault(window.log, state.logKey, logType, time, null); + } + if (value !== null) { + if (typeConfig?.previewType !== undefined) { + if (typeConfig?.previewType !== null) { + let numberArrayFormat: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d" = "Pose3d"; + let numberArrayUnits: "radians" | "degrees" = "radians"; + if ("format" in state.options) { + let formatRaw = state.options.format; + numberArrayFormat = + formatRaw === "Pose2d" || + formatRaw === "Pose3d" || + formatRaw === "Translation2d" || + formatRaw === "Translation3d" + ? formatRaw + : "Pose3d"; + } + if ("units" in state.options) { + numberArrayUnits = state.options.units === "degrees" ? "degrees" : "radians"; + } + let poseStrings: string[] = []; + if (typeConfig?.previewType === "SwerveModuleState[]") { + let swerveStates = grabSwerveStates( + window.log, + state.logKey, + state.logType, + time, + undefined, + numberArrayUnits, + this.UUID + ); + swerveStates.forEach((state) => { + poseStrings.push( + "\u03bd: " + + state.speed.toFixed(2) + + "m/s, \u03b8: " + + convert(state.angle, "radians", "degrees").toFixed(2) + + "\u00b0" + ); + }); + } else if (typeConfig?.previewType === "ChassisSpeeds") { + let chassisSpeeds = grabChassisSpeeds(window.log, state.logKey, time, this.UUID); + poseStrings.push( + "\u03bdx: " + + chassisSpeeds.vx.toFixed(2) + + "m/s, \u03bdy: " + + chassisSpeeds.vy.toFixed(2) + + "m/s, \u03a9: " + + convert(chassisSpeeds.omega, "radians", "degrees").toFixed(2) + + "\u00b0/s" + ); + } else { + let poses = grabPosesAuto( + window.log, + state.logKey, + state.logType, + time, + this.UUID, + numberArrayFormat, + numberArrayUnits, + "red", // Display in native coordinate system + 0, + 0 + ); + poseStrings = poses.map((annotatedPose) => { + switch (typeConfig?.previewType) { + case "Rotation2d": { + return ( + convert(rotation3dTo2d(annotatedPose.pose.rotation), "radians", "degrees").toFixed(2) + "\u00b0" + ); + } + case "Translation2d": { + return ( + "X: " + + annotatedPose.pose.translation[0].toFixed(2) + + "m, Y: " + + annotatedPose.pose.translation[1].toFixed(2) + + "m" + ); + } + case "Pose2d": + case "Transform2d": { + return ( + "X: " + + annotatedPose.pose.translation[0].toFixed(2) + + "m, Y: " + + annotatedPose.pose.translation[1].toFixed(2) + + "m, \u03b8: " + + convert(rotation3dTo2d(annotatedPose.pose.rotation), "radians", "degrees").toFixed(2) + + "\u00b0" + ); + } + case "Rotation3d": { + let rpy = rotation3dToRPY(annotatedPose.pose.rotation); + return ( + "Roll: " + + convert(rpy[0], "radians", "degrees").toFixed(2) + + "\u00b0, Pitch: " + + convert(rpy[1], "radians", "degrees").toFixed(2) + + "\u00b0, Yaw: " + + convert(rpy[2], "radians", "degrees").toFixed(2) + + "\u00b0" + ); + } + case "Translation3d": { + return ( + "X: " + + annotatedPose.pose.translation[0].toFixed(2) + + "m, Y: " + + annotatedPose.pose.translation[1].toFixed(2) + + "m, Z: " + + annotatedPose.pose.translation[2].toFixed(2) + + "m" + ); + } + case "Pose3d": { + let rpy = rotation3dToRPY(annotatedPose.pose.rotation); + return ( + "X: " + + annotatedPose.pose.translation[0].toFixed(2) + + "m, Y: " + + annotatedPose.pose.translation[1].toFixed(2) + + "m, Z: " + + annotatedPose.pose.translation[2].toFixed(2) + + "m, Roll: " + + convert(rpy[0], "radians", "degrees").toFixed(2) + + "\u00b0, Pitch: " + + convert(rpy[1], "radians", "degrees").toFixed(2) + + "\u00b0, Yaw: " + + convert(rpy[2], "radians", "degrees").toFixed(2) + + "\u00b0" + ); + } + default: { + return ""; + } + } + }); + } + if (poseStrings.length === 1) { + text = poseStrings[0]; + } else if (poseStrings.length === 0) { + text = "No values"; + } else { + text = text = + poseStrings.length.toString() + + " value" + + (poseStrings.length === 1 ? "" : "s") + + " \u2014 [" + + poseStrings.map((str) => "(" + str + ")").join(", ") + + "]"; + } + } + } else if (structuredType === "Mechanism2d") { + let mechanismState = getMechanismState(window.log, state.logKey, time); + if (mechanismState !== null) { + let count = mechanismState.lines.length; + text = count.toString() + " segment" + (count === 1 ? "" : "s"); + } + } else if ( + logType === LoggableType.BooleanArray || + logType === LoggableType.NumberArray || + logType === LoggableType.StringArray + ) { + text = + value.length.toString() + + " value" + + (value.length === 1 ? "" : "s") + + " \u2014 " + + getLogValueText(value, logType); + } else { + text = getLogValueText(value, logType); + } + } + } + } + + // Update state + valueSymbol.hidden = text === null; + valueText.hidden = text === null; + item.style.height = valueSymbol.hidden ? "30px" : "50px"; + if (text !== null && text !== valueText.innerText) { + valueText.innerText = text; + } + } +} diff --git a/src/hub/TabController.ts b/src/hub/TabController.ts deleted file mode 100644 index 9e8a1b8d..00000000 --- a/src/hub/TabController.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TabState } from "../shared/HubState"; - -/** A controller for a single tab. */ -export default interface TabController { - /** Returns the current state. */ - saveState(): TabState; - - /** Restores to the provided state. */ - restoreState(state: TabState): void; - - /** Refresh based on new log data. */ - refresh(): void; - - /** Notify that the set of assets was updated. */ - newAssets(): void; - - /** - * Returns the list of fields currently being displayed. This is - * used to selectively request fields from live sources, and all - * keys matching the provided prefixes will be made available. - **/ - getActiveFields(): string[]; - - /** Called every animation frame if the tab is visible. */ - periodic(): void; -} diff --git a/src/hub/Tabs.ts b/src/hub/Tabs.ts index 10c9d08b..e516da76 100644 --- a/src/hub/Tabs.ts +++ b/src/hub/Tabs.ts @@ -1,50 +1,120 @@ -import { TabGroupState } from "../shared/HubState"; -import TabType, { getDefaultTabTitle, getTabIcon, TIMELINE_VIZ_TYPES } from "../shared/TabType"; +import { TabsState } from "../shared/HubState"; +import LineGraphFilter from "../shared/LineGraphFilter"; +import TabType, { getDefaultTabTitle, getTabIcon } from "../shared/TabType"; +import ConsoleRenderer from "../shared/renderers/ConsoleRenderer"; +import DocumentationRenderer from "../shared/renderers/DocumentationRenderer"; +import JoysticksRenderer from "../shared/renderers/JoysticksRenderer"; +import LineGraphRenderer from "../shared/renderers/LineGraphRenderer"; +import MechanismRenderer from "../shared/renderers/MechanismRenderer"; +import MetadataRenderer from "../shared/renderers/MetadataRenderer"; +import OdometryRenderer from "../shared/renderers/OdometryRenderer"; +import PointsRenderer from "../shared/renderers/PointsRenderer"; +import StatisticsRenderer from "../shared/renderers/StatisticsRenderer"; +import SwerveRenderer from "../shared/renderers/SwerveRenderer"; +import TabRenderer, { NoopRenderer } from "../shared/renderers/TabRenderer"; +import TableRenderer from "../shared/renderers/TableRenderer"; +import ThreeDimensionRenderer from "../shared/renderers/ThreeDimensionRenderer"; +import VideoRenderer from "../shared/renderers/VideoRenderer"; import { UnitConversionPreset } from "../shared/units"; import ScrollSensor from "./ScrollSensor"; -import TabController from "./TabController"; -import ConsoleController from "./tabControllers/ConsoleController"; -import DocumentationController from "./tabControllers/DocumentationController"; -import JoysticksController from "./tabControllers/JoysticksController"; -import LineGraphController from "./tabControllers/LineGraphController"; -import MechanismController from "./tabControllers/MechanismController"; -import MetadataController from "./tabControllers/MetadataController"; -import OdometryController from "./tabControllers/OdometryController"; -import PointsController from "./tabControllers/PointsController"; -import StatisticsController from "./tabControllers/StatisticsController"; -import SwerveController from "./tabControllers/SwerveController"; -import TableController from "./tabControllers/TableController"; -import ThreeDimensionController from "./tabControllers/ThreeDimensionController"; -import TimelineVizController from "./tabControllers/TimelineVizController"; -import VideoController from "./tabControllers/VideoController"; +import Timeline from "./Timeline"; +import ConsoleController from "./controllers/ConsoleController"; +import JoysticksController from "./controllers/JoysticksController"; +import LineGraphController from "./controllers/LineGraphController"; +import MechanismController from "./controllers/MechanismController"; +import MetadataController from "./controllers/MetadataController"; +import OdometryController from "./controllers/OdometryController"; +import PointsController from "./controllers/PointsController"; +import StatisticsController from "./controllers/StatisticsController"; +import SwerveController from "./controllers/SwerveController"; +import TabController, { NoopController } from "./controllers/TabController"; +import TableController from "./controllers/TableController"; +import ThreeDimensionController from "./controllers/ThreeDimensionController"; +import VideoController from "./controllers/VideoController"; export default class Tabs { - private VIEWER = document.getElementsByClassName("viewer")[0] as HTMLElement; + private TAB_DRAG_THRESHOLD_PX = 5; + private DEFAULT_CONTROLS_HEIGHT = 200; + + private TIMELINE_CONTAINER = document.getElementsByClassName("timeline")[0] as HTMLElement; private TAB_BAR = document.getElementsByClassName("tab-bar")[0]; private SHADOW_LEFT = document.getElementsByClassName("tab-bar-shadow-left")[0] as HTMLElement; private SHADOW_RIGHT = document.getElementsByClassName("tab-bar-shadow-right")[0] as HTMLElement; private SCROLL_OVERLAY = document.getElementsByClassName("tab-bar-scroll")[0] as HTMLElement; - private CONTENT_TEMPLATES = document.getElementById("tabContentTemplates") as HTMLElement; + private DRAG_ITEM = document.getElementById("dragItem") as HTMLElement; + private DRAG_HIGHLIGHT = document.getElementsByClassName("tab-bar-drag-highlight")[0] as HTMLElement; + + private RENDERER_CONTENT = document.getElementsByClassName("renderer-content")[0] as HTMLElement; + private CONTROLS_CONTENT = document.getElementsByClassName("controls-content")[0] as HTMLElement; + private CONTROLS_HANDLE = document.getElementsByClassName("controls-handle")[0] as HTMLElement; - private LEFT_BUTTON = document.getElementsByClassName("move-left")[0] as HTMLElement; - private RIGHT_BUTTON = document.getElementsByClassName("move-right")[0] as HTMLElement; private CLOSE_BUTTON = document.getElementsByClassName("close")[0] as HTMLElement; + private POPUP_BUTTON = document.getElementsByClassName("popup")[0] as HTMLElement; private ADD_BUTTON = document.getElementsByClassName("add-tab")[0] as HTMLElement; + private FIXED_CONTROL_HEIGHTS: Map = new Map(); + + private tabsScrollSensor: ScrollSensor; + private timeline: Timeline; + private tabList: { type: TabType; title: string; - controller: TabController; titleElement: HTMLElement; - contentElement: HTMLElement; + controlsElement: HTMLElement; + rendererElement: HTMLElement; + controller: TabController; + renderer: TabRenderer; + controlsHeight: number; }[] = []; private selectedTab = 0; - private scrollSensor: ScrollSensor; + private activeSatellites: string[] = []; + private controlsHandleActive = false; constructor() { + // Set up tab configs + this.FIXED_CONTROL_HEIGHTS.set(TabType.Documentation, 0); + this.FIXED_CONTROL_HEIGHTS.set(TabType.LineGraph, undefined); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Table, 0); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Console, 0); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Statistics, undefined); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Odometry, undefined); + this.FIXED_CONTROL_HEIGHTS.set(TabType.ThreeDimension, undefined); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Video, 85); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Joysticks, 85); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Swerve, undefined); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Mechanism, undefined); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Points, undefined); + this.FIXED_CONTROL_HEIGHTS.set(TabType.Metadata, 0); + // Hover and click handling - this.SCROLL_OVERLAY.addEventListener("click", (event) => { - this.tabList.forEach((tab, index) => { + let mouseDownInfo: [number, number] | null = null; + this.SCROLL_OVERLAY.addEventListener("mousedown", (event) => { + mouseDownInfo = [event.clientX, event.clientY]; + }); + this.SCROLL_OVERLAY.addEventListener("mouseup", (event) => { + if (mouseDownInfo === null) return; + if ( + Math.abs(event.clientX - mouseDownInfo[0]) < this.TAB_DRAG_THRESHOLD_PX && + Math.abs(event.clientY - mouseDownInfo[1]) < this.TAB_DRAG_THRESHOLD_PX + ) { + this.tabList.forEach((tab, index) => { + let rect = tab.titleElement.getBoundingClientRect(); + if ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ) { + this.setSelected(index); + } + }); + } + mouseDownInfo = null; + }); + this.SCROLL_OVERLAY.addEventListener("mousemove", (event) => { + // Update hover + this.tabList.forEach((tab) => { let rect = tab.titleElement.getBoundingClientRect(); if ( event.clientX >= rect.left && @@ -52,11 +122,58 @@ export default class Tabs { event.clientY >= rect.top && event.clientY <= rect.bottom ) { - this.setSelected(index); + tab.titleElement.classList.add("tab-hovered"); + } else { + tab.titleElement.classList.remove("tab-hovered"); } }); + + // Start drag + if ( + mouseDownInfo !== null && + (Math.abs(event.clientX - mouseDownInfo[0]) >= this.TAB_DRAG_THRESHOLD_PX || + Math.abs(event.clientY - mouseDownInfo[1]) >= this.TAB_DRAG_THRESHOLD_PX) + ) { + // Find tab + let tabIndex = 0; + this.tabList.forEach((tab, index) => { + let rect = tab.titleElement.getBoundingClientRect(); + if ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ) { + tabIndex = index; + } + }); + if (tabIndex === 0) return; + + // Trigger drag event + while (this.DRAG_ITEM.firstChild) { + this.DRAG_ITEM.removeChild(this.DRAG_ITEM.firstChild); + } + let tab = document.createElement("div"); + tab.classList.add("tab"); + if (tabIndex === this.selectedTab) { + tab.classList.add("tab-selected"); + } + tab.innerText = this.tabList[tabIndex].titleElement.innerText; + this.DRAG_ITEM.appendChild(tab); + let tabRect = this.tabList[tabIndex].titleElement.getBoundingClientRect(); + window.startDrag(event.clientX, event.clientY, event.clientX - tabRect.left, event.clientY - tabRect.top, { + tabIndex: tabIndex + }); + mouseDownInfo = null; + } + }); + this.SCROLL_OVERLAY.addEventListener("mouseout", () => { + this.tabList.forEach((tab) => { + tab.titleElement.classList.remove("tab-hovered"); + }); }); this.SCROLL_OVERLAY.addEventListener("contextmenu", (event) => { + mouseDownInfo = null; this.tabList.forEach((tab, index) => { if (index === 0) return; let rect = tab.titleElement.getBoundingClientRect(); @@ -73,84 +190,222 @@ export default class Tabs { } }); }); - this.SCROLL_OVERLAY.addEventListener("mousemove", (event) => { - this.tabList.forEach((tab) => { - let rect = tab.titleElement.getBoundingClientRect(); - if ( - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom - ) { - tab.titleElement.classList.add("tab-hovered"); - } else { - tab.titleElement.classList.remove("tab-hovered"); + + // Controls handle + this.CONTROLS_HANDLE.addEventListener("mousedown", () => { + this.controlsHandleActive = true; + document.body.style.cursor = "row-resize"; + }); + window.addEventListener("mouseup", () => { + this.controlsHandleActive = false; + document.body.style.cursor = "initial"; + }); + window.addEventListener("mousemove", (event) => { + let fixedHeight = this.FIXED_CONTROL_HEIGHTS.get(this.tabList[this.selectedTab].type); + if (this.controlsHandleActive) { + let height = window.innerHeight - event.clientY; + if (height >= 30 && height < 100) height = 100; + if (height < 30) { + if (this.tabList[this.selectedTab].controlsHeight > 0) { + height = 0; + } else { + height = this.tabList[this.selectedTab].controlsHeight; + } } - }); + if (fixedHeight !== undefined && height !== 0) { + height = fixedHeight; + } + this.tabList[this.selectedTab].controlsHeight = height; + this.updateControlsHeight(); + } }); - this.SCROLL_OVERLAY.addEventListener("mouseout", () => { - this.tabList.forEach((tab) => { - tab.titleElement.classList.remove("tab-hovered"); - }); + let lastClick = 0; + this.CONTROLS_HANDLE.addEventListener("click", () => { + let now = new Date().getTime(); + if (now - lastClick < 400) { + this.toggleControlsVisible(); + lastClick = 0; + } else { + lastClick = now; + } }); + this.updateControlsHeight(); // Control buttons - this.LEFT_BUTTON.addEventListener("click", () => this.shift(this.selectedTab, -1)); - this.RIGHT_BUTTON.addEventListener("click", () => this.shift(this.selectedTab, 1)); this.CLOSE_BUTTON.addEventListener("click", () => this.close(this.selectedTab)); + this.POPUP_BUTTON.addEventListener("click", () => { + if (this.selectedTab >= 0) { + window.sendMainMessage("create-satellite", { + uuid: this.tabList[this.selectedTab].controller.UUID, + type: this.tabList[this.selectedTab].type + }); + } + }); this.ADD_BUTTON.addEventListener("click", () => { window.sendMainMessage("ask-new-tab"); }); + // Drag handling + window.addEventListener("drag-update", (event) => { + let dragData = (event as CustomEvent).detail; + if (!("tabIndex" in dragData.data)) return; + let end = dragData.end; + let x = dragData.x; + let y = dragData.y; + let tabIndex = dragData.data.tabIndex; + + let tabBarRect = this.SCROLL_OVERLAY.getBoundingClientRect(); + if (y > tabBarRect.bottom + 100) { + this.DRAG_HIGHLIGHT.hidden = true; + return; + } + + let closestDist = Infinity; + let closestIndex = 0; + this.tabList.forEach((tab, index) => { + let dist = Math.abs(x - tab.titleElement.getBoundingClientRect().right); + if (dist < closestDist) { + closestDist = dist; + closestIndex = index; + } + }); + + if (end) { + this.DRAG_HIGHLIGHT.hidden = true; + if (closestIndex >= tabIndex) { + this.shift(tabIndex, closestIndex - tabIndex); + } else { + this.shift(tabIndex, closestIndex - tabIndex + 1); + } + } else { + this.DRAG_HIGHLIGHT.hidden = false; + let highlightX = + this.tabList[closestIndex].titleElement.getBoundingClientRect().right - + this.SCROLL_OVERLAY.getBoundingClientRect().left + + 10; + this.DRAG_HIGHLIGHT.style.left = highlightX.toString() + "px"; + } + }); + // Add default tabs this.addTab(TabType.Documentation); this.addTab(TabType.LineGraph); + this.addTab(TabType.Odometry); + this.addTab(TabType.ThreeDimension); + this.setSelected(1); // Scroll management - this.scrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { - this.TAB_BAR.scrollLeft += dx + dy; - }); + this.tabsScrollSensor = new ScrollSensor( + this.SCROLL_OVERLAY, + (dx: number, dy: number) => { + this.TAB_BAR.scrollLeft += dx + dy; + }, + false + ); + + // Add timeline + this.timeline = new Timeline(this.TIMELINE_CONTAINER); // Periodic function let periodic = () => { + // Update tab bar this.SHADOW_LEFT.style.opacity = Math.floor(this.TAB_BAR.scrollLeft) <= 0 ? "0" : "1"; this.SHADOW_RIGHT.style.opacity = Math.ceil(this.TAB_BAR.scrollLeft) >= this.TAB_BAR.scrollWidth - this.TAB_BAR.clientWidth ? "0" : "1"; - this.tabList[this.selectedTab].controller.periodic(); - this.scrollSensor.periodic(); + this.tabsScrollSensor.periodic(); + + // Update timeline and controls + this.timeline.periodic(); + this.updateControlsHeight(); + this.updateTimelineVisibility(); + + // Render new frame + this.tabList[this.selectedTab].renderer.render(this.tabList[this.selectedTab].controller.getCommand()); + this.tabList.forEach((tab, index) => { + let activeLocal = index === this.selectedTab; + let activeSatellite = this.activeSatellites.includes(tab.controller.UUID); + if (activeLocal || activeSatellite) { + if (tab.type === TabType.Table) { + // Update range from renderer + let renderer = tab.renderer as TableRenderer; + let controller = tab.controller as TableController; + controller.addRendererRange(renderer.UUID, activeLocal ? renderer.getTimestampRange() : null); + } + let command = tab.controller.getCommand(); + if (activeLocal) { + tab.renderer.render(command); + } + if (activeSatellite) { + let title = tab.type === TabType.Documentation ? "Documentation" : tab.title; + window.sendMainMessage("update-satellite", { + uuid: tab.controller.UUID, + command: command, + title: title + }); + } + } + }); + window.requestAnimationFrame(periodic); }; window.requestAnimationFrame(periodic); } + private updateControlsHeight() { + let availableHeight = window.innerHeight - this.RENDERER_CONTENT.getBoundingClientRect().top; + availableHeight -= 150; + if (this.selectedTab < 0 || this.selectedTab >= this.tabList.length) return; + let selectedTab = this.tabList[this.selectedTab]; + let tabConfig = this.FIXED_CONTROL_HEIGHTS.get(selectedTab.type); + selectedTab.controlsHeight = Math.min(selectedTab.controlsHeight, availableHeight); + + let appliedHeight = Math.max(selectedTab.controlsHeight, 0); + this.CONTROLS_HANDLE.hidden = tabConfig === 0; + this.CONTROLS_CONTENT.hidden = appliedHeight === 0; + document.documentElement.style.setProperty("--tab-controls-height", appliedHeight.toString() + "px"); + document.documentElement.style.setProperty("--show-tab-controls", appliedHeight === 0 ? "0" : "1"); + } + /** Returns the current state. */ - saveState(): TabGroupState { + saveState(): TabsState { return { selected: this.selectedTab, tabs: this.tabList.map((tab) => { - let state = tab.controller.saveState(); - if (tab.type !== TabType.Documentation) { - state.title = tab.title; - } - return state; + return { + type: tab.type, + title: tab.title, + controller: tab.controller.saveState(), + controllerUUID: tab.controller.UUID, + renderer: tab.renderer.saveState(), + controlsHeight: tab.controlsHeight + }; }) }; } /** Restores to the provided state. */ - restoreState(state: TabGroupState) { + restoreState(state: TabsState) { this.tabList.forEach((tab) => { - this.VIEWER.removeChild(tab.contentElement); + this.RENDERER_CONTENT.removeChild(tab.rendererElement); + this.CONTROLS_CONTENT.removeChild(tab.controlsElement); }); this.tabList = []; this.selectedTab = 0; state.tabs.forEach((tabState, index) => { this.addTab(tabState.type); if (tabState.title) this.renameTab(index, tabState.title); - this.tabList[index].controller.restoreState(tabState); + this.tabList[index].controller.UUID = tabState.controllerUUID; + this.tabList[index].controller.restoreState(tabState.controller); + this.tabList[index].renderer.restoreState(tabState.renderer); + + let tabConfig = this.FIXED_CONTROL_HEIGHTS.get(tabState.type); + if (tabState.controlsHeight === 0 || tabConfig === undefined) { + this.tabList[index].controlsHeight = tabState.controlsHeight; + } }); this.selectedTab = state.selected >= this.tabList.length ? this.tabList.length - 1 : state.selected; this.updateElements(); + this.updateControlsHeight(); } /** Refresh based on new log data. */ @@ -178,6 +433,18 @@ export default class Tabs { return activeFields; } + /** Toggles the visibility of the tab controls. */ + toggleControlsVisible() { + let fixedHeight = this.FIXED_CONTROL_HEIGHTS.get(this.tabList[this.selectedTab].type); + if (this.tabList[this.selectedTab].controlsHeight === 0) { + this.tabList[this.selectedTab].controlsHeight = + fixedHeight === undefined ? this.DEFAULT_CONTROLS_HEIGHT : fixedHeight; + } else { + this.tabList[this.selectedTab].controlsHeight *= -1; + } + this.updateControlsHeight(); + } + /** Creates a new tab. */ addTab(type: TabType) { // Select existing metadata tab @@ -190,67 +457,68 @@ export default class Tabs { } // Add tab - let contentElement: HTMLElement; + let controlsElement = document.getElementById("controller" + type.toString())?.cloneNode(true) as HTMLElement; + let rendererElement = document.getElementById("renderer" + type.toString())?.cloneNode(true) as HTMLElement; + controlsElement.removeAttribute("id"); + rendererElement.removeAttribute("id"); let controller: TabController; + let renderer: TabRenderer; switch (type) { case TabType.Documentation: - contentElement = this.CONTENT_TEMPLATES.children[0].cloneNode(true) as HTMLElement; - controller = new DocumentationController(contentElement); + controller = new NoopController(); + renderer = new DocumentationRenderer(rendererElement); break; case TabType.LineGraph: - contentElement = this.CONTENT_TEMPLATES.children[1].cloneNode(true) as HTMLElement; - controller = new LineGraphController(contentElement); + controller = new LineGraphController(controlsElement); + renderer = new LineGraphRenderer(rendererElement, true); + break; + case TabType.Odometry: + controller = new OdometryController(controlsElement); + renderer = new OdometryRenderer(rendererElement); + break; + case TabType.ThreeDimension: + controller = new ThreeDimensionController(controlsElement); + renderer = new ThreeDimensionRenderer(rendererElement); break; case TabType.Table: - contentElement = this.CONTENT_TEMPLATES.children[2].cloneNode(true) as HTMLElement; - controller = new TableController(contentElement); + controller = new TableController(rendererElement); + renderer = new TableRenderer(rendererElement, true); break; case TabType.Console: - contentElement = this.CONTENT_TEMPLATES.children[3].cloneNode(true) as HTMLElement; - controller = new ConsoleController(contentElement); + controller = new ConsoleController(rendererElement); + renderer = new ConsoleRenderer(rendererElement, true); break; case TabType.Statistics: - contentElement = this.CONTENT_TEMPLATES.children[4].cloneNode(true) as HTMLElement; - controller = new StatisticsController(contentElement); - break; - case TabType.Odometry: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[6].cloneNode(true)); - controller = new OdometryController(contentElement); - break; - case TabType.ThreeDimension: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[7].cloneNode(true)); - controller = new ThreeDimensionController(contentElement); + controller = new StatisticsController(controlsElement); + renderer = new StatisticsRenderer(rendererElement); break; case TabType.Video: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[8].cloneNode(true)); - controller = new VideoController(contentElement); + controller = new VideoController(controlsElement); + renderer = new VideoRenderer(rendererElement); break; case TabType.Joysticks: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[9].cloneNode(true)); - controller = new JoysticksController(contentElement); + controller = new JoysticksController(controlsElement); + renderer = new JoysticksRenderer(rendererElement); break; case TabType.Swerve: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[10].cloneNode(true)); - controller = new SwerveController(contentElement); + controller = new SwerveController(controlsElement); + renderer = new SwerveRenderer(rendererElement); break; case TabType.Mechanism: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[11].cloneNode(true)); - controller = new MechanismController(contentElement); + controller = new MechanismController(controlsElement); + renderer = new MechanismRenderer(rendererElement); break; case TabType.Points: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[12].cloneNode(true)); - controller = new PointsController(contentElement); + controller = new PointsController(controlsElement); + renderer = new PointsRenderer(rendererElement); break; case TabType.Metadata: - contentElement = this.CONTENT_TEMPLATES.children[13].cloneNode(true) as HTMLElement; - controller = new MetadataController(contentElement); + controller = new MetadataController(); + renderer = new MetadataRenderer(rendererElement); + break; + default: + controller = new NoopController(); + renderer = new NoopRenderer(); break; } @@ -263,29 +531,28 @@ export default class Tabs { if (this.tabList.length === 0) { this.selectedTab = -1; } + let controlsHeightConfig = this.FIXED_CONTROL_HEIGHTS.get(type); this.tabList.splice(this.selectedTab + 1, 0, { type: type, title: getDefaultTabTitle(type), - controller: controller, titleElement: titleElement, - contentElement: contentElement + controlsElement: controlsElement, + rendererElement: rendererElement, + controller: controller, + renderer: renderer, + controlsHeight: controlsHeightConfig === undefined ? this.DEFAULT_CONTROLS_HEIGHT : controlsHeightConfig }); this.selectedTab += 1; - this.VIEWER.appendChild(contentElement); - controller.periodic(); // Some controllers need to initialize by running a periodic cycle while visible - if (TIMELINE_VIZ_TYPES.includes(type)) { - (controller as TimelineVizController).setTitle(getDefaultTabTitle(type)); - } + this.CONTROLS_CONTENT.appendChild(controlsElement); + this.RENDERER_CONTENT.appendChild(rendererElement); this.updateElements(); } /** Closes the specified tab. */ close(index: number) { if (index < 1 || index > this.tabList.length - 1) return; - if (TIMELINE_VIZ_TYPES.includes(this.tabList[index].type)) { - (this.tabList[index].controller as TimelineVizController).stopPeriodic(); - } - this.VIEWER.removeChild(this.tabList[index].contentElement); + this.RENDERER_CONTENT.removeChild(this.tabList[index].rendererElement); + this.CONTROLS_CONTENT.removeChild(this.tabList[index].controlsElement); this.tabList.splice(index, 1); if (this.selectedTab > index) this.selectedTab--; if (this.selectedTab > this.tabList.length - 1) this.selectedTab = this.tabList.length - 1; @@ -309,34 +576,55 @@ export default class Tabs { if (index === 0) return; if (index + shift < 1) shift = 1 - index; if (index + shift > this.tabList.length - 1) shift = this.tabList.length - 1 - index; - if (this.selectedTab === index) this.selectedTab += shift; + if (this.selectedTab === index) { + this.selectedTab += shift; + } else if (index > this.selectedTab && shift <= this.selectedTab - index) { + this.selectedTab++; + } else if (index < this.selectedTab && shift >= this.selectedTab - index) { + this.selectedTab--; + } let tab = this.tabList.splice(index, 1)[0]; this.tabList.splice(index + shift, 0, tab); this.updateElements(); } + /** Updates the list of active satellites for data publishing. */ + setActiveSatellites(activeSatellites: string[]) { + this.activeSatellites = activeSatellites; + } + + /** Check whether the UUID is associated with a tab. */ + isValidUUID(uuid: string) { + for (let i = 0; i < this.tabList.length; i++) { + if (uuid === this.tabList[i].controller.UUID) { + return true; + } + } + return false; + } + /** Renames a single tab. */ renameTab(index: number, name: string) { let tab = this.tabList[index]; tab.title = name; tab.titleElement.innerText = getTabIcon(tab.type) + " " + name; - if (TIMELINE_VIZ_TYPES.includes(tab.type)) { - (tab.controller as TimelineVizController).setTitle(name); - } - } - - /** Adds the enabled field to the discrete legend on the selected line graph. */ - addDiscreteEnabled() { - if (this.tabList[this.selectedTab].type === TabType.LineGraph) { - (this.tabList[this.selectedTab].controller as LineGraphController).addDiscreteEnabled(); - } } /** Adjusts the locked range and unit conversion for an axis on the selected line graph. */ - editAxis(legend: string, lockedRange: [number, number] | null, unitConversion: UnitConversionPreset) { + editAxis( + legend: string, + lockedRange: [number, number] | null, + unitConversion: UnitConversionPreset, + filter: LineGraphFilter + ) { if (this.tabList[this.selectedTab].type === TabType.LineGraph) { - (this.tabList[this.selectedTab].controller as LineGraphController).editAxis(legend, lockedRange, unitConversion); + (this.tabList[this.selectedTab].controller as LineGraphController).editAxis( + legend, + lockedRange, + unitConversion, + filter + ); } } @@ -347,26 +635,43 @@ export default class Tabs { } } + /** Adds the enabled field to the discrete legend on the selected line graph. */ + addDiscreteEnabled() { + if (this.tabList[this.selectedTab].type === TabType.LineGraph) { + (this.tabList[this.selectedTab].controller as LineGraphController).addDiscreteEnabled(); + } + } + /** Switches the selected camera for the selected 3D field. */ set3DCamera(index: number) { if (this.tabList[this.selectedTab].type === TabType.ThreeDimension) { - (this.tabList[this.selectedTab].controller as ThreeDimensionController).set3DCamera(index); + (this.tabList[this.selectedTab].renderer as ThreeDimensionRenderer).set3DCamera(index); } } /** Switches the orbit FOV for the selected 3D field. */ setFov(fov: number) { if (this.tabList[this.selectedTab].type === TabType.ThreeDimension) { - (this.tabList[this.selectedTab].controller as ThreeDimensionController).setFov(fov); + (this.tabList[this.selectedTab].renderer as ThreeDimensionRenderer).setFov(fov); } } + /** Switches the selected camera for the selected 3D field. */ + addTableRange(controllerUUID: string, rendererUUID: string, range: [number, number] | null) { + this.tabList.forEach((tab) => { + if (tab.type === TabType.Table && tab.controller.UUID === controllerUUID) { + (this.tabList[this.selectedTab].controller as TableController).addRendererRange(rendererUUID, range); + } + }); + } + /** Returns whether the selected tab is a video which * is unlocked (and thus requires access to the left * and right arrow keys) */ isUnlockedVideoSelected(): boolean { if (this.tabList[this.selectedTab].type === TabType.Video) { return !(this.tabList[this.selectedTab].controller as VideoController).isLocked(); + return false; } else { return false; } @@ -393,11 +698,23 @@ export default class Tabs { this.TAB_BAR.appendChild(item.titleElement); if (index === this.selectedTab) { item.titleElement.classList.add("tab-selected"); - item.contentElement.hidden = false; + item.rendererElement.hidden = false; + item.controlsElement.hidden = false; } else { item.titleElement.classList.remove("tab-selected"); - item.contentElement.hidden = true; + item.rendererElement.hidden = true; + item.controlsElement.hidden = true; } }); + + // Update timeline + this.updateTimelineVisibility(); + } + + /** Hides or shows the timeline based on the controller request. */ + private updateTimelineVisibility() { + let show = this.tabList[this.selectedTab].controller.showTimeline(); + document.documentElement.style.setProperty("--show-timeline", show ? "1" : "0"); + this.TIMELINE_CONTAINER.hidden = !show; } } diff --git a/src/hub/Timeline.ts b/src/hub/Timeline.ts new file mode 100644 index 00000000..3835fd13 --- /dev/null +++ b/src/hub/Timeline.ts @@ -0,0 +1,226 @@ +import { getRobotStateRanges } from "../shared/log/LogUtil"; +import { calcAxisStepSize, clampValue, cleanFloat, scaleValue } from "../shared/util"; +import ScrollSensor from "./ScrollSensor"; + +export default class Timeline { + private STEP_TARGET_PX = 125; + + private CONTAINER: HTMLElement; + private CANVAS: HTMLCanvasElement; + private SCROLL_OVERLAY: HTMLElement; + + private scrollSensor: ScrollSensor; + private mouseDownX = 0; + private grabZoomActive = false; + private grabZoomStartTime = 0; + private lastCursorX: number | null = null; + + constructor(container: HTMLElement) { + this.CONTAINER = container; + this.CANVAS = container.getElementsByClassName("timeline-canvas")[0] as HTMLCanvasElement; + this.SCROLL_OVERLAY = container.getElementsByClassName("timeline-scroll")[0] as HTMLCanvasElement; + + // Hover handling + this.SCROLL_OVERLAY.addEventListener("mousemove", (event) => { + this.lastCursorX = event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x; + }); + this.SCROLL_OVERLAY.addEventListener("mouseleave", () => { + this.lastCursorX = null; + window.selection.setHoveredTime(null); + }); + + // Selection handling + this.SCROLL_OVERLAY.addEventListener("mousedown", (event) => { + this.mouseDownX = event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x; + let hoveredTime = window.selection.getHoveredTime(); + if (event.shiftKey && hoveredTime !== null) { + this.grabZoomActive = true; + this.grabZoomStartTime = hoveredTime; + } + }); + this.SCROLL_OVERLAY.addEventListener("mousemove", () => { + let hoveredTime = window.selection.getHoveredTime(); + if (this.grabZoomActive && hoveredTime !== null) { + window.selection.setGrabZoomRange([this.grabZoomStartTime, hoveredTime]); + } + }); + this.SCROLL_OVERLAY.addEventListener("mouseup", () => { + if (this.grabZoomActive) { + window.selection.finishGrabZoom(); + this.grabZoomActive = false; + } + }); + this.SCROLL_OVERLAY.addEventListener("click", (event) => { + if (Math.abs(event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x - this.mouseDownX) <= 5) { + let hoveredTime = window.selection.getHoveredTime(); + if (hoveredTime) { + window.selection.setSelectedTime(hoveredTime); + } + } + }); + this.SCROLL_OVERLAY.addEventListener("contextmenu", () => { + window.selection.goIdle(); + }); + + // Scroll handling + this.scrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { + if (this.isHidden()) return; + window.selection.applyTimelineScroll(dx, dy, this.SCROLL_OVERLAY.clientWidth); + }); + } + + private isHidden() { + return this.CONTAINER.clientHeight === 0; + } + + periodic() { + if (this.isHidden()) return; + this.scrollSensor.periodic(); + + // Initial setup and scaling + const devicePixelRatio = window.devicePixelRatio; + let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; + let width = this.CONTAINER.clientWidth; + let height = this.CONTAINER.clientHeight; + let light = !window.matchMedia("(prefers-color-scheme: dark)").matches; + let timeRange = window.selection.getTimelineRange(); + this.CANVAS.width = width * devicePixelRatio; + this.CANVAS.height = height * devicePixelRatio; + context.scale(devicePixelRatio, devicePixelRatio); + context.clearRect(0, 0, width, height); + context.font = "200 12px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont"; + + // Calculate step size + let stepSize = calcAxisStepSize(timeRange, width, this.STEP_TARGET_PX); + + // Draw state ranges + context.lineWidth = 1; + let rangeBorders: number[] = []; + getRobotStateRanges(window.log).forEach((range) => { + if (range.mode === "disabled") return; + let startTime = range.start; + let endTime = range.end === undefined ? timeRange[1] : range.end; + let isAuto = range.mode === "auto"; + + if (isAuto) { + context.fillStyle = light ? "#00cc00" : "#00bb00"; + } else { + context.fillStyle = light ? "#00aaff" : "#0000bb"; + } + let startX = clampValue(scaleValue(startTime, timeRange, [0, width]), 0, width); + let endX = clampValue(scaleValue(endTime, timeRange, [0, width]), 0, width); + context.fillRect(startX, 0, endX - startX, height); + + if (!rangeBorders.includes(range.start)) { + rangeBorders.push(range.start); + } + if (range.end !== undefined && !rangeBorders.includes(range.end)) { + rangeBorders.push(range.end); + } + }); + + // Draw grab zoom range + let grabZoomRange = window.selection.getGrabZoomRange(); + if (grabZoomRange !== null) { + let startX = scaleValue(grabZoomRange[0], timeRange, [0, width]); + let endX = scaleValue(grabZoomRange[1], timeRange, [0, width]); + + context.globalAlpha = 0.6; + context.fillStyle = "yellow"; + context.fillRect(startX, 0, endX - startX, height); + context.globalAlpha = 1; + } + + // Update hovered time + if (this.lastCursorX !== null && this.lastCursorX > 0 && this.lastCursorX < width) { + let cursorTime = scaleValue(this.lastCursorX, [0, width], timeRange); + let nearestRangeBorder = rangeBorders.reduce((prev, border) => { + if (Math.abs(cursorTime - border) < Math.abs(cursorTime - prev)) { + return border; + } else { + return prev; + } + }, Infinity); + let nearestRangeBorderX = scaleValue(nearestRangeBorder, timeRange, [0, width]); + window.selection.setHoveredTime( + Math.abs(this.lastCursorX - nearestRangeBorderX) < 5 ? nearestRangeBorder : cursorTime + ); + } + + // Draw a vertical marker line at the time + let markedXs: number[] = []; + let markTime = (time: number, alpha: number) => { + if (time >= timeRange[0] && time <= timeRange[1]) { + context.globalAlpha = alpha; + context.lineWidth = 1; + context.strokeStyle = light ? "#222" : "#eee"; + + let x = scaleValue(time, timeRange, [0, width]); + if (x > 1 && x < width - 1) { + let triangleSideLength = 6; + let triangleHeight = 0.5 * Math.sqrt(3) * triangleSideLength; + + markedXs.push(x); + context.beginPath(); + context.moveTo(x, triangleHeight); + for (let i = x - triangleSideLength / 2; i <= x + triangleSideLength / 2; i++) { + context.lineTo(i, -1); + context.moveTo(x, triangleHeight); + } + context.lineTo(x, height - triangleHeight); + for (let i = x - triangleSideLength / 2; i <= x + triangleSideLength / 2; i++) { + context.lineTo(i, height + 1); + context.moveTo(x, height - triangleHeight); + } + context.stroke(); + } + context.globalAlpha = 1; + } + }; + + // Mark hovered and selected times + let hoveredTime = window.selection.getHoveredTime(); + let selectedTime = window.selection.getSelectedTime(); + if (hoveredTime !== null) markTime(hoveredTime, 0.5); + if (selectedTime !== null) markTime(selectedTime, 1); + + // Draw tick marks + context.lineWidth = 0.5; + context.strokeStyle = light ? "#222" : "#eee"; + context.fillStyle = light ? "#222" : "#eee"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.globalAlpha = 0.5; + let stepPos = Math.ceil(cleanFloat(timeRange[0] / stepSize)) * stepSize; + while (true) { + let x = scaleValue(stepPos, timeRange, [0, width]); + if (x > width + 1) { + break; + } + + let text = cleanFloat(stepPos).toString() + "s"; + let textWidth = context.measureText(text).width; + let textX = clampValue(x, textWidth / 2 + 3, width - textWidth / 2 - 3); + let textXRange = [textX - textWidth / 2, textX + textWidth / 2]; + let markDistance = markedXs.reduce((min, x) => { + let dist = 0; + if (x < textXRange[0]) dist = textXRange[0] - x; + if (x > textXRange[1]) dist = x - textXRange[1]; + return dist < min ? dist : min; + }, Infinity); + context.globalAlpha = clampValue(scaleValue(markDistance, [0, 20], [0.2, 0.5]), 0, 1); + context.fillText(text, textX, height / 2); + + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, 8); + context.moveTo(x, height - 8); + context.lineTo(x, height); + context.stroke(); + context.globalAlpha = 0.5; + + stepPos += stepSize; + } + context.globalAlpha = 1; + } +} diff --git a/src/hub/controllers/ConsoleController.ts b/src/hub/controllers/ConsoleController.ts new file mode 100644 index 00000000..2e65671b --- /dev/null +++ b/src/hub/controllers/ConsoleController.ts @@ -0,0 +1,84 @@ +import LoggableType from "../../shared/log/LoggableType"; +import { ConsoleRendererCommand } from "../../shared/renderers/ConsoleRenderer"; +import { createUUID } from "../../shared/util"; +import TabController from "./TabController"; + +export default class ConsoleController implements TabController { + UUID = createUUID(); + + private ROOT: HTMLElement; + private TABLE_CONTAINER: HTMLElement; + private DRAG_HIGHLIGHT: HTMLElement; + + private field: string | null = null; + + constructor(root: HTMLElement) { + this.ROOT = root; + this.TABLE_CONTAINER = root.getElementsByClassName("console-table-container")[0] as HTMLElement; + this.DRAG_HIGHLIGHT = root.getElementsByClassName("console-table-drag-highlight")[0] as HTMLElement; + + // Drag handling + window.addEventListener("drag-update", (event) => { + if (this.ROOT.hidden) return; + let dragData = (event as CustomEvent).detail; + if (!("fields" in dragData.data)) return; + let rect = this.TABLE_CONTAINER.getBoundingClientRect(); + let active = + dragData.x > rect.left && dragData.x < rect.right && dragData.y > rect.top && dragData.y < rect.bottom; + console.log(active); + let validType = window.log.getType(dragData.data.fields[0]) === LoggableType.String; + this.DRAG_HIGHLIGHT.hidden = true; + if (active && validType) { + if (dragData.end) { + this.field = dragData.data.fields[0]; + } else { + this.DRAG_HIGHLIGHT.hidden = false; + } + } + }); + + // Handle close field event + this.ROOT.addEventListener("close-field", () => { + this.field = null; + }); + } + + saveState(): unknown { + return this.field; + } + + restoreState(state: unknown): void { + if (typeof state === "string" || state === null) { + this.field = state; + } + } + + refresh(): void {} + + newAssets(): void {} + + getActiveFields(): string[] { + if (this.field !== null) { + return [this.field]; + } else { + return []; + } + } + + showTimeline(): boolean { + return false; + } + + getCommand(): ConsoleRendererCommand { + const isAvailable = this.field !== null && window.log.getFieldKeys().includes(this.field); + return { + key: this.field, + keyAvailable: isAvailable, + serialized: isAvailable ? window.log.getField(this.field!)?.toSerialized() : null, + + selectionMode: window.selection.getMode(), + selectedTime: window.selection.getSelectedTime(), + hoveredTime: window.selection.getHoveredTime() + }; + } +} diff --git a/src/hub/controllers/JoysticksController.ts b/src/hub/controllers/JoysticksController.ts new file mode 100644 index 00000000..57f33f5c --- /dev/null +++ b/src/hub/controllers/JoysticksController.ts @@ -0,0 +1,90 @@ +import { BlankJoystickState, JOYSTICK_KEYS, getJoystickState } from "../../shared/log/LogUtil"; +import { JoysticksRendererCommand } from "../../shared/renderers/JoysticksRenderer"; +import { checkArrayType, createUUID } from "../../shared/util"; +import TabController from "./TabController"; + +export default class JoysticksController implements TabController { + UUID = createUUID(); + + private SELECTS: HTMLSelectElement[]; + + constructor(root: HTMLElement) { + this.SELECTS = Array.from(root.getElementsByTagName("select")); + this.resetLayoutOptions(); + } + + /** Clears all options for the layout selectors then updates them with the latest options. */ + private resetLayoutOptions() { + let options = ["None", "Generic Joystick"]; + if (window.assets !== null) { + options = [...options, ...window.assets.joysticks.map((joystick) => joystick.name)]; + } + this.SELECTS.forEach((select) => { + let value = select.value; + while (select.firstChild) { + select.removeChild(select.firstChild); + } + options.forEach((title) => { + let option = document.createElement("option"); + option.innerText = title; + select.appendChild(option); + }); + if (options.includes(value)) { + select.value = value; + } else { + select.value = options[0]; + } + }); + } + + saveState(): unknown { + return this.SELECTS.map((element) => element.value); + } + + restoreState(state: unknown): void { + if (!checkArrayType(state, "string")) return; + (state as string[]).forEach((value, index) => { + if (index < this.SELECTS.length) { + let select = this.SELECTS[index]; + select.value = value; + if (select.value === "") { + select.selectedIndex = 0; + } + } + }); + } + + refresh(): void {} + + newAssets(): void { + this.resetLayoutOptions(); + } + + getActiveFields(): string[] { + let activeFields: string[] = []; + this.SELECTS.forEach((select, index) => { + if (select.value !== "None") { + activeFields = activeFields.concat(JOYSTICK_KEYS.map((key) => key + index.toString())); + } + }); + return activeFields; + } + + showTimeline(): boolean { + return true; + } + + getCommand(): JoysticksRendererCommand { + let command: JoysticksRendererCommand = []; + let time = window.selection.getRenderTime(); + this.SELECTS.forEach((select, index) => { + if (select.value !== "None") { + command.push({ + layout: select.value, + state: time === null ? BlankJoystickState : getJoystickState(window.log, index, time) + }); + } + }); + return command; + } +} diff --git a/src/hub/controllers/LineGraphController.ts b/src/hub/controllers/LineGraphController.ts new file mode 100644 index 00000000..35001f2d --- /dev/null +++ b/src/hub/controllers/LineGraphController.ts @@ -0,0 +1,555 @@ +import { ensureThemeContrast } from "../../shared/Colors"; +import LineGraphFilter from "../../shared/LineGraphFilter"; +import { SourceListState } from "../../shared/SourceListConfig"; +import { getEnabledKey, getLogValueText } from "../../shared/log/LogUtil"; +import { + LineGraphRendererCommand, + LineGraphRendererCommand_DiscreteField, + LineGraphRendererCommand_NumericField +} from "../../shared/renderers/LineGraphRenderer"; +import { NoopUnitConversion, UnitConversionPreset, convertWithPreset } from "../../shared/units"; +import { clampValue, createUUID, scaleValue } from "../../shared/util"; +import SourceList from "../SourceList"; +import { LineGraphController_DiscreteConfig, LineGraphController_NumericConfig } from "./LineGraphController_Config"; +import TabController from "./TabController"; + +export default class LineGraphController implements TabController { + UUID = createUUID(); + + private RANGE_MARGIN = 0.05; + private MIN_AXIS_RANGE = 1e-5; + private MAX_AXIS_RANGE = 1e20; + private MAX_VALUE = 1e20; + + private leftSourceList: SourceList; + private discreteSourceList: SourceList; + private rightSourceList: SourceList; + + private leftLockedRange: [number, number] | null = null; + private rightLockedRange: [number, number] | null = null; + private leftUnitConversion = NoopUnitConversion; + private rightUnitConversion = NoopUnitConversion; + private leftFilter = LineGraphFilter.None; + private rightFilter = LineGraphFilter.None; + + private numericCommandCache: { [key: string]: LineGraphRendererCommand_NumericField } = {}; + + constructor(root: HTMLElement) { + // Make source lists + this.leftSourceList = new SourceList( + root.getElementsByClassName("line-graph-left")[0] as HTMLElement, + LineGraphController_NumericConfig, + [() => this.rightSourceList.getState(), () => this.discreteSourceList.getState()], + (coordinates) => { + window.sendMainMessage("ask-edit-axis", { + x: coordinates[0], + y: coordinates[1], + legend: "left", + lockedRange: this.leftLockedRange, + unitConversion: this.leftUnitConversion, + filter: this.leftFilter, + config: LineGraphController_NumericConfig + }); + }, + (key: string, time: number) => this.getPreview(key, time) + ); + this.leftSourceList.setTitle("Left Axis"); + + this.rightSourceList = new SourceList( + root.getElementsByClassName("line-graph-right")[0] as HTMLElement, + LineGraphController_NumericConfig, + [() => this.leftSourceList.getState(), () => this.discreteSourceList.getState()], + (coordinates) => { + window.sendMainMessage("ask-edit-axis", { + x: coordinates[0], + y: coordinates[1], + legend: "right", + lockedRange: this.rightLockedRange, + unitConversion: this.rightUnitConversion, + filter: this.rightFilter, + config: LineGraphController_NumericConfig + }); + }, + (key: string, time: number) => this.getPreview(key, time) + ); + this.rightSourceList.setTitle("Right Axis"); + + this.discreteSourceList = new SourceList( + root.getElementsByClassName("line-graph-discrete")[0] as HTMLElement, + LineGraphController_DiscreteConfig, + [() => this.leftSourceList.getState(), () => this.rightSourceList.getState()], + (coordinates) => { + window.sendMainMessage("ask-edit-axis", { + x: coordinates[0], + y: coordinates[1], + legend: "discrete", + config: LineGraphController_DiscreteConfig + }); + } + ); + } + + saveState(): unknown { + return { + leftSources: this.leftSourceList.getState(), + rightSources: this.rightSourceList.getState(), + discreteSources: this.discreteSourceList.getState(), + + leftLockedRange: this.leftLockedRange, + rightLockedRange: this.rightLockedRange, + + leftUnitConversion: this.leftUnitConversion, + rightUnitConversion: this.rightUnitConversion, + + leftFilter: this.leftFilter, + rightFilter: this.rightFilter + }; + } + + restoreState(state: unknown): void { + if (typeof state !== "object" || state === null) return; + + if ("leftLockedRange" in state) { + this.leftLockedRange = state.leftLockedRange as [number, number] | null; + } + if ("rightLockedRange" in state) { + this.rightLockedRange = state.rightLockedRange as [number, number] | null; + } + if ("leftUnitConversion" in state) { + this.leftUnitConversion = state.leftUnitConversion as UnitConversionPreset; + } + if ("rightUnitConversion" in state) { + this.rightUnitConversion = state.rightUnitConversion as UnitConversionPreset; + } + if ("leftFilter" in state) { + this.leftFilter = state.leftFilter as LineGraphFilter; + } + if ("rightFilter" in state) { + this.rightFilter = state.rightFilter as LineGraphFilter; + } + this.updateAxisLabels(); + + if ("leftSources" in state) { + this.leftSourceList.setState(state.leftSources as SourceListState); + } + if ("rightSources" in state) { + this.rightSourceList.setState(state.rightSources as SourceListState); + } + if ("discreteSources" in state) { + this.discreteSourceList.setState(state.discreteSources as SourceListState); + } + } + + /** Updates the axis labels based on the locked and unit conversion status. */ + private updateAxisLabels() { + let leftLabels: string[] = []; + if (this.leftLockedRange !== null) { + leftLabels.push("Locked"); + } + if (this.leftUnitConversion.type !== null || this.leftUnitConversion.factor !== 1) { + leftLabels.push("Converted"); + } + switch (this.leftFilter) { + case LineGraphFilter.Differentiate: + leftLabels.push("Differentiated"); + break; + case LineGraphFilter.Integrate: + leftLabels.push("Integrated"); + break; + } + if (leftLabels.length > 0) { + this.leftSourceList.setTitle("Left Axis [" + leftLabels.join(", ") + "]"); + } else { + this.leftSourceList.setTitle("Left Axis"); + } + + let rightLabels: string[] = []; + if (this.rightLockedRange !== null) { + rightLabels.push("Locked"); + } + if (this.rightUnitConversion.type !== null || this.rightUnitConversion.factor !== 1) { + rightLabels.push("Converted"); + } + switch (this.rightFilter) { + case LineGraphFilter.Differentiate: + rightLabels.push("Differentiated"); + break; + case LineGraphFilter.Integrate: + rightLabels.push("Integrated"); + break; + } + if (rightLabels.length > 0) { + this.rightSourceList.setTitle("Right Axis [" + rightLabels.join(", ") + "]"); + } else { + this.rightSourceList.setTitle("Right Axis"); + } + } + + /** Adjusts the locked range and unit conversion for an axis. */ + editAxis( + legend: string, + lockedRange: [number, number] | null, + unitConversion: UnitConversionPreset, + filter: LineGraphFilter + ) { + switch (legend) { + case "left": + if (lockedRange === null) { + this.leftLockedRange = null; + } else if (lockedRange[0] === null && lockedRange[1] === null) { + this.leftLockedRange = this.getCommand().leftRange; + } else { + this.leftLockedRange = lockedRange; + } + this.leftUnitConversion = unitConversion; + this.leftFilter = filter; + break; + + case "right": + if (lockedRange === null) { + this.rightLockedRange = null; + } else if (lockedRange[0] === null && lockedRange[1] === null) { + this.rightLockedRange = this.getCommand().rightRange; + } else { + this.rightLockedRange = lockedRange; + } + this.rightUnitConversion = unitConversion; + this.rightFilter = filter; + break; + } + this.updateAxisLabels(); + } + + /** Clears the fields for an axis. */ + clearAxis(legend: string) { + switch (legend) { + case "left": + this.leftSourceList.clear(); + break; + case "right": + this.rightSourceList.clear(); + break; + case "discrete": + this.discreteSourceList.clear(); + break; + } + } + + /** Adds the enabled field to the discrete axis. */ + addDiscreteEnabled() { + let enabledKey = getEnabledKey(window.log); + if (enabledKey !== undefined) { + this.discreteSourceList.addField(enabledKey); + } + } + + refresh(): void { + this.leftSourceList.refresh(); + this.discreteSourceList.refresh(); + this.rightSourceList.refresh(); + } + + newAssets(): void {} + + getActiveFields(): string[] { + return [ + ...this.leftSourceList.getActiveFields(), + ...this.discreteSourceList.getActiveFields(), + ...this.rightSourceList.getActiveFields() + ]; + } + + showTimeline(): boolean { + return false; + } + + private getPreview(key: string, time: number): number | null { + if (!(key in this.numericCommandCache)) return null; + let command = this.numericCommandCache[key]; + let index = command.timestamps.findLastIndex((sample) => sample <= time); + if (index === -1) return null; + return command.values[index]; + } + + getCommand(): LineGraphRendererCommand { + let leftDataRange: [number, number] = [Infinity, -Infinity]; + let rightDataRange: [number, number] = [Infinity, -Infinity]; + let leftFieldsCommand: LineGraphRendererCommand_NumericField[] = []; + let rightFieldsCommand: LineGraphRendererCommand_NumericField[] = []; + let discreteFieldsCommand: LineGraphRendererCommand_DiscreteField[] = []; + const timeRange = window.selection.getTimelineRange(); + + // Add numeric fields + this.numericCommandCache = {}; + let addNumeric = ( + source: SourceListState, + dataRange: [number, number], + command: LineGraphRendererCommand_NumericField[], + unitConversion: UnitConversionPreset, + filter: LineGraphFilter + ) => { + source.forEach((fieldItem) => { + let data = window.log.getNumber( + fieldItem.logKey, + filter === LineGraphFilter.Integrate ? -Infinity : timeRange[0], + timeRange[1] + ); + if (data === undefined) return; + + // Apply filter + switch (filter) { + case LineGraphFilter.Differentiate: + { + let newValues: number[] = []; + for (let i = 0; i < data.values.length; i++) { + let prevIndex = Math.max(0, i - 1); + let nextIndex = Math.min(data.values.length - 1, i + 1); + newValues.push( + (data.values[nextIndex] - data.values[prevIndex]) / + (data.timestamps[nextIndex] - data.timestamps[prevIndex]) + ); + } + data.values = newValues; + } + break; + case LineGraphFilter.Integrate: + { + let newValues: number[] = []; + let startIndex: number | undefined = undefined; + let integral = 0; + for (let i = 0; i < data.values.length; i++) { + let prevIndex = Math.max(0, i - 1); + if (data.timestamps[i] > timeRange[0] && startIndex === undefined) { + startIndex = Math.max(0, i - 1); + } + + // Trapezoidal integration + integral += + (data.timestamps[i] - data.timestamps[prevIndex]) * (data.values[i] + data.values[prevIndex]) * 0.5; + newValues.push(integral); + } + data.values = newValues.slice(startIndex); + data.timestamps = data.timestamps.slice(startIndex); + } + break; + } + + // Clamp values + data.values = data.values.map((value) => + clampValue(convertWithPreset(value, unitConversion), -this.MAX_VALUE, this.MAX_VALUE) + ); + + // Trim early point + if (data.timestamps.length > 0 && data.timestamps[0] < timeRange[0]) { + switch (fieldItem.type) { + case "stepped": + // Keep, adjust timestamp + data.timestamps[0] = timeRange[0]; + break; + case "smooth": + // Interpolate to displayed value + if (data.timestamps.length >= 2) { + data.values[0] = scaleValue( + timeRange[0], + [data.timestamps[0], data.timestamps[1]], + [data.values[0], data.values[1]] + ); + data.timestamps[0] = timeRange[0]; + } + break; + case "points": + // Remove, no effect on displayed range + data.timestamps.shift(); + data.values.shift(); + break; + } + } + + // Trim late point + if (data.timestamps.length > 0 && data.timestamps[data.timestamps.length - 1] > timeRange[1]) { + switch (fieldItem.type) { + case "stepped": + case "points": + // Remove, no effect on displayed range + data.timestamps.pop(); + data.values.pop(); + break; + case "smooth": + // Interpolate to displayed value + data.values[data.values.length - 1] = scaleValue( + timeRange[1], + [data.timestamps[data.timestamps.length - 2], data.timestamps[data.timestamps.length - 1]], + [data.values[data.values.length - 2], data.values[data.values.length - 1]] + ); + data.timestamps[data.timestamps.length - 1] = timeRange[1]; + break; + } + } else if ( + fieldItem.type === "smooth" && + data.timestamps.length >= 1 && + data.timestamps[data.timestamps.length - 1] < timeRange[1] + ) { + // Assume constant until end of range + data.timestamps.push(timeRange[1]); + data.values.push(data.values[data.values.length - 1]); + } + + // Update data range + if (fieldItem.visible) { + data.values.forEach((value) => { + if (value < dataRange[0]) dataRange[0] = value; + if (value > dataRange[1]) dataRange[1] = value; + }); + } + + // Add field command + let itemCommand: LineGraphRendererCommand_NumericField = { + timestamps: data.timestamps, + values: data.values, + color: ensureThemeContrast(fieldItem.options.color), + type: fieldItem.type as "smooth" | "stepped" | "points", + size: fieldItem.options.size as "normal" | "bold" | "verybold" + }; + if (fieldItem.visible) command.push(itemCommand); + this.numericCommandCache[fieldItem.logKey] = itemCommand; + }); + }; + addNumeric( + this.leftSourceList.getState(), + leftDataRange, + leftFieldsCommand, + this.leftUnitConversion, + this.leftFilter + ); + addNumeric( + this.rightSourceList.getState(), + rightDataRange, + rightFieldsCommand, + this.rightUnitConversion, + this.rightFilter + ); + + // Add discrete fields + this.discreteSourceList.getState().forEach((fieldItem) => { + if (!fieldItem.visible) return; + + let data = window.log.getRange(fieldItem.logKey, timeRange[0], timeRange[1]); + if (data === undefined) return; + + // Get toggle reference + let toggleReference = window.log.getTimestamps([fieldItem.logKey]).indexOf(data.timestamps[0]) % 2 === 0; + toggleReference = toggleReference !== window.log.getStripingReference(fieldItem.logKey); + if (typeof data.values[0] === "boolean") toggleReference = !data.values[0]; + + // Adjust early point + if (data.timestamps.length > 0 && data.timestamps[0] < timeRange[0]) { + data.timestamps[0] = timeRange[0]; + } + + // Trim late point + if (data.timestamps.length > 0 && data.timestamps[data.timestamps.length - 1] > timeRange[1]) { + data.timestamps.pop(); + data.values.pop(); + } + + // Convert to text + let logType = window.log.getType(fieldItem.logKey); + if (logType === null) return; + data.values = data.values.map((value) => getLogValueText(value, logType!)); + + // Add field command + discreteFieldsCommand.push({ + timestamps: data.timestamps, + values: data.values, + color: ensureThemeContrast(fieldItem.options.color), + type: fieldItem.type as "stripes" | "graph", + toggleReference: toggleReference + }); + }); + + // Get numeric ranges + let calcRange = (dataRange: [number, number], lockedRange: [number, number] | null): [number, number] => { + let range: [number, number]; + if (lockedRange === null) { + let margin = (dataRange[1] - dataRange[0]) * this.RANGE_MARGIN; + range = [dataRange[0] - margin, dataRange[1] + margin]; + } else { + range = lockedRange; + } + if (!isFinite(range[0])) range[0] = -1; + if (!isFinite(range[1])) range[1] = 1; + return this.limitAxisRange(range); + }; + let leftRange = calcRange(leftDataRange, this.leftLockedRange); + let rightRange = calcRange(rightDataRange, this.rightLockedRange); + let showLeftAxis = this.leftLockedRange !== null || leftFieldsCommand.length > 0; + let showRightAxis = this.rightLockedRange !== null || rightFieldsCommand.length > 0; + if (!showLeftAxis && !showRightAxis) { + showLeftAxis = true; + } + + // Return command + leftFieldsCommand.reverse(); + rightFieldsCommand.reverse(); + return { + timeRange: timeRange, + selectionMode: window.selection.getMode(), + selectedTime: window.selection.getSelectedTime(), + hoveredTime: window.selection.getHoveredTime(), + grabZoomRange: window.selection.getGrabZoomRange(), + + leftRange: leftRange, + rightRange: rightRange, + showLeftAxis: showLeftAxis, + showRightAxis: showRightAxis, + priorityAxis: + (this.leftLockedRange === null && this.rightLockedRange !== null) || + (leftFieldsCommand.length === 0 && rightFieldsCommand.length > 0) + ? "right" + : "left", + leftFields: leftFieldsCommand, + rightFields: rightFieldsCommand, + discreteFields: discreteFieldsCommand + }; + } + + /** Adjusts the range to fit the extreme limits. */ + private limitAxisRange(range: [number, number]): [number, number] { + let adjustedRange = [range[0], range[1]] as [number, number]; + if (adjustedRange[0] > this.MAX_VALUE) { + adjustedRange[0] = this.MAX_VALUE; + } + if (adjustedRange[1] > this.MAX_VALUE) { + adjustedRange[1] = this.MAX_VALUE; + } + if (adjustedRange[0] < -this.MAX_VALUE) { + adjustedRange[0] = -this.MAX_VALUE; + } + if (adjustedRange[1] < -this.MAX_VALUE) { + adjustedRange[1] = -this.MAX_VALUE; + } + if (adjustedRange[0] === adjustedRange[1]) { + if (Math.abs(adjustedRange[0]) >= this.MAX_VALUE) { + if (adjustedRange[0] > 0) { + adjustedRange[0] *= 0.8; + } else { + adjustedRange[1] *= 0.8; + } + } else { + adjustedRange[0]--; + adjustedRange[1]++; + } + } + if (adjustedRange[1] - adjustedRange[0] > this.MAX_AXIS_RANGE) { + if (adjustedRange[0] + this.MAX_AXIS_RANGE < this.MAX_VALUE) { + adjustedRange[1] = adjustedRange[0] + this.MAX_AXIS_RANGE; + } else { + adjustedRange[0] = adjustedRange[1] - this.MAX_AXIS_RANGE; + } + } + if (adjustedRange[1] - adjustedRange[0] < this.MIN_AXIS_RANGE) { + adjustedRange[1] = adjustedRange[0] + this.MIN_AXIS_RANGE; + } + return adjustedRange; + } +} diff --git a/src/hub/controllers/LineGraphController_Config.ts b/src/hub/controllers/LineGraphController_Config.ts new file mode 100644 index 00000000..59fa8b54 --- /dev/null +++ b/src/hub/controllers/LineGraphController_Config.ts @@ -0,0 +1,132 @@ +import { GraphColors } from "../../shared/Colors"; +import { SourceListConfig } from "../../shared/SourceListConfig"; + +export const LineGraphController_NumericConfig: SourceListConfig = { + title: "Numeric Axis", + autoAdvance: "color", + allowChildrenFromDrag: true, + types: [ + { + key: "stepped", + display: "Stepped", + symbol: "stairs", + showInTypeName: false, + color: "color", + sourceTypes: ["Number"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + }, + { + key: "size", + display: "Thickness", + showInTypeName: false, + values: [ + { key: "normal", display: "Normal" }, + { key: "bold", display: "Bold" }, + { key: "verybold", display: "Very Bold" } + ] + } + ] + }, + { + key: "smooth", + display: "Smooth", + symbol: "scribble.variable", + showInTypeName: false, + color: "color", + sourceTypes: ["Number"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + }, + { + key: "size", + display: "Thickness", + showInTypeName: false, + values: [ + { key: "normal", display: "Normal" }, + { key: "bold", display: "Bold" }, + { key: "verybold", display: "Very Bold" } + ] + } + ] + }, + { + key: "points", + display: "Points", + symbol: "smallcircle.filled.circle", + showInTypeName: false, + color: "color", + sourceTypes: ["Number"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + }, + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "normal", display: "Normal" }, + { key: "bold", display: "Large" } + ] + } + ] + } + ] +}; + +export const LineGraphController_DiscreteConfig: SourceListConfig = { + title: "Discrete Fields", + autoAdvance: "color", + allowChildrenFromDrag: false, + types: [ + { + key: "stripes", + display: "Stripes", + symbol: "square.stack.3d.forward.dottedline.fill", + showInTypeName: false, + color: "color", + sourceTypes: ["Raw", "Boolean", "Number", "String", "BooleanArray", "NumberArray", "StringArray"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + } + ] + }, + { + key: "graph", + display: "Graph", + symbol: "chart.xyaxis.line", + showInTypeName: false, + color: "color", + sourceTypes: ["Raw", "Boolean", "Number", "String", "BooleanArray", "NumberArray", "StringArray"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + } + ] + } + ] +}; diff --git a/src/hub/controllers/MechanismController.ts b/src/hub/controllers/MechanismController.ts new file mode 100644 index 00000000..1e14b5ae --- /dev/null +++ b/src/hub/controllers/MechanismController.ts @@ -0,0 +1,57 @@ +import { SourceListState } from "../../shared/SourceListConfig"; +import { MechanismState, getMechanismState, mergeMechanismStates } from "../../shared/log/LogUtil"; +import { MechanismRendererCommand } from "../../shared/renderers/MechanismRenderer"; +import { createUUID } from "../../shared/util"; +import SourceList from "../SourceList"; +import MechanismController_Config from "./MechanismController_Config"; +import TabController from "./TabController"; + +export default class MechanismController implements TabController { + UUID = createUUID(); + + private sourceList: SourceList; + + constructor(root: HTMLElement) { + this.sourceList = new SourceList(root.firstElementChild as HTMLElement, MechanismController_Config, []); + } + + saveState(): unknown { + return this.sourceList.getState(); + } + + restoreState(state: unknown): void { + this.sourceList.setState(state as SourceListState); + } + + refresh(): void { + this.sourceList.refresh(); + } + + newAssets(): void {} + + getActiveFields(): string[] { + return this.sourceList.getActiveFields(); + } + + showTimeline(): boolean { + return true; + } + + getCommand(): MechanismRendererCommand { + let time = window.selection.getRenderTime(); + if (time === null) time = window.log.getTimestampRange()[1]; + + let states: MechanismState[] = []; + this.sourceList.getState(true).forEach((item) => { + let state = getMechanismState(window.log, item.logKey, time!); + if (state !== null) states.push(state); + }); + states.reverse(); + + if (states.length === 0) { + return null; + } else { + return mergeMechanismStates(states); + } + } +} diff --git a/src/hub/controllers/MechanismController_Config.ts b/src/hub/controllers/MechanismController_Config.ts new file mode 100644 index 00000000..25478c93 --- /dev/null +++ b/src/hub/controllers/MechanismController_Config.ts @@ -0,0 +1,21 @@ +import { SourceListConfig } from "../../shared/SourceListConfig"; + +const MechanismController_Config: SourceListConfig = { + title: "Sources", + autoAdvance: false, + allowChildrenFromDrag: false, + types: [ + { + key: "mechanism", + display: "Mechanism", + symbol: "gearshape.fill", + showInTypeName: false, + color: "#888888", + sourceTypes: ["Mechanism2d"], + showDocs: true, + options: [] + } + ] +}; + +export default MechanismController_Config; diff --git a/src/hub/controllers/MetadataController.ts b/src/hub/controllers/MetadataController.ts new file mode 100644 index 00000000..d2e7b185 --- /dev/null +++ b/src/hub/controllers/MetadataController.ts @@ -0,0 +1,61 @@ +import { MERGE_PREFIX, METADATA_KEYS } from "../../shared/log/LogUtil"; +import { MetadataRendererCommand } from "../../shared/renderers/MetadataRenderer"; +import { createUUID } from "../../shared/util"; +import TabController from "./TabController"; + +export default class MetadataController implements TabController { + UUID = createUUID(); + + private command: MetadataRendererCommand = {}; + + constructor() { + this.refresh(); + } + + getActiveFields(): string[] { + return METADATA_KEYS; + } + + refresh(): void { + this.command = {}; + window.log.getFieldKeys().forEach((key) => { + METADATA_KEYS.forEach((metadataKey) => { + if (key.startsWith(metadataKey)) { + let cleanKey = key.slice(metadataKey.length); + if (key.startsWith("/" + MERGE_PREFIX)) { + cleanKey = key.slice(0, key.indexOf("/", MERGE_PREFIX.length + 1)) + cleanKey; + } + if (!(cleanKey in this.command)) { + this.command[cleanKey] = { generic: null, real: null, replay: null }; + } + let logData = window.log.getString(key, Infinity, Infinity); + if (logData) { + if (metadataKey.includes("RealMetadata")) { + this.command[cleanKey]["real"] = logData.values[0]; + } else if (metadataKey.includes("ReplayMetadata")) { + this.command[cleanKey]["replay"] = logData.values[0]; + } else { + this.command[cleanKey]["generic"] = logData.values[0]; + } + } + } + }); + }); + } + + showTimeline(): boolean { + return false; + } + + getCommand(): MetadataRendererCommand { + return this.command; + } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} + + newAssets(): void {} +} diff --git a/src/hub/controllers/OdometryController.ts b/src/hub/controllers/OdometryController.ts new file mode 100644 index 00000000..85cff895 --- /dev/null +++ b/src/hub/controllers/OdometryController.ts @@ -0,0 +1,524 @@ +import { SourceListItemState, SourceListState } from "../../shared/SourceListConfig"; +import { + AnnotatedPose2d, + AnnotatedPose3d, + SwerveState, + Translation2d, + annotatedPose3dTo2d, + grabHeatmapData, + grabPosesAuto, + grabSwerveStates, + rotation3dTo2d, + translation3dTo2d +} from "../../shared/geometry"; +import { getIsRedAlliance } from "../../shared/log/LogUtil"; +import { + OdometryRendererCommand, + OdometryRendererCommand_AnyObj, + Orientation +} from "../../shared/renderers/OdometryRenderer"; +import { convert } from "../../shared/units"; +import { createUUID } from "../../shared/util"; +import SourceList from "../SourceList"; +import OdometryController_Config from "./OdometryController_Config"; +import TabController from "./TabController"; + +export default class OdometryController implements TabController { + UUID = createUUID(); + + private static TRAIL_LENGTH_SECS = 3; + private static TRAIL_DT = 0.1; + + private BUMPER_SWITCHER: HTMLElement; + private ORIGIN_SWITCHER: HTMLElement; + private ORIENTATION_SWITCHER: HTMLElement; + private SIZE_SWITCHER: HTMLElement; + private GAME_SELECT: HTMLSelectElement; + private GAME_SOURCE: HTMLElement; + + private sourceList: SourceList; + + private bumperSetting: "auto" | "blue" | "red" = "auto"; + private originSetting: "auto" | "blue" | "red" = "auto"; + private orientationSetting = Orientation.DEG_0; + private sizeSetting: 30 | 27 | 24 = 30; + + constructor(root: HTMLElement) { + this.sourceList = new SourceList( + root.getElementsByClassName("odometry-sources")[0] as HTMLElement, + OdometryController_Config, + [] + ); + let settings = root.getElementsByClassName("odometry-settings")[0] as HTMLElement; + this.BUMPER_SWITCHER = settings.getElementsByClassName("bumper-switcher")[0] as HTMLElement; + this.ORIGIN_SWITCHER = settings.getElementsByClassName("origin-switcher")[0] as HTMLElement; + this.ORIENTATION_SWITCHER = settings.getElementsByClassName("orientation-switcher")[0] as HTMLElement; + this.SIZE_SWITCHER = settings.getElementsByClassName("size-switcher")[0] as HTMLElement; + this.GAME_SELECT = settings.getElementsByClassName("game-select")[0] as HTMLSelectElement; + this.GAME_SOURCE = settings.getElementsByClassName("game-source")[0] as HTMLElement; + + // Set up game select + this.GAME_SELECT.addEventListener("change", () => this.updateGameDependentControls()); + this.GAME_SOURCE.addEventListener("click", () => { + window.sendMainMessage( + "open-link", + window.assets?.field2ds.find((game) => game.name === this.GAME_SELECT.value)?.sourceUrl + ); + }); + this.updateGameOptions(); + + // Set up switchers + (["auto", "blue", "red"] as const).forEach((value, index) => { + this.BUMPER_SWITCHER.children[index].addEventListener("click", () => { + this.bumperSetting = value; + this.updateSwitchers(); + }); + }); + (["auto", "blue", "red"] as const).forEach((value, index) => { + this.ORIGIN_SWITCHER.children[index].addEventListener("click", () => { + this.originSetting = value; + this.updateSwitchers(); + }); + }); + this.ORIENTATION_SWITCHER.children[0].addEventListener("click", () => { + this.orientationSetting--; + if (this.orientationSetting < 0) this.orientationSetting = 3; + }); + this.ORIENTATION_SWITCHER.children[1].addEventListener("click", () => { + this.orientationSetting++; + if (this.orientationSetting > 3) this.orientationSetting = 0; + }); + ([30, 27, 24] as const).forEach((value, index) => { + this.SIZE_SWITCHER.children[index].addEventListener("click", () => { + this.sizeSetting = value; + this.updateSwitchers(); + }); + }); + this.updateSwitchers(); + } + + /** Updates game select with the latest options. */ + private updateGameOptions() { + let value = this.GAME_SELECT.value; + while (this.GAME_SELECT.firstChild) { + this.GAME_SELECT.removeChild(this.GAME_SELECT.firstChild); + } + let options: string[] = []; + if (window.assets !== null) { + options = window.assets.field2ds.map((game) => game.name); + options.forEach((title) => { + let option = document.createElement("option"); + option.innerText = title; + this.GAME_SELECT.appendChild(option); + }); + } + if (options.includes(value)) { + this.GAME_SELECT.value = value; + } else { + this.GAME_SELECT.value = options[0]; + } + this.updateGameDependentControls(this.GAME_SELECT.value === value); // Skip origin reset if game is unchanged + } + + /** Updates the alliance and source buttons based on the selected value. */ + private updateGameDependentControls(skipOriginReset = false) { + let fieldConfig = window.assets?.field2ds.find((game) => game.name === this.GAME_SELECT.value); + this.GAME_SOURCE.hidden = fieldConfig !== undefined && fieldConfig.sourceUrl === undefined; + + if (fieldConfig !== undefined && !skipOriginReset) { + this.originSetting = fieldConfig.defaultOrigin; + this.updateSwitchers(); + } + } + + /** Updates the switcher elements to match the internal state. */ + private updateSwitchers() { + // Bumpers + { + let selectedIndex = ["auto", "blue", "red"].indexOf(this.bumperSetting); + if (selectedIndex === -1) selectedIndex = 0; + for (let i = 0; i < 3; i++) { + if (i === selectedIndex) { + this.BUMPER_SWITCHER.children[i].classList.add("selected"); + } else { + this.BUMPER_SWITCHER.children[i].classList.remove("selected"); + } + } + } + + // Origin + { + let selectedIndex = ["auto", "blue", "red"].indexOf(this.originSetting); + if (selectedIndex === -1) selectedIndex = 0; + for (let i = 0; i < 3; i++) { + if (i === selectedIndex) { + this.ORIGIN_SWITCHER.children[i].classList.add("selected"); + } else { + this.ORIGIN_SWITCHER.children[i].classList.remove("selected"); + } + } + } + + // Size + { + let selectedIndex = [30, 27, 24].indexOf(this.sizeSetting); + if (selectedIndex === -1) selectedIndex = 0; + for (let i = 0; i < 3; i++) { + if (i === selectedIndex) { + this.SIZE_SWITCHER.children[i].classList.add("selected"); + } else { + this.SIZE_SWITCHER.children[i].classList.remove("selected"); + } + } + } + } + + saveState(): unknown { + return { + sources: this.sourceList.getState(), + game: this.GAME_SELECT.value, + bumpers: this.bumperSetting, + origin: this.originSetting, + orientation: this.orientationSetting, + size: this.sizeSetting + }; + } + + restoreState(state: unknown): void { + if (typeof state !== "object" || state === null) return; + + this.updateGameOptions(); + if ("sources" in state) { + this.sourceList.setState(state.sources as SourceListState); + } + if ("game" in state && typeof state.game === "string") { + this.GAME_SELECT.value = state.game; + if (this.GAME_SELECT.value === "") { + this.GAME_SELECT.selectedIndex = 0; + } + } + if ("bumpers" in state && (state.bumpers === "auto" || state.bumpers === "blue" || state.bumpers === "red")) { + this.bumperSetting = state.bumpers; + } + if ("origin" in state && (state.origin === "auto" || state.origin === "blue" || state.origin === "red")) { + this.originSetting = state.origin; + } + if ( + "orientation" in state && + (state.orientation === Orientation.DEG_0 || + state.orientation === Orientation.DEG_90 || + state.orientation === Orientation.DEG_180 || + state.orientation === Orientation.DEG_270) + ) { + this.orientationSetting = state.orientation; + } + if ("size" in state && (state.size === 30 || state.size === 27 || state.size === 24)) { + this.sizeSetting = state.size; + } + this.updateGameDependentControls(true); + this.updateSwitchers(); + } + + refresh(): void { + this.sourceList.refresh(); + } + + newAssets(): void { + this.updateGameOptions(); + } + + getActiveFields(): string[] { + return this.sourceList.getActiveFields(); + } + + showTimeline(): boolean { + return true; + } + + getCommand(): OdometryRendererCommand { + // Get timestamp + let time = window.selection.getRenderTime(); + + // Get game data + let gameData = window.assets?.field2ds.find((game) => game.name === this.GAME_SELECT.value); + let fieldWidth = gameData === undefined ? 0 : convert(gameData.widthInches, "inches", "meters"); + let fieldHeight = gameData === undefined ? 0 : convert(gameData.heightInches, "inches", "meters"); + + // Get alliance + let autoRedAlliance = time === null ? false : getIsRedAlliance(window.log, time); + let bumpers: "blue" | "red" = + (this.bumperSetting === "auto" && autoRedAlliance) || this.bumperSetting === "red" ? "red" : "blue"; + let origin: "blue" | "red" = + (this.originSetting === "auto" && autoRedAlliance) || this.originSetting === "red" ? "red" : "blue"; + + // Get objects + let objects: OdometryRendererCommand_AnyObj[] = []; + let sources = this.sourceList.getState(true); + for (let i = 0; i < sources.length; i++) { + let source = sources[i]; + let typeConfig = OdometryController_Config.types.find((typeConfig) => typeConfig.key === source.type); + if (typeConfig?.childOf !== undefined) continue; // This is a child, don't render + + // Find children + let children: SourceListItemState[] = []; + while ( + sources.length > i + 1 && + OdometryController_Config.types.find((typeConfig) => typeConfig.key === sources[i + 1].type)?.childOf !== + undefined + ) { + i++; + children.push(sources[i]); + } + + // Get pose data + let numberArrayFormat: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d" = "Pose2d"; + let numberArrayUnits: "radians" | "degrees" = "radians"; + if ("format" in source.options) { + let formatRaw = source.options.format; + numberArrayFormat = + formatRaw === "Pose2d" || + formatRaw === "Pose3d" || + formatRaw === "Translation2d" || + formatRaw === "Translation3d" + ? formatRaw + : "Pose2d"; + } + if ("units" in source.options) { + numberArrayUnits = source.options.units === "degrees" ? "degrees" : "radians"; + } + let isHeatmap = source.type === "heatmap" || source.type === "heatmapLegacy"; + let pose3ds: AnnotatedPose3d[] = []; + if (!isHeatmap) { + if (time !== null) { + pose3ds = grabPosesAuto( + window.log, + source.logKey, + source.logType, + time, + this.UUID, + numberArrayFormat, + numberArrayUnits, + origin, + fieldWidth, + fieldHeight + ); + } + } else { + let timeRange: "enabled" | "auto" | "teleop" | "teleop-no-endgame" | "full" = "enabled"; + if ("timeRange" in source.options) { + let timeRangeRaw = source.options.timeRange; + timeRange = + timeRangeRaw === "enabled" || + timeRangeRaw === "auto" || + timeRangeRaw === "teleop" || + timeRangeRaw === "teleop-no-endgame" || + timeRangeRaw === "full" + ? timeRangeRaw + : "enabled"; + } + pose3ds = grabHeatmapData( + window.log, + source.logKey, + source.logType, + timeRange, + this.UUID, + numberArrayFormat, + numberArrayUnits, + origin, + fieldWidth, + fieldHeight + ); + } + let poses = pose3ds.map(annotatedPose3dTo2d); + + // Get trail data for robot + let trails: Translation2d[][] = Array(poses.length).fill([]); + if (time !== null) { + if (source.type === "robot" || source.type === "robotLegacy") { + let startTime = Math.max(window.log.getTimestampRange()[0], time - OdometryController.TRAIL_LENGTH_SECS); + let endTime = Math.min(window.log.getTimestampRange()[1], time + OdometryController.TRAIL_LENGTH_SECS); + + let timestamps = [startTime]; + for ( + let sampleTime = Math.ceil(startTime / OdometryController.TRAIL_DT) * OdometryController.TRAIL_DT; + sampleTime < endTime; + sampleTime += OdometryController.TRAIL_DT + ) { + timestamps.push(sampleTime); + } + timestamps.push(endTime); + timestamps.forEach((sampleTime) => { + let pose3ds = grabPosesAuto( + window.log, + source.logKey, + source.logType, + sampleTime, + this.UUID, + numberArrayFormat, + numberArrayUnits, + origin, + fieldWidth, + fieldHeight + ); + if (pose3ds.length !== trails.length) return; + pose3ds.forEach((pose, index) => { + trails[index].push(translation3dTo2d(pose.pose.translation)); + }); + }); + } + } + + // Add data from children + let visionTargets: AnnotatedPose2d[] = []; + let swerveStates: { + values: SwerveState[]; + color: string; + }[] = []; + children.forEach((child) => { + switch (child.type) { + case "rotationOverride": + case "rotationOverrideLegacy": { + let numberArrayUnits: "radians" | "degrees" = "radians"; + if ("units" in child.options) { + numberArrayUnits = child.options.units === "degrees" ? "degrees" : "radians"; + } + let rotations = grabPosesAuto( + window.log, + child.logKey, + child.logType, + time!, + this.UUID, + undefined, + numberArrayUnits + ); + if (rotations.length > 0) { + poses.forEach((value) => { + value.pose.rotation = rotation3dTo2d(rotations[0].pose.rotation); + }); + } + break; + } + + case "vision": + case "visionLegacy": { + let numberArrayFormat: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d" | undefined = undefined; + if ("format" in child.options) { + let formatRaw = child.options.format; + numberArrayFormat = + formatRaw === "Pose2d" || + formatRaw === "Pose3d" || + formatRaw === "Translation2d" || + formatRaw === "Translation3d" + ? formatRaw + : "Pose2d"; + } + let visionPose3ds = grabPosesAuto( + window.log, + child.logKey, + child.logType, + time!, + this.UUID, + numberArrayFormat, + "radians" + ); + visionPose3ds.forEach((annotatedPose) => { + annotatedPose.annotation.visionColor = child.options.color; + }); + visionTargets = visionTargets.concat(visionPose3ds.map(annotatedPose3dTo2d)); + break; + } + + case "swerveStates": + case "swerveStatesLegacy": { + let numberArrayUnits: "radians" | "degrees" = "radians"; + if ("units" in child.options) { + numberArrayUnits = child.options.units === "degrees" ? "degrees" : "radians"; + } + let states = grabSwerveStates( + window.log, + child.logKey, + child.logType, + time!, + child.options.arrangement, + numberArrayUnits, + this.UUID + ); + swerveStates.push({ + values: states, + color: child.options.color + }); + break; + } + } + }); + visionTargets.reverse(); + swerveStates.reverse(); + + // Add object + switch (source.type) { + case "robot": + case "robotLegacy": + objects.push({ + type: "robot", + poses: poses, + trails: trails, + visionTargets: visionTargets, + swerveStates: swerveStates + }); + break; + case "ghost": + case "ghostLegacy": + case "ghostZebra": + objects.push({ + type: "ghost", + poses: poses, + color: source.options.color, + visionTargets: visionTargets, + swerveStates: swerveStates + }); + break; + case "trajectory": + case "trajectoryLegacy": + objects.push({ + type: "trajectory", + poses: poses + }); + break; + case "heatmap": + case "heatmapLegacy": + objects.push({ + type: "heatmap", + poses: poses + }); + break; + case "arrow": + case "arrowLegacy": + let positionRaw = source.options.position; + let position: "center" | "back" | "front" = + positionRaw === "center" || positionRaw === "back" || positionRaw === "front" ? positionRaw : "center"; + objects.push({ + type: "arrow", + poses: poses, + position: position + }); + break; + case "zebra": + objects.push({ + type: "zebra", + poses: poses + }); + break; + } + } + + objects.reverse(); + return { + game: this.GAME_SELECT.value, + bumpers: bumpers, + origin: origin, + orientation: this.orientationSetting, + size: this.sizeSetting, + objects: objects + }; + } +} diff --git a/src/hub/controllers/OdometryController_Config.ts b/src/hub/controllers/OdometryController_Config.ts new file mode 100644 index 00000000..9317e28e --- /dev/null +++ b/src/hub/controllers/OdometryController_Config.ts @@ -0,0 +1,533 @@ +import { NeonColors, NeonColors_RedStart } from "../../shared/Colors"; +import { SourceListConfig } from "../../shared/SourceListConfig"; +import { SwerveArrangementValues } from "./SwerveController_Config"; + +const OdometryController_Config: SourceListConfig = { + title: "Poses", + autoAdvance: true, + allowChildrenFromDrag: false, + typeMemoryId: "odometry", + types: [ + { + key: "robot", + display: "Robot", + symbol: "location.fill", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]" + ], + showDocs: true, + options: [], + parentKey: "robot", + previewType: "Pose2d" + }, + { + key: "robotLegacy", + display: "Robot", + symbol: "location.fill", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + numberArrayDeprecated: true, + parentKey: "robot", + previewType: "Pose2d" + }, + { + key: "ghost", + display: "Ghost", + symbol: "location.fill.viewfinder", + showInTypeName: true, + color: "color", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]" + ], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + } + ], + initialSelectionOption: "color", + parentKey: "robot", + previewType: "Pose2d" + }, + { + key: "ghostLegacy", + display: "Ghost", + symbol: "location.fill.viewfinder", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "color", + parentKey: "robot", + numberArrayDeprecated: true, + previewType: "Pose2d" + }, + { + key: "ghostZebra", + display: "Ghost", + symbol: "location.fill.viewfinder", + showInTypeName: true, + color: "color", + sourceTypes: ["ZebraTranslation"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + } + ], + initialSelectionOption: "color", + parentKey: "robot", + previewType: "Translation2d" + }, + + { + key: "vision", + display: "Vision Target", + symbol: "scope", + showInTypeName: true, + color: "color", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "Translation2d", + "Translation3d", + "Translation2d[]", + "Translation3d[]" + ], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + } + ], + childOf: "robot", + previewType: "Translation2d" + }, + { + key: "visionLegacy", + display: "Vision Target", + symbol: "scope", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + } + ], + numberArrayDeprecated: true, + childOf: "robot", + previewType: "Translation2d" + }, + { + key: "swerveStates", + display: "Swerve States", + symbol: "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left", + showInTypeName: true, + color: "color", + sourceTypes: ["SwerveModuleState[]"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors_RedStart + }, + { + key: "arrangement", + display: "Arrangement", + showInTypeName: false, + values: SwerveArrangementValues + } + ], + initialSelectionOption: "color", + childOf: "robot", + previewType: "SwerveModuleState[]" + }, + { + key: "swerveStatesLegacy", + display: "Swerve States", + symbol: "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors_RedStart + }, + { + key: "arrangement", + display: "Arrangement", + showInTypeName: false, + values: SwerveArrangementValues + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "color", + numberArrayDeprecated: true, + childOf: "robot", + previewType: "SwerveModuleState[]" + }, + { + key: "rotationOverride", + display: "Rotation Override", + symbol: "angle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Rotation2d", "Rotation3d"], + showDocs: true, + options: [], + childOf: "robot", + previewType: "Rotation2d" + }, + { + key: "rotationOverrideLegacy", + display: "Rotation Override", + symbol: "angle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Number"], + showDocs: false, + options: [ + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + childOf: "robot", + previewType: "Rotation2d" + }, + { + key: "trajectory", + display: "Trajectory", + symbol: "point.bottomleft.forward.to.point.topright.scurvepath.fill", + showInTypeName: true, + color: "#ff8800", + sourceTypes: [ + "Pose2d[]", + "Pose3d[]", + "Transform2d[]", + "Transform3d[]", + "Translation2d[]", + "Translation3d[]", + "Trajectory" + ], + showDocs: true, + options: [], + previewType: "Translation2d" + }, + { + key: "trajectoryLegacy", + display: "Trajectory", + symbol: "point.bottomleft.forward.to.point.topright.scurvepath.fill", + showInTypeName: true, + color: "#ff8800", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + } + ], + numberArrayDeprecated: true, + previewType: "Translation2d" + }, + { + key: "heatmap", + display: "Heatmap", + symbol: "map.fill", + showInTypeName: true, + color: "#ff0000", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "Translation2d", + "Translation3d", + "Translation2d[]", + "Translation3d[]", + "ZebraTranslation" + ], + showDocs: true, + options: [ + { + key: "timeRange", + display: "Time Range", + showInTypeName: false, + values: [ + { key: "enabled", display: "Enabled" }, + { key: "auto", display: "Auto" }, + { key: "teleop", display: "Teleop" }, + { key: "teleop-no-endgame", display: "Teleop (No Endgame)" }, + { key: "full", display: "Full Log" } + ] + } + ], + initialSelectionOption: "timeRange", + previewType: null + }, + { + key: "heatmapLegacy", + display: "Heatmap", + symbol: "map.fill", + showInTypeName: true, + color: "#ff0000", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "timeRange", + display: "Time Range", + showInTypeName: false, + values: [ + { key: "enabled", display: "Enabled" }, + { key: "auto", display: "Auto" }, + { key: "teleop", display: "Teleop" }, + { key: "teleop-no-endgame", display: "Teleop (No Endgame)" }, + { key: "full", display: "Full Log" } + ] + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + } + ], + initialSelectionOption: "timeRange", + numberArrayDeprecated: true, + previewType: null + }, + { + key: "arrow", + display: "Arrow", + symbol: "arrow.up.circle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "Trajectory" + ], + showDocs: true, + options: [ + { + key: "position", + display: "Position", + showInTypeName: true, + values: [ + { key: "center", display: "Center" }, + { key: "back", display: "Back" }, + { key: "front", display: "Front" } + ] + } + ], + initialSelectionOption: "position", + previewType: "Pose2d" + }, + { + key: "arrowLegacy", + display: "Arrow", + symbol: "arrow.up.circle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "position", + display: "Position", + showInTypeName: true, + values: [ + { key: "center", display: "Center" }, + { key: "back", display: "Back" }, + { key: "front", display: "Front" } + ] + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "position", + numberArrayDeprecated: true, + previewType: "Pose2d" + }, + { + key: "zebra", + display: "Zebra Marker", + symbol: "mappin.circle.fill", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["ZebraTranslation"], + showDocs: true, + options: [], + previewType: "Translation2d" + } + ] +}; + +export default OdometryController_Config; diff --git a/src/hub/controllers/PointsController.ts b/src/hub/controllers/PointsController.ts new file mode 100644 index 00000000..727b29f7 --- /dev/null +++ b/src/hub/controllers/PointsController.ts @@ -0,0 +1,216 @@ +import { SourceListItemState, SourceListState } from "../../shared/SourceListConfig"; +import { grabPosesAuto, translation3dTo2d } from "../../shared/geometry"; +import { getOrDefault } from "../../shared/log/LogUtil"; +import LoggableType from "../../shared/log/LoggableType"; +import { PointsRendererCommand } from "../../shared/renderers/PointsRenderer"; +import { createUUID } from "../../shared/util"; +import SourceList from "../SourceList"; +import PointsController_Config from "./PointsController_Config"; +import TabController from "./TabController"; + +export default class PointsController implements TabController { + UUID = createUUID(); + + private WIDTH: HTMLInputElement; + private HEIGHT: HTMLInputElement; + private ORIENTATION: HTMLInputElement; + private ORIGIN: HTMLInputElement; + + private sourceList: SourceList; + + constructor(root: HTMLElement) { + this.sourceList = new SourceList(root.firstElementChild as HTMLElement, PointsController_Config, []); + let settings = root.getElementsByClassName("points-settings")[0] as HTMLElement; + this.WIDTH = settings.getElementsByClassName("dimensions-width")[0] as HTMLInputElement; + this.HEIGHT = settings.getElementsByClassName("dimensions-height")[0] as HTMLInputElement; + this.ORIENTATION = settings.getElementsByClassName("orientation")[0] as HTMLInputElement; + this.ORIGIN = settings.getElementsByClassName("origin")[0] as HTMLInputElement; + + // Enforce number ranges + [this.WIDTH, this.HEIGHT].forEach((input, index) => { + input.addEventListener("change", () => { + if (Number(input.value) % 1 !== 0) input.value = Math.round(Number(input.value)).toString(); + if (index === 2) { + if (Number(input.value) < 0) input.value = "0"; + } else { + if (Number(input.value) <= 0) input.value = "1"; + } + }); + }); + } + + saveState(): unknown { + return { + sources: this.sourceList.getState(), + width: Number(this.WIDTH.value), + height: Number(this.HEIGHT.value), + orientation: this.ORIENTATION.value, + origin: this.ORIGIN.value + }; + } + + restoreState(state: unknown): void { + if (typeof state !== "object" || state === null) return; + + if ("sources" in state) { + this.sourceList.setState(state.sources as SourceListState); + } + if ("width" in state && typeof state.width === "number") { + this.WIDTH.value = state.width.toString(); + } + if ("height" in state && typeof state.height === "number") { + this.HEIGHT.value = state.height.toString(); + } + if ("orientation" in state && typeof state.orientation === "string") { + this.ORIENTATION.value = state.orientation.toString(); + } + if ("origin" in state && typeof state.origin === "string") { + this.ORIGIN.value = state.origin.toString(); + } + } + + refresh(): void { + this.sourceList.refresh(); + } + + newAssets(): void {} + + getActiveFields(): string[] { + return this.sourceList.getActiveFields(); + } + + showTimeline(): boolean { + return true; + } + + getCommand(): PointsRendererCommand { + let time = window.selection.getRenderTime(); + if (time === null) time = window.log.getTimestampRange()[1]; + + let sets: PointsRendererCommand["sets"] = []; + let sources = this.sourceList.getState(true); + for (let i = 0; i < sources.length; i++) { + let source = sources[i]; + let typeConfig = PointsController_Config.types.find((typeConfig) => typeConfig.key === source.type); + if (typeConfig?.childOf !== undefined) continue; // This is a child, don't render + + // Find children + let children: SourceListItemState[] = []; + while ( + sources.length > i + 1 && + PointsController_Config.types.find((typeConfig) => typeConfig.key === sources[i + 1].type)?.childOf !== + undefined + ) { + i++; + children.push(sources[i]); + } + + // Get options + let shape: "plus" | "cross" | "circle" = "plus"; + if (source.type.startsWith("plus")) shape = "plus"; + if (source.type.startsWith("cross")) shape = "cross"; + if (source.type.startsWith("circle")) shape = "circle"; + let size: "small" | "medium" | "large" = "medium"; + if ("size" in source.options) { + let sizeRaw = source.options.size; + size = sizeRaw === "small" || sizeRaw === "medium" || sizeRaw === "large" ? sizeRaw : "medium"; + } + let groupSize = 0; + if ("groupSize" in source.options) { + groupSize = Number(source.options.groupSize); + } + + // Add data + switch (source.type) { + case "plus": + case "cross": + case "circle": + let poses = grabPosesAuto(window.log, source.logKey, source.logType, time, this.UUID, "Translation2d"); + sets.push({ + points: poses.map((x) => translation3dTo2d(x.pose.translation)), + shape: shape, + size: size, + groupSize: groupSize + }); + break; + + case "plusSplit": + case "crossSplit": + case "circleSplit": + let values: number[] = getOrDefault(window.log, source.logKey, LoggableType.NumberArray, time, [], this.UUID); + let points: [number, number][]; + if (source.options.component === "x") { + points = values.map((x) => [x, 0]); + } else { + points = values.map((y) => [0, y]); + } + sets.push({ + points: points, + shape: shape, + size: size, + groupSize: groupSize + }); + break; + } + + // Add child components + children.forEach((child) => { + let points = sets[sets.length - 1].points; + let values: number[] = getOrDefault(window.log, child.logKey, LoggableType.NumberArray, time!, [], this.UUID); + values.forEach((value, index) => { + if (index < points.length) { + if (child.options.component === "x") { + points[index][0] = value; + } else { + points[index][1] = value; + } + } + }); + }); + } + + // Apply orientation and origin + let dimensions: [number, number] = [Number(this.WIDTH.value), Number(this.HEIGHT.value)]; + sets.forEach((set) => { + set.points.forEach((point) => { + let newPoint: [number, number] = point; + switch (this.ORIENTATION.value) { + case "xr,yd": + // Default, no changes + break; + case "xr,yu": + newPoint = [newPoint[0], -newPoint[1]]; + break; + case "xu,yl": + newPoint = [-newPoint[1], -newPoint[0]]; + break; + } + switch (this.ORIGIN.value) { + case "ul": + // Default, no changes + break; + case "ur": + newPoint = [newPoint[0] + dimensions[0], newPoint[1]]; + break; + case "ll": + newPoint = [newPoint[0], newPoint[1] + dimensions[1]]; + break; + case "lr": + newPoint = [newPoint[0] + dimensions[0], newPoint[1] + dimensions[1]]; + break; + case "c": + newPoint = [newPoint[0] + dimensions[0] / 2, newPoint[1] + dimensions[1] / 2]; + break; + } + point[0] = newPoint[0]; + point[1] = newPoint[1]; + }); + }); + + sets.reverse(); + return { + dimensions: dimensions, + sets: sets + }; + } +} diff --git a/src/hub/controllers/PointsController_Config.ts b/src/hub/controllers/PointsController_Config.ts new file mode 100644 index 00000000..607b3fc4 --- /dev/null +++ b/src/hub/controllers/PointsController_Config.ts @@ -0,0 +1,273 @@ +import { SourceListConfig } from "../../shared/SourceListConfig"; +import { indexArray } from "../../shared/util"; + +const PointsController_Config: SourceListConfig = { + title: "Sources", + autoAdvance: false, + allowChildrenFromDrag: false, + typeMemoryId: "points", + types: [ + { + key: "plus", + display: "Plus", + symbol: "plus", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Translation2d", "Translation2d[]", "NumberArray"], + showDocs: true, + options: [ + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "medium", display: "Medium" }, + { key: "large", display: "Large" }, + { key: "small", display: "Small" } + ] + }, + { + key: "groupSize", + display: "Group Size", + showInTypeName: false, + values: indexArray(10).map((num) => { + return { + key: num.toString(), + display: num.toString() + }; + }) + } + ], + previewType: null + }, + { + key: "cross", + display: "Cross", + symbol: "xmark", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Translation2d", "Translation2d[]", "NumberArray"], + showDocs: true, + options: [ + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "medium", display: "Medium" }, + { key: "large", display: "Large" }, + { key: "small", display: "Small" } + ] + }, + { + key: "groupSize", + display: "Group Size", + showInTypeName: false, + values: indexArray(10).map((num) => { + return { + key: num.toString(), + display: num.toString() + }; + }) + } + ], + previewType: null + }, + { + key: "circle", + display: "Circle", + symbol: "circle.fill", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Translation2d", "Translation2d[]", "NumberArray"], + showDocs: true, + options: [ + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "medium", display: "Medium" }, + { key: "large", display: "Large" }, + { key: "small", display: "Small" } + ] + }, + { + key: "groupSize", + display: "Group Size", + showInTypeName: false, + values: indexArray(10).map((num) => { + return { + key: num.toString(), + display: num.toString() + }; + }) + } + ], + previewType: null + }, + { + key: "plusSplit", + display: "Plus/Split", + symbol: "plus", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + parentKey: "split", + showDocs: true, + options: [ + { + key: "component", + display: "Component", + showInTypeName: true, + values: [ + { key: "x", display: "X" }, + { key: "y", display: "Y" } + ] + }, + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "medium", display: "Medium" }, + { key: "large", display: "Large" }, + { key: "small", display: "Small" } + ] + }, + { + key: "groupSize", + display: "Group Size", + showInTypeName: false, + values: indexArray(10).map((num) => { + return { + key: num.toString(), + display: num.toString() + }; + }) + } + ], + initialSelectionOption: "component", + previewType: null + }, + { + key: "crossSplit", + display: "Cross/Split", + symbol: "xmark", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + parentKey: "split", + showDocs: true, + options: [ + { + key: "component", + display: "Component", + showInTypeName: true, + values: [ + { key: "x", display: "X" }, + { key: "y", display: "Y" } + ] + }, + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "medium", display: "Medium" }, + { key: "large", display: "Large" }, + { key: "small", display: "Small" } + ] + }, + { + key: "groupSize", + display: "Group Size", + showInTypeName: false, + values: indexArray(10).map((num) => { + return { + key: num.toString(), + display: num.toString() + }; + }) + } + ], + initialSelectionOption: "component", + previewType: null + }, + { + key: "circleSplit", + display: "Circle/Split", + symbol: "circle.fill", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + parentKey: "split", + showDocs: true, + options: [ + { + key: "component", + display: "Component", + showInTypeName: true, + values: [ + { key: "x", display: "X" }, + { key: "y", display: "Y" } + ] + }, + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "medium", display: "Medium" }, + { key: "large", display: "Large" }, + { key: "small", display: "Small" } + ] + }, + { + key: "groupSize", + display: "Group Size", + showInTypeName: false, + values: indexArray(10).map((num) => { + return { + key: num.toString(), + display: num.toString() + }; + }) + } + ], + initialSelectionOption: "component", + previewType: null + }, + { + key: "component", + display: "Component", + symbol: "number", + showInTypeName: false, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + childOf: "split", + showDocs: true, + options: [ + { + key: "component", + display: "Component", + showInTypeName: true, + values: [ + { key: "x", display: "X" }, + { key: "y", display: "Y" } + ] + } + ], + previewType: null + } + ] +}; + +export default PointsController_Config; diff --git a/src/hub/controllers/StatisticsController.ts b/src/hub/controllers/StatisticsController.ts new file mode 100644 index 00000000..41134ca6 --- /dev/null +++ b/src/hub/controllers/StatisticsController.ts @@ -0,0 +1,353 @@ +import * as stats from "simple-statistics"; +import { SourceListItemState, SourceListState } from "../../shared/SourceListConfig"; +import { AKIT_TIMESTAMP_KEYS, getRobotStateRanges } from "../../shared/log/LogUtil"; +import { StatisticsRendererCommand, StatisticsRendererCommand_Stats } from "../../shared/renderers/StatisticsRenderer"; +import { arraysEqual, cleanFloat, createUUID } from "../../shared/util"; +import SourceList from "../SourceList"; +import StatisticsController_Config from "./StatisticsController_Config"; +import TabController from "./TabController"; + +export default class StatisticsController implements TabController { + UUID = createUUID(); + + private UPDATE_PERIOD_MS = 100; + private DEFAULT_DT = 0.02; + + private TIME_RANGE: HTMLSelectElement; + private RANGE_MIN: HTMLInputElement; + private RANGE_MAX: HTMLInputElement; + private STEP_SIZE: HTMLInputElement; + + private sourceList: SourceList; + private command: StatisticsRendererCommand = { + changeCounter: 0, + bins: [], + stepSize: 1, + fields: [] + }; + private shouldUpdate = true; + private lastSourceStr = ""; + private lastTimelineRange: [number, number] = [0, 0]; + private lastUpdateTime = 0; + + constructor(root: HTMLElement) { + this.sourceList = new SourceList(root.firstElementChild as HTMLElement, StatisticsController_Config, []); + this.TIME_RANGE = root.getElementsByClassName("time-range")[0] as HTMLSelectElement; + this.RANGE_MIN = root.getElementsByClassName("range-min")[0] as HTMLInputElement; + this.RANGE_MAX = root.getElementsByClassName("range-max")[0] as HTMLInputElement; + this.STEP_SIZE = root.getElementsByClassName("step-size")[0] as HTMLInputElement; + + // Schedule updates when inputs change + [this.TIME_RANGE, this.RANGE_MIN, this.RANGE_MAX, this.STEP_SIZE].forEach((input) => + input.addEventListener("change", () => (this.shouldUpdate = true)) + ); + this.STEP_SIZE.addEventListener("change", () => { + this.updateHistogramInputs(); + }); + + // Set initial values for histogram inputs + this.RANGE_MIN.value = "0"; + this.RANGE_MAX.value = "10"; + this.STEP_SIZE.value = "1"; + this.updateHistogramInputs(); + } + + /** Updates the step size for each histogram input. */ + private updateHistogramInputs() { + if (Number(this.STEP_SIZE.value) <= 0) { + this.STEP_SIZE.value = cleanFloat(Number(this.STEP_SIZE.step) * 0.9).toString(); + } + let step = Math.pow(10, Math.floor(Math.log10(Number(this.STEP_SIZE.value)))); + this.STEP_SIZE.step = step.toString(); + + let minMaxStep = Math.pow(10, Math.ceil(Math.log10(Number(this.STEP_SIZE.value)))); + this.RANGE_MIN.step = minMaxStep.toString(); + this.RANGE_MAX.step = minMaxStep.toString(); + } + + saveState(): unknown { + return { + sources: this.sourceList.getState(), + timeRange: this.TIME_RANGE.value, + rangeMin: Number(this.RANGE_MIN.value), + rangeMax: Number(this.RANGE_MAX.value), + stepSize: Number(this.STEP_SIZE.value) + }; + } + + restoreState(state: unknown): void { + if (typeof state !== "object" || state === null) return; + + if ("sources" in state) { + this.sourceList.setState(state.sources as SourceListState); + } + if ("timeRange" in state && typeof state.timeRange === "string") { + this.TIME_RANGE.value = state.timeRange; + } + if ("rangeMin" in state && typeof state.rangeMin === "number") { + this.RANGE_MIN.value = state.rangeMin.toString(); + } + if ("rangeMax" in state && typeof state.rangeMax === "number") { + this.RANGE_MAX.value = state.rangeMax.toString(); + } + if ("stepSize" in state && typeof state.stepSize === "number") { + this.STEP_SIZE.value = state.stepSize.toString(); + } + this.updateHistogramInputs(); + } + + refresh(): void { + this.sourceList.refresh(); + this.shouldUpdate = true; + } + + newAssets(): void {} + + getActiveFields(): string[] { + return this.sourceList.getActiveFields(); + } + + showTimeline(): boolean { + return this.TIME_RANGE.value === "visible"; + } + + getCommand(): StatisticsRendererCommand { + // Update time range options + let isLive = window.selection.getCurrentLiveTime() !== null; + Array.from(this.TIME_RANGE.children).forEach((option) => { + if (option.classList.contains("live-only")) { + (option as HTMLOptionElement).disabled = !isLive; + } + }); + + // Check if command should be updated + let sourcesStr = JSON.stringify(this.sourceList.getState()); + let currentTime = new Date().getTime(); + let isLiveMode = window.selection.getCurrentLiveTime() !== null && this.TIME_RANGE.value.startsWith("live"); + let visibleChanged = + this.TIME_RANGE.value === "visible" && !arraysEqual(window.selection.getTimelineRange(), this.lastTimelineRange); + if ( + (this.shouldUpdate || sourcesStr !== this.lastSourceStr || isLiveMode || visibleChanged) && + currentTime - this.lastUpdateTime > this.UPDATE_PERIOD_MS + ) { + this.shouldUpdate = false; + this.lastSourceStr = sourcesStr; + this.lastTimelineRange = [...window.selection.getTimelineRange()]; + this.lastUpdateTime = currentTime; + + // Get bins + let min = Number(this.RANGE_MIN.value); + let max = Number(this.RANGE_MAX.value); + let step = Number(this.STEP_SIZE.value); + if (step <= 0) step = 1; + let bins: number[] = []; + for (let i = min; i < max; i += step) { + bins.push(i); + } + + // Get sample timestamps + let stateRanges = getRobotStateRanges(window.log); + let isValid = (timestamp: number): boolean => { + let liveTime = window.selection.getCurrentLiveTime(); + if (liveTime === null) liveTime = window.log.getTimestampRange()[1]; + let timelineRange = window.selection.getTimelineRange(); + switch (this.TIME_RANGE.value) { + case "full": + return true; + case "visible": + return timestamp >= timelineRange[0] && timestamp <= timelineRange[1]; + case "live-30": + return timestamp >= liveTime - 30; + case "live-10": + return timestamp >= liveTime - 10; + } + + if (stateRanges === null) return false; + let currentRange = stateRanges.findLast((range) => range.start <= timestamp); + switch (this.TIME_RANGE.value) { + case "enabled": + return currentRange?.mode !== "disabled"; + case "auto": + return currentRange?.mode === "auto"; + case "teleop": + return currentRange?.mode === "teleop"; + } + return false; + }; + + const akitTimestampKey = window.log.getFieldKeys().find((key) => AKIT_TIMESTAMP_KEYS.includes(key)); + let sampleTimes: number[] = []; + if (akitTimestampKey !== undefined) { + // Use synced AdvantageKit timestamps :) + const akitTimestamps = window.log.getNumber(akitTimestampKey, -Infinity, Infinity); + if (akitTimestamps !== undefined) sampleTimes = akitTimestamps.timestamps.filter(isValid); + } else { + // No synced timestamps, use fixed period :( + for ( + let sampleTime = window.log.getTimestampRange()[0]; + sampleTime < window.log.getTimestampRange()[1]; + sampleTime += this.DEFAULT_DT + ) { + if (isValid(sampleTime)) { + sampleTimes.push(sampleTime); + } + } + } + + // Get fields + let fields: StatisticsRendererCommand["fields"] = []; + let sources = this.sourceList.getState(true); + for (let i = 0; i < sources.length; i++) { + let source = sources[i]; + let typeConfig = StatisticsController_Config.types.find((typeConfig) => typeConfig.key === source.type); + if (typeConfig?.childOf !== undefined) continue; // This is a child, don't render + + // Find children + let children: SourceListItemState[] = []; + while ( + sources.length > i + 1 && + StatisticsController_Config.types.find((typeConfig) => typeConfig.key === sources[i + 1].type)?.childOf !== + undefined + ) { + i++; + children.push(sources[i]); + } + + // Add field from source + let addField = (source: SourceListItemState, refSource?: SourceListItemState) => { + let data = window.log.getNumber(source.logKey, -Infinity, Infinity); + let refData = + refSource === undefined ? undefined : window.log.getNumber(refSource.logKey, -Infinity, Infinity); + if (data === null) return; + + // Get samples + let index = 0; + let samples: number[] = sampleTimes.map((sampleTime) => { + while (index < data!.timestamps.length && data!.timestamps[index + 1] < sampleTime) { + index++; + } + return data!.values[index]; + }); + index = 0; + let refSamples: number[] | undefined = + refData === undefined + ? undefined + : sampleTimes.map((sampleTime) => { + while (index < refData!.timestamps.length && refData!.timestamps[index + 1] < sampleTime) { + index++; + } + return refData!.values[index]; + }); + + // Apply reference + if (refSamples !== undefined) { + switch (source.type) { + case "relativeError": + samples = samples.map((x, index) => x - refSamples![index]); + break; + case "absoluteError": + samples = samples.map((x, index) => Math.abs(x - refSamples![index])); + break; + } + } + + // Sort samples (required for some statistic calculations) + samples.sort(); + + // Get histogram counts + let histogramCounts: number[] = bins.map(() => 0); + samples.forEach((value) => { + if (value !== null) { + let binIndex = Math.floor((value - min) / step); + if (binIndex >= 0 && binIndex < bins.length) { + histogramCounts[binIndex]++; + } + } + }); + + // Get statistics + let statistics: StatisticsRendererCommand_Stats = { + count: samples.length, + min: samples.length === 0 ? NaN : stats.minSorted(samples), + max: samples.length === 0 ? NaN : stats.maxSorted(samples), + mean: samples.length === 0 ? NaN : stats.mean(samples), + median: samples.length === 0 ? NaN : stats.medianSorted(samples), + mode: samples.length === 0 ? NaN : stats.modeSorted(samples), + geometricMean: samples.length === 0 ? NaN : logAverage(samples.filter((x) => x >= 0)), + harmonicMean: samples.length === 0 ? NaN : stats.harmonicMean(samples.filter((x) => x > 0)), + quadraticMean: samples.length === 0 ? NaN : stats.rootMeanSquare(samples), + standardDeviation: samples.length === 0 ? NaN : stats.sampleStandardDeviation(samples), + medianAbsoluteDeviation: samples.length === 0 ? NaN : stats.medianAbsoluteDeviation(samples), + interquartileRange: samples.length === 0 ? NaN : stats.interquartileRange(samples), + skewness: samples.length === 0 ? NaN : stats.sampleSkewness(samples), + percentile01: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.01), + percentile05: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.05), + percentile10: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.1), + percentile25: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.25), + percentile50: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.5), + percentile75: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.75), + percentile90: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.9), + percentile95: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.95), + percentile99: samples.length === 0 ? NaN : stats.quantileSorted(samples, 0.99) + }; + + // Add field + fields.push({ + title: source.logKey, + color: source.options.color, + histogramCounts: histogramCounts, + stats: statistics + }); + }; + + // Add fields based on type + if (source.type === "independent") { + addField(source); + } else { + children.forEach((child) => { + addField(child, source); + }); + } + } + + // Update command + this.command = { + changeCounter: this.command.changeCounter + 1, + bins: bins, + stepSize: step, + fields: fields + }; + } + + return this.command; + } +} + +/** + * --- Copied from "https://github.com/simple-statistics/simple-statistics/blob/master/src/log_average.js" --- + * + * The [log average](https://en.wikipedia.org/wiki/https://en.wikipedia.org/wiki/Geometric_mean#Relationship_with_logarithms) + * is an equivalent way of computing the geometric mean of an array suitable for large or small products. + * + * It's found by calculating the average logarithm of the elements and exponentiating. + * + * @param {Array} x sample of one or more data points + * @returns {number} geometric mean + * @throws {Error} if x is empty + * @throws {Error} if x contains a negative number + */ +function logAverage(x: number[]): number { + if (x.length === 0) { + throw new Error("logAverage requires at least one data point"); + } + + let value = 0; + for (let i = 0; i < x.length; i++) { + if (x[i] < 0) { + throw new Error("logAverage requires only non-negative numbers as input"); + } + value += Math.log(x[i]); + } + + return Math.exp(value / x.length); +} diff --git a/src/hub/controllers/StatisticsController_Config.ts b/src/hub/controllers/StatisticsController_Config.ts new file mode 100644 index 00000000..5eea0100 --- /dev/null +++ b/src/hub/controllers/StatisticsController_Config.ts @@ -0,0 +1,82 @@ +import { GraphColors } from "../../shared/Colors"; +import { SourceListConfig } from "../../shared/SourceListConfig"; + +const StatisticsController_Config: SourceListConfig = { + title: "Measurements", + autoAdvance: "color", + allowChildrenFromDrag: false, + typeMemoryId: "statistics", + types: [ + { + key: "independent", + display: "Independent", + symbol: "line.3.horizontal", + showInTypeName: true, + color: "color", + sourceTypes: ["Number"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + } + ], + previewType: null + }, + { + key: "reference", + display: "Reference", + symbol: "line.horizontal.star.fill.line.horizontal", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Number"], + parentKey: "reference", + showDocs: true, + options: [], + previewType: null + }, + { + key: "relativeError", + display: "Relative Error", + symbol: "arrow.down.and.line.horizontal.and.arrow.up", + showInTypeName: true, + color: "color", + sourceTypes: ["Number"], + childOf: "reference", + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + } + ], + previewType: null + }, + { + key: "absoluteError", + display: "Absolute Error", + symbol: "arrow.down.to.line", + showInTypeName: true, + color: "color", + sourceTypes: ["Number"], + childOf: "reference", + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + } + ], + previewType: null + } + ] +}; + +export default StatisticsController_Config; diff --git a/src/hub/controllers/SwerveController.ts b/src/hub/controllers/SwerveController.ts new file mode 100644 index 00000000..890dacbc --- /dev/null +++ b/src/hub/controllers/SwerveController.ts @@ -0,0 +1,163 @@ +import { SourceListState } from "../../shared/SourceListConfig"; +import { Rotation2d, grabChassiSpeeds, grabPosesAuto, grabSwerveStates, rotation3dTo2d } from "../../shared/geometry"; +import { Orientation } from "../../shared/renderers/OdometryRenderer"; +import { SwerveRendererCommand } from "../../shared/renderers/SwerveRenderer"; +import { clampValue, createUUID } from "../../shared/util"; +import SourceList from "../SourceList"; +import SwerveController_Config from "./SwerveController_Config"; +import TabController from "./TabController"; + +export default class SwerveController implements TabController { + UUID = createUUID(); + + private MAX_SPEED: HTMLInputElement; + private SIZE_X: HTMLInputElement; + private SIZE_Y: HTMLInputElement; + private ORIENTATION_SWITCHER: HTMLElement; + + private sourceList: SourceList; + private orientation = Orientation.DEG_90; + + constructor(root: HTMLElement) { + this.sourceList = new SourceList(root.firstElementChild as HTMLElement, SwerveController_Config, []); + let settings = root.getElementsByClassName("swerve-settings")[0] as HTMLElement; + this.MAX_SPEED = settings.getElementsByClassName("max-speed")[0] as HTMLInputElement; + this.SIZE_X = settings.getElementsByClassName("size-x")[0] as HTMLInputElement; + this.SIZE_Y = settings.getElementsByClassName("size-y")[0] as HTMLInputElement; + this.ORIENTATION_SWITCHER = settings.getElementsByClassName("orientation-switcher")[0] as HTMLElement; + + // Enforce ranges + this.MAX_SPEED.addEventListener("change", () => { + if (Number(this.MAX_SPEED.value) <= 0) this.MAX_SPEED.value = "0.1"; + }); + this.SIZE_X.addEventListener("change", () => { + if (Number(this.SIZE_X.value) <= 0) this.SIZE_X.value = "0.1"; + }); + this.SIZE_Y.addEventListener("change", () => { + if (Number(this.SIZE_Y.value) <= 0) this.SIZE_Y.value = "0.1"; + }); + + // Orientation controls + this.ORIENTATION_SWITCHER.children[0].addEventListener("click", () => { + this.orientation--; + if (this.orientation < 0) this.orientation = 3; + }); + this.ORIENTATION_SWITCHER.children[1].addEventListener("click", () => { + this.orientation++; + if (this.orientation > 3) this.orientation = 0; + }); + } + + saveState(): unknown { + return { + sources: this.sourceList.getState(), + maxSpeed: Number(this.MAX_SPEED.value), + sizeX: Number(this.SIZE_X.value), + sizeY: Number(this.SIZE_Y.value), + orientation: this.orientation + }; + } + + restoreState(state: unknown): void { + if (typeof state !== "object" || state === null) return; + + if ("sources" in state) { + this.sourceList.setState(state.sources as SourceListState); + } + if ("maxSpeed" in state && typeof state.maxSpeed === "number") { + this.MAX_SPEED.value = state.maxSpeed.toString(); + } + if ("sizeX" in state && typeof state.sizeX === "number") { + this.SIZE_X.value = state.sizeX.toString(); + } + if ("sizeY" in state && typeof state.sizeY === "number") { + this.SIZE_Y.value = state.sizeY.toString(); + } + if ( + "orientation" in state && + (state.orientation === Orientation.DEG_0 || + state.orientation === Orientation.DEG_90 || + state.orientation === Orientation.DEG_180 || + state.orientation === Orientation.DEG_270) + ) { + this.orientation = state.orientation; + } + } + + refresh(): void { + this.sourceList.refresh(); + } + + newAssets(): void {} + + getActiveFields(): string[] { + return this.sourceList.getActiveFields(); + } + + showTimeline(): boolean { + return true; + } + + getCommand(): SwerveRendererCommand { + let time = window.selection.getRenderTime(); + if (time === null) time = window.log.getTimestampRange()[1]; + + let rotation: Rotation2d = 0; + let commandStates: SwerveRendererCommand["states"] = []; + let commandSpeeds: SwerveRendererCommand["speeds"] = []; + let sources = this.sourceList.getState(true); + for (let i = 0; i < sources.length; i++) { + let source = sources[i]; + let units: "radians" | "degrees" = "radians"; + if ("units" in source.options) { + units = source.options.units === "degrees" ? "degrees" : "radians"; + } + + if (source.type === "states" || source.type === "statesLegacy") { + let states = grabSwerveStates( + window.log, + source.logKey, + source.logType, + time, + source.options.arrangement, + units, + this.UUID + ); + states.forEach((state) => { + // Normalize + state.speed = clampValue(state.speed / Number(this.MAX_SPEED.value), -1, 1); + }); + commandStates.push({ + values: states, + color: source.options.color + }); + } else if (source.type === "chassisSpeeds") { + let speeds = grabChassiSpeeds(window.log, source.logKey, time, this.UUID); + let angle = Math.atan2(speeds.vy, speeds.vx); + let length = Math.hypot(speeds.vx, speeds.vy); + length = clampValue(length / Number(this.MAX_SPEED.value), -1, 1); + speeds.vx = Math.cos(angle) * length; + speeds.vy = Math.sin(angle) * length; + commandSpeeds.push({ + value: speeds, + color: source.options.color + }); + } else { + let poses = grabPosesAuto(window.log, source.logKey, source.logType, time, this.UUID, undefined, units); + if (poses.length > 0) { + rotation = rotation3dTo2d(poses[0].pose.rotation); + } + } + } + rotation += this.orientation * (Math.PI / 2); + + commandStates.reverse(); + commandSpeeds.reverse(); + return { + rotation: rotation, + frameAspectRatio: Number(this.SIZE_Y.value) / Number(this.SIZE_X.value), + states: commandStates, + speeds: commandSpeeds + }; + } +} diff --git a/src/hub/controllers/SwerveController_Config.ts b/src/hub/controllers/SwerveController_Config.ts new file mode 100644 index 00000000..ba6d45f3 --- /dev/null +++ b/src/hub/controllers/SwerveController_Config.ts @@ -0,0 +1,135 @@ +import { NeonColors_RedStart } from "../../shared/Colors"; +import { SourceListConfig, SourceListOptionValueConfig } from "../../shared/SourceListConfig"; + +export const SwerveArrangementValues: SourceListOptionValueConfig[] = [ + { display: "FL/FR/BL/BR", key: "0,1,2,3" }, + { display: "FR/FL/BR/BL", key: "1,0,3,2" }, + { display: "FL/FR/BR/BL", key: "0,1,3,2" }, + { display: "FL/BL/BR/FR", key: "0,3,1,2" }, + { display: "FR/BR/BL/FL", key: "3,0,2,1" }, + { display: "FR/FL/BL/BR", key: "1,0,2,3" } +]; + +const SwerveController_Config: SourceListConfig = { + title: "Sources", + autoAdvance: "color", + allowChildrenFromDrag: false, + typeMemoryId: "swerve", + types: [ + { + key: "states", + display: "Module States", + symbol: "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left", + showInTypeName: true, + color: "color", + sourceTypes: ["SwerveModuleState[]"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors_RedStart + }, + { + key: "arrangement", + display: "Arrangement", + showInTypeName: false, + values: SwerveArrangementValues + } + ], + initialSelectionOption: "color", + previewType: "SwerveModuleState[]" + }, + { + key: "statesLegacy", + display: "Module States", + symbol: "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors_RedStart + }, + { + key: "arrangement", + display: "Arrangement", + showInTypeName: false, + values: SwerveArrangementValues + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "color", + numberArrayDeprecated: true, + previewType: "SwerveModuleState[]" + }, + { + key: "chassisSpeeds", + display: "Chassis Speeds", + symbol: "arrow.up.and.down.square.fill", + showInTypeName: true, + color: "color", + sourceTypes: ["ChassisSpeeds"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors_RedStart + } + ], + initialSelectionOption: "color", + previewType: "ChassisSpeeds" + }, + { + key: "rotation", + display: "Rotation", + symbol: "angle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Rotation2d", "Rotation3d"], + showDocs: true, + options: [], + previewType: "Rotation2d" + }, + { + key: "rotationLegacy", + display: "Rotation", + symbol: "angle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Number"], + showDocs: false, + options: [ + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + previewType: "Rotation2d" + } + ] +}; + +export default SwerveController_Config; diff --git a/src/hub/controllers/TabController.ts b/src/hub/controllers/TabController.ts new file mode 100644 index 00000000..fc55bc4c --- /dev/null +++ b/src/hub/controllers/TabController.ts @@ -0,0 +1,57 @@ +import { createUUID } from "../../shared/util"; + +/** A controller for a single tab. Updates user controls and produces commands to be used by renderers. */ +export default interface TabController { + UUID: string; + + /** Returns the current state. */ + saveState(): unknown; + + /** Restores to the provided state. */ + restoreState(state: unknown): void; + + /** Refresh based on new log data. */ + refresh(): void; + + /** Notify that the set of assets was updated. */ + newAssets(): void; + + /** + * Returns the list of fields currently being displayed. This is + * used to selectively request fields from live sources, and all + * keys matching the provided prefixes will be made available. + **/ + getActiveFields(): string[]; + + /** Returns whether to display the timeline. */ + showTimeline(): boolean; + + /** Returns data required by renderers. */ + getCommand(): unknown; +} + +export class NoopController implements TabController { + UUID = createUUID(); + + saveState(): unknown { + return null; + } + + restoreState(): void {} + + refresh(): void {} + + newAssets(): void {} + + getActiveFields(): string[] { + return []; + } + + showTimeline(): boolean { + return false; + } + + getCommand(): unknown { + return null; + } +} diff --git a/src/hub/controllers/TableController.ts b/src/hub/controllers/TableController.ts new file mode 100644 index 00000000..ad6bcd3c --- /dev/null +++ b/src/hub/controllers/TableController.ts @@ -0,0 +1,251 @@ +import { LogValueSetAny } from "../../shared/log/LogValueSets"; +import LoggableType from "../../shared/log/LoggableType"; +import { TableRendererCommand } from "../../shared/renderers/TableRenderer"; +import { checkArrayType, createUUID } from "../../shared/util"; +import TabController from "./TabController"; + +export default class TableController implements TabController { + UUID = createUUID(); + + private DRAG_THRESHOLD_PX = 5; + + private ROOT: HTMLElement; + private TABLE_CONTAINER: HTMLElement; + private TABLE_BODY: HTMLElement; + private HEADER: HTMLElement; + private DRAG_HIGHLIGHT: HTMLElement; + private DRAG_ITEM = document.getElementById("dragItem") as HTMLElement; + + private fields: string[] = []; + private ranges: { [key: string]: [number, number] } = {}; + + constructor(root: HTMLElement) { + this.ROOT = root; + this.TABLE_CONTAINER = root.getElementsByClassName("data-table-container")[0] as HTMLElement; + this.TABLE_BODY = root.getElementsByClassName("data-table")[0].firstElementChild as HTMLElement; + this.HEADER = this.TABLE_BODY.firstElementChild as HTMLElement; + this.DRAG_HIGHLIGHT = root.getElementsByClassName("data-table-drag-highlight")[0] as HTMLElement; + + // Drag handling + window.addEventListener("drag-update", (event) => { + this.handleDrag((event as CustomEvent).detail); + }); + + // Close field events + this.ROOT.addEventListener("close-field", (event) => { + let index = (event as CustomEvent).detail; + if (index < this.fields.length) { + this.fields.splice(index, 1); + } + }); + + // Column dragging + let mouseDownInfo: [number, number] | null = null; + root.addEventListener("mousedown", (event) => { + if (event.clientY < this.HEADER.firstElementChild!.getBoundingClientRect().bottom) { + mouseDownInfo = [event.clientX, event.clientY]; + } + }); + root.addEventListener("mouseup", () => { + mouseDownInfo = null; + }); + root.addEventListener("mousemove", (event) => { + // Start drag + if ( + mouseDownInfo !== null && + (Math.abs(event.clientX - mouseDownInfo[0]) >= this.DRAG_THRESHOLD_PX || + Math.abs(event.clientY - mouseDownInfo[1]) >= this.DRAG_THRESHOLD_PX) + ) { + // Find item + let index = -1; + Array.from(this.HEADER.children).forEach((element, i) => { + if (i === 0) return; + let rect = element.getBoundingClientRect(); + if ( + mouseDownInfo![0] >= rect.left && + mouseDownInfo![0] <= rect.right && + mouseDownInfo![1] >= rect.top && + mouseDownInfo![1] <= rect.bottom + ) { + index = i; + } + }); + mouseDownInfo = null; + if (index === -1) return; + + // Update drag item + while (this.DRAG_ITEM.firstChild) { + this.DRAG_ITEM.removeChild(this.DRAG_ITEM.firstChild); + } + let element = this.HEADER.children[index]; + let keyContainer = element.firstElementChild as HTMLElement; + let dragContainer = document.createElement("div"); + dragContainer.style.position = "absolute"; + dragContainer.style.width = + Math.min(keyContainer.clientWidth, (keyContainer.firstElementChild as HTMLElement).offsetWidth).toString() + + "px"; + dragContainer.style.height = "30px"; + dragContainer.style.left = "0px"; + dragContainer.style.top = "0px"; + dragContainer.style.margin = "none"; + dragContainer.style.padding = "none"; + let keyContainerClone = keyContainer.cloneNode(true) as HTMLElement; + keyContainerClone.style.width = "100%"; + keyContainerClone.style.fontWeight = "bold"; + keyContainerClone.style.fontSize = "14px"; + dragContainer.appendChild(keyContainerClone); + this.DRAG_ITEM.appendChild(dragContainer); + + // Start drag + let itemRect = element.getBoundingClientRect(); + window.startDrag(event.clientX, event.clientY, event.clientX - itemRect.left, event.clientY - itemRect.top, { + tableIndex: index - 1 + }); + } + }); + } + + saveState(): unknown { + return this.fields; + } + + restoreState(state: unknown): void { + if (checkArrayType(state, "string")) { + this.fields = state as string[]; + } + } + + /** Processes a drag event, including adding a field if necessary. */ + private handleDrag(dragData: any) { + if (this.ROOT.hidden) return; + const isField = "fields" in dragData.data; + const isColumn = "tableIndex" in dragData.data; + if (!isField && !isColumn) return; + + // Remove empty fields + let dragFields: string[] = []; + if (isField) { + dragFields = dragData.data.fields; + dragFields = dragFields.filter((field) => window.log.getType(field) !== LoggableType.Empty); + if (dragFields.length === 0) return; + } + + // Find selected section + let tableBox = this.TABLE_CONTAINER.getBoundingClientRect(); + let selected: number | null = null; + let selectedX: number | null = null; + if (dragData.y > tableBox.y) { + for (let i = 0; i < this.HEADER.childElementCount; i++) { + let targetX = 0; + if (i === 0 && this.fields.length > 0) { + targetX = this.HEADER.children[1].getBoundingClientRect().left; + } else { + targetX = this.HEADER.children[i].getBoundingClientRect().right; + } + if (targetX < (this.HEADER.firstElementChild as HTMLElement).getBoundingClientRect().right) continue; + let leftBound = i === 0 ? tableBox.x : targetX - this.HEADER.children[i].getBoundingClientRect().width / 2; + let rightBound = + i === this.HEADER.childElementCount - 1 + ? Infinity + : targetX + this.HEADER.children[i + 1].getBoundingClientRect().width / 2; + if (leftBound < dragData.x && rightBound > dragData.x) { + selected = i; + selectedX = targetX; + } + } + } + + // Update highlight or add field + if (dragData.end) { + this.DRAG_HIGHLIGHT.hidden = true; + if (selected !== null) { + if (isField) { + this.fields.splice(selected, 0, ...dragFields); + } else if (isColumn) { + let sourceIndex = dragData.data.tableIndex; + let fields = this.fields.splice(sourceIndex, 1)[0]; + if (selected <= sourceIndex) { + this.fields.splice(selected, 0, fields); + } else { + this.fields.splice(selected - 1, 0, fields); + } + } + } + } else { + this.DRAG_HIGHLIGHT.hidden = selected === null; + if (selected !== null && selectedX !== null) { + this.DRAG_HIGHLIGHT.style.left = (selectedX - tableBox.x - 12.5).toString() + "px"; + } + } + } + + refresh(): void {} + + newAssets(): void {} + + getActiveFields(): string[] { + return this.fields; + } + + showTimeline(): boolean { + return false; + } + + addRendererRange(uuid: string, range: [number, number] | null) { + if (range === null) { + if (uuid in this.ranges) delete this.ranges[uuid]; + } else { + this.ranges[uuid] = range; + } + } + + getCommand(): TableRendererCommand { + const availableKeys = window.log.getFieldKeys(); + + let ranges = Object.values(this.ranges); + ranges.sort((a, b) => a[0] - b[0]); + const mergedRanges: [number, number][] = []; + for (const range of ranges) { + if (mergedRanges.length === 0 || range[0] > mergedRanges[mergedRanges.length - 1][1]) { + mergedRanges.push(range); + } else { + mergedRanges[mergedRanges.length - 1][1] = Math.max(mergedRanges[mergedRanges.length - 1][1], range[1]); + } + } + + let fieldData: TableRendererCommand["fields"] = this.fields.map((key) => { + const isAvailable = availableKeys.includes(key); + if (!isAvailable) { + return { + key: key, + isAvailable: false, + data: null, + type: null + }; + } else { + let data: LogValueSetAny = { timestamps: [], values: [] }; + mergedRanges.forEach((range) => { + let newData = window.log.getRange(key, range[0], range[1], this.UUID); + if (newData !== undefined) { + data.timestamps = data.timestamps.concat(newData.timestamps); + data.values = data.values.concat(newData.values); + } + }); + return { + key: key, + isAvailable: true, + data: data, + type: window.log.getType(key) + }; + } + }); + + return { + timestamps: window.log.getTimestamps(this.fields, this.UUID), + fields: fieldData, + selectionMode: window.selection.getMode(), + selectedTime: window.selection.getSelectedTime(), + hoveredTime: window.selection.getHoveredTime() + }; + } +} diff --git a/src/hub/controllers/ThreeDimensionController.ts b/src/hub/controllers/ThreeDimensionController.ts new file mode 100644 index 00000000..e18cea04 --- /dev/null +++ b/src/hub/controllers/ThreeDimensionController.ts @@ -0,0 +1,518 @@ +import { SourceListItemState, SourceListOptionValueConfig, SourceListState } from "../../shared/SourceListConfig"; +import { + APRIL_TAG_16H5_COUNT, + APRIL_TAG_36H11_COUNT, + AnnotatedPose3d, + SwerveState, + grabHeatmapData, + grabPosesAuto, + grabSwerveStates +} from "../../shared/geometry"; +import { + MechanismState, + getDriverStation, + getIsRedAlliance, + getMechanismState, + getOrDefault, + mergeMechanismStates +} from "../../shared/log/LogUtil"; +import LoggableType from "../../shared/log/LoggableType"; +import { + ThreeDimensionRendererCommand, + ThreeDimensionRendererCommand_AnyObj +} from "../../shared/renderers/ThreeDimensionRenderer"; +import { convert } from "../../shared/units"; +import { clampValue, createUUID } from "../../shared/util"; +import SourceList from "../SourceList"; +import TabController from "./TabController"; +import ThreeDimensionController_Config from "./ThreeDimensionController_Config"; + +export default class ThreeDimensionController implements TabController { + UUID = createUUID(); + + private ORIGIN_SWITCHER: HTMLElement; + private XR_BUTTON: HTMLButtonElement; + private GAME_SELECT: HTMLSelectElement; + + private sourceList: SourceList; + private originSetting: "auto" | "blue" | "red" = "auto"; + + constructor(root: HTMLElement) { + this.sourceList = new SourceList( + root.getElementsByClassName("three-dimension-sources")[0] as HTMLElement, + ThreeDimensionController_Config, + [] + ); + let settings = root.getElementsByClassName("three-dimension-settings")[0] as HTMLElement; + this.ORIGIN_SWITCHER = settings.getElementsByClassName("origin-switcher")[0] as HTMLElement; + this.XR_BUTTON = settings.getElementsByClassName("xr-button")[0] as HTMLButtonElement; + this.GAME_SELECT = settings.getElementsByClassName("game-select")[0] as HTMLSelectElement; + + // Set up XR button + this.XR_BUTTON.addEventListener("click", () => { + window.sendMainMessage("open-xr"); + }); + + // Set up game select + this.GAME_SELECT.addEventListener("change", () => this.updateGameDependentControls()); + this.updateGameOptions(); + this.updateRobotOptions(); + + // Set up switchers + (["auto", "blue", "red"] as const).forEach((value, index) => { + this.ORIGIN_SWITCHER.children[index].addEventListener("click", () => { + this.originSetting = value; + this.updateOriginSwitcher(); + }); + }); + this.updateOriginSwitcher(); + } + + /** Updates game select with the latest options. */ + private updateGameOptions() { + let value = this.GAME_SELECT.value; + while (this.GAME_SELECT.firstChild) { + this.GAME_SELECT.removeChild(this.GAME_SELECT.firstChild); + } + let options: string[] = []; + if (window.assets !== null) { + options = [...window.assets.field3ds.map((game) => game.name), "Evergreen", "Axes"]; + options.forEach((title) => { + let option = document.createElement("option"); + option.innerText = title; + this.GAME_SELECT.appendChild(option); + }); + } + if (options.includes(value)) { + this.GAME_SELECT.value = value; + } else { + this.GAME_SELECT.value = options[0]; + } + this.updateGameDependentControls(this.GAME_SELECT.value === value); // Skip origin reset if game is unchanged + } + + /** Updates source list with the latest robot models. */ + private updateRobotOptions() { + let robotList: string[] = []; + if (window.assets !== null) { + robotList = window.assets.robots.map((robot) => robot.name); + } + if (robotList.length === 0) { + robotList.push("KitBot"); + } + let sourceListValues: SourceListOptionValueConfig[] = robotList.map((name) => { + return { key: name, display: name }; + }); + this.sourceList.setOptionValues("robot", "model", sourceListValues); + this.sourceList.setOptionValues("robotLegacy", "model", sourceListValues); + this.sourceList.setOptionValues("ghost", "model", sourceListValues); + this.sourceList.setOptionValues("ghostLegacy", "model", sourceListValues); + this.sourceList.setOptionValues("ghostZebra", "model", sourceListValues); + } + + /** Updates the alliance select, source button, and game pieces based on the selected value. */ + private updateGameDependentControls(skipOriginReset = false) { + let fieldConfig = window.assets?.field3ds.find((game) => game.name === this.GAME_SELECT.value); + + if (fieldConfig !== undefined && !skipOriginReset) { + this.originSetting = fieldConfig.defaultOrigin; + this.updateOriginSwitcher(); + } + + let gamePieces: string[] = []; + if (fieldConfig !== undefined) { + gamePieces = fieldConfig.gamePieces.map((x) => x.name); + } + if (gamePieces.length === 0) { + gamePieces.push("None"); + } + let sourceListValues: SourceListOptionValueConfig[] = gamePieces.map((name) => { + return { key: name, display: name }; + }); + this.sourceList.setOptionValues("gamePiece", "variant", sourceListValues); + this.sourceList.setOptionValues("gamePieceLegacy", "variant", sourceListValues); + } + + /** Updates the switcher elements to match the internal state. */ + private updateOriginSwitcher() { + let selectedIndex = ["auto", "blue", "red"].indexOf(this.originSetting); + if (selectedIndex === -1) selectedIndex = 0; + for (let i = 0; i < 3; i++) { + if (i === selectedIndex) { + this.ORIGIN_SWITCHER.children[i].classList.add("selected"); + } else { + this.ORIGIN_SWITCHER.children[i].classList.remove("selected"); + } + } + } + + saveState(): unknown { + return { + sources: this.sourceList.getState(), + game: this.GAME_SELECT.value, + origin: this.originSetting + }; + } + + restoreState(state: unknown): void { + if (typeof state !== "object" || state === null) return; + + this.updateGameOptions(); + if ("sources" in state) { + this.sourceList.setState(state.sources as SourceListState); + } + if ("game" in state && typeof state.game === "string") { + this.GAME_SELECT.value = state.game; + if (this.GAME_SELECT.value === "") { + this.GAME_SELECT.selectedIndex = 0; + } + } + if ("origin" in state && (state.origin === "auto" || state.origin === "blue" || state.origin === "red")) { + this.originSetting = state.origin; + } + this.updateGameDependentControls(true); + this.updateOriginSwitcher(); + } + + refresh(): void { + this.sourceList.refresh(); + } + + newAssets(): void { + this.updateGameOptions(); + this.updateRobotOptions(); + } + + getActiveFields(): string[] { + return this.sourceList.getActiveFields(); + } + + showTimeline(): boolean { + return true; + } + + getCommand(): ThreeDimensionRendererCommand { + // Get timestamp + let time = window.selection.getRenderTime(); + + // Get game data + let gameData = window.assets?.field2ds.find((game) => game.name === this.GAME_SELECT.value); + let fieldWidth = gameData === undefined ? 0 : convert(gameData.widthInches, "inches", "meters"); + let fieldHeight = gameData === undefined ? 0 : convert(gameData.heightInches, "inches", "meters"); + + // Get alliance + let autoRedAlliance = time === null ? false : getIsRedAlliance(window.log, time); + let origin: "blue" | "red" = + (this.originSetting === "auto" && autoRedAlliance) || this.originSetting === "red" ? "red" : "blue"; + + let objects: ThreeDimensionRendererCommand_AnyObj[] = []; + let cameraOverride: AnnotatedPose3d | null = null; + let sources = this.sourceList.getState(true); + for (let i = 0; i < sources.length; i++) { + let source = sources[i]; + let typeConfig = ThreeDimensionController_Config.types.find((typeConfig) => typeConfig.key === source.type); + if (typeConfig?.childOf !== undefined) continue; // This is a child, don't render + + // Find children + let children: SourceListItemState[] = []; + while ( + sources.length > i + 1 && + ThreeDimensionController_Config.types.find((typeConfig) => typeConfig.key === sources[i + 1].type)?.childOf !== + undefined + ) { + i++; + children.push(sources[i]); + } + + // Get pose data + let numberArrayFormat: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d" = "Pose3d"; + let numberArrayUnits: "radians" | "degrees" = "radians"; + if ("format" in source.options) { + let formatRaw = source.options.format; + numberArrayFormat = + formatRaw === "Pose2d" || + formatRaw === "Pose3d" || + formatRaw === "Translation2d" || + formatRaw === "Translation3d" + ? formatRaw + : "Pose3d"; + } + if ("units" in source.options) { + numberArrayUnits = source.options.units === "degrees" ? "degrees" : "radians"; + } + let isHeatmap = source.type === "heatmap" || source.type === "heatmapLegacy"; + let poses: AnnotatedPose3d[] = []; + + if (!isHeatmap) { + if (time !== null) { + poses = grabPosesAuto( + window.log, + source.logKey, + source.logType, + time, + this.UUID, + numberArrayFormat, + numberArrayUnits, + origin, + fieldWidth, + fieldHeight + ); + } + } else { + let timeRange: "enabled" | "auto" | "teleop" | "teleop-no-endgame" | "full" = "enabled"; + if ("timeRange" in source.options) { + let timeRangeRaw = source.options.timeRange; + timeRange = + timeRangeRaw === "enabled" || + timeRangeRaw === "auto" || + timeRangeRaw === "teleop" || + timeRangeRaw === "teleop-no-endgame" || + timeRangeRaw === "full" + ? timeRangeRaw + : "enabled"; + } + poses = grabHeatmapData( + window.log, + source.logKey, + source.logType, + timeRange, + this.UUID, + numberArrayFormat, + numberArrayUnits, + origin, + fieldWidth, + fieldHeight + ); + } + + // Add data from children + let components: AnnotatedPose3d[] = []; + let mechanisms: MechanismState[] = []; + let visionTargets: AnnotatedPose3d[] = []; + let swerveStates: { + values: SwerveState[]; + color: string; + }[] = []; + if (time !== null) { + children.forEach((child) => { + switch (child.type) { + case "component": + case "componentLegacy": { + // Components are always 3D poses so assume number array format + components = components.concat( + grabPosesAuto(window.log, child.logKey, child.logType, time!, this.UUID, "Pose3d") + ); + break; + } + + case "mechanism": { + let state = getMechanismState(window.log, child.logKey, time!); + if (state !== null) { + mechanisms.push(state); + } + break; + } + + case "rotationOverride": + case "rotationOverrideLegacy": { + let numberArrayUnits: "radians" | "degrees" = "radians"; + if ("units" in child.options) { + numberArrayUnits = child.options.units === "degrees" ? "degrees" : "radians"; + } + let rotations = grabPosesAuto( + window.log, + child.logKey, + child.logType, + time!, + this.UUID, + undefined, + numberArrayUnits + ); + if (rotations.length > 0) { + poses.forEach((value) => { + value.pose.rotation = rotations[0].pose.rotation; + }); + } + break; + } + + case "vision": + case "visionLegacy": { + let numberArrayFormat: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d" | undefined = undefined; + if ("format" in child.options) { + let formatRaw = child.options.format; + numberArrayFormat = + formatRaw === "Pose2d" || + formatRaw === "Pose3d" || + formatRaw === "Translation2d" || + formatRaw === "Translation3d" + ? formatRaw + : "Pose2d"; + } + let newVisionTargets = grabPosesAuto( + window.log, + child.logKey, + child.logType, + time!, + this.UUID, + numberArrayFormat, + "radians" + ); + newVisionTargets.forEach((annotatedPose) => { + annotatedPose.annotation.visionColor = child.options.color; + }); + visionTargets = visionTargets.concat(newVisionTargets); + break; + } + + case "swerveStates": + case "swerveStatesLegacy": { + let numberArrayUnits: "radians" | "degrees" = "radians"; + if ("units" in child.options) { + numberArrayUnits = child.options.units === "degrees" ? "degrees" : "radians"; + } + let states = grabSwerveStates( + window.log, + child.logKey, + child.logType, + time!, + child.options.arrangement, + numberArrayUnits, + this.UUID + ); + swerveStates.push({ + values: states, + color: child.options.color + }); + break; + } + + case "aprilTagIDs": { + let values: number[] = getOrDefault( + window.log, + child.logKey, + LoggableType.NumberArray, + time!, + [], + this.UUID + ); + let tagCount = source.options.family === "36h11" ? APRIL_TAG_36H11_COUNT : APRIL_TAG_16H5_COUNT; + values.forEach((id) => { + id = clampValue(Math.floor(id), 0, tagCount - 1); + let index = poses.findIndex((value) => value.annotation.aprilTagId === undefined); + if (index !== -1) { + poses[index].annotation.aprilTagId = id; + } + }); + break; + } + } + }); + } + let mechanism = mechanisms.length === 0 ? null : mergeMechanismStates(mechanisms); + visionTargets.reverse(); + swerveStates.reverse(); + + // Add object + switch (source.type) { + case "robot": + case "robotLegacy": + objects.push({ + type: "robot", + model: source.options.model, + poses: poses, + components: components, + mechanism: mechanism, + visionTargets: visionTargets, + swerveStates: swerveStates + }); + break; + case "ghost": + case "ghostLegacy": + case "ghostZebra": + objects.push({ + type: "ghost", + color: source.options.color, + model: source.options.model, + poses: poses, + components: components, + mechanism: mechanism, + visionTargets: visionTargets, + swerveStates: swerveStates + }); + break; + case "gamePiece": + case "gamePieceLegacy": + objects.push({ + type: "gamePiece", + variant: source.options.variant, + poses: poses + }); + break; + case "trajectory": + case "trajectoryLegacy": + objects.push({ + type: "trajectory", + poses: poses + }); + break; + case "heatmap": + case "heatmapLegacy": + objects.push({ + type: "heatmap", + poses: poses + }); + break; + case "aprilTag": + case "aprilTagLegacy": + let familyRaw = source.options.family; + let family: "36h11" | "16h5" = familyRaw === "36h11" || familyRaw === "16h5" ? familyRaw : "36h11"; + objects.push({ + type: "aprilTag", + poses: poses, + family: family + }); + break; + case "axes": + case "axesLegacy": + objects.push({ + type: "axes", + poses: poses + }); + break; + case "cone": + case "coneLegacy": + let positionRaw = source.options.position; + let position: "center" | "back" | "front" = + positionRaw === "center" || positionRaw === "back" || positionRaw === "front" ? positionRaw : "center"; + objects.push({ + type: "cone", + color: source.options.color, + position: position, + poses: poses + }); + break; + case "cameraOverride": + case "cameraOverrideLegacy": + if (cameraOverride === null) { + cameraOverride = poses[0]; + } + break; + case "zebra": + objects.push({ + type: "zebra", + poses: poses + }); + break; + } + } + + return { + game: this.GAME_SELECT.value, + origin: origin, + objects: objects, + cameraOverride: cameraOverride, + autoDriverStation: getDriverStation(window.log, time!) + }; + } +} diff --git a/src/hub/controllers/ThreeDimensionController_Config.ts b/src/hub/controllers/ThreeDimensionController_Config.ts new file mode 100644 index 00000000..2eb36e40 --- /dev/null +++ b/src/hub/controllers/ThreeDimensionController_Config.ts @@ -0,0 +1,751 @@ +import { NeonColors, NeonColors_RedStart } from "../../shared/Colors"; +import { SourceListConfig } from "../../shared/SourceListConfig"; +import { SwerveArrangementValues } from "./SwerveController_Config"; + +const ThreeDimensionController_Config: SourceListConfig = { + title: "Poses", + autoAdvance: true, + allowChildrenFromDrag: false, + typeMemoryId: "threeDimension", + types: [ + { + key: "robot", + display: "Robot", + symbol: "location.fill", + showInTypeName: false, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "ZebraTranslation" + ], + showDocs: true, + options: [ + { + key: "model", + display: "Model", + showInTypeName: true, + values: [] + } + ], + initialSelectionOption: "model", + parentKey: "robot", + previewType: "Pose3d" + }, + { + key: "robotLegacy", + display: "Robot", + symbol: "location.fill", + showInTypeName: false, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "model", + display: "Model", + showInTypeName: true, + values: [] + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "model", + numberArrayDeprecated: true, + parentKey: "robot", + previewType: "Pose3d" + }, + { + key: "ghost", + display: "Ghost", + symbol: "location.fill.viewfinder", + showInTypeName: true, + color: "color", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "ZebraTranslation" + ], + showDocs: true, + options: [ + { + key: "model", + display: "Model", + showInTypeName: true, + values: [] + }, + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + } + ], + initialSelectionOption: "model", + parentKey: "robot", + previewType: "Pose3d" + }, + { + key: "ghostLegacy", + display: "Ghost", + symbol: "location.fill.viewfinder", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "model", + display: "Model", + showInTypeName: true, + values: [] + }, + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "model", + parentKey: "robot", + numberArrayDeprecated: true, + previewType: "Pose3d" + }, + { + key: "component", + display: "Component", + symbol: "puzzlepiece.extension.fill", + showInTypeName: true, + color: "#9370db", + sourceTypes: ["Pose3d", "Pose3d[]", "Transform3d", "Transform3d[]"], + showDocs: true, + options: [], + childOf: "robot", + previewType: "Pose3d" + }, + { + key: "componentLegacy", + display: "Component", + symbol: "puzzlepiece.extension.fill", + showInTypeName: true, + color: "#9370db", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [], + childOf: "robot", + numberArrayDeprecated: true, + previewType: "Pose3d" + }, + { + key: "mechanism", + display: "Mechanism", + symbol: "gearshape.fill", + showInTypeName: true, + color: "#888888", + sourceTypes: ["Mechanism2d"], + showDocs: true, + options: [], + childOf: "robot" + }, + { + key: "vision", + display: "Vision Target", + symbol: "scope", + showInTypeName: true, + color: "color", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "Translation2d", + "Translation3d", + "Translation2d[]", + "Translation3d[]" + ], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + } + ], + childOf: "robot", + previewType: "Translation3d" + }, + { + key: "visionLegacy", + display: "Vision Target", + symbol: "scope", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + } + ], + numberArrayDeprecated: true, + childOf: "robot", + previewType: "Translation3d" + }, + { + key: "swerveStates", + display: "Swerve States", + symbol: "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left", + showInTypeName: true, + color: "color", + sourceTypes: ["SwerveModuleState[]"], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors_RedStart + }, + { + key: "arrangement", + display: "Arrangement", + showInTypeName: false, + values: SwerveArrangementValues + } + ], + initialSelectionOption: "color", + childOf: "robot", + previewType: "SwerveModuleState[]" + }, + { + key: "swerveStatesLegacy", + display: "Swerve States", + symbol: "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors_RedStart + }, + { + key: "arrangement", + display: "Arrangement", + showInTypeName: false, + values: SwerveArrangementValues + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "color", + numberArrayDeprecated: true, + childOf: "robot", + previewType: "SwerveModuleState[]" + }, + { + key: "rotationOverride", + display: "Rotation Override", + symbol: "angle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Rotation2d", "Rotation3d"], + showDocs: true, + options: [], + childOf: "robot", + previewType: "Rotation3d" + }, + { + key: "rotationOverrideLegacy", + display: "Rotation Override", + symbol: "angle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Number"], + showDocs: false, + options: [ + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + childOf: "robot", + previewType: "Rotation3d" + }, + { + key: "gamePiece", + display: "Game Piece", + symbol: "star.fill", + showInTypeName: false, + color: "#ffd700", + sourceTypes: ["Pose3d", "Pose3d[]", "Transform3d", "Transform3d[]", "Translation3d", "Translation3d[]"], + showDocs: true, + options: [ + { + key: "variant", + display: "Variant", + showInTypeName: true, + values: [ + { key: "Note", display: "Note" }, + { key: "High Note", display: "High Note" } + ] + } + ], + initialSelectionOption: "variant", + previewType: "Pose3d" + }, + { + key: "gamePieceLegacy", + display: "Game Piece", + symbol: "star.fill", + showInTypeName: false, + color: "#ffd700", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "variant", + display: "Variant", + showInTypeName: true, + values: [ + { key: "Note", display: "Note" }, + { key: "High Note", display: "High Note" } + ] + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + } + ], + initialSelectionOption: "variant", + numberArrayDeprecated: true, + previewType: "Pose3d" + }, + { + key: "trajectory", + display: "Trajectory", + symbol: "point.bottomleft.forward.to.point.topright.scurvepath.fill", + showInTypeName: true, + color: "#ff8800", + sourceTypes: [ + "Pose2d[]", + "Pose3d[]", + "Transform2d[]", + "Transform3d[]", + "Translation2d[]", + "Translation3d[]", + "Trajectory" + ], + showDocs: true, + options: [], + previewType: "Translation3d" + }, + { + key: "trajectoryLegacy", + display: "Trajectory", + symbol: "point.bottomleft.forward.to.point.topright.scurvepath.fill", + showInTypeName: true, + color: "#ff8800", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + } + ], + numberArrayDeprecated: true, + previewType: "Translation3d" + }, + { + key: "heatmap", + display: "Heatmap", + symbol: "map.fill", + showInTypeName: true, + color: "#ff0000", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "Translation2d", + "Translation3d", + "Translation2d[]", + "Translation3d[]", + "ZebraTranslation" + ], + showDocs: true, + options: [ + { + key: "timeRange", + display: "Time Range", + showInTypeName: false, + values: [ + { key: "enabled", display: "Enabled" }, + { key: "auto", display: "Auto" }, + { key: "teleop", display: "Teleop" }, + { key: "teleop-no-endgame", display: "Teleop (No Endgame)" }, + { key: "full", display: "No Filter" } + ] + } + ], + initialSelectionOption: "timeRange", + previewType: null + }, + { + key: "heatmapLegacy", + display: "Heatmap", + symbol: "map.fill", + showInTypeName: true, + color: "#ff0000", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "timeRange", + display: "Time Range", + showInTypeName: false, + values: [ + { key: "enabled", display: "Enabled" }, + { key: "auto", display: "Auto" }, + { key: "teleop", display: "Teleop" }, + { key: "teleop-no-endgame", display: "Teleop (No Endgame)" }, + { key: "full", display: "Full Log" } + ] + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + } + ], + initialSelectionOption: "timeRange", + numberArrayDeprecated: true, + previewType: null + }, + { + key: "aprilTag", + display: "AprilTag", + symbol: "qrcode", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Pose3d", "Pose3d[]", "Transform3d", "Transform3d[]", "Trajectory"], + showDocs: true, + options: [ + { + key: "family", + display: "Family", + showInTypeName: true, + values: [ + { key: "36h11", display: "36h11" }, + { key: "16h5", display: "16h5" } + ] + } + ], + parentKey: "aprilTag", + initialSelectionOption: "family", + previewType: "Pose3d" + }, + { + key: "aprilTagLegacy", + display: "AprilTag", + symbol: "qrcode", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "family", + display: "Family", + showInTypeName: true, + values: [ + { key: "36h11", display: "36h11" }, + { key: "16h5", display: "16h5" } + ] + } + ], + numberArrayDeprecated: true, + parentKey: "aprilTag", + initialSelectionOption: "family", + previewType: "Pose3d" + }, + { + key: "aprilTagIDs", + display: "AprilTag IDs", + symbol: "number", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + showDocs: true, + options: [], + childOf: "aprilTag", + previewType: null + }, + { + key: "axes", + display: "Axes", + symbol: "move.3d", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["Pose3d", "Pose3d[]", "Transform3d", "Transform3d[]", "Trajectory"], + showDocs: true, + options: [], + previewType: "Pose3d" + }, + { + key: "axesLegacy", + display: "Axes", + symbol: "move.3d", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [], + numberArrayDeprecated: true, + previewType: "Pose3d" + }, + { + key: "cone", + display: "Cone", + symbol: "cone.fill", + showInTypeName: true, + color: "color", + sourceTypes: [ + "Pose2d", + "Pose3d", + "Pose2d[]", + "Pose3d[]", + "Transform2d", + "Transform3d", + "Transform2d[]", + "Transform3d[]", + "Trajectory" + ], + showDocs: true, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + }, + { + key: "position", + display: "Position", + showInTypeName: true, + values: [ + { key: "center", display: "Center" }, + { key: "back", display: "Back" }, + { key: "front", display: "Front" } + ] + } + ], + initialSelectionOption: "color", + previewType: "Pose3d" + }, + { + key: "coneLegacy", + display: "Cone", + symbol: "cone.fill", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: NeonColors + }, + { + key: "position", + display: "Position", + showInTypeName: true, + values: [ + { key: "center", display: "Center" }, + { key: "back", display: "Back" }, + { key: "front", display: "Front" } + ] + }, + { + key: "format", + display: "Format", + showInTypeName: false, + values: [ + { key: "Pose2d", display: "2D Pose(s)" }, + { key: "Pose3d", display: "3D Pose(s)" }, + { key: "Translation2d", display: "2D Translation(s)" }, + { key: "Translation3d", display: "3D Translation(s)" } + ] + }, + { + key: "units", + display: "Rotation Units", + showInTypeName: false, + values: [ + { key: "radians", display: "Radians" }, + { key: "degrees", display: "Degrees" } + ] + } + ], + initialSelectionOption: "color", + numberArrayDeprecated: true, + previewType: "Pose3d" + }, + { + key: "cameraOverride", + display: "Camera Override", + symbol: "camera.fill", + showInTypeName: true, + color: "#888888", + sourceTypes: ["Pose3d", "Transform3d"], + showDocs: true, + options: [], + previewType: "Pose3d" + }, + { + key: "cameraOverrideLegacy", + display: "Camera Override", + symbol: "camera.fill", + showInTypeName: true, + color: "#888888", + sourceTypes: ["NumberArray"], + showDocs: false, + options: [], + numberArrayDeprecated: true, + previewType: "Pose3d" + }, + { + key: "zebra", + display: "Zebra Marker", + symbol: "mappin.circle.fill", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["ZebraTranslation"], + showDocs: true, + options: [], + previewType: "Translation2d" + } + ] +}; + +export default ThreeDimensionController_Config; diff --git a/src/hub/tabControllers/VideoController.ts b/src/hub/controllers/VideoController.ts similarity index 79% rename from src/hub/tabControllers/VideoController.ts rename to src/hub/controllers/VideoController.ts index 2ec7b7c9..82b24689 100644 --- a/src/hub/tabControllers/VideoController.ts +++ b/src/hub/controllers/VideoController.ts @@ -1,11 +1,12 @@ import { MatchType } from "../../shared/MatchInfo"; -import TabType from "../../shared/TabType"; import VideoSource from "../../shared/VideoSource"; import { getEnabledData, getMatchInfo } from "../../shared/log/LogUtil"; -import VideoVisualizer from "../../shared/visualizers/VideoVisualizer"; -import TimelineVizController from "./TimelineVizController"; +import { createUUID } from "../../shared/util"; +import TabController from "./TabController"; + +export default class VideoController implements TabController { + UUID = createUUID(); -export default class VideoController extends TimelineVizController { private BUTTON_BORDER_RADIUS = 6; private LOCAL_SOURCE: HTMLButtonElement; private YOUTUBE_SOURCE: HTMLButtonElement; @@ -35,38 +36,30 @@ export default class VideoController extends TimelineVizController { private playStartFrame: number = 0; private playStartReal: number = 0; - constructor(content: HTMLElement) { - super( - content, - TabType.Video, - [], - [], - new VideoVisualizer(content.getElementsByClassName("video-container")[0].firstElementChild as HTMLImageElement) - ); - + constructor(root: HTMLElement) { // Get elements - let configTable = content.getElementsByClassName("timeline-viz-config")[0] as HTMLElement; - let controlsCellUpper = configTable.firstElementChild!.children[1].children[1] as HTMLElement; - let controlsCellLower = configTable.getElementsByClassName("video-controls")[0] as HTMLElement; - this.LOCK_BUTTON = controlsCellUpper.getElementsByTagName("button")[0] as HTMLButtonElement; - this.UNLOCK_BUTTON = controlsCellUpper.getElementsByTagName("button")[1] as HTMLButtonElement; - this.PLAY_BUTTON = controlsCellLower.getElementsByTagName("button")[2] as HTMLButtonElement; - this.PAUSE_BUTTON = controlsCellLower.getElementsByTagName("button")[3] as HTMLButtonElement; - this.FRAME_BACK_BUTTON = controlsCellLower.getElementsByTagName("button")[1] as HTMLButtonElement; - this.FRAME_FORWARD_BUTTON = controlsCellLower.getElementsByTagName("button")[4] as HTMLButtonElement; - this.SKIP_BACK_BUTTON = controlsCellLower.getElementsByTagName("button")[0] as HTMLButtonElement; - this.SKIP_FORWARD_BUTTON = controlsCellLower.getElementsByTagName("button")[5] as HTMLButtonElement; - this.VIDEO_TIMELINE_INPUT = configTable.getElementsByClassName( - "timeline-viz-timeline-slider" - )[0] as HTMLInputElement; - this.VIDEO_TIMELINE_PROGRESS = configTable.getElementsByClassName("timeline-viz-timeline-marker-container")[0] - .firstElementChild as HTMLElement; + let sourceSection = root.getElementsByClassName("video-source")[0] as HTMLElement; + let timelineSection = root.getElementsByClassName("video-timeline-section")[0] as HTMLElement; + let timelineControlsSection = root.getElementsByClassName("video-timeline-controls")[0] as HTMLElement; + + this.LOCAL_SOURCE = sourceSection.children[0] as HTMLButtonElement; + this.YOUTUBE_SOURCE = sourceSection.children[1] as HTMLButtonElement; + this.TBA_SOURCE = sourceSection.children[2] as HTMLButtonElement; + + this.LOCK_BUTTON = timelineSection.children[0] as HTMLButtonElement; + this.UNLOCK_BUTTON = timelineSection.children[1] as HTMLButtonElement; + + this.PLAY_BUTTON = timelineControlsSection.children[2] as HTMLButtonElement; + this.PAUSE_BUTTON = timelineControlsSection.children[3] as HTMLButtonElement; + this.FRAME_BACK_BUTTON = timelineControlsSection.children[1] as HTMLButtonElement; + this.FRAME_FORWARD_BUTTON = timelineControlsSection.children[4] as HTMLButtonElement; + this.SKIP_BACK_BUTTON = timelineControlsSection.children[0] as HTMLButtonElement; + this.SKIP_FORWARD_BUTTON = timelineControlsSection.children[5] as HTMLButtonElement; + + this.VIDEO_TIMELINE_INPUT = timelineSection.lastElementChild?.children[0] as HTMLInputElement; + this.VIDEO_TIMELINE_PROGRESS = timelineSection.lastElementChild?.children[1].firstElementChild as HTMLElement; // Source selection - let sourceCell = content.getElementsByClassName("video-source")[0] as HTMLElement; - this.LOCAL_SOURCE = sourceCell.children[0] as HTMLButtonElement; - this.YOUTUBE_SOURCE = sourceCell.children[1] as HTMLButtonElement; - this.TBA_SOURCE = sourceCell.children[2] as HTMLButtonElement; this.LOCAL_SOURCE.addEventListener("click", () => { this.YOUTUBE_SOURCE.classList.remove("animating"); this.TBA_SOURCE.classList.remove("animating"); @@ -178,7 +171,7 @@ export default class VideoController extends TimelineVizController { this.SKIP_BACK_BUTTON.addEventListener("click", () => skipTime(-5)); this.SKIP_FORWARD_BUTTON.addEventListener("click", () => skipTime(5)); window.addEventListener("keydown", (event) => { - if (content.parentElement === null || content.hidden || event.target !== document.body || event.metaKey) return; + if (root === null || root.hidden || event.target !== document.body || event.metaKey) return; switch (event.code) { case "ArrowUp": case "ArrowDown": @@ -201,6 +194,7 @@ export default class VideoController extends TimelineVizController { break; } }); + this.updateButtons(); } isLocked(): boolean { @@ -212,18 +206,21 @@ export default class VideoController extends TimelineVizController { } private updateButtons() { + this.LOCK_BUTTON.disabled = !this.hasData(); + this.UNLOCK_BUTTON.disabled = !this.hasData(); this.LOCK_BUTTON.hidden = this.locked; this.UNLOCK_BUTTON.hidden = !this.locked; this.PLAY_BUTTON.hidden = this.playing; this.PAUSE_BUTTON.hidden = !this.playing; - this.PLAY_BUTTON.disabled = this.locked; - - this.PAUSE_BUTTON.disabled = this.locked; - this.FRAME_BACK_BUTTON.disabled = this.locked; - this.FRAME_FORWARD_BUTTON.disabled = this.locked; - this.SKIP_BACK_BUTTON.disabled = this.locked; - this.SKIP_FORWARD_BUTTON.disabled = this.locked; - this.VIDEO_TIMELINE_INPUT.disabled = this.locked; + + let disableControls = this.locked || !this.hasData(); + this.PLAY_BUTTON.disabled = disableControls; + this.PAUSE_BUTTON.disabled = disableControls; + this.FRAME_BACK_BUTTON.disabled = disableControls; + this.FRAME_FORWARD_BUTTON.disabled = disableControls; + this.SKIP_BACK_BUTTON.disabled = disableControls; + this.SKIP_FORWARD_BUTTON.disabled = disableControls; + this.VIDEO_TIMELINE_INPUT.disabled = disableControls; } private createButtonAnimation(button: HTMLElement) { @@ -333,7 +330,6 @@ export default class VideoController extends TimelineVizController { this.playing = false; this.locked = true; this.lockedStartLog = enabledTime - (data.matchStartFrame - 1) / this.fps!; - this.updateButtons(); } } this.matchStartFrame = data.matchStartFrame; @@ -341,27 +337,16 @@ export default class VideoController extends TimelineVizController { // Start to load new source, reset controls this.locked = false; this.playing = false; - this.updateButtons(); } + this.updateButtons(); } - get options(): { [id: string]: any } { - return {}; - } - - set options(options: { [id: string]: any }) {} - - newAssets() {} - - getAdditionalActiveFields(): string[] { - return []; - } - - getCommand(time: number) { + getCommand(): unknown { if (this.hasData()) { // Set time if locked - if (this.locked) { - this.VIDEO_TIMELINE_INPUT.value = (Math.floor((time - this.lockedStartLog) * this.fps!) + 1).toString(); + let renderTime = window.selection.getRenderTime(); + if (this.locked && renderTime !== null) { + this.VIDEO_TIMELINE_INPUT.value = (Math.floor((renderTime - this.lockedStartLog) * this.fps!) + 1).toString(); } // Set time if playing @@ -382,4 +367,22 @@ export default class VideoController extends TimelineVizController { } return ""; } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} + + refresh(): void {} + + newAssets(): void {} + + getActiveFields(): string[] { + return []; + } + + showTimeline(): boolean { + return true; + } } diff --git a/src/hub/dataSources/HistoricalDataSource.ts b/src/hub/dataSources/HistoricalDataSource.ts index 0d4071f0..e168d00f 100644 --- a/src/hub/dataSources/HistoricalDataSource.ts +++ b/src/hub/dataSources/HistoricalDataSource.ts @@ -1,6 +1,4 @@ import Log from "../../shared/log/Log"; -import { getOrDefault } from "../../shared/log/LogUtil"; -import LoggableType from "../../shared/log/LoggableType"; import WorkerManager from "../WorkerManager"; /** A provider of historical log data (i.e. all the data is returned at once). */ diff --git a/src/hub/dataSources/dslog/dsLogWorker.ts b/src/hub/dataSources/dslog/dsLogWorker.ts index 08a13b45..a9df7086 100644 --- a/src/hub/dataSources/dslog/dsLogWorker.ts +++ b/src/hub/dataSources/dslog/dsLogWorker.ts @@ -4,6 +4,7 @@ import { DSLogReader } from "./DSLogReader"; self.onmessage = (event) => { // WORKER SETUP + self.onmessage = null; let { id, payload } = event.data; function resolve(result: any) { self.postMessage({ id: id, payload: result }); diff --git a/src/hub/dataSources/rlog/rlogWorker.ts b/src/hub/dataSources/rlog/rlogWorker.ts index 928353f1..85fa8c49 100644 --- a/src/hub/dataSources/rlog/rlogWorker.ts +++ b/src/hub/dataSources/rlog/rlogWorker.ts @@ -3,6 +3,7 @@ import RLOGDecoder from "./RLOGDecoder"; self.onmessage = (event) => { // WORKER SETUP + self.onmessage = null; let { id, payload } = event.data; function resolve(result: any) { self.postMessage({ id: id, payload: result }); diff --git a/src/hub/dataSources/wpilog/wpilogWorker.ts b/src/hub/dataSources/wpilog/wpilogWorker.ts index 01326338..24a8d705 100644 --- a/src/hub/dataSources/wpilog/wpilogWorker.ts +++ b/src/hub/dataSources/wpilog/wpilogWorker.ts @@ -6,6 +6,7 @@ import { WPILOGDecoder } from "./WPILOGDecoder"; self.onmessage = (event) => { // WORKER SETUP + self.onmessage = null; let { id, payload } = event.data; function resolve(result: any) { self.postMessage({ id: id, payload: result }); diff --git a/src/hub/exportWorker.ts b/src/hub/exportWorker.ts index 7ad936b2..6a4bfb22 100644 --- a/src/hub/exportWorker.ts +++ b/src/hub/exportWorker.ts @@ -10,6 +10,7 @@ import { WPILOGEncoder, WPILOGEncoderRecord } from "./dataSources/wpilog/WPILOGE self.onmessage = async (event) => { // WORKER SETUP + self.onmessage = null; let { id, payload } = event.data; function resolve(result: any) { self.postMessage({ id: id, payload: result }); diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 7ebef7a7..edc6970f 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -1,27 +1,31 @@ import { AdvantageScopeAssets } from "../shared/AdvantageScopeAssets"; import { HubState } from "../shared/HubState"; import { SIM_ADDRESS, USB_ADDRESS } from "../shared/IPAddresses"; -import Log from "../shared/log/Log"; -import { AKIT_TIMESTAMP_KEYS } from "../shared/log/LogUtil"; import NamedMessage from "../shared/NamedMessage"; import Preferences from "../shared/Preferences"; +import Selection from "../shared/Selection"; +import { SourceListItemState, SourceListTypeMemory } from "../shared/SourceListConfig"; +import Log from "../shared/log/Log"; +import { AKIT_TIMESTAMP_KEYS } from "../shared/log/LogUtil"; import { clampValue, htmlEncode, scaleValue } from "../shared/util"; +import SelectionImpl from "./SelectionImpl"; +import Sidebar from "./Sidebar"; +import SourceList from "./SourceList"; +import Tabs from "./Tabs"; +import WorkerManager from "./WorkerManager"; import { HistoricalDataSource, HistoricalDataSourceStatus } from "./dataSources/HistoricalDataSource"; import { LiveDataSource, LiveDataSourceStatus } from "./dataSources/LiveDataSource"; import LiveDataTuner from "./dataSources/LiveDataTuner"; import loadZebra from "./dataSources/LoadZebra"; -import { NT4Publisher, NT4PublisherStatus } from "./dataSources/nt4/NT4Publisher"; -import NT4Source from "./dataSources/nt4/NT4Source"; import PathPlannerSource from "./dataSources/PathPlannerSource"; import PhoenixDiagnosticsSource from "./dataSources/PhoenixDiagnosticsSource"; +import { NT4Publisher, NT4PublisherStatus } from "./dataSources/nt4/NT4Publisher"; +import NT4Source from "./dataSources/nt4/NT4Source"; import RLOGServerSource from "./dataSources/rlog/RLOGServerSource"; -import Selection from "./Selection"; -import Sidebar from "./Sidebar"; -import Tabs from "./Tabs"; -import WorkerManager from "./WorkerManager"; // Constants -const SAVE_PERIOD_MS = 250; +const STATE_SAVE_PERIOD_MS = 250; +const TYPE_MEMORY_SAVE_PERIOD_MS = 1000; const DRAG_ITEM = document.getElementById("dragItem") as HTMLElement; const UPDATE_BUTTON = document.getElementsByClassName("update")[0] as HTMLElement; @@ -31,6 +35,7 @@ declare global { log: Log; preferences: Preferences | null; assets: AdvantageScopeAssets | null; + typeMemory: SourceListTypeMemory; platform: string; platformRelease: string; appVersion: string; @@ -46,11 +51,18 @@ declare global { messagePort: MessagePort | null; sendMainMessage: (name: string, data?: any) => void; startDrag: (x: number, y: number, offsetX: number, offsetY: number, data: any) => void; + + // Provided by preload script + electron: { + getFilePath(file: File): string; + }; } } + window.log = new Log(); window.preferences = null; window.assets = null; +window.typeMemory = {}; window.platform = ""; window.platformRelease = ""; window.isFullscreen = false; @@ -58,7 +70,7 @@ window.isFocused = true; window.isBattery = false; window.fps = false; -window.selection = new Selection(); +window.selection = new SelectionImpl(); window.sidebar = new Sidebar(); window.tabs = new Tabs(); window.tuner = null; @@ -101,17 +113,38 @@ function setLoading(progress: number | null) { function updateFancyWindow() { // Using fancy title bar? - if (window.platform === "darwin" && Number(window.platformRelease.split(".")[0]) >= 20 && !window.isFullscreen) { - document.body.classList.add("fancy-title-bar"); + let releaseSplit = window.platformRelease.split("."); + if ( + window.platform === "darwin" && + Number(releaseSplit[0]) >= 20 && // macOS Big Sur + !window.isFullscreen + ) { + document.body.classList.add("fancy-title-bar-mac"); } else { - document.body.classList.remove("fancy-title-bar"); + document.body.classList.remove("fancy-title-bar-mac"); + } + if (window.platform === "win32") { + document.body.classList.add("fancy-title-bar-win"); + } else { + document.body.classList.remove("fancy-title-bar-win"); + } + if (window.platform === "linux") { + document.body.classList.add("fancy-title-bar-linux"); + } else { + document.body.classList.remove("fancy-title-bar-linux"); } // Using fancy sidebar? if (window.platform === "darwin") { - document.body.classList.add("fancy-side-bar"); + document.body.classList.add("fancy-side-bar-mac"); + } else { + document.body.classList.remove("fancy-side-bar-mac"); + } + if (window.platform === "win32" && Number(releaseSplit[releaseSplit.length - 1]) >= 22621) { + // Windows 11 22H2 + document.body.classList.add("fancy-side-bar-win"); } else { - document.body.classList.remove("fancy-side-bar"); + document.body.classList.remove("fancy-side-bar-win"); } } @@ -140,7 +173,11 @@ function restoreState(state: HubState) { setInterval(() => { window.sendMainMessage("save-state", saveState()); -}, SAVE_PERIOD_MS); +}, STATE_SAVE_PERIOD_MS); + +setInterval(() => { + window.sendMainMessage("save-type-memory", window.typeMemory); +}, TYPE_MEMORY_SAVE_PERIOD_MS); // MANAGE DRAGGING @@ -155,6 +192,7 @@ window.startDrag = (x, y, offsetX, offsetY, data) => { DRAG_ITEM.hidden = false; DRAG_ITEM.style.left = (x - offsetX).toString() + "px"; DRAG_ITEM.style.top = (y - offsetY).toString() + "px"; + document.body.style.cursor = "grabbing"; }; function dragMove(x: number, y: number) { @@ -181,6 +219,7 @@ function dragEnd() { if (dragActive) { dragActive = false; DRAG_ITEM.hidden = true; + document.body.style.cursor = ""; window.dispatchEvent( new CustomEvent("drag-update", { detail: { end: true, x: dragLastX, y: dragLastY, data: dragData } @@ -362,7 +401,7 @@ document.addEventListener("drop", (event) => { if (event.dataTransfer) { let files: string[] = []; for (const file of event.dataTransfer.files) { - files.push(file.path); + files.push(window.electron.getFilePath(file)); } if (files.length > 0) { startHistorical(files); @@ -416,6 +455,10 @@ function handleMainMessage(message: NamedMessage) { restoreState(message.data); break; + case "restore-type-memory": + window.typeMemory = message.data; + break; + case "set-fullscreen": window.isFullscreen = message.data; updateFancyWindow(); @@ -474,6 +517,59 @@ function handleMainMessage(message: NamedMessage) { } break; + case "set-active-satellites": + window.tabs.setActiveSatellites(message.data); + break; + + case "call-selection-setter": + let uuid: string = message.data.uuid; + let name: string = message.data.name; + let args: any[] = message.data.args; + if (window.tabs.isValidUUID(uuid)) { + switch (name) { + case "setHoveredTime": + window.selection.setHoveredTime(args[0]); + break; + case "setSelectedTime": + window.selection.setSelectedTime(args[0]); + break; + case "goIdle": + window.selection.goIdle(); + break; + case "play": + window.selection.play(); + break; + case "pause": + window.selection.pause(); + break; + case "togglePlayback": + window.selection.togglePlayback(); + break; + case "lock": + window.selection.lock(); + break; + case "unlock": + window.selection.unlock(); + break; + case "toggleLock": + window.selection.toggleLock(); + break; + case "stepCycle": + window.selection.stepCycle(args[0]); + break; + case "setGrabZoomRange": + window.selection.setGrabZoomRange(args[0]); + break; + case "finishGrabZoom": + window.selection.finishGrabZoom(); + break; + case "applyTimelineScroll": + window.selection.applyTimelineScroll(args[0], args[1], args[2]); + break; + } + } + break; + case "show-update-button": document.documentElement.style.setProperty("--show-update-button", message.data ? "1" : "0"); UPDATE_BUTTON.hidden = !message.data; @@ -569,6 +665,14 @@ function handleMainMessage(message: NamedMessage) { window.selection.setPlaybackSpeed(message.data); break; + case "toggle-sidebar": + window.sidebar.toggleVisible(); + break; + + case "toggle-controls": + window.tabs.toggleControlsVisible(); + break; + case "new-tab": window.tabs.addTab(message.data); break; @@ -589,12 +693,36 @@ function handleMainMessage(message: NamedMessage) { window.tabs.renameTab(message.data.index, message.data.name); break; + case "source-list-type-response": + { + let uuid: string = message.data.uuid; + let state: SourceListItemState = message.data.state; + if (uuid in SourceList.typePromptCallbacks) { + SourceList.typePromptCallbacks[uuid](state); + } + } + break; + + case "source-list-clear-response": + { + let uuid: string = message.data.uuid; + if (uuid in SourceList.clearPromptCallbacks) { + SourceList.clearPromptCallbacks[uuid](); + } + } + break; + case "add-discrete-enabled": window.tabs.addDiscreteEnabled(); break; case "edit-axis": - window.tabs.editAxis(message.data.legend, message.data.lockedRange, message.data.unitConversion); + window.tabs.editAxis( + message.data.legend, + message.data.lockedRange, + message.data.unitConversion, + message.data.filter + ); break; case "clear-axis": @@ -609,6 +737,10 @@ function handleMainMessage(message: NamedMessage) { window.tabs.setFov(message.data); break; + case "add-table-range": + window.tabs.addTableRange(message.data.controllerUUID, message.data.rendererUUID, message.data.range); + break; + case "video-data": window.tabs.processVideoData(message.data); break; diff --git a/src/hub/tabControllers/JoysticksController.ts b/src/hub/tabControllers/JoysticksController.ts deleted file mode 100644 index 12693d01..00000000 --- a/src/hub/tabControllers/JoysticksController.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { getJoystickState, JOYSTICK_KEYS } from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import JoysticksVisualizer from "../../shared/visualizers/JoysticksVisualizer"; -import TimelineVizController from "./TimelineVizController"; - -export default class JoysticksController extends TimelineVizController { - private CONFIG_IDS: HTMLInputElement[]; - private CONFIG_LAYOUTS: HTMLInputElement[]; - - constructor(content: HTMLElement) { - super( - content, - TabType.Joysticks, - [], - [], - new JoysticksVisualizer(content.getElementsByClassName("joysticks-canvas")[0] as HTMLCanvasElement) - ); - - // Get option inputs - let cells = content.getElementsByClassName("joysticks-config")[0].firstElementChild?.lastElementChild?.children; - this.CONFIG_IDS = Array.from(cells === undefined ? [] : cells).map( - (cell) => cell.firstElementChild as HTMLInputElement - ); - this.CONFIG_LAYOUTS = Array.from(cells === undefined ? [] : cells).map( - (cell) => cell.lastElementChild as HTMLInputElement - ); - - // Add initial set of options - this.resetLayoutOptions(); - - // Enforce range - this.CONFIG_IDS.forEach((input) => { - input.addEventListener("change", () => { - if (Number(input.value) < 0) input.value = "0"; - if (Number(input.value) > 5) input.value = "5"; - if (Number(input.value) % 1 !== 0) input.value = Math.round(Number(input.value)).toString(); - }); - }); - } - - /** Clears all options for the layout selectors then updates them with the latest options. */ - private resetLayoutOptions() { - let options = ["None", "Generic Joystick"]; - if (window.assets !== null) { - options = [...options, ...window.assets.joysticks.map((joystick) => joystick.name)]; - } - this.CONFIG_LAYOUTS.forEach((select) => { - let value = select.value; - while (select.firstChild) { - select.removeChild(select.firstChild); - } - options.forEach((title) => { - let option = document.createElement("option"); - option.innerText = title; - select.appendChild(option); - }); - if (options.includes(value)) { - select.value = value; - } else { - select.value = options[0]; - } - }); - } - - get options(): { [id: string]: any } { - return { - ids: this.CONFIG_IDS.map((input) => Number(input.value)), - layouts: this.CONFIG_LAYOUTS.map((input) => input.value) - }; - } - - set options(options: { [id: string]: any }) { - this.resetLayoutOptions(); - this.CONFIG_IDS.forEach((input, index) => { - input.value = options.ids[index]; - }); - this.CONFIG_LAYOUTS.forEach((input, index) => { - input.value = options.layouts[index]; - }); - } - - newAssets() { - this.resetLayoutOptions(); - } - - getAdditionalActiveFields(): string[] { - let activeFields: string[] = []; - this.CONFIG_IDS.forEach((element, index) => { - let joystickId = Number(element.value); - let joystickLayout = this.CONFIG_LAYOUTS[index].value; - if (joystickLayout !== "None") { - activeFields = activeFields.concat(JOYSTICK_KEYS.map((key) => key + joystickId.toString())); - } - }); - return activeFields; - } - - getCommand(time: number) { - let command: any[] = []; - this.CONFIG_LAYOUTS.forEach((layoutInput, index) => { - if (layoutInput.value !== "None") { - let joystickId = Number(this.CONFIG_IDS[index].value); - command.push({ - layoutTitle: layoutInput.value, - state: getJoystickState(window.log, joystickId, time) - }); - } - }); - return command; - } -} diff --git a/src/hub/tabControllers/LineGraphController.ts b/src/hub/tabControllers/LineGraphController.ts deleted file mode 100644 index 6e8fbb37..00000000 --- a/src/hub/tabControllers/LineGraphController.ts +++ /dev/null @@ -1,1316 +0,0 @@ -import { AllColors } from "../../shared/Colors"; -import { LineGraphState } from "../../shared/HubState"; -import TabType from "../../shared/TabType"; -import { getEnabledKey, getLogValueText } from "../../shared/log/LogUtil"; -import { LogValueSetAny, LogValueSetNumber } from "../../shared/log/LogValueSets"; -import LoggableType from "../../shared/log/LoggableType"; -import { UnitConversionPreset, convertWithPreset } from "../../shared/units"; -import { ValueScaler, clampValue, cleanFloat, scaleValue, shiftColor } from "../../shared/util"; -import ScrollSensor from "../ScrollSensor"; -import { SelectionMode } from "../Selection"; -import TabController from "../TabController"; - -export default class LineGraphController implements TabController { - private MIN_ZOOM_TIME = 0.05; - private ZOOM_BASE = 1.001; - private MIN_AXIS_RANGE = 1e-5; - private MAX_AXIS_RANGE = 1e20; - private MAX_DECIMAL_VALUE = 1e9; // After this, stop trying to display fractional values - private MAX_VALUE = 1e20; - - private CONTENT: HTMLElement; - private LEGEND_ITEM_TEMPLATE: HTMLElement; - private LEGEND_HANDLE: HTMLElement; - private CANVAS_CONTAINER: HTMLElement; - private CANVAS: HTMLCanvasElement; - private SCROLL_OVERLAY: HTMLElement; - - private LEFT_LIST: HTMLElement; - private DISCRETE_LIST: HTMLElement; - private RIGHT_LIST: HTMLElement; - private LEFT_LABELS: HTMLElement; - private RIGHT_LABELS: HTMLElement; - private LEFT_DRAG_TARGET: HTMLElement; - private DISCRETE_DRAG_TARGET: HTMLElement; - private RIGHT_DRAG_TARGET: HTMLElement; - - private leftFields: { - key: string; - color: string; - show: boolean; - }[] = []; - private discreteFields: { - key: string; - color: string; - show: boolean; - }[] = []; - private rightFields: { - key: string; - color: string; - show: boolean; - }[] = []; - private leftLockedRange: [number, number] | null = null; - private rightLockedRange: [number, number] | null = null; - private leftRenderedRange: [number, number] = [-1, 1]; - private rightRenderedRange: [number, number] = [-1, 1]; - private leftUnitConversion: UnitConversionPreset = { - type: null, - factor: 1 - }; - private rightUnitConversion: UnitConversionPreset = { - type: null, - factor: 1 - }; - - private legendHandleActive = false; - private legendHeightPercent = 0.3; - private timestampRange: [number, number] = [0, 10]; - private maxZoom = true; // When at maximum zoom, maintain it as the available range increases - private lastCursorX: number | null = null; - private panActive = false; - private panStartCursorX = 0; - private panLastCursorX = 0; - private scrollSensor: ScrollSensor; - private lastRenderState = ""; - private refreshCount = 0; - - constructor(content: HTMLElement) { - this.CONTENT = content; - this.LEGEND_ITEM_TEMPLATE = content.getElementsByClassName("legend-item-template")[0] - .firstElementChild as HTMLElement; - this.LEGEND_HANDLE = content.getElementsByClassName("legend-handle")[0] as HTMLElement; - this.CANVAS_CONTAINER = content.getElementsByClassName("line-graph-canvas-container")[0] as HTMLElement; - this.CANVAS = content.getElementsByClassName("line-graph-canvas")[0] as HTMLCanvasElement; - this.SCROLL_OVERLAY = content.getElementsByClassName("line-graph-scroll")[0] as HTMLElement; - - this.LEFT_LIST = content.getElementsByClassName("legend-left")[0] as HTMLElement; - this.DISCRETE_LIST = content.getElementsByClassName("legend-discrete")[0] as HTMLElement; - this.RIGHT_LIST = content.getElementsByClassName("legend-right")[0] as HTMLElement; - this.LEFT_LABELS = this.LEFT_LIST.firstElementChild?.firstElementChild?.lastElementChild as HTMLElement; - this.RIGHT_LABELS = this.RIGHT_LIST.firstElementChild?.firstElementChild?.lastElementChild as HTMLElement; - this.LEFT_DRAG_TARGET = content.getElementsByClassName("legend-left")[1] as HTMLElement; - this.DISCRETE_DRAG_TARGET = content.getElementsByClassName("legend-discrete")[1] as HTMLElement; - this.RIGHT_DRAG_TARGET = content.getElementsByClassName("legend-right")[1] as HTMLElement; - - // Scroll handling - this.SCROLL_OVERLAY.addEventListener("mousemove", (event) => { - this.lastCursorX = event.clientX - this.CONTENT.getBoundingClientRect().x; - }); - this.SCROLL_OVERLAY.addEventListener("mouseleave", () => { - this.lastCursorX = null; - }); - this.scrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { - this.updateScroll(dx, dy); - }); - - // Pan handling - this.SCROLL_OVERLAY.addEventListener("mousedown", (event) => { - this.panActive = true; - let x = event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x; - this.panStartCursorX = x; - this.panLastCursorX = x; - }); - this.SCROLL_OVERLAY.addEventListener("mouseleave", () => { - this.panActive = false; - }); - this.SCROLL_OVERLAY.addEventListener("mouseup", () => { - this.panActive = false; - }); - this.SCROLL_OVERLAY.addEventListener("mousemove", (event) => { - if (this.panActive) { - let cursorX = event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x; - this.updateScroll(this.panLastCursorX - cursorX, 0); - this.panLastCursorX = cursorX; - } - }); - - // Selection handling - this.SCROLL_OVERLAY.addEventListener("click", (event) => { - if (Math.abs(event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x - this.panStartCursorX) <= 5) { - let hoveredTime = window.selection.getHoveredTime(); - if (hoveredTime) { - window.selection.setSelectedTime(hoveredTime); - } - } - }); - this.SCROLL_OVERLAY.addEventListener("contextmenu", () => { - window.selection.goIdle(); - }); - - // Drag handling - window.addEventListener("drag-update", (event) => { - this.handleDrag((event as CustomEvent).detail); - }); - - // Edit axis handling - let leftExitAxisButton = this.LEFT_LIST.firstElementChild?.lastElementChild!; - leftExitAxisButton.addEventListener("click", () => { - let rect = leftExitAxisButton.getBoundingClientRect(); - window.sendMainMessage("ask-edit-axis", { - x: Math.round(rect.right), - y: Math.round(rect.top), - legend: "left", - lockedRange: this.leftLockedRange, - unitConversion: this.leftUnitConversion - }); - }); - let discreteEditAxisButton = this.DISCRETE_LIST.firstElementChild?.lastElementChild!; - discreteEditAxisButton.addEventListener("click", () => { - let rect = discreteEditAxisButton.getBoundingClientRect(); - window.sendMainMessage("ask-edit-axis", { - x: Math.round(rect.right), - y: Math.round(rect.top), - legend: "discrete" - }); - }); - let rightEditAxisButton = this.RIGHT_LIST.firstElementChild?.lastElementChild!; - rightEditAxisButton.addEventListener("click", () => { - let rect = rightEditAxisButton.getBoundingClientRect(); - window.sendMainMessage("ask-edit-axis", { - x: Math.round(rect.right), - y: Math.round(rect.top), - legend: "right", - lockedRange: this.rightLockedRange, - unitConversion: this.rightUnitConversion - }); - }); - - // Legend resizing - this.LEGEND_HANDLE.addEventListener("mousedown", () => { - this.legendHandleActive = true; - document.body.style.cursor = "row-resize"; - }); - window.addEventListener("mouseup", () => { - if (this.legendHandleActive) { - this.legendHandleActive = false; - document.body.style.cursor = "initial"; - } - }); - window.addEventListener("mousemove", (event) => { - if (this.legendHandleActive) { - let rect = this.CONTENT.getBoundingClientRect(); - this.legendHeightPercent = (rect.bottom - event.clientY) / rect.height; - this.updateLegendHeight(); - } - }); - this.updateLegendHeight(); - } - - saveState(): LineGraphState { - return { - type: TabType.LineGraph, - legendHeight: this.legendHeightPercent, - legends: { - left: { - lockedRange: this.leftLockedRange, - unitConversion: this.leftUnitConversion, - fields: this.leftFields - }, - discrete: { - fields: this.discreteFields - }, - right: { - lockedRange: this.rightLockedRange, - unitConversion: this.rightUnitConversion, - fields: this.rightFields - } - } - }; - } - - restoreState(state: LineGraphState) { - this.leftLockedRange = state.legends.left.lockedRange; - this.rightLockedRange = state.legends.right.lockedRange; - this.leftUnitConversion = state.legends.left.unitConversion; - this.rightUnitConversion = state.legends.right.unitConversion; - this.updateAxisLabels(); - - // Remove old fields - this.leftFields = []; - this.discreteFields = []; - this.rightFields = []; - while (this.LEFT_LIST.children[1]) this.LEFT_LIST.removeChild(this.LEFT_LIST.children[1]); - while (this.DISCRETE_LIST.children[1]) this.DISCRETE_LIST.removeChild(this.DISCRETE_LIST.children[1]); - while (this.RIGHT_LIST.children[1]) this.RIGHT_LIST.removeChild(this.RIGHT_LIST.children[1]); - - // Add new fields - state.legends.left.fields.forEach((field) => { - this.addField("left", field.key, field.color, field.show); - }); - state.legends.discrete.fields.forEach((field) => { - this.addField("discrete", field.key, field.color, field.show); - }); - state.legends.right.fields.forEach((field) => { - this.addField("right", field.key, field.color, field.show); - }); - - // Update legend height - this.legendHeightPercent = state.legendHeight; - this.updateLegendHeight(); - } - - /** Updates the displayed height based on the current state. */ - private updateLegendHeight() { - this.legendHeightPercent = clampValue(this.legendHeightPercent, 0.15, 0.75); - this.CONTENT.style.setProperty("--legend-height", (this.legendHeightPercent * 100).toString() + "%"); - } - - /** Updates the axis labels based on the locked and unit conversion status. */ - updateAxisLabels() { - let leftLocked = this.leftLockedRange !== null; - let leftConverted = this.leftUnitConversion.type !== null || this.leftUnitConversion.factor !== 1; - if (leftLocked && leftConverted) { - this.LEFT_LABELS.innerText = " [Locked, Converted]"; - } else if (leftLocked) { - this.LEFT_LABELS.innerText = " [Locked]"; - } else if (leftConverted) { - this.LEFT_LABELS.innerText = " [Converted]"; - } else { - this.LEFT_LABELS.innerText = ""; - } - - let rightLocked = this.rightLockedRange !== null; - let rightConverted = this.rightUnitConversion.type !== null || this.rightUnitConversion.factor !== 1; - if (rightLocked && rightConverted) { - this.RIGHT_LABELS.innerText = " [Locked, Converted]"; - } else if (rightLocked) { - this.RIGHT_LABELS.innerText = " [Locked]"; - } else if (rightConverted) { - this.RIGHT_LABELS.innerText = " [Converted]"; - } else { - this.RIGHT_LABELS.innerText = ""; - } - } - /** Adds the enabled field to the discrete legend. */ - addDiscreteEnabled() { - let enabledKey = getEnabledKey(window.log); - if (enabledKey !== undefined) { - this.addField("discrete", enabledKey); - } - } - - /** Adjusts the locked range and unit conversion for an axis. */ - editAxis(legend: string, lockedRange: [number, number] | null, unitConversion: UnitConversionPreset) { - switch (legend) { - case "left": - if (lockedRange === null) { - this.leftLockedRange = null; - } else if (lockedRange[0] === null && lockedRange[1] === null) { - this.leftLockedRange = this.leftRenderedRange; - } else { - this.leftLockedRange = lockedRange; - } - this.leftUnitConversion = unitConversion; - break; - - case "right": - if (lockedRange === null) { - this.rightLockedRange = null; - } else if (lockedRange[0] === null && lockedRange[1] === null) { - this.rightLockedRange = this.rightRenderedRange; - } else { - this.rightLockedRange = lockedRange; - } - this.rightUnitConversion = unitConversion; - break; - } - this.updateAxisLabels(); - } - - /** Clears the fields for a legend. */ - clearAxis(legend: string) { - switch (legend) { - case "left": - this.leftFields = []; - while (this.LEFT_LIST.children[1]) this.LEFT_LIST.removeChild(this.LEFT_LIST.children[1]); - break; - - case "discrete": - this.discreteFields = []; - while (this.DISCRETE_LIST.children[1]) this.DISCRETE_LIST.removeChild(this.DISCRETE_LIST.children[1]); - break; - - case "right": - this.rightFields = []; - while (this.RIGHT_LIST.children[1]) this.RIGHT_LIST.removeChild(this.RIGHT_LIST.children[1]); - break; - } - } - - refresh() { - this.updateScroll(); - this.refreshCount += 1; - - // Update field strikethrough - let availableFields = window.log.getFieldKeys(); - [ - { fields: this.leftFields, element: this.LEFT_LIST }, - { fields: this.discreteFields, element: this.DISCRETE_LIST }, - { fields: this.rightFields, element: this.RIGHT_LIST } - ].forEach((data) => { - let fields = data.fields; - let element = data.element; - - for (let i = 0; i < fields.length; i++) { - let keyElement = element.children[i + 1].getElementsByClassName("legend-key")[0] as HTMLElement; - keyElement.style.textDecoration = availableFields.includes(fields[i].key) ? "initial" : "line-through"; - } - }); - } - - newAssets() {} - - /** Processes a drag event, including adding a field if necessary. */ - private handleDrag(dragData: any) { - if (this.CONTENT.hidden) return; - [ - { - legend: "left", - element: this.LEFT_LIST, - target: this.LEFT_DRAG_TARGET, - normalTypes: [LoggableType.Number], - arrayTypes: [LoggableType.NumberArray] - }, - { - legend: "discrete", - element: this.DISCRETE_LIST, - target: this.DISCRETE_DRAG_TARGET, - normalTypes: [ - LoggableType.Raw, - LoggableType.Boolean, - LoggableType.Number, - LoggableType.String, - LoggableType.BooleanArray, - LoggableType.NumberArray, - LoggableType.StringArray - ], - arrayTypes: [] - }, - { - legend: "right", - element: this.RIGHT_LIST, - target: this.RIGHT_DRAG_TARGET, - normalTypes: [LoggableType.Number], - arrayTypes: [LoggableType.NumberArray] - } - ].forEach((data) => { - let legend = data.legend as "left" | "discrete" | "right"; - let element = data.element; - let target = data.target; - let normalTypes = data.normalTypes; - let arrayTypes = data.arrayTypes; - - // Check if active and valid type - let rect = element.getBoundingClientRect(); - let active = - dragData.x > rect.left && dragData.x < rect.right && dragData.y > rect.top && dragData.y < rect.bottom; - let validType = false; - dragData.data.fields.forEach((key: string) => { - let type = window.log.getType(key) as LoggableType; - if (normalTypes.includes(type)) { - validType = true; - } - if (arrayTypes.includes(type)) { - validType = true; - } - }); - if ( - dragData.data.children.length > 0 && - dragData.data.children.some((childKey: string) => { - let childType = window.log.getType(childKey); - return childType !== null && normalTypes.includes(childType); - }) - ) { - validType = true; - } - - // Add field - if (dragData.end) { - target.hidden = true; - if (active && validType) { - dragData.data.fields.forEach((key: string) => { - let type = window.log.getType(key) as LoggableType; - if (normalTypes.includes(type)) { - this.addField(legend, key); - } else { - dragData.data.children.forEach((childKey: string) => { - let childType = window.log.getType(childKey); - if (childType !== null && normalTypes.includes(childType)) { - this.addField(legend, childKey); - } - }); - } - }); - } - } else { - target.hidden = !(active && validType); - } - }); - } - - /** Adds a new field. */ - private addField(legend: "left" | "discrete" | "right", key: string, color?: string, show: boolean = true) { - // Get color if not provided - if (color !== null) { - let usedColors: string[] = []; - [this.leftFields, this.discreteFields, this.rightFields].forEach((legend) => { - legend.forEach((field) => { - usedColors.push(field.color); - }); - }); - let availableColors = AllColors.filter((color) => !usedColors.includes(color)); - if (availableColors.length === 0) { - color = AllColors[Math.floor(Math.random() * AllColors.length)]; - } else { - color = availableColors[0]; - } - } - - // Find field list - let fieldList = { - left: this.leftFields, - discrete: this.discreteFields, - right: this.rightFields - }[legend]; - - // Create element - let isFound = window.log.getFieldKeys().includes(key); - let itemElement = this.LEGEND_ITEM_TEMPLATE.cloneNode(true) as HTMLElement; - let splotchElement = itemElement.getElementsByClassName("legend-splotch")[0] as HTMLElement; - let keyElement = itemElement.getElementsByClassName("legend-key")[0] as HTMLElement; - let removeElement = itemElement.getElementsByClassName("legend-edit")[0] as HTMLElement; - - itemElement.title = key; - keyElement.innerText = key; - if (!isFound) keyElement.style.textDecoration = "line-through"; - - splotchElement.style.fill = color; - itemElement.getElementsByClassName("legend-splotch")[0].addEventListener("click", () => { - if (!itemElement.parentElement) return; - let index = Array.from(itemElement.parentElement.children).indexOf(itemElement) - 1; - let show = !fieldList[index].show; - fieldList[index].show = show; - splotchElement.style.fill = show ? (color as string) : "transparent"; - }); - splotchElement.style.fill = show ? color : "transparent"; - - removeElement.title = ""; - removeElement.addEventListener("click", () => { - if (!itemElement.parentElement) return; - let index = Array.from(itemElement.parentElement.children).indexOf(itemElement) - 1; - itemElement.parentElement.removeChild(itemElement); - fieldList.splice(index, 1); - }); - - // Add field - fieldList.push({ - key: key, - color: color, - show: show - }); - switch (legend) { - case "left": - this.LEFT_LIST.appendChild(itemElement); - break; - case "discrete": - this.DISCRETE_LIST.appendChild(itemElement); - break; - case "right": - this.RIGHT_LIST.appendChild(itemElement); - break; - } - } - - getActiveFields(): string[] { - return [ - ...this.leftFields.map((field) => field.key), - ...this.discreteFields.map((field) => field.key), - ...this.rightFields.map((field) => field.key) - ]; - } - - /** Apply the scroll and update the timestamp range. */ - private updateScroll(dx: number = 0, dy: number = 0) { - // Find available timestamp range - let availableRange = window.log.getTimestampRange(); - availableRange = [availableRange[0], availableRange[1]]; - let liveTime = window.selection.getCurrentLiveTime(); - if (liveTime !== null) { - availableRange[1] = liveTime; - } - if (availableRange[1] - availableRange[0] < this.MIN_ZOOM_TIME) { - availableRange[1] = availableRange[0] + this.MIN_ZOOM_TIME; - } - - // Apply horizontal scroll - if (window.selection.getMode() === SelectionMode.Locked) { - let zoom = this.timestampRange[1] - this.timestampRange[0]; - this.timestampRange[0] = availableRange[1] - zoom; - this.timestampRange[1] = availableRange[1]; - if (dx < 0) window.selection.unlock(); // Unlock if attempting to scroll away - } else if (dx !== 0) { - let secsPerPixel = (this.timestampRange[1] - this.timestampRange[0]) / this.SCROLL_OVERLAY.clientWidth; - this.timestampRange[0] += dx * secsPerPixel; - this.timestampRange[1] += dx * secsPerPixel; - } - - // Apply vertical scroll - if (dy !== 0 && (!this.maxZoom || dy < 0)) { - // If max zoom, ignore positive scroll (no effect, just apply the max zoom) - let zoomPercent = Math.pow(this.ZOOM_BASE, dy); - let newZoom = (this.timestampRange[1] - this.timestampRange[0]) * zoomPercent; - if (newZoom < this.MIN_ZOOM_TIME) newZoom = this.MIN_ZOOM_TIME; - if (newZoom > availableRange[1] - availableRange[0]) newZoom = availableRange[1] - availableRange[0]; - - let hoveredTime = window.selection.getHoveredTime(); - if (hoveredTime !== null) { - let hoveredPercent = (hoveredTime - this.timestampRange[0]) / (this.timestampRange[1] - this.timestampRange[0]); - this.timestampRange[0] = hoveredTime - newZoom * hoveredPercent; - this.timestampRange[1] = hoveredTime + newZoom * (1 - hoveredPercent); - } - } else if (this.maxZoom) { - this.timestampRange = availableRange; - } - - // Enforce max range - if (this.timestampRange[1] - this.timestampRange[0] > availableRange[1] - availableRange[0]) { - this.timestampRange = availableRange; - } - this.maxZoom = this.timestampRange[1] - this.timestampRange[0] === availableRange[1] - availableRange[0]; - - // Enforce left limit - if (this.timestampRange[0] < availableRange[0]) { - let shift = availableRange[0] - this.timestampRange[0]; - this.timestampRange[0] += shift; - this.timestampRange[1] += shift; - } - - // Enforce right limit - if (this.timestampRange[1] > availableRange[1]) { - let shift = availableRange[1] - this.timestampRange[1]; - this.timestampRange[0] += shift; - this.timestampRange[1] += shift; - if (dx > 0) window.selection.lock(); // Lock if action is intentional - } - } - - /** Adjusts the range to fit the extreme limits. */ - private limitAxisRange(range: [number, number]): [number, number] { - let adjustedRange = [range[0], range[1]] as [number, number]; - if (adjustedRange[0] > this.MAX_VALUE) { - adjustedRange[0] = this.MAX_VALUE; - } - if (adjustedRange[1] > this.MAX_VALUE) { - adjustedRange[1] = this.MAX_VALUE; - } - if (adjustedRange[0] < -this.MAX_VALUE) { - adjustedRange[0] = -this.MAX_VALUE; - } - if (adjustedRange[1] < -this.MAX_VALUE) { - adjustedRange[1] = -this.MAX_VALUE; - } - if (adjustedRange[0] === adjustedRange[1]) { - if (Math.abs(adjustedRange[0]) >= this.MAX_VALUE) { - if (adjustedRange[0] > 0) { - adjustedRange[0] *= 0.8; - } else { - adjustedRange[1] *= 0.8; - } - } else { - adjustedRange[0]--; - adjustedRange[1]++; - } - } - if (adjustedRange[1] - adjustedRange[0] > this.MAX_AXIS_RANGE) { - if (adjustedRange[0] + this.MAX_AXIS_RANGE < this.MAX_VALUE) { - adjustedRange[1] = adjustedRange[0] + this.MAX_AXIS_RANGE; - } else { - adjustedRange[0] = adjustedRange[1] - this.MAX_AXIS_RANGE; - } - } - if (adjustedRange[1] - adjustedRange[0] < this.MIN_AXIS_RANGE) { - adjustedRange[1] = adjustedRange[0] + this.MIN_AXIS_RANGE; - } - return adjustedRange; - } - - /** - * Calculates appropriate bounds and steps based on data. - * @param primaryAxis The config from another axis (gridlines will be aligned). - * @param sizePx (If no primary axis) The available size on the graph - * @param targetStepPx (If no primary axis) The optimal size of each step - * - * @param lockedRange Always use this range instead of the automatic version - * @param valueRange (If not locked) The range of values that are visible in the current timestamp range - * @param marginProportion (If not locked) The size of the margin above and below visible data - * - * @param customUnit The multiplier for an extra unit that can be used for large values (e.g. 60 for minutes) - * - * @returns The parameters for the axis. - */ - private calcAutoAxis( - primaryAxis: AxisConfig | null, - sizePx: number | null, - targetStepPx: number | null, - lockedRange: [number, number] | null, - valueRange: [number, number] | null, - marginProportion: number | null, - customUnit: number = 1 - ): AxisConfig { - // Calc target range - let targetRange: [number, number] = [0, 1]; - if (lockedRange !== null) { - targetRange = this.limitAxisRange(lockedRange); - } else if (valueRange !== null && marginProportion !== null) { - let adjustedRange = this.limitAxisRange(valueRange); - let margin = (adjustedRange[1] - adjustedRange[0]) * marginProportion; - targetRange = [adjustedRange[0] - margin, adjustedRange[1] + margin]; - } - - // How many steps? - let stepCount: number = 1; - if (primaryAxis !== null) { - stepCount = (primaryAxis.max - primaryAxis.min) / primaryAxis.step; - } else if (sizePx !== null && targetStepPx !== null) { - stepCount = sizePx / targetStepPx; - } - let stepValueApprox = (targetRange[1] - targetRange[0]) / stepCount; - - // Clean up step size - let useCustomUnit = customUnit !== null && stepValueApprox > customUnit; - let roundBase; - if (useCustomUnit) { - roundBase = customUnit * 10 ** Math.floor(Math.log10(stepValueApprox / customUnit)); - } else { - roundBase = 10 ** Math.floor(Math.log10(stepValueApprox)); - } - let multiplierLookup: number[]; - if (primaryAxis === null) { - multiplierLookup = [0, 1, 2, 2, 5, 5, 5, 5, 5, 10, 10]; // Use friendly numbers if possible - } else { - multiplierLookup = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; // Use all numbers to get a better fit - } - let stepValue = roundBase * multiplierLookup[Math.round(stepValueApprox / roundBase)]; - - // Adjust to match primary gridlines - if (primaryAxis !== null) { - let midPrimary = (primaryAxis.min + primaryAxis.max) / 2; - let midSecondary = (targetRange[0] + targetRange[1]) / 2; - let midStepPrimary = Math.ceil(cleanFloat(midPrimary / primaryAxis.step)) * primaryAxis.step; - let midStepSecondary = Math.ceil(cleanFloat(midSecondary / stepValue)) * stepValue; - - let newMin = ((primaryAxis.min - midStepPrimary) / primaryAxis.step) * stepValue + midStepSecondary; - let newMax = ((primaryAxis.max - midStepPrimary) / primaryAxis.step) * stepValue + midStepSecondary; - return { - min: newMin, - max: newMax, - step: stepValue, - unit: useCustomUnit ? customUnit : 1 - }; - } else { - return { - min: targetRange[0], - max: targetRange[1], - step: stepValue, - unit: useCustomUnit ? customUnit : 1 - }; - } - } - - periodic() { - // Scroll sensor periodic - this.scrollSensor.periodic(); - - // Update to ensure smoothness when locked - this.updateScroll(0, 0); - - // Calculate initial setup and scaling - const devicePixelRatio = window.devicePixelRatio; - let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; - let width = this.CANVAS_CONTAINER.clientWidth; - let height = this.CANVAS_CONTAINER.clientHeight; - let light = !window.matchMedia("(prefers-color-scheme: dark)").matches; - - // Exit if render state unchanged - let renderState: any[] = [ - width, - height, - light, - devicePixelRatio, - this.timestampRange, - this.lastCursorX, - this.refreshCount, - this.leftFields, - this.discreteFields, - this.rightFields, - window.selection.getMode(), - window.selection.getSelectedTime(), - window.selection.getHoveredTime() - ]; - let renderStateString = JSON.stringify(renderState); - if (renderStateString === this.lastRenderState) { - return; - } - this.lastRenderState = renderStateString; - - // Apply initial setup and scaling - this.CANVAS.width = width * devicePixelRatio; - this.CANVAS.height = height * devicePixelRatio; - context.scale(devicePixelRatio, devicePixelRatio); - context.clearRect(0, 0, width, height); - context.font = "12px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont"; - - // Cache data for all fields - let dataCache: { [id: string]: LogValueSetAny } = {}; - let typeCache: { [id: string]: LoggableType } = {}; - let leftRange: [number, number] = [-1, 1]; - let rightRange: [number, number] = [-1, 1]; - - let availableKeys = window.log.getFieldKeys(); - [this.leftFields, this.discreteFields, this.rightFields].forEach((array, legendIndex) => { - let range: [number, number] = [Infinity, -Infinity]; - array.forEach((field) => { - if (!field.show || !availableKeys.includes(field.key)) return; - - // Read data for field - if (!Object.keys(dataCache).includes(field.key)) { - let logData = window.log.getRange( - field.key, - this.timestampRange[0], - this.timestampRange[1] - ) as LogValueSetAny; - if ( - logData.timestamps.length > 0 && - logData.timestamps[logData.timestamps.length - 1] > this.timestampRange[1] - ) { - // Last value is after end of timestamp range - logData.timestamps.pop(); - logData.values.pop(); - } - dataCache[field.key] = logData; - typeCache[field.key] = window.log.getType(field.key) as LoggableType; - } - - // Update range for left & right legends - if (legendIndex !== 1 && typeCache[field.key] === LoggableType.Number) { - if ( - dataCache[field.key].timestamps.length === 1 && - dataCache[field.key].timestamps[0] > this.timestampRange[1] - ) { - return; // Not displayed - } - - (dataCache[field.key] as LogValueSetNumber).values.forEach((value) => { - if (legendIndex === 0) value = convertWithPreset(value, this.leftUnitConversion); - if (legendIndex === 2) value = convertWithPreset(value, this.rightUnitConversion); - if (value < range[0]) range[0] = value; - if (value > range[1]) range[1] = value; - }); - } - }); - - // Save range - if (!isFinite(range[0])) range[0] = -1; - if (!isFinite(range[1])) range[1] = 1; - if (legendIndex === 0) leftRange = range; - if (legendIndex === 2) rightRange = range; - }); - - let visibleFieldsLeft = this.leftFields.filter((field) => field.show && Object.keys(dataCache).includes(field.key)); - let visibleFieldsDiscrete = this.discreteFields.filter( - (field) => field.show && Object.keys(dataCache).includes(field.key) - ); - let visibleFieldsRight = this.rightFields.filter( - (field) => field.show && Object.keys(dataCache).includes(field.key) - ); - - // Calculate vertical layout for graph (based on discrete fields) - let graphTop = 8; - let graphHeight = height - graphTop - 50; - if (graphHeight < 1) graphHeight = 1; - let graphHeightOpen = graphHeight - visibleFieldsDiscrete.length * 20 - (visibleFieldsDiscrete.length > 0 ? 5 : 0); - if (graphHeightOpen < 1) graphHeightOpen = 1; - - // Calculate y axes - const TARGET_STEP_PX = 50; - const PRIMARY_MARGIN = 0.05; - const SECONDARY_MARGIN = 0.3; - let showLeftAxis = visibleFieldsLeft.length > 0 || this.leftLockedRange !== null; - let showRightAxis = visibleFieldsRight.length > 0 || this.rightLockedRange !== null; - if (!showLeftAxis && !showRightAxis) showLeftAxis = true; - let leftIsPrimary = this.leftLockedRange !== null; - let rightIsPrimary = this.rightLockedRange !== null; - if (!leftIsPrimary && !rightIsPrimary) { - if (visibleFieldsRight.length > visibleFieldsLeft.length) { - rightIsPrimary = true; - } else { - leftIsPrimary = true; - } - } - let leftAxis: AxisConfig; - let rightAxis: AxisConfig; - if (leftIsPrimary && rightIsPrimary) { - leftAxis = this.calcAutoAxis( - null, - graphHeightOpen, - TARGET_STEP_PX, - this.leftLockedRange, - leftRange, - PRIMARY_MARGIN - ); - rightAxis = this.calcAutoAxis( - null, - graphHeightOpen, - TARGET_STEP_PX, - this.rightLockedRange, - rightRange, - PRIMARY_MARGIN - ); - } else if (leftIsPrimary && !rightIsPrimary) { - leftAxis = this.calcAutoAxis( - null, - graphHeightOpen, - TARGET_STEP_PX, - this.leftLockedRange, - leftRange, - PRIMARY_MARGIN - ); - rightAxis = this.calcAutoAxis(leftAxis, null, null, this.rightLockedRange, rightRange, SECONDARY_MARGIN); - } else { - rightAxis = this.calcAutoAxis( - null, - graphHeightOpen, - TARGET_STEP_PX, - this.rightLockedRange, - rightRange, - PRIMARY_MARGIN - ); - leftAxis = this.calcAutoAxis(rightAxis, null, null, this.leftLockedRange, leftRange, SECONDARY_MARGIN); - } - this.leftRenderedRange = [leftAxis.min, leftAxis.max]; - this.rightRenderedRange = [rightAxis.min, rightAxis.max]; - - // Calculate horizontal layout for graph - let getTextWidth = (config: AxisConfig): number => { - let length = 0; - let value = Math.floor(config.max / config.step) * config.step; - while (value > config.min) { - length = Math.max(length, context.measureText(cleanFloat(value).toString()).width); - value -= config.step; - } - return Math.ceil(length / 10) * 10; - }; - let graphLeft = 25 + (showLeftAxis ? getTextWidth(leftAxis) : 0); - let graphRight = 25 + (showRightAxis ? getTextWidth(rightAxis) : 0); - let graphWidth = width - graphLeft - graphRight; - if (graphWidth < 1) graphWidth = 1; - - // Calculate x axis - let xAxis = this.calcAutoAxis(null, graphWidth, 100, null, this.timestampRange, 0, 60); - - // Update hovered time based on graph layout - if (this.lastCursorX === null || this.lastCursorX < graphLeft || this.lastCursorX > graphLeft + graphWidth) { - window.selection.setHoveredTime(null); - } else { - window.selection.setHoveredTime( - scaleValue(this.lastCursorX, [graphLeft, graphLeft + graphWidth], this.timestampRange) - ); - } - - // Update scroll layout - this.SCROLL_OVERLAY.style.left = graphLeft.toString() + "px"; - this.SCROLL_OVERLAY.style.right = graphRight.toString() + "px"; - - // Render discrete data - context.globalAlpha = 1; - context.textAlign = "left"; - context.textBaseline = "middle"; - visibleFieldsDiscrete.forEach((field, renderIndex) => { - let type = typeCache[field.key]; - let data = dataCache[field.key]; - - let isDark = window.log.getTimestamps([field.key]).indexOf(data.timestamps[0]) % 2 === 0; - isDark = isDark !== window.log.getStripingReference(field.key); - for (let i = 0; i < data.timestamps.length; i++) { - let startX = scaleValue(data.timestamps[i], this.timestampRange, [graphLeft, graphLeft + graphWidth]); - let endX: number; - if (i === data.timestamps.length - 1) { - endX = graphLeft + graphWidth; - } else { - endX = scaleValue(data.timestamps[i + 1], this.timestampRange, [graphLeft, graphLeft + graphWidth]); - } - if (endX > graphLeft + graphWidth) endX = graphLeft + graphWidth; - let topY = graphTop + graphHeight - 20 - renderIndex * 20; - - // Draw rectangle - isDark = !isDark; - if (type === LoggableType.Boolean) isDark = data.values[i]; - context.fillStyle = isDark ? shiftColor(field.color, -30) : shiftColor(field.color, 30); - context.fillRect(startX, topY, endX - startX, 15); - - // Draw text - let adjustedStartX = startX < graphLeft ? graphLeft : startX; - if (endX - adjustedStartX > 10) { - let text = getLogValueText(data.values[i], type); - context.fillStyle = isDark ? shiftColor(field.color, 130) : shiftColor(field.color, -130); - context.fillText(text, adjustedStartX + 5, topY + 15 / 2, endX - adjustedStartX - 10); - } - } - }); - - // Render continuous data - const xScaler = new ValueScaler(this.timestampRange, [graphLeft, graphLeft + graphWidth]); - [ - { fields: visibleFieldsLeft, axis: leftAxis, unitConversion: this.leftUnitConversion }, - { fields: visibleFieldsRight, axis: rightAxis, unitConversion: this.rightUnitConversion } - ].forEach((set) => { - set.fields.forEach((field) => { - let data: LogValueSetNumber = dataCache[field.key]; - let axis = set.axis; - let unitConversion = set.unitConversion; - const yScaler = new ValueScaler([axis.min, axis.max], [graphTop + graphHeightOpen, graphTop]); - context.lineWidth = 1; - context.strokeStyle = field.color; - context.beginPath(); - - // Render starting point - context.moveTo( - graphLeft + graphWidth, - yScaler.calculate( - clampValue( - convertWithPreset(data.values[data.values.length - 1], unitConversion), - -this.MAX_VALUE, - this.MAX_VALUE - ) - ) - ); - - // Render main data - let i = data.values.length - 1; - while (true) { - let x = xScaler.calculate(data.timestamps[i]); - - // Render start of current data point - let convertedValue = clampValue( - convertWithPreset(data.values[i], unitConversion), - -this.MAX_VALUE, - this.MAX_VALUE - ); - context.lineTo(x, yScaler.calculate(convertedValue)); - - // Find previous data point and vertical range - let currentX = Math.floor(x * devicePixelRatio); - let newX = currentX; - let vertRange = [convertedValue, convertedValue]; - do { - i--; - let convertedValue = clampValue( - convertWithPreset(data.values[i], unitConversion), - -this.MAX_VALUE, - this.MAX_VALUE - ); - if (convertedValue < vertRange[0]) vertRange[0] = convertedValue; - if (convertedValue > vertRange[1]) vertRange[1] = convertedValue; - newX = Math.floor(xScaler.calculate(data.timestamps[i]) * devicePixelRatio); - } while (i >= 0 && newX >= currentX); // Compile values to vertical range until the pixel changes - if (i < 0) break; - - // Render vertical range - context.moveTo(x, yScaler.calculate(vertRange[0])); - context.lineTo(x, yScaler.calculate(vertRange[1])); - - // Move to end of previous data point - context.moveTo( - x, - yScaler.calculate( - clampValue(convertWithPreset(data.values[i], unitConversion), -this.MAX_VALUE, this.MAX_VALUE) - ) - ); - } - context.stroke(); - }); - }); - - //Use similar logic as main axes but with an extra decimal point of precision to format the popup timestamps - let formatMarkedTimestampText = (time: number): string => { - let fractionDigits = Math.max(0, -Math.floor(Math.log10(xAxis.step / 10))); - return time.toFixed(fractionDigits) + "s"; - }; - - // Write formatted timestamp popups to graph view - let writeCenteredTime = (text: string, x: number, alpha: number, drawRect: boolean) => { - context.globalAlpha = alpha; - context.strokeStyle = light ? "#222" : "#eee"; - context.fillStyle = light ? "#222" : "#eee"; - let textSize = context.measureText(text); - context.clearRect( - x - textSize.actualBoundingBoxLeft - 5, - graphTop, - textSize.width + 10, - textSize.actualBoundingBoxDescent + 10 - ); - if (drawRect) { - context.strokeRect( - x - textSize.actualBoundingBoxLeft - 5, - graphTop, - textSize.width + 10, - textSize.actualBoundingBoxDescent + 10 - ); - } - - context.fillText(text, x, graphTop + 5); - context.globalAlpha = 1; - }; - - // Draw a vertical dotted line at the time - let markTime = (time: number, alpha: number) => { - if (time >= this.timestampRange[0] && time <= this.timestampRange[1]) { - context.globalAlpha = alpha; - context.lineWidth = 1; - context.setLineDash([5, 5]); - context.strokeStyle = light ? "#222" : "#eee"; - context.fillStyle = light ? "#222" : "#eee"; - - let x = scaleValue(time, this.timestampRange, [graphLeft, graphLeft + graphWidth]); - context.beginPath(); - context.moveTo(x, graphTop); - context.lineTo(x, graphTop + graphHeight); - context.stroke(); - context.setLineDash([]); - context.globalAlpha = 1; - } - }; - - // Render selected times - context.textBaseline = "top"; - context.textAlign = "center"; - let selectionMode = window.selection.getMode(); - let selectedTime = window.selection.getSelectedTime(); - let hoveredTime = window.selection.getHoveredTime(); - let selectedX = - selectedTime === null ? null : scaleValue(selectedTime, this.timestampRange, [graphLeft, graphLeft + graphWidth]); - let hoveredX = - hoveredTime === null ? null : scaleValue(hoveredTime, this.timestampRange, [graphLeft, graphLeft + graphWidth]); - let selectedText = selectedTime === null ? null : formatMarkedTimestampText(selectedTime); - let hoveredText = hoveredTime === null ? null : formatMarkedTimestampText(hoveredTime); - if (hoveredTime !== null) markTime(hoveredTime!, 0.35); - if (selectionMode === SelectionMode.Static || selectionMode === SelectionMode.Playback) { - // There is a valid selected time - selectedTime = selectedTime as number; - selectedX = selectedX as number; - selectedText = selectedText as string; - markTime(selectedTime!, 1); - if (hoveredTime !== null && hoveredTime !== selectedTime) { - // Write both selected and hovered time, figure out layout - hoveredTime = hoveredTime as number; - hoveredX = hoveredX as number; - hoveredText = hoveredText as string; - - let deltaText = "\u0394" + formatMarkedTimestampText(hoveredTime - selectedTime); - let xSpace = clampValue(selectedX, graphLeft, graphLeft + graphWidth) - hoveredX; - let textHalfWidths = - (context.measureText(selectedText).width + 10) / 2 + (context.measureText(hoveredText).width + 10) / 2 + 4; - let deltaTextMetrics = context.measureText(deltaText); - let deltaWidth = deltaTextMetrics.width + 10 + 4; - let offsetAmount = textHalfWidths - Math.abs(xSpace); - let doesDeltaFit = deltaWidth <= Math.abs(xSpace); - if (doesDeltaFit) { - // Enough space for delta text - offsetAmount = textHalfWidths + deltaWidth - Math.abs(xSpace); - - // Draw connecting line between two cursors, overlapping parts will be automatically cleared - let centerY = (deltaTextMetrics.actualBoundingBoxDescent + 10) / 2 + graphTop; - context.globalAlpha = 0.35; - context.lineWidth = 1; - context.setLineDash([]); - context.strokeStyle = light ? "#222" : "#eee"; - context.beginPath(); - context.moveTo(selectedX, centerY); - context.lineTo(hoveredX, centerY); - context.stroke(); - context.globalAlpha = 1; - - // Draw delta text - let deltaX = (selectedX + hoveredX) / 2; - if (selectedTime < this.timestampRange[0]) { - deltaX = Math.max(deltaX, graphLeft + deltaWidth / 2 - 2); - } else if (selectedTime > this.timestampRange[1]) { - deltaX = Math.min(deltaX, graphLeft + graphWidth - deltaWidth / 2 + 2); - } - writeCenteredTime(deltaText, deltaX, 0.35, false); - } - if (offsetAmount > 0) { - selectedX = selectedX + (offsetAmount / 2) * (selectedX < hoveredX ? -1 : 1); - hoveredX = hoveredX - (offsetAmount / 2) * (selectedX < hoveredX ? -1 : 1); - } - writeCenteredTime(selectedText, selectedX, 1, true); - writeCenteredTime(hoveredText, hoveredX, 0.35, true); - } else { - // No valid hovered time, only write selected time - writeCenteredTime(selectedText, selectedX, 1, true); - } - } else if (hoveredTime !== null) { - // No valid selected time, only write hovered time - writeCenteredTime(hoveredText!, hoveredX!, 0.35, true); - } - - // Clear overflow & draw graph outline - context.lineWidth = 1; - context.strokeStyle = light ? "#222" : "#eee"; - context.clearRect(0, 0, width, graphTop); - context.clearRect(0, graphTop + graphHeight, width, height - graphTop - graphHeight); - context.clearRect(0, graphTop, graphLeft, graphHeight); - context.clearRect(graphLeft + graphWidth, graphTop, width - graphLeft - graphWidth, graphHeight); - context.strokeRect(graphLeft, graphTop, graphWidth, graphHeight); - - // Render y axes - context.lineWidth = 1; - context.strokeStyle = light ? "#222" : "#eee"; - context.fillStyle = light ? "#222" : "#eee"; - context.textBaseline = "middle"; - - if (showLeftAxis) { - context.textAlign = "right"; - let stepPos = Math.floor(leftAxis.max / leftAxis.step) * leftAxis.step; - while (true) { - let y = scaleValue(stepPos, [leftAxis.min, leftAxis.max], [graphTop + graphHeightOpen, graphTop]); - if (y > graphTop + graphHeight) break; - - context.globalAlpha = 1; - if (Math.abs(stepPos) < this.MAX_DECIMAL_VALUE || stepPos % 1 === 0) { - let value = Math.abs(stepPos) < this.MAX_DECIMAL_VALUE ? cleanFloat(stepPos) : Math.round(stepPos); - context.fillText(value.toString(), graphLeft - 15, y); - context.beginPath(); - context.moveTo(graphLeft, y); - context.lineTo(graphLeft - 5, y); - context.stroke(); - } - - if (leftIsPrimary) { - context.globalAlpha = 0.1; - context.beginPath(); - context.moveTo(graphLeft, y); - context.lineTo(graphLeft + graphWidth, y); - context.stroke(); - } - - stepPos -= leftAxis.step; - } - } - - if (showRightAxis) { - context.textAlign = "left"; - let stepPos = Math.floor(rightAxis.max / rightAxis.step) * rightAxis.step; - while (true) { - let y = scaleValue(stepPos, [rightAxis.min, rightAxis.max], [graphTop + graphHeightOpen, graphTop]); - if (y > graphTop + graphHeight) break; - - context.globalAlpha = 1; - if (Math.abs(stepPos) < this.MAX_DECIMAL_VALUE || stepPos % 1 === 0) { - let value = Math.abs(stepPos) < this.MAX_DECIMAL_VALUE ? cleanFloat(stepPos) : Math.round(stepPos); - context.fillText(value.toString(), graphLeft + graphWidth + 15, y); - context.beginPath(); - context.moveTo(graphLeft + graphWidth, y); - context.lineTo(graphLeft + graphWidth + 5, y); - context.stroke(); - } - - if (!leftIsPrimary) { - context.globalAlpha = 0.1; - context.beginPath(); - context.moveTo(graphLeft, y); - context.lineTo(graphLeft + graphWidth, y); - context.stroke(); - } - - stepPos -= rightAxis.step; - } - } - - // Render x axis - context.textAlign = "center"; - let stepPos = Math.ceil(cleanFloat(xAxis.min / xAxis.step)) * xAxis.step; - while (true) { - let x = scaleValue(stepPos, [xAxis.min, xAxis.max], [graphLeft, graphLeft + graphWidth]); - - // Clean up final x (scroll can cause rounding problems) - if (x - graphLeft - graphWidth > 1) { - break; - } else if (x - graphLeft - graphWidth > 0) { - x = graphLeft + graphWidth; - } - - let text = cleanFloat(stepPos / xAxis.unit).toString() + (xAxis.unit === 60 ? "m" : "s"); - - context.globalAlpha = 1; - context.fillText(text, x, graphTop + graphHeight + 15); - context.beginPath(); - context.moveTo(x, graphTop + graphHeight); - context.lineTo(x, graphTop + graphHeight + 5); - context.stroke(); - - context.globalAlpha = 0.1; - context.beginPath(); - context.moveTo(x, graphTop); - context.lineTo(x, graphTop + graphHeight); - context.stroke(); - - stepPos += xAxis.step; - } - - // Update value preview - let previewTime: number | null = null; - if (selectionMode === SelectionMode.Playback || selectionMode === SelectionMode.Locked) { - previewTime = selectedTime as number; - } else if (hoveredTime !== null) { - previewTime = hoveredTime; - } else if (selectedTime !== null) { - previewTime = selectedTime; - } - [ - [this.LEFT_LIST, this.leftFields], - [this.DISCRETE_LIST, this.discreteFields], - [this.RIGHT_LIST, this.rightFields] - ].forEach((fieldData, legendIndex) => { - let parentElement = fieldData[0] as HTMLElement; - let fieldList = fieldData[1] as { - key: string; - color: string; - show: boolean; - }[]; - Array.from(parentElement.children).forEach((itemElement, index) => { - if (index === 0) return; - let valueElement = itemElement.getElementsByClassName("legend-value")[0] as HTMLElement; - let key = fieldList[index - 1].key; - let hasValue = false; - if (previewTime !== null && availableKeys.includes(key)) { - let currentData = window.log.getRange(key, previewTime, previewTime); - if (currentData && currentData.timestamps.length > 0 && currentData.timestamps[0] <= previewTime) { - let value = currentData.values[0]; - if (legendIndex === 0) value = convertWithPreset(value, this.leftUnitConversion); - if (legendIndex === 2) value = convertWithPreset(value, this.rightUnitConversion); - let text = getLogValueText(value, window.log.getType(key)!); - if (text !== valueElement.innerText) valueElement.innerText = text; - hasValue = true; - } - } - - if (previewTime !== null && availableKeys.includes(key) && hasValue) { - itemElement.classList.add("legend-item-with-value"); - } else { - itemElement.classList.remove("legend-item-with-value"); - } - }); - }); - } -} - -interface AxisConfig { - min: number; - max: number; - step: number; - - /** The multipler used for the unit, if applicable. Does not affect the - * actual size of the other values, but it can be used for labeling. */ - unit: number; -} diff --git a/src/hub/tabControllers/MechanismController.ts b/src/hub/tabControllers/MechanismController.ts deleted file mode 100644 index 5e546b04..00000000 --- a/src/hub/tabControllers/MechanismController.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getMechanismState, MechanismState, mergeMechanismStates } from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import MechanismVisualizer from "../../shared/visualizers/MechanismVisualizer"; -import TimelineVizController from "./TimelineVizController"; - -export default class MechanismController extends TimelineVizController { - constructor(content: HTMLElement) { - let configBody = content.getElementsByClassName("timeline-viz-config")[0].firstElementChild as HTMLElement; - super( - content, - TabType.Mechanism, - [ - { - element: configBody.children[1].children[0] as HTMLElement, - types: ["Mechanism2d"] - }, - { - element: configBody.children[1].children[1] as HTMLElement, - types: ["Mechanism2d"] - }, - { - element: configBody.children[1].children[2] as HTMLElement, - types: ["Mechanism2d"] - } - ], - [], - new MechanismVisualizer(content.getElementsByClassName("mechanism-svg-container")[0] as HTMLElement) - ); - } - - get options(): { [id: string]: any } { - return {}; - } - - set options(options: { [id: string]: any }) {} - - newAssets() {} - - getAdditionalActiveFields(): string[] { - return []; - } - - getCommand(time: number): MechanismState | null { - let states: MechanismState[] = []; - this.getFields() - .filter((field) => field !== null) - .forEach((field) => { - let state = getMechanismState(window.log, field!.key, time); - if (state !== null) states.push(state); - }); - - if (states.length === 0) { - return null; - } else { - return mergeMechanismStates(states); - } - } -} diff --git a/src/hub/tabControllers/OdometryController.ts b/src/hub/tabControllers/OdometryController.ts deleted file mode 100644 index 4c84dae0..00000000 --- a/src/hub/tabControllers/OdometryController.ts +++ /dev/null @@ -1,568 +0,0 @@ -import TabType from "../../shared/TabType"; -import { - Pose2d, - Translation2d, - logReadNumberArrayToPose2dArray, - logReadPose2d, - logReadPose2dArray, - logReadTrajectoryToPose2dArray, - logReadTranslation2dArrayToPose2dArray, - logReadTranslation2dToPose2d, - numberArrayToPose2dArray -} from "../../shared/geometry"; -import { ALLIANCE_KEYS, getEnabledData, getIsRedAlliance, getOrDefault } from "../../shared/log/LogUtil"; -import LoggableType from "../../shared/log/LoggableType"; -import { convert } from "../../shared/units"; -import { scaleValue } from "../../shared/util"; -import OdometryVisualizer from "../../shared/visualizers/OdometryVisualizer"; -import TimelineVizController from "./TimelineVizController"; - -export default class OdometryController extends TimelineVizController { - private static HEATMAP_DT = 0.1; - private static TRAIL_LENGTH_SECS = 5; - private static POSE_TYPES = [ - "Robot", - "Ghost", - "Trajectory", - "Vision Target", - "Heatmap", - "Heatmap (Enabled)", - "Arrow (Front)", - "Arrow (Center)", - "Arrow (Back)" - ]; - - private GAME: HTMLInputElement; - private GAME_SOURCE_LINK: HTMLElement; - private UNIT_DISTANCE: HTMLInputElement; - private UNIT_ROTATION: HTMLInputElement; - private ORIGIN: HTMLInputElement; - private SIZE: HTMLInputElement; - private SIZE_TEXT: HTMLElement; - private ALLIANCE_BUMPERS: HTMLInputElement; - private ALLIANCE_ORIGIN: HTMLInputElement; - private ORIENTATION: HTMLInputElement; - - private lastUnitDistance = "meters"; - - constructor(content: HTMLElement) { - let configBody = content.getElementsByClassName("timeline-viz-config")[0].firstElementChild as HTMLElement; - super( - content, - TabType.Odometry, - [], - [ - { - element: configBody.children[1].firstElementChild as HTMLElement, - types: [ - LoggableType.NumberArray, - "Pose2d", - "Pose2d[]", - "Transform2d", - "Transform2d[]", - "Translation2d", - "Translation2d[]", - "Trajectory", - "ZebraTranslation" - ], - options: [ - OdometryController.POSE_TYPES, // NumberArray - OdometryController.POSE_TYPES.filter((x) => x !== "Trajectory"), // Pose2d - OdometryController.POSE_TYPES, // Pose2d[] - OdometryController.POSE_TYPES.filter((x) => x !== "Trajectory"), // Transform2d - OdometryController.POSE_TYPES, // Transform2d[] - ["Vision Target", "Heatmap", "Heatmap (Enabled)"], // Translation2d - ["Trajectory", "Vision Target", "Heatmap", "Heatmap (Enabled)"], // Translation2d[] - ["Trajectory"], // Trajectory - ["Zebra Marker", "Ghost"] // ZebraTranslation - ], - autoAdvanceOptions: [true, true, true, true, true, true, true, true, false] - } - ], - new OdometryVisualizer( - content.getElementsByClassName("odometry-canvas-container")[0] as HTMLElement, - content.getElementsByClassName("odometry-heatmap-container")[0] as HTMLElement - ) - ); - - // Get option inputs - this.GAME = configBody.children[1].children[1].children[1] as HTMLInputElement; - this.GAME_SOURCE_LINK = configBody.children[1].children[1].children[2] as HTMLElement; - this.UNIT_DISTANCE = configBody.children[2].children[0].children[1] as HTMLInputElement; - this.UNIT_ROTATION = configBody.children[2].children[0].children[2] as HTMLInputElement; - this.ORIGIN = configBody.children[3].children[0].lastElementChild as HTMLInputElement; - this.SIZE = configBody.children[1].lastElementChild?.children[1] as HTMLInputElement; - this.SIZE_TEXT = configBody.children[1].lastElementChild?.lastElementChild as HTMLElement; - this.ALLIANCE_BUMPERS = configBody.children[2].lastElementChild?.children[1] as HTMLInputElement; - this.ALLIANCE_ORIGIN = configBody.children[2].lastElementChild?.children[2] as HTMLInputElement; - this.ORIENTATION = configBody.children[3].lastElementChild?.lastElementChild as HTMLInputElement; - - // Set default alliance values - this.ALLIANCE_BUMPERS.value = "auto"; - this.ALLIANCE_ORIGIN.value = "auto"; - - // Add initial set of options - this.resetGameOptions(); - - // Unit conversion for distance - this.UNIT_DISTANCE.addEventListener("change", () => { - let newUnit = this.UNIT_DISTANCE.value; - if (newUnit !== this.lastUnitDistance) { - let oldSize = Number(this.SIZE.value); - if (newUnit === "meters") { - this.SIZE.value = (Math.round(convert(oldSize, "inches", "meters") * 1000) / 1000).toString(); - this.SIZE.step = "0.01"; - } else { - this.SIZE.value = (Math.round(convert(oldSize, "meters", "inches") * 100) / 100).toString(); - this.SIZE.step = "1"; - } - this.SIZE_TEXT.innerText = newUnit; - this.lastUnitDistance = newUnit; - } - }); - - // Bind source link - this.GAME.addEventListener("change", () => this.updateGameDependentControls()); - this.GAME_SOURCE_LINK.addEventListener("click", () => { - window.sendMainMessage( - "open-link", - window.assets?.field2ds.find((game) => game.name === this.GAME.value)?.sourceUrl - ); - }); - - // Enforce side length range - this.SIZE.addEventListener("change", () => { - if (Number(this.SIZE.value) < 0) this.SIZE.value = "0.1"; - if (Number(this.SIZE.value) === 0) this.SIZE.value = "0.1"; - }); - } - - /** Clears all options from the game selector then updates it with the latest options. */ - private resetGameOptions() { - let value = this.GAME.value; - while (this.GAME.firstChild) { - this.GAME.removeChild(this.GAME.firstChild); - } - let options: string[] = []; - if (window.assets !== null) { - options = window.assets.field2ds.map((game) => game.name); - options.forEach((title) => { - let option = document.createElement("option"); - option.innerText = title; - this.GAME.appendChild(option); - }); - } - if (options.includes(value)) { - this.GAME.value = value; - } else { - this.GAME.value = options[0]; - } - this.updateGameDependentControls(this.GAME.value === value); // Skip origin reset if game is unchanged - } - - /** Updates the alliance and source buttons based on the selected value. */ - private updateGameDependentControls(skipOriginReset = false) { - let fieldConfig = window.assets?.field2ds.find((game) => game.name === this.GAME.value); - this.GAME_SOURCE_LINK.hidden = fieldConfig !== undefined && fieldConfig.sourceUrl === undefined; - - if (fieldConfig !== undefined && !skipOriginReset) { - this.ALLIANCE_ORIGIN.value = fieldConfig.defaultOrigin; - } - } - - get options(): { [id: string]: any } { - return { - game: this.GAME.value, - unitDistance: this.UNIT_DISTANCE.value, - unitRotation: this.UNIT_ROTATION.value, - origin: this.ORIGIN.value, - size: Number(this.SIZE.value), - allianceBumpers: this.ALLIANCE_BUMPERS.value, - allianceOrigin: this.ALLIANCE_ORIGIN.value, - orientation: this.ORIENTATION.value - }; - } - - set options(options: { [id: string]: any }) { - this.resetGameOptions(); - this.GAME.value = options.game; - this.UNIT_DISTANCE.value = options.unitDistance; - this.UNIT_ROTATION.value = options.unitRotation; - this.ORIGIN.value = options.origin; - this.SIZE.value = options.size; - this.SIZE_TEXT.innerText = options.unitDistance; - this.lastUnitDistance = options.unitDistance; - this.ALLIANCE_BUMPERS.value = options.allianceBumpers; - this.ALLIANCE_ORIGIN.value = options.allianceOrigin; - this.ORIENTATION.value = options.orientation; - this.updateGameDependentControls(true); - } - - newAssets() { - this.resetGameOptions(); - } - - getAdditionalActiveFields(): string[] { - if (this.ALLIANCE_BUMPERS.value === "auto" || this.ALLIANCE_ORIGIN.value === "auto") { - return ALLIANCE_KEYS; - } else { - return []; - } - } - - getCommand(time: number) { - const distanceConversion = convert(1, this.UNIT_DISTANCE.value, "meters"); - const rotationConversion = convert(1, this.UNIT_ROTATION.value, "radians"); - - // Returns the current value for a field - let getCurrentValue = (key: string, type: LoggableType | string): Pose2d[] => { - if (type === LoggableType.NumberArray) { - return logReadNumberArrayToPose2dArray(window.log, key, time, distanceConversion, rotationConversion); - } else if (type === "Trajectory") { - return logReadTrajectoryToPose2dArray(window.log, key, time, distanceConversion); - } else if (typeof type === "string" && type.endsWith("[]")) { - return type.startsWith("Translation") - ? logReadTranslation2dArrayToPose2dArray(window.log, key, time, distanceConversion) - : logReadPose2dArray(window.log, key, time, distanceConversion); - } else { - let pose = - typeof type === "string" && type.startsWith("Translation") - ? logReadTranslation2dToPose2d(window.log, key, time, distanceConversion) - : logReadPose2d(window.log, key, time, distanceConversion); - return pose === null ? [] : [pose]; - } - }; - - // Get data - let robotData: Pose2d[] = []; - let trailData: Translation2d[][] = []; - let ghostData: Pose2d[] = []; - let trajectoryData: Pose2d[][] = []; - let visionTargetData: Pose2d[] = []; - let heatmapData: Translation2d[] = []; - let arrowFrontData: Pose2d[] = []; - let arrowCenterData: Pose2d[] = []; - let arrowBackData: Pose2d[] = []; - let zebraMarkerData: { [key: string]: { translation: Translation2d; alliance: string } } = {}; - let zebraGhostDataTranslations: Translation2d[] = []; - let zebraGhostData: Pose2d[] = []; - this.getListFields()[0].forEach((field) => { - switch (field.type) { - case "Robot": - let currentRobotData = getCurrentValue(field.key, field.sourceType); - robotData = robotData.concat(currentRobotData); - - // Get timestamps for trail - let keys: string[] = []; - let arrayLength = 0; - if (field.sourceType === LoggableType.NumberArray) { - keys = [field.key]; - } else if (typeof field.sourceType === "string" && field.sourceType.endsWith("[]")) { - arrayLength = getOrDefault(window.log, field.key + "/length", LoggableType.Number, time, 0); - for (let i = 0; i < arrayLength; i++) { - let itemKey = field.key + "/" + i.toString(); - keys = keys.concat([itemKey + "/translation/x", itemKey + "/translation/y", itemKey + "/rotation/value"]); - } - } else if (typeof field.sourceType === "string") { - keys = [field.key + "/translation/x", field.key + "/translation/y", field.key + "/rotation/value"]; - } - let timestamps = window.log - .getTimestamps([field.key], this.UUID) - .filter( - (x) => x > time - OdometryController.TRAIL_LENGTH_SECS && x < time + OdometryController.TRAIL_LENGTH_SECS - ); - - // Get trail data - let trailsTemp: Translation2d[][] = currentRobotData.map(() => []); - if (field.sourceType === LoggableType.NumberArray) { - let data = window.log.getNumberArray(field.key, timestamps[0], timestamps[timestamps.length - 1]); - if (data !== undefined) { - let dataIndex = 1; - timestamps.forEach((timestamp) => { - let poses = numberArrayToPose2dArray( - data!.values[dataIndex - 1], - distanceConversion, - rotationConversion - ); - poses.forEach((pose, index) => { - if (index < trailsTemp.length) { - trailsTemp[index].push(pose.translation); - } - }); - while (dataIndex < data!.timestamps.length && data!.timestamps[dataIndex] < timestamp) { - dataIndex++; - } - }); - } - } else if (typeof field.sourceType === "string") { - let addTrail = (key: string, trailIndex = 0) => { - if (trailIndex >= trailsTemp.length) { - return; - } - let xData = window.log.getNumber( - key + "/translation/x", - timestamps[0], - timestamps[timestamps.length - 1] - ); - let yData = window.log.getNumber( - key + "/translation/y", - timestamps[0], - timestamps[timestamps.length - 1] - ); - if (xData !== undefined && yData !== undefined) { - let xDataIndex = 1; - let yDataIndex = 1; - timestamps.forEach((timestamp) => { - trailsTemp[trailIndex].push([ - xData!.values[xDataIndex - 1] * distanceConversion, - yData!.values[yDataIndex - 1] * distanceConversion - ]); - while (xDataIndex < xData!.timestamps.length && xData!.timestamps[xDataIndex] < timestamp) { - xDataIndex++; - } - while (yDataIndex < yData!.timestamps.length && yData!.timestamps[yDataIndex] < timestamp) { - yDataIndex++; - } - }); - } - }; - if (field.sourceType.endsWith("[]")) { - for (let i = 0; i < arrayLength; i++) { - addTrail(field.key + "/" + i.toString(), i); - } - } else { - addTrail(field.key); - } - } - trailData = trailData.concat(trailsTemp); - break; - case "Ghost": - if (field.sourceType !== "ZebraTranslation") { - ghostData = ghostData.concat(getCurrentValue(field.key, field.sourceType)); - } else { - let x: number | null = null; - let y: number | null = null; - { - let xData = window.log.getNumber(field.key + "/x", time, time); - if (xData !== undefined && xData.values.length > 0) { - if (xData.values.length === 1) { - x = xData.values[0]; - } else { - x = scaleValue(time, [xData.timestamps[0], xData.timestamps[1]], [xData.values[0], xData.values[1]]); - } - } - } - { - let yData = window.log.getNumber(field.key + "/y", time, time); - if (yData !== undefined && yData.values.length > 0) { - if (yData.values.length === 1) { - y = yData.values[0]; - } else { - y = scaleValue(time, [yData.timestamps[0], yData.timestamps[1]], [yData.values[0], yData.values[1]]); - } - } - } - if (x !== null && y !== null) { - zebraGhostDataTranslations.push([convert(x, "feet", "meters"), convert(y, "feet", "meters")]); - } - } - break; - case "Trajectory": - trajectoryData.push(getCurrentValue(field.key, field.sourceType)); - break; - case "Vision Target": - visionTargetData = visionTargetData.concat(getCurrentValue(field.key, field.sourceType)); - break; - case "Heatmap": - case "Heatmap (Enabled)": - { - // Get enabled data - let enabledFilter = field.type === "Heatmap (Enabled)"; - let enabledData = enabledFilter ? getEnabledData(window.log) : null; - let isEnabled = (timestamp: number) => { - if (!enabledFilter) return true; - if (enabledData === null) return false; - let enabledDataIndex = enabledData.timestamps.findLastIndex((x) => x <= timestamp); - if (enabledDataIndex === -1) return false; - return enabledData.values[enabledDataIndex]; - }; - - // Get timestamps - let timestamps: number[] = []; - for ( - let sampleTime = window.log.getTimestampRange()[0]; - sampleTime < window.log.getTimestampRange()[1]; - sampleTime += OdometryController.HEATMAP_DT - ) { - timestamps.push(sampleTime); - } - - // Get data - if (field.sourceType === LoggableType.NumberArray) { - let data = window.log.getNumberArray(field.key, timestamps[0], timestamps[timestamps.length - 1]); - if (data !== undefined) { - let dataIndex = 1; - timestamps.forEach((timestamp) => { - if (!isEnabled(timestamp)) return; - let poses = numberArrayToPose2dArray( - data!.values[dataIndex - 1], - distanceConversion, - rotationConversion - ); - poses.forEach((pose, index) => { - heatmapData.push(pose.translation); - }); - while (dataIndex < data!.timestamps.length && data!.timestamps[dataIndex] < timestamp) { - dataIndex++; - } - }); - } - } else if (typeof field.sourceType === "string") { - let addData = (key: string) => { - let xData = window.log.getNumber( - key + "/translation/x", - timestamps[0], - timestamps[timestamps.length - 1] - ); - let yData = window.log.getNumber( - key + "/translation/y", - timestamps[0], - timestamps[timestamps.length - 1] - ); - if (xData !== undefined && yData !== undefined) { - let xDataIndex = 1; - let yDataIndex = 1; - timestamps.forEach((timestamp) => { - if (!isEnabled(timestamp)) return; - heatmapData.push([ - xData!.values[xDataIndex - 1] * distanceConversion, - yData!.values[yDataIndex - 1] * distanceConversion - ]); - while (xDataIndex < xData!.timestamps.length && xData!.timestamps[xDataIndex] < timestamp) { - xDataIndex++; - } - while (yDataIndex < yData!.timestamps.length && yData!.timestamps[yDataIndex] < timestamp) { - yDataIndex++; - } - }); - } - }; - if (field.sourceType.endsWith("[]")) { - let length = getOrDefault(window.log, field.key + "/length", LoggableType.Number, time, 0); - for (let i = 0; i < length; i++) { - addData(field.key + "/" + i.toString()); - } - } else { - addData(field.key); - } - } - } - break; - case "Arrow (Front)": - arrowFrontData = arrowFrontData.concat(getCurrentValue(field.key, field.sourceType)); - break; - case "Arrow (Center)": - arrowCenterData = arrowCenterData.concat(getCurrentValue(field.key, field.sourceType)); - break; - case "Arrow (Back)": - arrowBackData = arrowBackData.concat(getCurrentValue(field.key, field.sourceType)); - break; - case "Zebra Marker": - let team = field.key.split("FRC")[1]; - let x: number | null = null; - let y: number | null = null; - { - let xData = window.log.getNumber(field.key + "/x", time, time); - if (xData !== undefined && xData.values.length > 0) { - if (xData.values.length === 1) { - x = xData.values[0]; - } else { - x = scaleValue(time, [xData.timestamps[0], xData.timestamps[1]], [xData.values[0], xData.values[1]]); - } - } - } - { - let yData = window.log.getNumber(field.key + "/y", time, time); - if (yData !== undefined && yData.values.length > 0) { - if (yData.values.length === 1) { - y = yData.values[0]; - } else { - y = scaleValue(time, [yData.timestamps[0], yData.timestamps[1]], [yData.values[0], yData.values[1]]); - } - } - } - let alliance = getOrDefault(window.log, field.key + "/alliance", LoggableType.String, Infinity, "blue"); - if (x !== null && y !== null) { - zebraMarkerData[team] = { - translation: [convert(x, "feet", "meters"), convert(y, "feet", "meters")], - alliance: alliance - }; - } - break; - } - }); - - // Get alliance colors - let allianceRedBumpers = false; - let allianceRedOrigin = false; - let autoRedAlliance = getIsRedAlliance(window.log, time); - switch (this.ALLIANCE_BUMPERS.value) { - case "auto": - allianceRedBumpers = autoRedAlliance; - break; - case "blue": - allianceRedBumpers = false; - break; - case "red": - allianceRedBumpers = true; - break; - } - switch (this.ALLIANCE_ORIGIN.value) { - case "auto": - allianceRedOrigin = autoRedAlliance; - break; - case "blue": - allianceRedOrigin = false; - break; - case "red": - allianceRedOrigin = true; - break; - } - - // Apply robot rotation to Zebra ghost translations - let robotRotation = 0; - if (robotData.length > 0) { - robotRotation = robotData[0].rotation; - if (!allianceRedOrigin) { - // Switch from blue to red origin to match translation - robotRotation += Math.PI; - } - } - zebraGhostDataTranslations.forEach((translation) => { - zebraGhostData.push({ - translation: translation, - rotation: robotRotation - }); - }); - - // Package command data - return { - poses: { - robot: robotData, - trail: trailData, - ghost: ghostData, - trajectory: trajectoryData, - visionTarget: visionTargetData, - heatmap: heatmapData, - arrowFront: arrowFrontData, - arrowCenter: arrowCenterData, - arrowBack: arrowBackData, - zebraMarker: zebraMarkerData, - zebraGhost: zebraGhostData - }, - options: this.options, - allianceRedBumpers: allianceRedBumpers, - allianceRedOrigin: allianceRedOrigin - }; - } -} diff --git a/src/hub/tabControllers/PointsController.ts b/src/hub/tabControllers/PointsController.ts deleted file mode 100644 index eee263e2..00000000 --- a/src/hub/tabControllers/PointsController.ts +++ /dev/null @@ -1,158 +0,0 @@ -import LoggableType from "../../shared/log/LoggableType"; -import { getOrDefault } from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import PointsVisualizer from "../../shared/visualizers/PointsVisualizer"; -import TimelineVizController from "./TimelineVizController"; - -export default class PointsController extends TimelineVizController { - private WIDTH: HTMLInputElement; - private HEIGHT: HTMLInputElement; - private COORDINATES: HTMLInputElement; - private ORIGIN: HTMLInputElement; - private POINT_SHAPE: HTMLInputElement; - private POINT_SIZE: HTMLInputElement; - private GROUP_SIZE: HTMLInputElement; - - constructor(content: HTMLElement) { - let configBody = content.getElementsByClassName("timeline-viz-config")[0].firstElementChild as HTMLElement; - super( - content, - TabType.Points, - [ - // Combined - { - element: configBody.children[1].firstElementChild as HTMLElement, - types: [LoggableType.NumberArray, "Translation2d[]"] - }, - - // X - { - element: configBody.children[2].firstElementChild as HTMLElement, - types: [LoggableType.NumberArray] - }, - - // Y - { - element: configBody.children[3].firstElementChild as HTMLElement, - types: [LoggableType.NumberArray] - } - ], - [], - new PointsVisualizer(content.getElementsByClassName("points-background-container")[0] as HTMLElement) - ); - - // Get option inputs - this.WIDTH = configBody.children[1].children[1].children[1] as HTMLInputElement; - this.HEIGHT = configBody.children[1].children[1].children[3] as HTMLInputElement; - this.COORDINATES = configBody.children[2].children[1].children[1] as HTMLInputElement; - this.ORIGIN = configBody.children[3].children[1].children[1] as HTMLInputElement; - this.POINT_SHAPE = configBody.children[1].children[2].children[1] as HTMLInputElement; - this.POINT_SIZE = configBody.children[2].children[2].children[1] as HTMLInputElement; - this.GROUP_SIZE = configBody.children[3].children[2].children[1] as HTMLInputElement; - - // Enforce number ranges - [this.WIDTH, this.HEIGHT, this.GROUP_SIZE].forEach((input, index) => { - input.addEventListener("change", () => { - if (Number(input.value) % 1 !== 0) input.value = Math.round(Number(input.value)).toString(); - if (index === 2) { - if (Number(input.value) < 0) input.value = "0"; - } else { - if (Number(input.value) <= 0) input.value = "1"; - } - }); - }); - } - - get options(): { [id: string]: any } { - return { - width: Number(this.WIDTH.value), - height: Number(this.HEIGHT.value), - coordinates: this.COORDINATES.value, - origin: this.ORIGIN.value, - pointShape: this.POINT_SHAPE.value, - pointSize: this.POINT_SIZE.value, - groupSize: Number(this.GROUP_SIZE.value) - }; - } - - set options(options: { [id: string]: any }) { - this.WIDTH.value = options.width; - this.HEIGHT.value = options.height; - this.COORDINATES.value = options.coordinates; - this.ORIGIN.value = options.origin; - this.POINT_SHAPE.value = options.pointShape; - this.POINT_SIZE.value = options.pointSize; - this.GROUP_SIZE.value = options.groupSize; - } - - newAssets() {} - - getAdditionalActiveFields(): string[] { - return []; - } - - getCommand(time: number) { - let fields = this.getFields(); - - // Get current data - let xData: number[] = []; - let yData: number[] = []; - - // Get combined data - if (fields[0] !== null) { - switch (fields[0].sourceType) { - case LoggableType.NumberArray: - const value = getOrDefault(window.log, fields[0].key, LoggableType.NumberArray, time, []); - if (value.length % 2 === 0) { - for (let i = 0; i < value.length; i += 2) { - xData.push(value[i]); - yData.push(value[i + 1]); - } - } - break; - case "Translation2d[]": - let length = getOrDefault(window.log, fields[0].key + "/length", LoggableType.Number, time, 0); - for (let i = 0; i < length; i++) { - const x = getOrDefault( - window.log, - fields[0].key + "/" + i.toString() + "/x", - LoggableType.Number, - time, - null - ); - const y = getOrDefault( - window.log, - fields[0].key + "/" + i.toString() + "/y", - LoggableType.Number, - time, - null - ); - if (x !== null && y !== null) { - xData.push(x); - yData.push(y); - } - } - break; - } - } - - // Get component data - if (fields[1] !== null && fields[2] !== null) { - const xValue = getOrDefault(window.log, fields[1].key, LoggableType.NumberArray, time, []); - const yValue = getOrDefault(window.log, fields[2].key, LoggableType.NumberArray, time, []); - if (xValue.length === yValue.length) { - xData = xData.concat(xValue); - yData = yData.concat(yValue); - } - } - - // Package command data - return { - data: { - x: xData, - y: yData - }, - options: this.options - }; - } -} diff --git a/src/hub/tabControllers/StatisticsController.ts b/src/hub/tabControllers/StatisticsController.ts deleted file mode 100644 index 86e30a8c..00000000 --- a/src/hub/tabControllers/StatisticsController.ts +++ /dev/null @@ -1,589 +0,0 @@ -import { Chart, ChartDataset, LegendOptions, LinearScaleOptions, registerables, TooltipCallbacks } from "chart.js"; -import * as stats from "simple-statistics"; -import { SimpleColors } from "../../shared/Colors"; -import { StatisticsState } from "../../shared/HubState"; -import LoggableType from "../../shared/log/LoggableType"; -import { getEnabledData } from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import { cleanFloat, createUUID } from "../../shared/util"; -import TabController from "../TabController"; - -export default class StatisticsController implements TabController { - private UPDATE_PERIOD_MS = 100; - private UUID = createUUID(); - private static registeredChart = false; - - private CONTENT: HTMLElement; - private CONFIG_TABLE: HTMLElement; - private VALUES_TABLE_CONTAINER: HTMLElement; - private VALUES_TABLE_BODY: HTMLElement; - private HISTOGRAM_CONTAINER: HTMLElement; - private DRAG_HIGHLIGHT: HTMLElement; - - private FIELD_CELLS: HTMLElement[]; - private SELECTION_TYPE: HTMLInputElement; - private SELECTION_RANGE_MIN: HTMLInputElement; - private SELECTION_RANGE_MAX: HTMLInputElement; - private MEASUREMENT_TYPE: HTMLInputElement; - private MEASUREMENT_SAMPLING: HTMLInputElement; - private MEASUREMENT_SAMPLING_PERIOD: HTMLInputElement; - private HISTOGRAM_MIN: HTMLInputElement; - private HISTOGRAM_MAX: HTMLInputElement; - private HISTOGRAM_STEP: HTMLInputElement; - - private fields: (string | null)[] = []; - private shouldUpdate = true; - private lastUpdateTime = 0; - private lastIsLight: boolean | null = null; - private histogram: Chart; - - constructor(content: HTMLElement) { - this.CONTENT = content; - this.CONFIG_TABLE = content.getElementsByClassName("stats-config")[0] as HTMLElement; - this.VALUES_TABLE_CONTAINER = content.getElementsByClassName("stats-values-container")[0] as HTMLElement; - this.VALUES_TABLE_BODY = this.VALUES_TABLE_CONTAINER.firstElementChild?.firstElementChild as HTMLElement; - this.HISTOGRAM_CONTAINER = content.getElementsByClassName("stats-histogram-container")[0] as HTMLElement; - this.DRAG_HIGHLIGHT = content.getElementsByClassName("stats-drag-highlight")[0] as HTMLElement; - - let configBody = this.CONFIG_TABLE.firstElementChild as HTMLElement; - this.FIELD_CELLS = Array.from(configBody.firstElementChild?.children!).map((cell) => cell as HTMLElement); - this.SELECTION_TYPE = configBody.lastElementChild?.children[0].children[2] as HTMLInputElement; - this.SELECTION_RANGE_MIN = configBody.lastElementChild?.children[0].children[3] as HTMLInputElement; - this.SELECTION_RANGE_MAX = configBody.lastElementChild?.children[0].children[4] as HTMLInputElement; - this.MEASUREMENT_TYPE = configBody.lastElementChild?.children[1].children[2] as HTMLInputElement; - this.MEASUREMENT_SAMPLING = configBody.lastElementChild?.children[1].children[4] as HTMLInputElement; - this.MEASUREMENT_SAMPLING_PERIOD = configBody.lastElementChild?.children[1].children[6] as HTMLInputElement; - this.HISTOGRAM_MIN = configBody.lastElementChild?.children[2].children[2] as HTMLInputElement; - this.HISTOGRAM_MAX = configBody.lastElementChild?.children[2].children[3] as HTMLInputElement; - this.HISTOGRAM_STEP = configBody.lastElementChild?.children[2].children[4] as HTMLInputElement; - - // Bind help link - configBody.lastElementChild?.children[1].children[5].addEventListener("click", () => { - window.sendMainMessage("alert", { - title: "About sampling", - content: - "Fixed sampling reads data at the specified interval and is recommended in most cases. Auto sampling reads data whenever any field is updated and is recommended for timestamp synchronized logs (like those produced by AdvantageKit)." - }); - }); - - // Bind input disabling - this.SELECTION_TYPE.addEventListener("change", () => { - let disabled = this.SELECTION_TYPE.value !== "range"; - this.SELECTION_RANGE_MIN.disabled = disabled; - this.SELECTION_RANGE_MAX.disabled = disabled; - }); - this.MEASUREMENT_SAMPLING.addEventListener("change", () => { - this.MEASUREMENT_SAMPLING_PERIOD.disabled = this.MEASUREMENT_SAMPLING.value !== "fixed"; - }); - - // Drag handling - window.addEventListener("drag-update", (event) => { - this.handleDrag((event as CustomEvent).detail); - }); - - // Create field list and clear fields on right click - Object.values(this.FIELD_CELLS).forEach((cell, index) => { - this.fields.push(null); - cell.addEventListener("contextmenu", () => { - this.fields[index] = null; - this.updateFields(); - }); - }); - - // Enforce range - this.SELECTION_RANGE_MIN.addEventListener("change", () => { - if (Number(this.SELECTION_RANGE_MIN.value) < 0) { - this.SELECTION_RANGE_MIN.value = "0"; - } - if (Number(this.SELECTION_RANGE_MAX.value) < Number(this.SELECTION_RANGE_MIN.value)) { - this.SELECTION_RANGE_MAX.value = this.SELECTION_RANGE_MIN.value; - } - }); - this.SELECTION_RANGE_MAX.addEventListener("change", () => { - if (Number(this.SELECTION_RANGE_MAX.value) < 0) { - this.SELECTION_RANGE_MAX.value = "0"; - } - if (Number(this.SELECTION_RANGE_MAX.value) < Number(this.SELECTION_RANGE_MIN.value)) { - this.SELECTION_RANGE_MAX.value = this.SELECTION_RANGE_MIN.value; - } - }); - this.MEASUREMENT_SAMPLING_PERIOD.addEventListener("change", () => { - if (Number(this.MEASUREMENT_SAMPLING_PERIOD.value) <= 0) { - this.MEASUREMENT_SAMPLING_PERIOD.value = "1"; - } - }); - this.HISTOGRAM_STEP.addEventListener("change", () => { - this.updateHistogramInputs(); - }); - - // Schedule update when config changes - this.SELECTION_TYPE.addEventListener("change", () => (this.shouldUpdate = true)); - this.SELECTION_RANGE_MIN.addEventListener("change", () => (this.shouldUpdate = true)); - this.SELECTION_RANGE_MAX.addEventListener("change", () => (this.shouldUpdate = true)); - this.MEASUREMENT_TYPE.addEventListener("change", () => { - this.shouldUpdate = true; - this.updateFields(); - }); - this.MEASUREMENT_SAMPLING.addEventListener("change", () => (this.shouldUpdate = true)); - this.MEASUREMENT_SAMPLING_PERIOD.addEventListener("change", () => (this.shouldUpdate = true)); - this.HISTOGRAM_MIN.addEventListener("change", () => (this.shouldUpdate = true)); - this.HISTOGRAM_MAX.addEventListener("change", () => (this.shouldUpdate = true)); - this.HISTOGRAM_STEP.addEventListener("change", () => (this.shouldUpdate = true)); - - // Set initial values for histogram inputs - this.HISTOGRAM_MIN.value = "0"; - this.HISTOGRAM_MAX.value = "10"; - this.HISTOGRAM_STEP.value = "1"; - this.updateHistogramInputs(); - - // Create chart - StatisticsController.registerChart(); - this.histogram = new Chart(this.HISTOGRAM_CONTAINER.firstElementChild as HTMLCanvasElement, { - type: "bar", - data: { - labels: [], - datasets: [] - }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: { - duration: 0 - }, - scales: { - x: { - type: "linear", - stacked: true, - offset: false, - grid: { - offset: false - }, - ticks: { - stepSize: 1 - } - }, - y: { - stacked: true, - grace: 0.1 - } - } - } - }); - } - - /** Registers all Chart.js elements. */ - private static registerChart() { - if (!this.registeredChart) { - this.registeredChart = true; - Chart.register(...registerables); - } - } - - /** Updates the step size for each histogram input. */ - private updateHistogramInputs() { - if (Number(this.HISTOGRAM_STEP.value) <= 0) { - this.HISTOGRAM_STEP.value = cleanFloat(Number(this.HISTOGRAM_STEP.step) * 0.9).toString(); - } - let step = Math.pow(10, Math.floor(Math.log10(Number(this.HISTOGRAM_STEP.value)))); - this.HISTOGRAM_STEP.step = step.toString(); - - let minMaxStep = Math.pow(10, Math.ceil(Math.log10(Number(this.HISTOGRAM_STEP.value)))); - this.HISTOGRAM_MIN.step = minMaxStep.toString(); - this.HISTOGRAM_MAX.step = minMaxStep.toString(); - } - - saveState(): StatisticsState { - return { - type: TabType.Statistics, - fields: this.fields, - selectionType: this.SELECTION_TYPE.value, - selectionRangeMin: Number(this.SELECTION_RANGE_MIN.value), - selectionRangeMax: Number(this.SELECTION_RANGE_MAX.value), - measurementType: this.MEASUREMENT_TYPE.value, - measurementSampling: this.MEASUREMENT_SAMPLING.value, - measurementSamplingPeriod: Number(this.MEASUREMENT_SAMPLING_PERIOD.value), - histogramMin: Number(this.HISTOGRAM_MIN.value), - histogramMax: Number(this.HISTOGRAM_MAX.value), - histogramStep: Number(this.HISTOGRAM_STEP.value) - }; - } - - restoreState(state: StatisticsState) { - this.SELECTION_TYPE.value = state.selectionType; - this.SELECTION_RANGE_MIN.value = state.selectionRangeMin.toString(); - this.SELECTION_RANGE_MAX.value = state.selectionRangeMax.toString(); - this.MEASUREMENT_TYPE.value = state.measurementType; - this.MEASUREMENT_SAMPLING.value = state.measurementSampling; - this.MEASUREMENT_SAMPLING_PERIOD.value = state.measurementSamplingPeriod.toString(); - this.HISTOGRAM_MIN.value = state.histogramMin.toString(); - this.HISTOGRAM_MAX.value = state.histogramMax.toString(); - this.HISTOGRAM_STEP.value = state.histogramStep.toString(); - this.updateHistogramInputs(); - - this.SELECTION_RANGE_MIN.disabled = state.selectionType !== "range"; - this.SELECTION_RANGE_MAX.disabled = state.selectionType !== "range"; - this.MEASUREMENT_SAMPLING_PERIOD.disabled = state.measurementSampling !== "fixed"; - - this.fields = state.fields; - this.updateFields(); - } - - /** Processes a drag event, including updating a field if necessary. */ - private handleDrag(dragData: any) { - if (this.CONTENT.hidden) return; - - this.DRAG_HIGHLIGHT.hidden = true; - Object.values(this.FIELD_CELLS).forEach((cell, index) => { - let rect = cell.getBoundingClientRect(); - let active = - dragData.x > rect.left && dragData.x < rect.right && dragData.y > rect.top && dragData.y < rect.bottom; - let type = window.log.getType(dragData.data.fields[0]); - let validType = type === LoggableType.Number; - - if (active && validType) { - if (dragData.end) { - this.fields[index] = dragData.data.fields[0]; - this.updateFields(); - } else { - let contentRect = this.CONTENT.getBoundingClientRect(); - this.DRAG_HIGHLIGHT.style.left = (rect.left - contentRect.left).toString() + "px"; - this.DRAG_HIGHLIGHT.style.top = (rect.top - contentRect.top).toString() + "px"; - this.DRAG_HIGHLIGHT.style.width = rect.width.toString() + "px"; - this.DRAG_HIGHLIGHT.style.height = rect.height.toString() + "px"; - this.DRAG_HIGHLIGHT.hidden = false; - } - } - }); - } - - /** Updates the field elements based on the internal field list. */ - private updateFields() { - this.shouldUpdate = true; - const titles = - this.MEASUREMENT_TYPE.value === "independent" - ? ["Field #1", "Field #2", "Field #3"] - : ["Reference", "Measurement #1", "Measurement #2"]; - Object.values(this.FIELD_CELLS).forEach((cell, index) => { - let titleElement = cell.firstElementChild as HTMLElement; - titleElement.innerText = titles[index] + ":"; - - let fieldElement = cell.lastElementChild as HTMLElement; - let key = this.fields[index]; - let availableKeys = window.log.getFieldKeys(); - if (key === null) { - fieldElement.innerText = ""; - fieldElement.style.textDecoration = ""; - } else if (!availableKeys.includes(key)) { - fieldElement.innerText = key; - fieldElement.style.textDecoration = "line-through"; - } else { - fieldElement.innerText = key; - fieldElement.style.textDecoration = ""; - } - }); - } - - refresh() { - this.updateFields(); - this.shouldUpdate = true; - } - - newAssets() {} - - getActiveFields(): string[] { - return this.fields.filter((field) => field !== null) as string[]; - } - - periodic() { - // Update histogram layout - this.VALUES_TABLE_CONTAINER.style.top = (this.CONFIG_TABLE.clientHeight + 10).toString() + "px"; - this.HISTOGRAM_CONTAINER.style.top = (this.CONFIG_TABLE.clientHeight + 20).toString() + "px"; - this.HISTOGRAM_CONTAINER.style.left = (this.VALUES_TABLE_CONTAINER.clientWidth + 20).toString() + "px"; - - // Update histogram colors - const isLight = !window.matchMedia("(prefers-color-scheme: dark)").matches; - if (isLight !== this.lastIsLight) { - this.lastIsLight = isLight; - (this.histogram.options.plugins!.legend as LegendOptions<"bar">).labels.color = isLight ? "#222" : "#eee"; - let xAxisOptions = this.histogram.options.scales!.x as LinearScaleOptions; - let yAxisOptions = this.histogram.options.scales!.y as LinearScaleOptions; - xAxisOptions.ticks.color = isLight ? "#222" : "#eee"; - yAxisOptions.ticks.color = isLight ? "#222" : "#eee"; - xAxisOptions.border.color = isLight ? "#222" : "#eee"; - yAxisOptions.border.color = isLight ? "#222" : "#eee"; - xAxisOptions.grid.color = isLight ? "#eee" : "#333"; - yAxisOptions.grid.color = isLight ? "#eee" : "#333"; - this.histogram.update(); - } - - // Check if data should be updated - let currentTime = new Date().getTime(); - if (this.shouldUpdate && currentTime - this.lastUpdateTime > this.UPDATE_PERIOD_MS) { - this.shouldUpdate = false; - this.lastUpdateTime = currentTime; - - // Get sample ranges - let sampleRanges: [number, number][] = []; - switch (this.SELECTION_TYPE.value) { - case "full": - sampleRanges.push(window.log.getTimestampRange()); - break; - case "enabled": - let enabledData = getEnabledData(window.log); - if (enabledData) { - for (let i = 0; i < enabledData.values.length; i++) { - if (enabledData.values[i]) { - let startTime = enabledData.timestamps[i]; - let endTime = - i === enabledData.values.length - 1 - ? window.log.getTimestampRange()[1] - : enabledData.timestamps[i + 1]; - sampleRanges.push([startTime, endTime]); - } - } - } - break; - case "range": - sampleRanges.push([Number(this.SELECTION_RANGE_MIN.value), Number(this.SELECTION_RANGE_MAX.value)]); - break; - } - - // Get sample timestamps - let sampleTimes: number[] = []; - switch (this.MEASUREMENT_SAMPLING.value) { - case "fixed": - let period = Number(this.MEASUREMENT_SAMPLING_PERIOD.value) / 1000; - sampleRanges.forEach((range) => { - for (let i = range[0]; i < range[1]; i += period) { - sampleTimes.push(i); - } - }); - break; - case "auto": - let allTimestamps = window.log.getTimestamps(window.log.getFieldKeys(), this.UUID); - sampleRanges.forEach((range) => { - sampleTimes = sampleTimes.concat( - allTimestamps.filter((timestamp) => timestamp >= range[0] && timestamp <= range[1]) - ); - }); - break; - } - - // Get valid fields and sample data - let sampleData: (number | null)[][] = this.fields.map((field) => { - let blankData: (number | null)[] = sampleTimes.map(() => null); - if (field === null) return blankData; - let logData = window.log.getNumber(field!, -Infinity, Infinity); - if (!logData) return blankData; - - logData.timestamps.push(Infinity); - let index = 0; - let fieldSampleData: number[] = sampleTimes.map((sampleTime) => { - if (!logData) { - return 0.0; - } - while (index < logData.timestamps.length && logData.timestamps[index + 1] < sampleTime) { - index++; - } - return logData.values[index]; - }); - return fieldSampleData; - }); - - // Calculate errors if using reference - if (this.MEASUREMENT_TYPE.value !== "independent") { - let isAbsolute = this.MEASUREMENT_TYPE.value === "absolute"; - for (let valueIndex = 0; valueIndex < sampleTimes.length; valueIndex++) { - let reference = sampleData[0][valueIndex]; - for (let fieldIndex = 1; fieldIndex < 3; fieldIndex++) { - let measurement = sampleData[fieldIndex][valueIndex]; - if (reference === null || measurement === null) { - sampleData[fieldIndex][valueIndex] = null; - } else { - let error = measurement - reference; - sampleData[fieldIndex][valueIndex] = isAbsolute ? Math.abs(error) : error; - } - } - } - sampleData.shift(); // Remove reference data - } - - // Find which fields have valid data - let fieldsHaveData = sampleData.map((data) => { - return data.length > 0 && !data.every((value) => value === null); - }); - let fieldHavaDataCount = fieldsHaveData.filter((hasData) => hasData).length; - - // Sort sample data - sampleData.forEach((data) => { - data.sort((a, b) => { - if (a === null || b === null) return 0; - return a - b; - }); - }); - - // Clear values - while (this.VALUES_TABLE_BODY.firstChild) { - this.VALUES_TABLE_BODY.removeChild(this.VALUES_TABLE_BODY.firstChild); - } - - // Add a new title row - let addTitle = () => { - let titles = - this.MEASUREMENT_TYPE.value === "independent" - ? ["Field #1", "Field #2", "Field #3"] - : ["Error #1", "Error #2"]; - let row = document.createElement("tr"); - this.VALUES_TABLE_BODY.appendChild(row); - row.classList.add("title"); - let cell = document.createElement("td"); - row.appendChild(cell); - titles.forEach((title, index) => { - if (fieldsHaveData[index] || (fieldHavaDataCount === 0 && index === 0)) { - let cell = document.createElement("td"); - row.appendChild(cell); - cell.innerText = title; - } - }); - }; - - // Add a new section header - let addSection = (title: string) => { - let row = document.createElement("tr"); - this.VALUES_TABLE_BODY.appendChild(row); - row.classList.add("section"); - let cell = document.createElement("td"); - row.appendChild(cell); - cell.colSpan = Math.max(1 + fieldHavaDataCount, 2); - cell.innerText = title; - }; - - // Add a new row with data - let addValues = (title: string, digits: number, calcFunction: (data: number[]) => number) => { - let row = document.createElement("tr"); - this.VALUES_TABLE_BODY.appendChild(row); - row.classList.add("values"); - let titleCell = document.createElement("td"); - row.appendChild(titleCell); - titleCell.innerText = title; - for (let i = 0; i < sampleData.length; i++) { - if (fieldsHaveData[i] || (fieldHavaDataCount === 0 && i === 0)) { - let valueCell = document.createElement("td"); - row.appendChild(valueCell); - valueCell.innerText = "-"; - try { - let value = calcFunction(sampleData[i].filter((value) => value !== null) as number[]); - if (!isNaN(value) && isFinite(value)) { - valueCell.innerText = value.toFixed(digits); - } - } catch {} - } - } - }; - - // Add all rows - addTitle(); - addSection("Summary"); - addValues("Count", 0, (data) => data.length); - addValues("Min", 3, stats.minSorted); - addValues("Max", 3, stats.maxSorted); - addSection("Center"); - addValues("Mean", 3, stats.mean); - addValues("Median", 3, stats.medianSorted); - addValues("Mode", 3, stats.modeSorted); - addValues("Geometric Mean", 3, (data) => this.logAverage(data.filter((value) => value > 0))); - addValues("Harmonic Mean", 3, (data) => stats.harmonicMean(data.filter((value) => value > 0))); - addValues("Quadratic Mean", 3, stats.rootMeanSquare); - addSection("Spread"); - addValues("Standard Deviation", 3, stats.sampleStandardDeviation); - addValues("Median Absolute Deviation", 3, stats.medianAbsoluteDeviation); - addValues("Interquartile Range", 3, stats.interquartileRange); - addValues("Skewness", 3, stats.sampleSkewness); - addSection("Percentiles"); - addValues("1st Percentile", 3, (data) => stats.quantileSorted(data, 0.01)); - addValues("5th Percentile", 3, (data) => stats.quantileSorted(data, 0.05)); - addValues("10th Percentile", 3, (data) => stats.quantileSorted(data, 0.1)); - addValues("25th Percentile", 3, (data) => stats.quantileSorted(data, 0.25)); - addValues("50th Percentile", 3, (data) => stats.quantileSorted(data, 0.5)); - addValues("75th Percentile", 3, (data) => stats.quantileSorted(data, 0.75)); - addValues("90th Percentile", 3, (data) => stats.quantileSorted(data, 0.9)); - addValues("95th Percentile", 3, (data) => stats.quantileSorted(data, 0.95)); - addValues("99th Percentile", 3, (data) => stats.quantileSorted(data, 0.99)); - - // Get histogram data - let min = Number(this.HISTOGRAM_MIN.value); - let max = Number(this.HISTOGRAM_MAX.value); - let step = Number(this.HISTOGRAM_STEP.value); - if (step <= 0) step = 1; - let bins: number[] = []; - for (let i = min; i < max; i += step) { - bins.push(i); - } - let counts: number[][] = sampleData.map(() => bins.map(() => 0)); - sampleData.forEach((field, fieldIndex) => { - field.forEach((value) => { - if (value !== null) { - let binIndex = Math.floor((value - min) / step); - if (binIndex >= 0 && binIndex < bins.length) { - counts[fieldIndex][binIndex]++; - } - } - }); - }); - - // Update histogram data - this.histogram.data.labels = bins.map((value) => value + step / 2); - this.histogram.data.datasets = counts.map((data, index) => { - const dataset: ChartDataset = { - label: (this.MEASUREMENT_TYPE.value === "independent" ? "Field" : "Error") + " #" + (index + 1).toString(), - data: data, - backgroundColor: SimpleColors[index], - barPercentage: 1, - categoryPercentage: 1 - }; - return dataset; - }); - (this.histogram.options.scales!.x as LinearScaleOptions).ticks.stepSize = step; - (this.histogram.options.plugins!.tooltip!.callbacks as TooltipCallbacks<"bar">).title = (items) => { - if (items.length < 1) { - return ""; - } - const item = items[0]; - const x = item.parsed.x; - const min = x - step / 2; - const max = x + step / 2; - return cleanFloat(min).toString() + " to " + cleanFloat(max).toString(); - }; - this.histogram.update(); - } - } - - /** - * --- Copied from "https://github.com/simple-statistics/simple-statistics/blob/master/src/log_average.js" --- - * - * The [log average](https://en.wikipedia.org/wiki/https://en.wikipedia.org/wiki/Geometric_mean#Relationship_with_logarithms) - * is an equivalent way of computing the geometric mean of an array suitable for large or small products. - * - * It's found by calculating the average logarithm of the elements and exponentiating. - * - * @param {Array} x sample of one or more data points - * @returns {number} geometric mean - * @throws {Error} if x is empty - * @throws {Error} if x contains a negative number - */ - private logAverage(x: number[]): number { - if (x.length === 0) { - throw new Error("logAverage requires at least one data point"); - } - - let value = 0; - for (let i = 0; i < x.length; i++) { - if (x[i] < 0) { - throw new Error("logAverage requires only non-negative numbers as input"); - } - value += Math.log(x[i]); - } - - return Math.exp(value / x.length); - } -} diff --git a/src/hub/tabControllers/SwerveController.ts b/src/hub/tabControllers/SwerveController.ts deleted file mode 100644 index 0cdcaeee..00000000 --- a/src/hub/tabControllers/SwerveController.ts +++ /dev/null @@ -1,183 +0,0 @@ -import LoggableType from "../../shared/log/LoggableType"; -import { getOrDefault } from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import { convert } from "../../shared/units"; -import SwerveVisualizer, { NormalizedModuleState } from "../../shared/visualizers/SwerveVisualizer"; -import TimelineVizController from "./TimelineVizController"; - -export default class JoysticksController extends TimelineVizController { - private MAX_SPEED: HTMLInputElement; - private ROTATION_UNITS: HTMLInputElement; - private ARRANGEMENT: HTMLInputElement; - private SIZE_LEFT_RIGHT: HTMLInputElement; - private SIZE_FRONT_BACK: HTMLInputElement; - private FORWARD_DIRECTION: HTMLInputElement; - - constructor(content: HTMLElement) { - let configBody = content.getElementsByClassName("timeline-viz-config")[0].firstElementChild as HTMLElement; - super( - content, - TabType.Swerve, - [ - // Status (red) - { - element: configBody.children[1].firstElementChild as HTMLElement, - types: [LoggableType.NumberArray, "SwerveModuleState[]"] - }, - - // Status (blue) - { - element: configBody.children[2].firstElementChild as HTMLElement, - types: [LoggableType.NumberArray, "SwerveModuleState[]"] - }, - - // Robot rotation - { - element: configBody.children[3].firstElementChild as HTMLElement, - types: [LoggableType.Number, "Rotation2d"] - } - ], - [], - new SwerveVisualizer(content.getElementsByClassName("swerve-canvas-container")[0] as HTMLElement) - ); - - // Get option inputs - this.MAX_SPEED = configBody.children[1].children[1].children[1] as HTMLInputElement; - this.ROTATION_UNITS = configBody.children[2].children[1].children[1] as HTMLInputElement; - this.ARRANGEMENT = configBody.children[3].children[1].children[1] as HTMLInputElement; - this.SIZE_LEFT_RIGHT = configBody.children[1].children[2].children[1] as HTMLInputElement; - this.SIZE_FRONT_BACK = configBody.children[2].children[2].children[1] as HTMLInputElement; - this.FORWARD_DIRECTION = configBody.children[3].children[2].children[1] as HTMLInputElement; - - // Enforce ranges - this.MAX_SPEED.addEventListener("change", () => { - if (Number(this.MAX_SPEED.value) <= 0) this.MAX_SPEED.value = "0.1"; - }); - this.SIZE_LEFT_RIGHT.addEventListener("change", () => { - if (Number(this.SIZE_LEFT_RIGHT.value) <= 0) this.SIZE_LEFT_RIGHT.value = "0.1"; - }); - this.SIZE_FRONT_BACK.addEventListener("change", () => { - if (Number(this.SIZE_FRONT_BACK.value) <= 0) this.SIZE_FRONT_BACK.value = "0.1"; - }); - } - - get options(): { [id: string]: any } { - return { - maxSpeed: Number(this.MAX_SPEED.value), - rotationUnits: this.ROTATION_UNITS.value, - arrangement: this.ARRANGEMENT.value, - sizeLeftRight: Number(this.SIZE_LEFT_RIGHT.value), - sizeFrontBack: Number(this.SIZE_FRONT_BACK.value), - forwardDirection: this.FORWARD_DIRECTION.value - }; - } - - set options(options: { [id: string]: any }) { - this.MAX_SPEED.value = options.maxSpeed; - this.ROTATION_UNITS.value = options.rotationUnits; - this.ARRANGEMENT.value = options.arrangement; - this.SIZE_LEFT_RIGHT.value = options.sizeLeftRight; - this.SIZE_FRONT_BACK.value = options.sizeFrontBack; - this.FORWARD_DIRECTION.value = options.forwardDirection; - } - - newAssets() {} - - getAdditionalActiveFields(): string[] { - return []; - } - - getCommand(time: number) { - let fields = this.getFields(); - - // Get module states - let getModuleStates = (isRed: boolean): NormalizedModuleState[] | null => { - let field = fields[isRed ? 0 : 1]; - if (field !== null) { - if (field.sourceType === LoggableType.NumberArray) { - let moduleData = getOrDefault(window.log, field.key, LoggableType.NumberArray, time, []) as number[]; - if (moduleData.length === 8) { - return this.ARRANGEMENT.value.split(",").map((stateIndex) => { - let stateIndexNum = Number(stateIndex); - let rotationValue = moduleData[stateIndexNum * 2]; - let velocityValue = moduleData[stateIndexNum * 2 + 1]; - let state: NormalizedModuleState = { - rotation: - this.ROTATION_UNITS.value === "radians" - ? rotationValue - : convert(rotationValue, "degrees", "radians"), - normalizedVelocity: Math.min(Math.max(velocityValue / Number(this.MAX_SPEED.value), -1), 1) - }; - return state; - }); - } - } else { - let length = getOrDefault(window.log, field!.key + "/length", LoggableType.Number, time, 0); - if (length === 4) { - return this.ARRANGEMENT.value.split(",").map((stateIndex) => { - let rotationValue = getOrDefault( - window.log, - field!.key + "/" + stateIndex + "/angle/value", - LoggableType.Number, - time, - 0 - ); - let velocityValue = getOrDefault( - window.log, - field!.key + "/" + stateIndex + "/speed", - LoggableType.Number, - time, - 0 - ); - let state: NormalizedModuleState = { - rotation: - this.ROTATION_UNITS.value === "radians" - ? rotationValue - : convert(rotationValue, "degrees", "radians"), - normalizedVelocity: Math.min(Math.max(velocityValue / Number(this.MAX_SPEED.value), -1), 1) - }; - return state; - }); - } - } - } - return null; - }; - - // Get robot rotation - let robotRotation = 0; - switch (this.FORWARD_DIRECTION.value) { - case "right": - robotRotation = 0; - break; - case "up": - robotRotation = Math.PI / 2; - break; - case "left": - robotRotation = Math.PI; - break; - case "down": - robotRotation = -Math.PI / 2; - break; - } - if (fields[2] !== null) { - let key = fields[2].key + (fields[2].sourceType === LoggableType.Number ? "" : "/value"); - let robotRotationRaw = getOrDefault(window.log, key, LoggableType.Number, time, 0); - if (this.ROTATION_UNITS.value === "radians" || fields[2].sourceType !== LoggableType.Number) { - robotRotation += robotRotationRaw; - } else { - robotRotation += convert(robotRotationRaw, "degrees", "radians"); - } - } - - // Calculate frame aspect ratio - let frameAspectRatio = Number(this.SIZE_LEFT_RIGHT.value) / Number(this.SIZE_FRONT_BACK.value); - - return { - redStates: getModuleStates(true), - blueStates: getModuleStates(false), - robotRotation: robotRotation, - frameAspectRatio: frameAspectRatio - }; - } -} diff --git a/src/hub/tabControllers/ThreeDimensionController.ts b/src/hub/tabControllers/ThreeDimensionController.ts deleted file mode 100644 index 630492f2..00000000 --- a/src/hub/tabControllers/ThreeDimensionController.ts +++ /dev/null @@ -1,753 +0,0 @@ -import { - APRIL_TAG_16H5_COUNT, - APRIL_TAG_36H11_COUNT, - AprilTag, - logReadNumberArrayToPose2dArray, - logReadNumberArrayToPose3dArray, - logReadPose2d, - logReadPose2dArray, - logReadPose3d, - logReadPose3dArray, - logReadTrajectoryToPose2dArray, - logReadTranslation2dArrayToPose2dArray, - logReadTranslation2dToPose2d, - logReadTranslation3dArrayToPose3dArray, - logReadTranslation3dToPose3d, - pose2dArrayTo3d, - pose2dTo3d, - Pose3d, - rotation2dTo3d, - rotation3dTo2d, - Translation2d -} from "../../shared/geometry"; -import LoggableType from "../../shared/log/LoggableType"; -import { - ALLIANCE_KEYS, - getDriverStation, - getIsRedAlliance, - getMechanismState, - getOrDefault, - MechanismState, - mergeMechanismStates -} from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import { convert } from "../../shared/units"; -import { cleanFloat, scaleValue } from "../../shared/util"; -import ThreeDimensionVisualizer from "../../shared/visualizers/ThreeDimensionVisualizer"; -import ThreeDimensionVisualizerSwitching from "../../shared/visualizers/ThreeDimensionVisualizerSwitching"; -import TimelineVizController from "./TimelineVizController"; - -export default class ThreeDimensionController extends TimelineVizController { - private TRAJECTORY_MAX_LENGTH = 40; - private static POSE_3D_TYPES = [ - "Robot", - ...ThreeDimensionVisualizer.GHOST_COLORS.map((color) => color + " Ghost"), - "Camera Override", - "Component (Robot)", - "Component (Ghost)", - "Game Piece 0", - "Game Piece 1", - "Game Piece 2", - "Game Piece 3", - "Game Piece 4", - "Game Piece 5", - "Trajectory", - "Vision Target", - "Axes", - "AprilTag 36h11", - "AprilTag 36h11 ID", - "AprilTag 16h5", - "AprilTag 16h5 ID", - "Blue Cone (Front)", - "Blue Cone (Center)", - "Blue Cone (Back)", - "Yellow Cone (Front)", - "Yellow Cone (Center)", - "Yellow Cone (Back)" - ]; - private static APRIL_TAG_TYPES = [ - "AprilTag 36h11", - "AprilTag 16h5", - "Vision Target", - "Axes", - "Blue Cone (Front)", - "Blue Cone (Center)", - "Blue Cone (Back)", - "Yellow Cone (Front)", - "Yellow Cone (Center)", - "Yellow Cone (Back)" - ]; - private static POSE_2D_TYPES = [ - "Robot", - ...ThreeDimensionVisualizer.GHOST_COLORS.map((color) => color + " Ghost"), - "Trajectory", - "Vision Target", - "Blue Cone (Front)", - "Blue Cone (Center)", - "Blue Cone (Back)", - "Yellow Cone (Front)", - "Yellow Cone (Center)", - "Yellow Cone (Back)" - ]; - - private FIELD: HTMLSelectElement; - private ALLIANCE: HTMLSelectElement; - private FIELD_SOURCE_LINK: HTMLInputElement; - private ROBOT: HTMLSelectElement; - private ROBOT_SOURCE_LINK: HTMLInputElement; - private UNIT_DISTANCE: HTMLInputElement; - private UNIT_ROTATION: HTMLInputElement; - - private newAssetsCounter = 0; - - constructor(content: HTMLElement) { - let configBody = content.getElementsByClassName("timeline-viz-config")[0].firstElementChild as HTMLElement; - super( - content, - TabType.ThreeDimension, - [], - [ - { - element: configBody.children[1].children[0] as HTMLElement, - types: [ - LoggableType.NumberArray, - "Pose3d", - "Pose3d[]", - "Transform3d", - "Transform3d[]", - "Translation3d", - "Translation3d[]", - "AprilTag", - "AprilTag[]" - ], - options: [ - ThreeDimensionController.POSE_3D_TYPES, // NumberArray - ThreeDimensionController.POSE_3D_TYPES.filter((x) => !x.endsWith("ID") && x !== "Trajectory"), // Pose3d - ThreeDimensionController.POSE_3D_TYPES.filter((x) => !x.endsWith("ID") && x !== "Camera Override"), // Pose3d[] - ThreeDimensionController.POSE_3D_TYPES.filter((x) => !x.endsWith("ID") && x !== "Trajectory"), // Transform3d - ThreeDimensionController.POSE_3D_TYPES.filter((x) => !x.endsWith("ID") && x !== "Camera Override"), // Transform3d[] - ["Vision Target"], // Translation3d - ["Trajectory", "Vision Target"], // Translation3d[] - ThreeDimensionController.APRIL_TAG_TYPES, // AprilTag - ThreeDimensionController.APRIL_TAG_TYPES // AprilTag[] - ] - }, - { - element: configBody.children[1].children[1] as HTMLElement, - types: [ - LoggableType.NumberArray, - "Pose2d", - "Pose2d[]", - "Transform2d", - "Transform2d[]", - "Translation2d", - "Translation2d[]", - "Trajectory", - "Mechanism2d", - "ZebraTranslation" - ], - options: [ - ThreeDimensionController.POSE_2D_TYPES, // NumberArray - ThreeDimensionController.POSE_2D_TYPES.filter((x) => x !== "Trajectory"), // Pose2d - ThreeDimensionController.POSE_2D_TYPES, // Pose2d[] - ThreeDimensionController.POSE_2D_TYPES.filter((x) => x !== "Trajectory"), // Transform2d - ThreeDimensionController.POSE_2D_TYPES, // Transform2d[] - ["Vision Target"], // Translation2d - ["Trajectory", "Vision Target"], // Translation2d[] - ["Trajectory"], // Trajectory - ["Mechanism (Robot)", "Mechanism (Ghost)"], // Mechanism2d - ["Zebra Marker", ...ThreeDimensionVisualizer.GHOST_COLORS.map((color) => color + " Ghost")] // ZebraTranslation - ], - autoAdvanceOptions: [true, true, true, true, true, true, true, true, true, false] - } - ], - new ThreeDimensionVisualizerSwitching( - content, - content.getElementsByClassName("three-dimension-canvas")[0] as HTMLCanvasElement, - content.getElementsByClassName("three-dimension-annotations")[0] as HTMLElement, - content.getElementsByClassName("three-dimension-alert")[0] as HTMLElement - ) - ); - - // Get option inputs - this.FIELD = configBody.children[1].children[2].children[1] as HTMLSelectElement; - this.ALLIANCE = configBody.children[1].children[2].children[2] as HTMLSelectElement; - this.FIELD_SOURCE_LINK = configBody.children[1].children[2].children[3] as HTMLInputElement; - this.ROBOT = configBody.children[2].children[0].children[1] as HTMLSelectElement; - this.ROBOT_SOURCE_LINK = configBody.children[2].children[0].children[2] as HTMLInputElement; - this.UNIT_DISTANCE = configBody.children[3].children[0].children[1] as HTMLInputElement; - this.UNIT_ROTATION = configBody.children[3].children[0].children[2] as HTMLInputElement; - - // Set default alliance value - this.ALLIANCE.value = "auto"; - - // Add initial set of options - this.resetFieldRobotOptions(); - - // Bind source links - this.FIELD.addEventListener("change", () => this.updateFieldRobotDependentControls()); - this.FIELD_SOURCE_LINK.addEventListener("click", () => { - window.sendMainMessage( - "open-link", - window.assets?.field3ds.find((field) => field.name === this.FIELD.value)?.sourceUrl - ); - }); - this.ROBOT.addEventListener("change", () => this.updateFieldRobotDependentControls(true)); - this.ROBOT_SOURCE_LINK.addEventListener("click", () => { - window.sendMainMessage( - "open-link", - window.assets?.robots.find((robot) => robot.name === this.ROBOT.value)?.sourceUrl - ); - }); - } - - /** Clears all options from the field and robot selectors then updates them with the latest options. */ - private resetFieldRobotOptions() { - let fieldChanged = false; - { - let value = this.FIELD.value; - while (this.FIELD.firstChild) { - this.FIELD.removeChild(this.FIELD.firstChild); - } - let options: string[] = []; - if (window.assets !== null) { - options = [...window.assets.field3ds.map((game) => game.name), "Evergreen", "Axes"]; - options.forEach((title) => { - let option = document.createElement("option"); - option.innerText = title; - this.FIELD.appendChild(option); - }); - } - if (options.includes(value)) { - this.FIELD.value = value; - } else { - this.FIELD.value = options[0]; - } - fieldChanged = this.FIELD.value !== value; - } - { - let value = this.ROBOT.value; - while (this.ROBOT.firstChild) { - this.ROBOT.removeChild(this.ROBOT.firstChild); - } - let options: string[] = []; - if (window.assets !== null) { - options = window.assets.robots.map((robot) => robot.name); - options.forEach((title) => { - let option = document.createElement("option"); - option.innerText = title; - this.ROBOT.appendChild(option); - }); - } - if (options.includes(value)) { - this.ROBOT.value = value; - } else { - this.ROBOT.value = options[0]; - } - } - this.updateFieldRobotDependentControls(!fieldChanged); - } - - /** Updates the alliance chooser, source buttons, and game piece names based on the selected value. */ - private updateFieldRobotDependentControls(skipAllianceReset = false) { - let fieldConfig = window.assets?.field3ds.find((game) => game.name === this.FIELD.value); - this.FIELD_SOURCE_LINK.hidden = fieldConfig === undefined || fieldConfig.sourceUrl === undefined; - let robotConfig = window.assets?.robots.find((game) => game.name === this.ROBOT.value); - this.ROBOT_SOURCE_LINK.hidden = robotConfig !== undefined && robotConfig.sourceUrl === undefined; - - if (this.FIELD.value === "Axes") this.ALLIANCE.value = "blue"; - this.ALLIANCE.hidden = this.FIELD.value === "Axes"; - if (fieldConfig !== undefined && !skipAllianceReset) { - this.ALLIANCE.value = fieldConfig.defaultOrigin; - } - - let aliases: { [key: string]: string | null } = { - "Game Piece 0": null, - "Game Piece 1": null, - "Game Piece 2": null, - "Game Piece 3": null, - "Game Piece 4": null, - "Game Piece 5": null - }; - if (fieldConfig !== undefined) { - fieldConfig.gamePieces.forEach((gamePiece, index) => { - aliases["Game Piece " + index.toString()] = gamePiece.name; - }); - } - this.setListOptionAliases(aliases); - } - - get options(): { [id: string]: any } { - return { - field: this.FIELD.value, - alliance: this.ALLIANCE.value, - robot: this.ROBOT.value, - unitDistance: this.UNIT_DISTANCE.value, - unitRotation: this.UNIT_ROTATION.value - }; - } - - set options(options: { [id: string]: any }) { - this.resetFieldRobotOptions(); // Cannot set field and robot values without options - this.FIELD.value = options.field; - this.ALLIANCE.value = options.alliance; - this.ROBOT.value = options.robot; - this.UNIT_DISTANCE.value = options.unitDistance; - this.UNIT_ROTATION.value = options.unitRotation; - this.updateFieldRobotDependentControls(true); - } - - newAssets() { - this.resetFieldRobotOptions(); - this.newAssetsCounter++; - } - - /** Switches the selected camera for the main visualizer. */ - set3DCamera(index: number) { - (this.visualizer as ThreeDimensionVisualizerSwitching).set3DCamera(index); - } - - /** Switches the orbit FOV for the main visualizer. */ - setFov(fov: number) { - (this.visualizer as ThreeDimensionVisualizerSwitching).setFov(fov); - } - - getAdditionalActiveFields(): string[] { - if (this.ALLIANCE.value === "auto") { - return ALLIANCE_KEYS; - } else { - return []; - } - } - - getCommand(time: number) { - const distanceConversion = convert(1, this.UNIT_DISTANCE.value, "meters"); - const rotationConversion = convert(1, this.UNIT_ROTATION.value, "radians"); - - // Returns the current value for a 3D field - let get3DValue = (key: string, type: LoggableType | string): Pose3d[] => { - if (type === LoggableType.NumberArray) { - return logReadNumberArrayToPose3dArray(window.log, key, time, distanceConversion); - } else if (type === "AprilTag[]") { - let length = getOrDefault(window.log, key + "/length", LoggableType.Number, time, 0); - let poses: Pose3d[] = []; - for (let i = 0; i < length; i++) { - let pose = logReadPose3d(window.log, key + "/" + i.toString() + "/pose", time, distanceConversion); - if (pose !== null) { - poses.push(pose); - } - } - return poses; - } else if (type === "AprilTag") { - let pose = logReadPose3d(window.log, key + "/pose", time, distanceConversion); - return pose === null ? [] : [pose]; - } else if (typeof type === "string" && type.endsWith("[]")) { - return type.startsWith("Translation") - ? logReadTranslation3dArrayToPose3dArray(window.log, key, time, distanceConversion) - : logReadPose3dArray(window.log, key, time, distanceConversion); - } else { - let pose = - typeof type === "string" && type.startsWith("Translation") - ? logReadTranslation3dToPose3d(window.log, key, time, distanceConversion) - : logReadPose3d(window.log, key, time, distanceConversion); - return pose === null ? [] : [pose]; - } - }; - - // Returns the current value for a 2D field - let get2DValue = (key: string, type: LoggableType | string, height = 0): Pose3d[] => { - if (type === LoggableType.NumberArray) { - return pose2dArrayTo3d( - logReadNumberArrayToPose2dArray(window.log, key, time, distanceConversion, rotationConversion), - height - ); - } else if (type === "Trajectory") { - return pose2dArrayTo3d(logReadTrajectoryToPose2dArray(window.log, key, time, distanceConversion), height); - } else if (typeof type === "string" && type.endsWith("[]")) { - return pose2dArrayTo3d( - type.startsWith("Translation") - ? logReadTranslation2dArrayToPose2dArray(window.log, key, time, distanceConversion) - : logReadPose2dArray(window.log, key, time, distanceConversion), - height - ); - } else { - let pose = - typeof type === "string" && type.startsWith("Translation") - ? logReadTranslation2dToPose2d(window.log, key, time, distanceConversion) - : logReadPose2d(window.log, key, time, distanceConversion); - return pose === null ? [] : [pose2dTo3d(pose, height)]; - } - }; - - // Set up data - let robotData: Pose3d[] = []; - let ghostData: { [key: string]: Pose3d[] } = {}; - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => (ghostData[color] = [])); - let aprilTag36h11Data: AprilTag[] = []; - let aprilTag36h11PoseData: Pose3d[] = []; - let aprilTag36h11IdData: number[] = []; - let aprilTag16h5Data: AprilTag[] = []; - let aprilTag16h5PoseData: Pose3d[] = []; - let aprilTag16h5IdData: number[] = []; - let cameraOverrideData: Pose3d[] = []; - let componentRobotData: Pose3d[] = []; - let componentGhostData: Pose3d[] = []; - let gamePieceData: Pose3d[][] = [[], [], [], [], [], []]; - let hasUserGamePieces = false; - let trajectoryData: Pose3d[][] = []; - let visionTargetData: Pose3d[] = []; - let axesData: Pose3d[] = []; - let coneBlueFrontData: Pose3d[] = []; - let coneBlueCenterData: Pose3d[] = []; - let coneBlueBackData: Pose3d[] = []; - let coneYellowFrontData: Pose3d[] = []; - let coneYellowCenterData: Pose3d[] = []; - let coneYellowBackData: Pose3d[] = []; - let mechanismRobotData: MechanismState | null = null; - let mechanismGhostData: MechanismState | null = null; - let zebraMarkerData: { [key: string]: { translation: Translation2d; alliance: string } } = {}; - let zebraGhostDataTranslations: { [key: string]: Translation2d[] } = {}; - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => (zebraGhostDataTranslations[color] = [])); - let zebraGhostData: { [key: string]: Pose3d[] } = {}; - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => (zebraGhostData[color] = [])); - - // Get 3D data - this.getListFields()[0].forEach((field) => { - switch (field.type) { - case "Robot": - robotData = robotData.concat(get3DValue(field.key, field.sourceType)); - break; - case "AprilTag 36h11": - case "AprilTag 16h5": - if (field.type === "AprilTag 36h11") { - aprilTag36h11PoseData = aprilTag36h11PoseData.concat(get3DValue(field.key, field.sourceType)); - } else { - aprilTag16h5PoseData = aprilTag16h5PoseData.concat(get3DValue(field.key, field.sourceType)); - } - let idData = field.type === "AprilTag 36h11" ? aprilTag36h11IdData : aprilTag16h5IdData; - if (field.sourceType === "AprilTag") { - idData.push(getOrDefault(window.log, field.key + "/ID", LoggableType.Number, time, 0)); - } else if (field.sourceType === "AprilTag[]") { - let length = getOrDefault(window.log, field.key + "/length", LoggableType.Number, time, 0); - for (let i = 0; i < length; i++) { - idData.push( - getOrDefault(window.log, field.key + "/" + i.toString() + "/ID", LoggableType.Number, time, 0) - ); - } - } - break; - case "AprilTag 36h11 ID": - case "AprilTag 16h5 ID": - { - let idData = field.type === "AprilTag 36h11 ID" ? aprilTag36h11IdData : aprilTag16h5IdData; - let logData = window.log.getNumberArray(field.key, time, time); - if (logData && logData.timestamps[0] <= time) { - for (let i = 0; i < logData.values[0].length; i += 1) { - idData.push(logData.values[0][i]); - } - } - } - break; - case "Camera Override": - cameraOverrideData = cameraOverrideData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Component (Robot)": - componentRobotData = componentRobotData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Component (Ghost)": - componentGhostData = componentGhostData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Game Piece 0": - case "Game Piece 1": - case "Game Piece 2": - case "Game Piece 3": - case "Game Piece 4": - case "Game Piece 5": - let index = Number(field.type[field.type.length - 1]); - gamePieceData[index] = gamePieceData[index].concat(get3DValue(field.key, field.sourceType)); - hasUserGamePieces = true; - break; - case "Trajectory": - trajectoryData.push(get3DValue(field.key, field.sourceType)); - break; - case "Vision Target": - visionTargetData = visionTargetData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Axes": - axesData = axesData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Blue Cone (Front)": - coneBlueFrontData = coneBlueFrontData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Blue Cone (Center)": - coneBlueCenterData = coneBlueCenterData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Blue Cone (Back)": - coneBlueBackData = coneBlueBackData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Yellow Cone (Front)": - coneYellowFrontData = coneYellowFrontData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Yellow Cone (Center)": - coneYellowCenterData = coneYellowCenterData.concat(get3DValue(field.key, field.sourceType)); - break; - case "Yellow Cone (Back)": - coneYellowBackData = coneYellowBackData.concat(get3DValue(field.key, field.sourceType)); - break; - default: - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - if (field.type === color + " Ghost") { - ghostData[color] = ghostData[color].concat(get3DValue(field.key, field.sourceType)); - } - }); - break; - } - }); - - // Get 2D data - this.getListFields()[1].forEach((field) => { - switch (field.type) { - case "Robot": - robotData = robotData.concat(get2DValue(field.key, field.sourceType)); - break; - case "Trajectory": - trajectoryData.push(get2DValue(field.key, field.sourceType, 0.02)); // Render outside the floor - break; - case "Vision Target": - visionTargetData = visionTargetData.concat(get2DValue(field.key, field.sourceType, 0.75)); - break; - case "Blue Cone (Front)": - coneBlueFrontData = coneBlueFrontData.concat(get2DValue(field.key, field.sourceType)); - break; - case "Blue Cone (Center)": - coneBlueCenterData = coneBlueCenterData.concat(get2DValue(field.key, field.sourceType)); - break; - case "Blue Cone (Back)": - coneBlueBackData = coneBlueBackData.concat(get2DValue(field.key, field.sourceType)); - break; - case "Yellow Cone (Front)": - coneYellowFrontData = coneYellowFrontData.concat(get2DValue(field.key, field.sourceType)); - break; - case "Yellow Cone (Center)": - coneYellowCenterData = coneYellowCenterData.concat(get2DValue(field.key, field.sourceType)); - break; - case "Yellow Cone (Back)": - coneYellowBackData = coneYellowBackData.concat(get2DValue(field.key, field.sourceType)); - break; - case "Mechanism (Robot)": - { - let mechanismState = getMechanismState(window.log, field.key, time); - if (mechanismState) { - if (mechanismRobotData === null) { - mechanismRobotData = mechanismState; - } else { - mechanismRobotData = mergeMechanismStates([mechanismRobotData, mechanismState]); - } - } - } - break; - case "Mechanism (Ghost)": - { - let mechanismState = getMechanismState(window.log, field.key, time); - if (mechanismState) { - if (mechanismGhostData === null) { - mechanismGhostData = mechanismState; - } else { - mechanismGhostData = mergeMechanismStates([mechanismGhostData, mechanismState]); - } - } - } - break; - case "Zebra Marker": - let team = field.key.split("FRC")[1]; - let x: number | null = null; - let y: number | null = null; - { - let xData = window.log.getNumber(field.key + "/x", time, time); - if (xData !== undefined && xData.values.length > 0) { - if (xData.values.length === 1) { - x = xData.values[0]; - } else { - x = scaleValue(time, [xData.timestamps[0], xData.timestamps[1]], [xData.values[0], xData.values[1]]); - } - } - } - { - let yData = window.log.getNumber(field.key + "/y", time, time); - if (yData !== undefined && yData.values.length > 0) { - if (yData.values.length === 1) { - y = yData.values[0]; - } else { - y = scaleValue(time, [yData.timestamps[0], yData.timestamps[1]], [yData.values[0], yData.values[1]]); - } - } - } - let alliance = getOrDefault(window.log, field.key + "/alliance", LoggableType.String, Infinity, "blue"); - if (x !== null && y !== null) { - zebraMarkerData[team] = { - translation: [convert(x, "feet", "meters"), convert(y, "feet", "meters")], - alliance: alliance - }; - } - break; - default: - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - if (field.type === color + " Ghost") { - if (field.sourceType !== "ZebraTranslation") { - ghostData[color] = ghostData[color].concat(get2DValue(field.key, field.sourceType)); - } else { - let x: number | null = null; - let y: number | null = null; - { - let xData = window.log.getNumber(field.key + "/x", time, time); - if (xData !== undefined && xData.values.length > 0) { - if (xData.values.length === 1) { - x = xData.values[0]; - } else { - x = scaleValue( - time, - [xData.timestamps[0], xData.timestamps[1]], - [xData.values[0], xData.values[1]] - ); - } - } - } - { - let yData = window.log.getNumber(field.key + "/y", time, time); - if (yData !== undefined && yData.values.length > 0) { - if (yData.values.length === 1) { - y = yData.values[0]; - } else { - y = scaleValue( - time, - [yData.timestamps[0], yData.timestamps[1]], - [yData.values[0], yData.values[1]] - ); - } - } - } - if (x !== null && y !== null) { - zebraGhostDataTranslations[color].push([convert(x, "feet", "meters"), convert(y, "feet", "meters")]); - } - } - } - }); - break; - } - }); - - // Combine AprilTag data - aprilTag36h11Data = aprilTag36h11PoseData.map((pose) => { - return { - id: null, - pose: pose - }; - }); - aprilTag16h5Data = aprilTag16h5PoseData.map((pose) => { - return { - id: null, - pose: pose - }; - }); - aprilTag36h11IdData.forEach((id, index) => { - if (index < aprilTag36h11Data.length) { - let cleanId = Math.round(cleanFloat(id)); - if (cleanId >= 0 && cleanId < APRIL_TAG_36H11_COUNT) { - aprilTag36h11Data[index].id = cleanId; - } - } - }); - aprilTag16h5IdData.forEach((id, index) => { - if (index < aprilTag16h5Data.length) { - let cleanId = Math.round(cleanFloat(id)); - if (cleanId >= 0 && cleanId < APRIL_TAG_16H5_COUNT) { - aprilTag16h5Data[index].id = cleanId; - } - } - }); - - // Clean up trajectories (filter empty & resample) - trajectoryData = trajectoryData.filter((trajectory) => trajectory.length > 0); - trajectoryData = trajectoryData.map((trajectory) => { - if (trajectory.length < this.TRAJECTORY_MAX_LENGTH) { - return trajectory; - } else { - let newTrajectory: Pose3d[] = []; - let lastSourceIndex = -1; - for (let i = 0; i < this.TRAJECTORY_MAX_LENGTH; i++) { - let sourceIndex = Math.round((i / (this.TRAJECTORY_MAX_LENGTH - 1)) * (trajectory.length - 1)); - if (sourceIndex !== lastSourceIndex) { - lastSourceIndex = sourceIndex; - newTrajectory.push(trajectory[sourceIndex]); - } - } - return newTrajectory; - } - }); - - // Get origin location - let allianceRedOrigin = false; - switch (this.ALLIANCE.value) { - case "auto": - allianceRedOrigin = getIsRedAlliance(window.log, time); - break; - case "blue": - allianceRedOrigin = false; - break; - case "red": - allianceRedOrigin = true; - break; - } - - // Apply robot rotation to Zebra ghost translations - let robotRotation2d = 0; - if (robotData.length > 0) { - robotRotation2d = rotation3dTo2d(robotData[0].rotation); - if (!allianceRedOrigin) { - // Switch from blue to red origin to match translation - robotRotation2d += Math.PI; - } - } - let robotRotation3d = rotation2dTo3d(robotRotation2d); - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - zebraGhostDataTranslations[color].forEach((translation) => { - zebraGhostData[color].push({ - translation: [translation[0], translation[1], 0], - rotation: robotRotation3d - }); - }); - }); - - // Package command data - return { - poses: { - robot: robotData, - ghost: ghostData, - aprilTag36h11: aprilTag36h11Data, - aprilTag16h5: aprilTag16h5Data, - cameraOverride: cameraOverrideData, - componentRobot: componentRobotData, - componentGhost: componentGhostData, - gamePiece: gamePieceData, - trajectory: trajectoryData, - visionTarget: visionTargetData, - axes: axesData, - coneBlueFront: coneBlueFrontData, - coneBlueCenter: coneBlueCenterData, - coneBlueBack: coneBlueBackData, - coneYellowFront: coneYellowFrontData, - coneYellowCenter: coneYellowCenterData, - coneYellowBack: coneYellowBackData, - mechanismRobot: mechanismRobotData, - mechanismGhost: mechanismGhostData, - zebraMarker: zebraMarkerData, - zebraGhost: zebraGhostData - }, - options: this.options, - allianceRedOrigin: allianceRedOrigin, - autoDriverStation: getDriverStation(window.log, time), - newAssetsCounter: this.newAssetsCounter, - hasUserGamePieces: hasUserGamePieces - }; - } -} diff --git a/src/hub/tabControllers/TimelineVizController.ts b/src/hub/tabControllers/TimelineVizController.ts deleted file mode 100644 index ab1c6a01..00000000 --- a/src/hub/tabControllers/TimelineVizController.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { TimelineVisualizerState } from "../../shared/HubState"; -import LoggableType from "../../shared/log/LoggableType"; -import { getEnabledData } from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import { arraysEqual, clampValue, createUUID, scaleValue } from "../../shared/util"; -import Visualizer from "../../shared/visualizers/Visualizer"; -import { SelectionMode } from "../Selection"; -import TabController from "../TabController"; - -export default abstract class TimelineVizController implements TabController { - private HANDLE_WIDTH = 4; - - protected UUID = createUUID(); - protected CONTENT: HTMLElement; - private TIMELINE_INPUT: HTMLInputElement; - private TIMELINE_MARKER_CONTAINER: HTMLElement; - private TIMELINE_LABEL: HTMLElement; - private DRAG_HIGHLIGHT: HTMLElement; - private CONFIG_TABLE: HTMLElement; - private HIDE_BUTTON: HTMLButtonElement; - private SHOW_BUTTON: HTMLButtonElement; - - private type: TabType; - private title: string = ""; - private fieldConfig: { element: HTMLElement; types: (LoggableType | string)[] }[]; - private fields: ({ key: string; sourceTypeIndex: number; sourceType: LoggableType | string } | null)[] = []; - private listConfig: { - element: HTMLElement; - types: (LoggableType | string)[]; - options: string[][]; - autoAdvanceOptions?: boolean[]; - }[]; - private listOptionAliases: { [key: string]: string | null } = {}; - private listFields: { type: string; key: string; sourceTypeIndex: number; sourceType: LoggableType | string }[][] = - []; - private lastListFieldsStr: string = ""; - private lastListOptionAliasesStr: string = ""; - private lastAllKeys: string[] = []; - private periodicInterval: number; - protected visualizer: Visualizer; - - constructor( - content: HTMLElement, - type: TabType, - fieldConfig: { element: HTMLElement; types: (LoggableType | string)[] }[], - listConfig: { - element: HTMLElement; - types: (LoggableType | string)[]; - options: string[][]; - autoAdvanceOptions?: boolean[]; - }[], - visualizer: Visualizer - ) { - this.CONTENT = content; - this.type = type; - this.fieldConfig = fieldConfig; - this.listConfig = listConfig; - this.visualizer = visualizer; - - this.TIMELINE_INPUT = content.getElementsByClassName("timeline-viz-timeline-slider")[0] as HTMLInputElement; - this.TIMELINE_MARKER_CONTAINER = content.getElementsByClassName( - "timeline-viz-timeline-marker-container" - )[0] as HTMLElement; - this.TIMELINE_LABEL = content.getElementsByClassName("timeline-viz-timeline-label")[0] as HTMLElement; - this.DRAG_HIGHLIGHT = content.getElementsByClassName("timeline-viz-drag-highlight")[0] as HTMLElement; - this.CONFIG_TABLE = content.getElementsByClassName("timeline-viz-config")[0] as HTMLElement; - - // Timeline controls - this.TIMELINE_INPUT.addEventListener("input", () => { - window.selection.setSelectedTime(Number(this.TIMELINE_INPUT.value)); - }); - content.getElementsByClassName("timeline-viz-reset-button")[0].addEventListener("click", () => { - let enabledData = getEnabledData(window.log); - if (enabledData) { - for (let i = 0; i < enabledData.timestamps.length; i++) { - if (enabledData.values[i]) { - window.selection.setSelectedTime(enabledData.timestamps[i]); - return; - } - } - } - }); - this.HIDE_BUTTON = content.getElementsByClassName("timeline-viz-hide-button")[0] as HTMLButtonElement; - this.SHOW_BUTTON = content.getElementsByClassName("timeline-viz-show-button")[0] as HTMLButtonElement; - this.HIDE_BUTTON.addEventListener("click", () => { - this.HIDE_BUTTON.hidden = true; - this.SHOW_BUTTON.hidden = false; - this.CONFIG_TABLE.hidden = true; - }); - this.SHOW_BUTTON.addEventListener("click", () => { - this.HIDE_BUTTON.hidden = false; - this.SHOW_BUTTON.hidden = true; - this.CONFIG_TABLE.hidden = false; - }); - content.getElementsByClassName("timeline-viz-popup-button")[0].addEventListener("click", () => { - window.sendMainMessage("create-satellite", { - uuid: this.UUID, - type: this.type - }); - }); - this.TIMELINE_INPUT.addEventListener("mouseenter", () => { - this.TIMELINE_LABEL.classList.add("show"); - }); - this.TIMELINE_INPUT.addEventListener("mouseleave", () => { - this.TIMELINE_LABEL.classList.remove("show"); - }); - - // Drag handling - window.addEventListener("drag-update", (event) => { - this.handleDrag((event as CustomEvent).detail); - }); - - // Create field list and clear fields on right click - Object.values(this.fieldConfig).forEach((field, index) => { - this.fields.push(null); - field.element.addEventListener("contextmenu", () => { - this.fields[index] = null; - this.updateFields(); - }); - }); - - // Create empty arrays for list fields - Object.values(this.listConfig).forEach(() => { - this.listFields.push([]); - }); - - // Start periodic cycle - this.periodicInterval = window.setInterval(() => this.customPeriodic(), 1000 / 60); - - // Refresh timeline immediately - this.refresh(); - } - - stopPeriodic() { - window.clearInterval(this.periodicInterval); - } - - saveState(): TimelineVisualizerState { - return { - type: this.type as any, - uuid: this.UUID, - fields: this.fields, - listFields: this.listFields, - options: this.options, - configHidden: this.CONFIG_TABLE.hidden, - visualizer: this.visualizer.saveState() - }; - } - - restoreState(state: TimelineVisualizerState) { - if (state.type !== this.type) return; - this.fields = state.fields; - this.UUID = state.uuid; - this.listFields = state.listFields; - this.options = state.options; - this.HIDE_BUTTON.hidden = state.configHidden; - this.SHOW_BUTTON.hidden = !state.configHidden; - this.CONFIG_TABLE.hidden = state.configHidden; - this.visualizer.restoreState(state.visualizer); - this.updateFields(); - } - - /** Processes a drag event, including updating a field if necessary. */ - private handleDrag(dragData: any) { - if (this.CONTENT.hidden) return; - - this.DRAG_HIGHLIGHT.hidden = true; - [Object.values(this.fieldConfig), Object.values(this.listConfig)].forEach((configList, configIndex) => { - configList.forEach((field, index) => { - let rect = field.element.getBoundingClientRect(); - let active = - dragData.x > rect.left && dragData.x < rect.right && dragData.y > rect.top && dragData.y < rect.bottom; - let anyValidType = false; - dragData.data.fields.forEach((dragField: string, dragFieldIndex: number) => { - if (configIndex === 0 && anyValidType) return; // Single field and valid field already found - let logType = window.log.getType(dragField); - let structuredType = window.log.getStructuredType(dragField); - let validLogType = logType !== null && field.types.includes(logType); - let validStructuredType = structuredType !== null && field.types.includes(structuredType); - let validType = validLogType || validStructuredType; - anyValidType = anyValidType || validType; - - if (active && validType && dragData.end) { - if (configIndex === 0) { - // Single field - let typeIndex = this.fieldConfig[index].types.indexOf(validStructuredType ? structuredType! : logType!); - this.fields[index] = { - key: dragField, - sourceTypeIndex: typeIndex, - sourceType: this.fieldConfig[index].types[typeIndex] - }; - } else { - // List field - let selectedOptions = this.listFields[index].map((field) => field.type); - let typeIndex = this.listConfig[index].types.indexOf(validStructuredType ? structuredType! : logType!); - let availableOptions = this.listConfig[index].options[typeIndex]; - if ( - this.listConfig[index].autoAdvanceOptions === undefined || - this.listConfig[index].autoAdvanceOptions![typeIndex] - ) { - availableOptions = availableOptions.filter((option) => !selectedOptions.includes(option)); - if (availableOptions.length === 0) { - availableOptions.push(this.listConfig[index].options[typeIndex][0]); - } - } - this.listFields[index].push({ - type: availableOptions[0], - key: dragField, - sourceTypeIndex: typeIndex, - sourceType: this.listConfig[index].types[typeIndex] - }); - } - } - }); - if (active && anyValidType) { - if (dragData.end) { - this.updateFields(); - if (configIndex === 1) { - // List field, scroll to bottom - let listContent = field.element.firstElementChild as HTMLElement; - listContent.scrollTop = listContent.scrollHeight; - } - } else { - let contentRect = this.CONTENT.getBoundingClientRect(); - this.DRAG_HIGHLIGHT.style.left = (rect.left - contentRect.left).toString() + "px"; - this.DRAG_HIGHLIGHT.style.top = (rect.top - contentRect.top).toString() + "px"; - this.DRAG_HIGHLIGHT.style.width = rect.width.toString() + "px"; - this.DRAG_HIGHLIGHT.style.height = rect.height.toString() + "px"; - this.DRAG_HIGHLIGHT.hidden = false; - } - } - }); - }); - } - - /** Checks if a key or its children are available. */ - private keyAvailable(key: string): boolean { - let allKeys = window.log.getFieldKeys(); - for (let i = 0; i < allKeys.length; i++) { - if (allKeys[i].startsWith(key)) { - return true; - } - } - return false; - } - - /** Updates the set of aliases for list options. Setting the alias to "null" hides the option. */ - protected setListOptionAliases(aliases: { [key: string]: string | null }) { - this.listOptionAliases = aliases; - this.updateFields(); - } - - /** Updates the field elements based on the internal field list. */ - private updateFields() { - let allKeys = window.log.getFieldKeys(); - - // Single fields - Object.values(this.fieldConfig).forEach((field, index) => { - let textElement = field.element.lastElementChild as HTMLElement; - let key = this.fields[index]?.key; - - if (key === undefined) { - textElement.innerText = ""; - textElement.style.textDecoration = ""; - } else if (!this.keyAvailable(key)) { - textElement.innerText = key; - textElement.style.textDecoration = "line-through"; - } else { - textElement.innerText = key; - textElement.style.textDecoration = ""; - } - }); - - // Exit if list fields, list option aliases, and available fields have not changed - let listFieldsStr = JSON.stringify(this.listFields); - let listOptionAliasesStr = JSON.stringify(this.listOptionAliases); - if ( - arraysEqual(allKeys, this.lastAllKeys) && - listFieldsStr === this.lastListFieldsStr && - listOptionAliasesStr === this.lastListOptionAliasesStr - ) { - return; - } - this.lastAllKeys = allKeys; - this.lastListFieldsStr = listFieldsStr; - this.lastListOptionAliasesStr = listOptionAliasesStr; - - // List fields - Object.values(this.listConfig).forEach((list, index) => { - let content = list.element.firstElementChild as HTMLElement; - - // Clear elements - while (content.firstChild) { - content.removeChild(content.firstChild); - } - - // Add filler if necessary - if (this.listFields[index].length === 0) { - let fillerElement = document.createElement("div"); - fillerElement.classList.add("list-filler"); - fillerElement.innerText = ""; - content.appendChild(fillerElement); - } - - // Add fields - this.listFields[index].forEach((field, fieldIndex) => { - let itemElement = document.createElement("div"); - itemElement.classList.add("list-item"); - content.appendChild(itemElement); - itemElement.addEventListener("contextmenu", () => { - this.listFields[index].splice(fieldIndex, 1); - this.updateFields(); - }); - - let labelElement = document.createElement("span"); - labelElement.classList.add("label"); - labelElement.innerHTML = ": "; - itemElement.appendChild(labelElement); - - let selectElement = labelElement.firstChild as HTMLSelectElement; - let validOptions: string[] = []; - list.options[field.sourceTypeIndex].forEach((option) => { - let displayName = option; - if (option in this.listOptionAliases) { - let alias = this.listOptionAliases[option]; - if (alias === null) return; - displayName = alias; - } - let optionElement = document.createElement("option"); - optionElement.innerText = displayName; - optionElement.value = option; - selectElement.appendChild(optionElement); - validOptions.push(option); - }); - if (validOptions.includes(field.type)) { - selectElement.value = field.type; - } - selectElement.addEventListener("change", () => { - this.listFields[index][fieldIndex].type = selectElement.value; - }); - - let fieldNameElement = document.createElement("span"); - fieldNameElement.classList.add("field-name"); - fieldNameElement.innerText = field.key; - fieldNameElement.style.textDecoration = this.keyAvailable(field.key) ? "" : "line-through"; - itemElement.appendChild(fieldNameElement); - }); - }); - } - - /** Called when this tab's title changes, so that the updated title can be sent to the satellites. */ - setTitle(title: string) { - this.title = title; - } - - refresh() { - // Update fields - this.updateFields(); - } - - getActiveFields(): string[] { - let activeFields = this.fields.filter((field) => field !== null).map((field) => field?.key) as string[]; - this.listFields.forEach((group) => - group.forEach((field) => { - activeFields.push(field.key); - }) - ); - return [...activeFields, ...this.getAdditionalActiveFields()]; - } - - periodic() { - // Update list shadows - Object.values(this.listConfig).forEach((list) => { - let content = list.element.firstElementChild as HTMLElement; - let shadowTop = list.element.getElementsByClassName("list-shadow-top")[0] as HTMLElement; - let shadowBottom = list.element.getElementsByClassName("list-shadow-bottom")[0] as HTMLElement; - shadowTop.style.opacity = content.scrollTop === 0 ? "0" : "1"; - shadowBottom.style.opacity = - Math.ceil(content.scrollTop + content.clientHeight) >= content.scrollHeight ? "0" : "1"; - }); - - // Update timeline label position - { - let contentBox = this.CONTENT.getBoundingClientRect(); - let timelineBox = this.TIMELINE_MARKER_CONTAINER.getBoundingClientRect(); - let windowX = scaleValue( - Number(this.TIMELINE_INPUT.value), - [Number(this.TIMELINE_INPUT.min), Number(this.TIMELINE_INPUT.max)], - [timelineBox.left + this.HANDLE_WIDTH / 2, timelineBox.right - this.HANDLE_WIDTH / 2] - ); - let contentX = windowX - contentBox.left; - this.TIMELINE_LABEL.style.left = contentX.toString() + "px"; - } - } - - /** Called every 15ms (regardless of the visible tab). */ - private customPeriodic() { - // Get time to render - let time: number; - let range = window.log.getTimestampRange(); - let selectionMode = window.selection.getMode(); - let hoveredTime = window.selection.getHoveredTime(); - let selectedTime = window.selection.getSelectedTime(); - if (selectionMode === SelectionMode.Playback || selectionMode === SelectionMode.Locked) { - time = selectedTime as number; - } else if (hoveredTime !== null) { - time = hoveredTime; - } else if (selectedTime !== null) { - time = selectedTime; - } else { - time = range[0]; - } - let liveTime = window.selection.getCurrentLiveTime(); - if (liveTime !== null) { - range[1] = liveTime; - } - - // Render timeline sections - while (this.TIMELINE_MARKER_CONTAINER.firstChild) { - this.TIMELINE_MARKER_CONTAINER.removeChild(this.TIMELINE_MARKER_CONTAINER.firstChild); - } - this.TIMELINE_INPUT.min = range[0].toString(); - this.TIMELINE_INPUT.max = range[1].toString(); - this.TIMELINE_INPUT.disabled = window.selection.getMode() === SelectionMode.Locked; - - let enabledData = getEnabledData(window.log); - if (enabledData) { - for (let i = 0; i < enabledData.values.length; i++) { - if (enabledData.values[i]) { - let div = document.createElement("div"); - this.TIMELINE_MARKER_CONTAINER.appendChild(div); - let nextTime = i === enabledData.values.length - 1 ? range[1] : enabledData.timestamps[i + 1]; - let marginPercent = (this.HANDLE_WIDTH / 2 / this.TIMELINE_MARKER_CONTAINER.clientWidth) * 100; - let leftPercent = scaleValue(enabledData.timestamps[i], range, [i === 0 ? 0 : marginPercent, 100]); - let rightPercent = scaleValue(nextTime, range, [ - 0, - 100 - (i === enabledData.values.length - 1 ? 0 : marginPercent) - ]); - leftPercent = clampValue(leftPercent, 0, 100); - rightPercent = clampValue(rightPercent, 0, 100); - let widthPercent = rightPercent - leftPercent; - div.style.left = leftPercent.toString() + "%"; - div.style.width = widthPercent.toString() + "%"; - } - } - } - - // Update timeline value - if (selectedTime !== null) { - this.TIMELINE_INPUT.value = selectedTime.toString(); - } else { - this.TIMELINE_INPUT.value = range[0].toString(); - } - - // Update timeline label - let labelNumber = Math.round(time * 10) / 10; - let labelString = labelNumber.toString(); - if (labelNumber % 1 === 0) { - labelString += ".0"; - } - this.TIMELINE_LABEL.innerText = labelString + "s"; - - // Update content height - this.CONTENT.style.setProperty( - "--bottom-margin", - this.CONFIG_TABLE.getBoundingClientRect().height.toString() + "px" - ); - - // Get command - let command = this.getCommand(time); - - // Update visualizers - if (!this.CONTENT.hidden) this.visualizer.render(command); - window.sendMainMessage("update-satellite", { - uuid: this.UUID, - command: command, - title: this.title - }); - } - - /** Returns the list of selected fields. */ - protected getFields() { - return this.fields; - } - - /** Returns the list of selected fields from lists. */ - protected getListFields() { - return this.listFields; - } - - /** Returns the set of selected options. */ - abstract get options(): { [id: string]: any }; - - /** Updates the set of selected options. */ - abstract set options(options: { [id: string]: any }); - - /** Notify that the set of assets was updated. */ - abstract newAssets(): void; - - /** - * Returns the list of fields currently being displayed. This is - * used to selectively request fields from live sources, and all - * keys matching the provided prefixes will be made available. - * */ - abstract getAdditionalActiveFields(): string[]; - - /** Returns a command to render a single frame. */ - abstract getCommand(time: number): any; -} diff --git a/src/main/Constants.ts b/src/main/Constants.ts index 0fae2ed6..d055ee3b 100644 --- a/src/main/Constants.ts +++ b/src/main/Constants.ts @@ -12,6 +12,8 @@ export const STATE_FILENAME = path.join( app.getPath("userData"), "state-" + (app.isPackaged ? app.getVersion().replaceAll(".", "_") : "dev") + ".json" ); +export const TYPE_MEMORY_FILENAME = path.join(app.getPath("userData"), "type-memory.json"); +export const RECENT_UNITS_FILENAME = path.join(app.getPath("userData"), "recent-units.json"); export const BUNDLED_ASSETS = path.join(__dirname, "..", "bundledAssets"); export const AUTO_ASSETS = path.join(app.getPath("userData"), "autoAssets"); export const DEFAULT_USER_ASSETS = path.join(app.getPath("userData"), "userAssets"); @@ -34,7 +36,8 @@ export const DEFAULT_PREFS: Preferences = { tbaApiKey: "", userAssetsFolder: null, skipHootNonProWarning: false, - skipFrcLogFolderDefault: false + skipFrcLogFolderDefault: false, + skipNumericArrayDeprecationWarning: false }; export const HUB_DEFAULT_WIDTH = 1100; export const HUB_DEFAULT_HEIGHT = 650; diff --git a/src/main/VideoProcessor.ts b/src/main/VideoProcessor.ts index 16590191..b59d24f9 100644 --- a/src/main/VideoProcessor.ts +++ b/src/main/VideoProcessor.ts @@ -1,10 +1,10 @@ +import ytdl from "@distube/ytdl-core"; import { ChildProcess, spawn } from "child_process"; import { BrowserWindow, Menu, MenuItem, app, clipboard, dialog } from "electron"; import fs from "fs"; import jsonfile from "jsonfile"; import path from "path"; import Tesseract, { createWorker } from "tesseract.js"; -import ytdl from "@distube/ytdl-core"; import MatchInfo from "../shared/MatchInfo"; import Preferences from "../shared/Preferences"; import { getTBAMatchInfo, getTBAMatchKey } from "../shared/TBAUtil"; diff --git a/src/main/main.ts b/src/main/main.ts index 10b66703..de738a22 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,3 +1,4 @@ +import { hex } from "color-convert"; import { BrowserWindow, BrowserWindowConstructorOptions, @@ -6,6 +7,7 @@ import { MenuItem, MessageChannelMain, MessagePortMain, + TitleBarOverlay, TouchBar, TouchBarSlider, app, @@ -21,15 +23,19 @@ import jsonfile from "jsonfile"; import net from "net"; import os from "os"; import path from "path"; +import { PNG } from "pngjs"; import { Client } from "ssh2"; import { AdvantageScopeAssets } from "../shared/AdvantageScopeAssets"; +import { ensureThemeContrast } from "../shared/Colors"; import ExportOptions from "../shared/ExportOptions"; +import LineGraphFilter from "../shared/LineGraphFilter"; import NamedMessage from "../shared/NamedMessage"; import Preferences from "../shared/Preferences"; -import TabType, { getAllTabTypes, getDefaultTabTitle, getTabIcon } from "../shared/TabType"; +import { SourceListConfig, SourceListItemState, SourceListTypeMemory } from "../shared/SourceListConfig"; +import TabType, { getAllTabTypes, getDefaultTabTitle, getTabAccelerator, getTabIcon } from "../shared/TabType"; import { BUILD_DATE, COPYRIGHT, DISTRIBUTOR, Distributor } from "../shared/buildConstants"; import { MERGE_MAX_FILES } from "../shared/log/LogUtil"; -import { UnitConversionPreset } from "../shared/units"; +import { MAX_RECENT_UNITS, NoopUnitConversion, UnitConversionPreset } from "../shared/units"; import { DEFAULT_PREFS, DOWNLOAD_CONNECT_TIMEOUT_MS, @@ -47,6 +53,7 @@ import { PATHPLANNER_PING_TEXT, PATHPLANNER_PORT, PREFS_FILENAME, + RECENT_UNITS_FILENAME, REPOSITORY, RLOG_CONNECT_TIMEOUT_MS, RLOG_DATA_TIMEOUT_MS, @@ -54,6 +61,7 @@ import { RLOG_HEARTBEAT_DELAY_MS, SATELLITE_DEFAULT_HEIGHT, SATELLITE_DEFAULT_WIDTH, + TYPE_MEMORY_FILENAME, WINDOW_ICON } from "./Constants"; import StateTracker, { ApplicationState, SatelliteWindowState, WindowState } from "./StateTracker"; @@ -68,6 +76,7 @@ let hubWindows: BrowserWindow[] = []; // Ordered by last focus time (recent firs let downloadWindow: BrowserWindow | null = null; let prefsWindow: BrowserWindow | null = null; let licensesWindow: BrowserWindow | null = null; +let xrWindow: BrowserWindow | null = null; let satelliteWindows: { [id: string]: BrowserWindow[] } = {}; let windowPorts: { [id: number]: MessagePortMain } = {}; let hubTouchBarSliders: { [id: number]: TouchBarSlider } = {}; @@ -157,12 +166,25 @@ function sendAssets() { }); } +/** Sends the list of active satellites to all hub windows. */ +function sendActiveSatellites() { + let activeSatellites: string[] = []; + Object.entries(satelliteWindows).forEach(([uuid, windows]) => { + if (windows.length > 0) { + activeSatellites.push(uuid); + } + }); + hubWindows.forEach((window) => { + sendMessage(window, "set-active-satellites", activeSatellites); + }); +} + /** * Process a message from a hub window. * @param window The source hub window * @param message The received message */ -function handleHubMessage(window: BrowserWindow, message: NamedMessage) { +async function handleHubMessage(window: BrowserWindow, message: NamedMessage) { if (window.isDestroyed()) return; let windowId = window.id; switch (message.name) { @@ -190,6 +212,24 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { stateTracker.saveRendererState(window, message.data); break; + case "save-type-memory": + let typeMemory: SourceListTypeMemory = fs.existsSync(TYPE_MEMORY_FILENAME) + ? jsonfile.readFileSync(TYPE_MEMORY_FILENAME) + : {}; + let originalTypeMemoryStr = JSON.stringify(typeMemory); + Object.entries(message.data as SourceListTypeMemory).forEach(([memoryId, fields]) => { + if (memoryId in typeMemory) { + typeMemory[memoryId] = { ...typeMemory[memoryId], ...fields }; + } else { + typeMemory[memoryId] = fields; + } + }); + let newTypeMemoryStr = JSON.stringify(typeMemory); + if (originalTypeMemoryStr !== newTypeMemoryStr) { + jsonfile.writeFileSync(TYPE_MEMORY_FILENAME, typeMemory); + } + break; + case "prompt-update": updateChecker.showPrompt(); break; @@ -290,9 +330,9 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { .showMessageBox(window, { type: "info", title: "Alert", - message: "About Non-Pro Signals", + message: "Limited Signals Available", detail: - "This log includes CTRE devices that are not Phoenix Pro licensed. Not all signals are available for these devices (check the Phoenix 6 documentation for details).", + "This log file includes a limited number of signals from Phoenix devices. Check the Phoenix documentation for details.", checkboxLabel: "Don't Show Again", icon: WINDOW_ICON }) @@ -306,6 +346,25 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { }); break; + case "numeric-array-deprecation-warning": + let shouldForce: boolean = message.data.force; + let prefs: Preferences = jsonfile.readFileSync(PREFS_FILENAME); + if (!shouldForce && prefs.skipNumericArrayDeprecationWarning) return; + if (!prefs.skipNumericArrayDeprecationWarning) { + prefs.skipNumericArrayDeprecationWarning = true; + jsonfile.writeFileSync(PREFS_FILENAME, prefs); + sendAllPreferences(); + } + dialog.showMessageBox(window, { + type: "info", + title: "Alert", + message: "Deprecated data format", + detail: + "The legacy numeric array format for structured data is deprecated and will be removed in 2026. Check the AdvantageScope documentation for details on migrating to a modern alternative.", + icon: WINDOW_ICON + }); + break; + case "live-rlog-start": rlogSockets[windowId]?.destroy(); rlogSockets[windowId] = net.createConnection({ @@ -425,6 +484,26 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { shell.openExternal(message.data); break; + case "open-app-menu": + case "close-app-menu": + { + let index: number = message.data.index; + let appMenu = Menu.getApplicationMenu(); + if (appMenu === null || index >= appMenu.items.length) return; + let submenu = appMenu.items[index].submenu; + if (submenu === undefined) return; + if (message.name === "open-app-menu") { + submenu.popup({ + window: window, + x: message.data.coordinates[0], + y: message.data.coordinates[1] + }); + } else { + submenu.closePopup(window); + } + } + break; + case "ask-playback-speed": const playbackSpeedMenu = new Menu(); Array(0.25, 0.5, 1, 1.5, 2, 4, 8).forEach((value) => { @@ -450,6 +529,198 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { newTabPopup(window); break; + case "source-list-type-prompt": + let uuid: string = message.data.uuid; + let config: SourceListConfig = message.data.config; + let state: SourceListItemState = message.data.state; + let coordinates: [number, number] = message.data.coordinates; + const menu = new Menu(); + + let respond = () => { + sendMessage(window, "source-list-type-response", { + uuid: uuid, + state: state + }); + }; + + // Make color icon + let getIcon = (value: string): Electron.NativeImage | undefined => { + if (!value.startsWith("#")) { + return undefined; + } + + // Make icon with color + const size = 15; + const color = hex.rgb(ensureThemeContrast(value, nativeTheme.shouldUseDarkColors)); + const png = new PNG({ width: size, height: size }); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const idx = (y * size + x) * 4; + png.data[idx + 0] = color[0]; + png.data[idx + 1] = color[1]; + png.data[idx + 2] = color[2]; + png.data[idx + 3] = 255; + } + } + const data = PNG.sync.write(png).toString("base64"); + return nativeImage.createFromDataURL("data:image/png;base64," + data); + }; + + // Add options + let currentTypeConfig = config.types.find((typeConfig) => typeConfig.key === state.type)!; + if (currentTypeConfig.options.length === 1) { + let optionConfig = currentTypeConfig.options[0]; + optionConfig.values.forEach((optionValue) => { + menu.append( + new MenuItem({ + label: optionValue.display, + type: "checkbox", + checked: optionValue.key === state.options[optionConfig.key], + icon: getIcon(optionValue.key), + click() { + state.options[optionConfig.key] = optionValue.key; + respond(); + } + }) + ); + }); + } else { + currentTypeConfig.options.forEach((optionConfig) => { + menu.append( + new MenuItem({ + label: optionConfig.display, + submenu: optionConfig.values.map((optionValue) => { + return { + label: optionValue.display, + type: "checkbox", + checked: optionValue.key === state.options[optionConfig.key], + icon: getIcon(optionValue.key), + click() { + state.options[optionConfig.key] = optionValue.key; + respond(); + } + }; + }) + }) + ); + }); + } + + // Add type options + let validTypes = config.types.filter( + (typeConfig) => + typeConfig.sourceTypes.includes(state.logType) && typeConfig.childOf === currentTypeConfig.childOf + ); + if (validTypes.length > 1) { + if (menu.items.length > 0) { + menu.append( + new MenuItem({ + type: "separator" + }) + ); + } + validTypes.forEach((typeConfig) => { + let current = state.type === typeConfig.key; + let optionConfig = current + ? undefined + : typeConfig.options.find((optionConfig) => optionConfig.key === typeConfig.initialSelectionOption); + menu.append( + new MenuItem({ + label: typeConfig.display, + type: current ? "checkbox" : optionConfig !== undefined ? "submenu" : "normal", + checked: current, + submenu: + optionConfig === undefined + ? undefined + : optionConfig.values.map((optionValue) => { + return { + label: optionValue.display, + icon: getIcon(optionValue.key), + click() { + state.type = typeConfig.key; + let newOptions: { [key: string]: string } = {}; + typeConfig.options.forEach((optionConfig) => { + if ( + optionConfig.key in state.options && + optionConfig.values + .map((valueConfig) => valueConfig.key) + .includes(state.options[optionConfig.key]) + ) { + newOptions[optionConfig.key] = state.options[optionConfig.key]; + } else { + newOptions[optionConfig.key] = optionConfig.values[0].key; + } + }); + state.options = newOptions; + state.options[typeConfig.initialSelectionOption!] = optionValue.key; + respond(); + } + }; + }), + click: + optionConfig !== undefined + ? undefined + : () => { + state.type = typeConfig.key; + let newOptions: { [key: string]: string } = {}; + typeConfig.options.forEach((optionConfig) => { + if ( + optionConfig.key in state.options && + optionConfig.values + .map((valueConfig) => valueConfig.key) + .includes(state.options[optionConfig.key]) + ) { + newOptions[optionConfig.key] = state.options[optionConfig.key]; + } else { + newOptions[optionConfig.key] = optionConfig.values[0].key; + } + }); + state.options = newOptions; + respond(); + } + }) + ); + }); + } + + if (menu.items.length === 0) { + menu.append( + new MenuItem({ + label: "No Options", + enabled: false + }) + ); + } + menu.popup({ + window: window, + x: coordinates[0], + y: coordinates[1] + }); + break; + + case "source-list-clear-prompt": + const clearMenu = new Menu(); + clearMenu.append( + new MenuItem({ + label: "Clear All", + click() { + sendMessage(window, "source-list-clear-response", { + uuid: message.data.uuid + }); + } + }) + ); + clearMenu.popup({ + window: window, + x: message.data.coordinates[0], + y: message.data.coordinates[1] + }); + break; + + case "source-list-help": + openSourceListHelp(window, message.data); + break; + case "ask-edit-axis": let legend: string = message.data.legend; const editAxisMenu = new Menu(); @@ -468,6 +739,7 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { // Left and right controls let lockedRange: [number, number] | null = message.data.lockedRange; let unitConversion: UnitConversionPreset = message.data.unitConversion; + let filter: LineGraphFilter = message.data.filter; editAxisMenu.append( new MenuItem({ @@ -478,7 +750,8 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { sendMessage(window, "edit-axis", { legend: legend, lockedRange: lockedRange === null ? [null, null] : null, - unitConversion: unitConversion + unitConversion: unitConversion, + filter: filter }); } }) @@ -492,7 +765,8 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { sendMessage(window, "edit-axis", { legend: legend, lockedRange: newLockedRange, - unitConversion: unitConversion + unitConversion: unitConversion, + filter: filter }); }); } @@ -503,23 +777,133 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { type: "separator" }) ); + let updateRecents = (newUnitConversion: UnitConversionPreset) => { + let newUnitConversionStr = JSON.stringify(newUnitConversion); + if (newUnitConversionStr !== JSON.stringify(NoopUnitConversion)) { + let recentUnits: UnitConversionPreset[] = fs.existsSync(RECENT_UNITS_FILENAME) + ? jsonfile.readFileSync(RECENT_UNITS_FILENAME) + : []; + recentUnits = recentUnits.filter((x) => JSON.stringify(x) !== newUnitConversionStr); + recentUnits.splice(0, 0, newUnitConversion); + while (recentUnits.length > MAX_RECENT_UNITS) { + recentUnits.pop(); + } + jsonfile.writeFileSync(RECENT_UNITS_FILENAME, recentUnits); + } + }; editAxisMenu.append( new MenuItem({ - label: "Unit Conversion...", + label: "Edit Units...", click() { createUnitConversionWindow(window, unitConversion, (newUnitConversion) => { sendMessage(window, "edit-axis", { legend: legend, lockedRange: lockedRange, - unitConversion: newUnitConversion + unitConversion: newUnitConversion, + filter: filter }); + updateRecents(newUnitConversion); + }); + } + }) + ); + let recentUnits: UnitConversionPreset[] = fs.existsSync(RECENT_UNITS_FILENAME) + ? jsonfile.readFileSync(RECENT_UNITS_FILENAME) + : []; + editAxisMenu.append( + new MenuItem({ + label: "Recent Presets", + type: "submenu", + enabled: recentUnits.length > 0, + submenu: recentUnits.map((preset) => { + let fromToText = + preset.from === undefined || preset.to === undefined + ? "" + : preset.from?.replace(/(^\w|\s\w|\/\w)/g, (m) => m.toUpperCase()) + + " \u2192 " + + preset.to?.replace(/(^\w|\s\w|\/\w)/g, (m) => m.toUpperCase()); + let factorText = preset.factor === 1 ? "" : "x" + preset.factor.toString(); + let bothPresent = fromToText.length > 0 && factorText.length > 0; + return { + label: fromToText + (bothPresent ? ", " : "") + factorText, + click() { + sendMessage(window, "edit-axis", { + legend: legend, + lockedRange: lockedRange, + unitConversion: preset, + filter: filter + }); + updateRecents(preset); + } + }; + }) + }) + ); + editAxisMenu.append( + new MenuItem({ + label: "Reset Units", + enabled: JSON.stringify(unitConversion) !== JSON.stringify(NoopUnitConversion), + click() { + sendMessage(window, "edit-axis", { + legend: legend, + lockedRange: lockedRange, + unitConversion: NoopUnitConversion, + filter: filter + }); + } + }) + ); + editAxisMenu.append( + new MenuItem({ + type: "separator" + }) + ); + editAxisMenu.append( + new MenuItem({ + label: "Differentiate", + type: "checkbox", + checked: filter === LineGraphFilter.Differentiate, + click() { + sendMessage(window, "edit-axis", { + legend: legend, + lockedRange: lockedRange, + unitConversion: unitConversion, + filter: filter === LineGraphFilter.Differentiate ? LineGraphFilter.None : LineGraphFilter.Differentiate }); } }) ); + editAxisMenu.append( + new MenuItem({ + label: "Integrate", + type: "checkbox", + checked: filter === LineGraphFilter.Integrate, + click() { + sendMessage(window, "edit-axis", { + legend: legend, + lockedRange: lockedRange, + unitConversion: unitConversion, + filter: filter === LineGraphFilter.Integrate ? LineGraphFilter.None : LineGraphFilter.Integrate + }); + } + }) + ); + editAxisMenu.append( + new MenuItem({ + type: "separator" + }) + ); } - // Always include clear button + // Always include help and clear buttons + editAxisMenu.append( + new MenuItem({ + label: "Help", + click() { + openSourceListHelp(window, message.data.config); + } + }) + ); editAxisMenu.append( new MenuItem({ label: "Clear All", @@ -560,11 +944,11 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { break; case "update-satellite": - let uuid = message.data.uuid; + let satelliteUUID = message.data.uuid; let command = message.data.command; let title = message.data.title; - if (uuid in satelliteWindows) { - satelliteWindows[uuid].forEach((satellite) => { + if (satelliteUUID in satelliteWindows) { + satelliteWindows[satelliteUUID].forEach((satellite) => { if (satellite.isVisible()) { sendMessage(satellite, "render", { command: command, title: title }); } @@ -635,6 +1019,10 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { } break; + case "open-xr": + openXR(window); + break; + default: console.warn("Unknown message from hub renderer process", message); break; @@ -659,21 +1047,22 @@ function newTabPopup(window: BrowserWindow) { const newTabMenu = new Menu(); getAllTabTypes() .slice(1) - .forEach((tabType, index) => { + .forEach((tabType) => { newTabMenu.append( new MenuItem({ label: getTabIcon(tabType) + " " + getDefaultTabTitle(tabType), - accelerator: index < 9 ? "CmdOrCtrl+" + (index + 1).toString() : "", + accelerator: getTabAccelerator(tabType), click() { sendMessage(window, "new-tab", tabType); } }) ); }); + newTabMenu.popup({ window: window, x: window.getBounds().width - 12, - y: 10 + y: process.platform === "win32" ? 48 : 10 }); } @@ -1106,7 +1495,8 @@ function setupMenu() { { label: "Open...", accelerator: "CmdOrCtrl+O", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; dialog .showOpenDialog(window, { @@ -1117,7 +1507,7 @@ function setupMenu() { }) .then((files) => { if (files.filePaths.length > 0) { - sendMessage(window, "open-files", [files.filePaths[0]]); + sendMessage(window!, "open-files", [files.filePaths[0]]); } }); } @@ -1125,7 +1515,8 @@ function setupMenu() { { label: "Open Multiple...", accelerator: "CmdOrCtrl+Shift+O", - async click(_, window) { + async click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; let filesResponse = await dialog.showOpenDialog(window, { title: "Select up to " + MERGE_MAX_FILES.toString() + " robot log files to open", @@ -1144,7 +1535,8 @@ function setupMenu() { { label: "Connect to Robot", accelerator: "CmdOrCtrl+K", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "start-live", false); } @@ -1152,7 +1544,8 @@ function setupMenu() { { label: "Connect to Simulator", accelerator: "CmdOrCtrl+Shift+K", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "start-live", true); } @@ -1160,15 +1553,17 @@ function setupMenu() { { label: "Download Logs...", accelerator: "CmdOrCtrl+D", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined) return; openDownload(window); } }, { label: "Load Zebra MotionWorks™", - accelerator: "Option+Z", - click(_, window) { + accelerator: "Alt+Z", + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined) return; sendMessage(window, "load-zebra"); } @@ -1187,7 +1582,8 @@ function setupMenu() { { label: "Export Data...", accelerator: "CmdOrCtrl+E", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "start-export"); } @@ -1198,7 +1594,8 @@ function setupMenu() { { label: "Connect to Robot", accelerator: "CmdOrCtrl+P", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "start-publish", false); } @@ -1206,15 +1603,17 @@ function setupMenu() { { label: "Connect to Simulator", accelerator: "CmdOrCtrl+Shift+P", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "start-publish", true); } }, { label: "Stop Publishing", - accelerator: "Option+P", - click(_, window) { + accelerator: "CmdOrCtrl+Alt+P", + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "stop-publish"); } @@ -1347,7 +1746,37 @@ function setupMenu() { ] }, { role: "editMenu" }, - { role: "viewMenu" }, + { + role: "viewMenu", + submenu: [ + { role: "reload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { + label: "Toggle Sidebar", + accelerator: "CmdOrCtrl+.", + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; + if (window === undefined || !hubWindows.includes(window)) return; + sendMessage(window, "toggle-sidebar"); + } + }, + { + label: "Toggle Controls", + accelerator: "CmdOrCtrl+/", + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; + if (window === undefined || !hubWindows.includes(window)) return; + sendMessage(window, "toggle-controls"); + } + }, + { role: "togglefullscreen" } + ] + }, { label: "Tabs", submenu: [ @@ -1355,11 +1784,12 @@ function setupMenu() { label: "New Tab", submenu: getAllTabTypes() .slice(1) - .map((tabType, index) => { + .map((tabType) => { return { label: getTabIcon(tabType) + " " + getDefaultTabTitle(tabType), - accelerator: index < 9 ? "CmdOrCtrl+" + (index + 1).toString() : "", - click(_, window) { + accelerator: getTabAccelerator(tabType), + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "new-tab", tabType); } @@ -1370,7 +1800,8 @@ function setupMenu() { label: "New Tab (Popup)", // Hidden item to add keyboard shortcut visible: false, accelerator: "CmdOrCtrl+T", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window) newTabPopup(window); } }, @@ -1378,7 +1809,8 @@ function setupMenu() { { label: "Previous Tab", accelerator: "CmdOrCtrl+Left", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "move-tab", -1); } @@ -1386,7 +1818,8 @@ function setupMenu() { { label: "Next Tab", accelerator: "CmdOrCtrl+Right", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "move-tab", 1); } @@ -1395,7 +1828,8 @@ function setupMenu() { { label: "Shift Left", accelerator: "CmdOrCtrl+[", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "shift-tab", -1); } @@ -1403,7 +1837,8 @@ function setupMenu() { { label: "Shift Right", accelerator: "CmdOrCtrl+]", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined || !hubWindows.includes(window)) return; sendMessage(window, "shift-tab", 1); } @@ -1412,7 +1847,8 @@ function setupMenu() { { label: "Close Tab", accelerator: "CmdOrCtrl+W", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined) return; if (hubWindows.includes(window)) { sendMessage(window, "close-tab"); @@ -1551,7 +1987,8 @@ function setupMenu() { { label: "Settings...", accelerator: "Cmd+,", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined) return; openPreferences(window); } @@ -1568,7 +2005,8 @@ function setupMenu() { : []), { label: "Show Licenses...", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined) return; openLicenses(window); } @@ -1596,7 +2034,8 @@ function setupMenu() { { label: "Show Preferences...", accelerator: "Ctrl+,", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined) return; openPreferences(window); } @@ -1613,7 +2052,8 @@ function setupMenu() { : []), { label: "Show Licenses...", - click(_, window) { + click(_, baseWindow) { + const window = baseWindow as BrowserWindow | undefined; if (window === undefined) return; openLicenses(window); } @@ -1643,7 +2083,7 @@ function createAboutWindow() { title: "About", message: "AdvantageScope", detail: COPYRIGHT + "\n\n" + detail, - buttons: ["Close", "Copy & Close"], + buttons: ["Close", process.platform === "win32" ? "Copy and Close" : "Copy & Close"], defaultId: 0, icon: WINDOW_ICON }) @@ -1686,14 +2126,39 @@ function createHubWindow(state?: WindowState) { } // Set fancy window effects - if (process.platform === "darwin") { - prefs.vibrancy = "sidebar"; - if (Number(os.release().split(".")[0]) >= 20) prefs.titleBarStyle = "hiddenInset"; + switch (process.platform) { + case "darwin": + prefs.vibrancy = "sidebar"; + if (Number(os.release().split(".")[0]) >= 20) prefs.titleBarStyle = "hiddenInset"; // macOS Big Sur + break; + case "win32": + prefs.titleBarStyle = "hidden"; + let releaseSplit = os.release().split("."); + if (Number(releaseSplit[releaseSplit.length - 1]) >= 22621) { + // Windows 11 22H2 + prefs.backgroundMaterial = "acrylic"; + } + let overlayOptions: TitleBarOverlay = { + color: "#00000000", + symbolColor: nativeTheme.shouldUseDarkColors ? "#ffffff" : "#000000", + height: 38 + }; + prefs.titleBarOverlay = overlayOptions; + nativeTheme.addListener("updated", () => { + if (window) { + overlayOptions.symbolColor = nativeTheme.shouldUseDarkColors ? "#ffffff" : "#000000"; + window.setTitleBarOverlay(overlayOptions); + } + }); + break; } // Create window let window = new BrowserWindow(prefs); hubWindows.push(window); + if (process.platform === "linux") { + window.setMenuBarVisibility(false); + } // Add touch bar menu let resetTouchBar = () => { @@ -1778,6 +2243,10 @@ function createHubWindow(state?: WindowState) { }); sendMessage(window, "show-update-button", updateChecker.getShouldPrompt()); sendAllPreferences(); + sendActiveSatellites(); + if (fs.existsSync(TYPE_MEMORY_FILENAME)) { + sendMessage(window, "restore-type-memory", jsonfile.readFileSync(TYPE_MEMORY_FILENAME)); + } if (firstLoad && state !== undefined) { sendMessage(window, "restore-state", state.state); } else { @@ -1829,7 +2298,7 @@ function createEditRangeWindow( ) { const editWindow = new BrowserWindow({ width: 300, - height: process.platform === "win32" ? 125 : 108, // "useContentSize" is broken on Windows when not resizable + height: 108, useContentSize: true, resizable: false, icon: WINDOW_ICON, @@ -1873,7 +2342,7 @@ function createUnitConversionWindow( ) { const unitConversionWindow = new BrowserWindow({ width: 300, - height: process.platform === "win32" ? 179 : 162, // "useContentSize" is broken on Windows when not resizable + height: 162, useContentSize: true, resizable: false, icon: WINDOW_ICON, @@ -1917,7 +2386,7 @@ function createRenameTabWindow( ) { const renameTabWindow = new BrowserWindow({ width: 300, - height: process.platform === "win32" ? 98 : 81, // "useContentSize" is broken on Windows when not resizable + height: 81, useContentSize: true, resizable: false, icon: WINDOW_ICON, @@ -1957,7 +2426,7 @@ function createRenameTabWindow( function createEditFovWindow(parentWindow: Electron.BrowserWindow, fov: number, callback: (newFov: number) => void) { const editFovWindow = new BrowserWindow({ width: 300, - height: process.platform === "win32" ? 98 : 81, // "useContentSize" is broken on Windows when not resizable + height: 81, useContentSize: true, resizable: false, icon: WINDOW_ICON, @@ -2001,7 +2470,7 @@ function createExportWindow( ) { const exportWindow = new BrowserWindow({ width: 300, - height: process.platform === "win32" ? 206 : 189, // "useContentSize" is broken on Windows when not resizable + height: 189, useContentSize: true, resizable: false, icon: WINDOW_ICON, @@ -2098,6 +2567,7 @@ function createSatellite( }) : undefined; const state = "state" in config ? config.state : undefined; + const uuid = configData !== undefined ? configData.uuid : state!.uuid; const width = state === undefined ? SATELLITE_DEFAULT_WIDTH : state.width; const height = state === undefined ? SATELLITE_DEFAULT_HEIGHT : state.height; @@ -2153,9 +2623,34 @@ function createSatellite( select3DCameraPopup(satellite, message.data.options, message.data.selectedIndex, message.data.fov); break; + case "add-table-range": + hubWindows.forEach((window) => { + sendMessage(window, "add-table-range", { + controllerUUID: uuid, + rendererUUID: message.data.uuid, + range: message.data.range + }); + }); + break; + case "save-state": stateTracker.saveRendererState(satellite, message.data); break; + + case "call-selection-setter": + message.data.uuid = configData !== undefined ? configData.uuid : state!.uuid; + hubWindows.forEach((window) => { + sendMessage(window, "call-selection-setter", message.data); + }); + break; + + case "open-link": + shell.openExternal(message.data); + break; + + default: + console.warn("Unknown message from satellite renderer process", message); + break; } }); port2.start(); @@ -2179,15 +2674,16 @@ function createSatellite( powerMonitor.on("on-ac", () => sendMessage(satellite, "set-battery", false)); powerMonitor.on("on-battery", () => sendMessage(satellite, "set-battery", true)); - const uuid = configData !== undefined ? configData.uuid : state!.uuid; if (!(uuid in satelliteWindows)) { satelliteWindows[uuid] = []; } satelliteWindows[uuid].push(satellite); stateTracker.saveSatelliteIds(satelliteWindows); + sendActiveSatellites(); satellite.once("closed", () => { satelliteWindows[uuid!].splice(satelliteWindows[uuid!].indexOf(satellite), 1); stateTracker.saveSatelliteIds(satelliteWindows); + sendActiveSatellites(); }); } @@ -2203,7 +2699,7 @@ function openPreferences(parentWindow: Electron.BrowserWindow) { const width = 400; const rows = 10; - const height = process.platform === "win32" ? rows * 27 + 114 : rows * 27 + 54; // "useContentSize" is broken on Windows when not resizable + const height = rows * 27 + 54; prefsWindow = new BrowserWindow({ width: width, height: height, @@ -2331,16 +2827,87 @@ function openLicenses(parentWindow: Electron.BrowserWindow) { licensesWindow.loadFile(path.join(__dirname, "../www/licenses.html")); } -// APPLICATION EVENTS - -// Workaround to set menu bar color on some Linux environments -if (process.platform === "linux" && fs.existsSync(PREFS_FILENAME)) { - let prefs: Preferences = jsonfile.readFileSync(PREFS_FILENAME); - if (prefs.theme === "dark") { - process.env["GTK_THEME"] = "Adwaita:dark"; +/** + * Creates a new XR window if it doesn't already exist. + * @param parentWindow The parent window to use for alignment + */ +function openXR(parentWindow: Electron.BrowserWindow) { + if (xrWindow !== null && !xrWindow.isDestroyed()) { + xrWindow.focus(); + return; } + + const width = 400; + const height = 400; + xrWindow = new BrowserWindow({ + width: width, + height: height, + x: Math.floor(parentWindow.getBounds().x + parentWindow.getBounds().width / 2 - width / 2), + y: Math.floor(parentWindow.getBounds().y + parentWindow.getBounds().height / 2 - height / 2), + resizable: false, + icon: WINDOW_ICON, + show: false, + fullscreenable: false, + webPreferences: { + preload: path.join(__dirname, "preload.js") + } + }); + + // Finish setup + xrWindow.setMenu(null); + xrWindow.setFullScreenable(false); // Call separately b/c the normal behavior is broken: https://github.com/electron/electron/pull/39086 + xrWindow.once("ready-to-show", xrWindow.show); + xrWindow.once("close", downloadStop); + xrWindow.loadFile(path.join(__dirname, "../www/xr.html")); } +/** + * Creates a new source list help window. + * @param parentWindow The parent window to use for alignment + */ +function openSourceListHelp(parentWindow: Electron.BrowserWindow, config: SourceListConfig) { + const width = 350; + const height = 500; + let helpWindow = new BrowserWindow({ + width: width, + height: height, + minWidth: width - 75, + maxWidth: width + 250, + minHeight: 200, + x: Math.floor(parentWindow.getBounds().x + 30), + y: Math.floor(parentWindow.getBounds().y + parentWindow.getBounds().height / 2 - height / 2), + resizable: true, + icon: WINDOW_ICON, + show: false, + fullscreenable: false, + alwaysOnTop: true, + webPreferences: { + preload: path.join(__dirname, "preload.js") + } + }); + + // Finish setup + helpWindow.setMenu(null); + helpWindow.setFullScreenable(false); // Call separately b/c the normal behavior is broken: https://github.com/electron/electron/pull/39086 + helpWindow.once("ready-to-show", helpWindow.show); + helpWindow.once("close", downloadStop); + helpWindow.webContents.on("dom-ready", () => { + // Create ports on reload + if (helpWindow === null) return; + const { port1, port2 } = new MessageChannelMain(); + helpWindow.webContents.postMessage("port", null, [port1]); + windowPorts[helpWindow.id] = port2; + port2.start(); + + // Init messages + sendMessage(helpWindow, "set-config", config); + sendAllPreferences(); + }); + helpWindow.loadFile(path.join(__dirname, "../www/sourceListHelp.html")); +} + +// APPLICATION EVENTS + function checkForUpdate(alwaysPrompt: boolean) { updateChecker.check().then(() => { hubWindows.forEach((window) => { @@ -2366,6 +2933,9 @@ function getDefaultLogPath(): string | undefined { // "unsafe-eval" is required in the hub for protobufjs process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true"; +// Silence unhandled promise rejections +process.on("unhandledRejection", () => {}); + app.whenReady().then(() => { // Check preferences and set theme if (!fs.existsSync(PREFS_FILENAME)) { @@ -2464,6 +3034,12 @@ app.whenReady().then(() => { if ("skipHootNonProWarning" in oldPrefs && typeof oldPrefs.skipHootNonProWarning === "boolean") { prefs.skipHootNonProWarning = oldPrefs.skipHootNonProWarning; } + if ( + "skipNumericArrayDeprecationWarning" in oldPrefs && + typeof oldPrefs.skipNumericArrayDeprecationWarning === "boolean" + ) { + prefs.skipNumericArrayDeprecationWarning = oldPrefs.skipNumericArrayDeprecationWarning; + } if ("skipFrcLogFolderDefault" in oldPrefs && typeof oldPrefs.skipFrcLogFolderDefault === "boolean") { prefs.skipFrcLogFolderDefault = oldPrefs.skipFrcLogFolderDefault; } diff --git a/src/preferences.ts b/src/preferences.ts index 64815c6e..f7cf5763 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -94,7 +94,8 @@ window.addEventListener("message", (event) => { tbaApiKey: TBA_API_KEY.value, userAssetsFolder: oldPrefs.userAssetsFolder, skipHootNonProWarning: oldPrefs.skipHootNonProWarning, - skipFrcLogFolderDefault: oldPrefs.skipFrcLogFolderDefault + skipFrcLogFolderDefault: oldPrefs.skipFrcLogFolderDefault, + skipNumericArrayDeprecationWarning: oldPrefs.skipNumericArrayDeprecationWarning }; messagePort.postMessage(newPrefs); } else { diff --git a/src/preload.ts b/src/preload.ts index da68d438..1d7b91dd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,4 +1,4 @@ -import { ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; const windowLoaded = new Promise((resolve) => { window.onload = resolve; @@ -8,3 +8,9 @@ ipcRenderer.on("port", async (event) => { await windowLoaded; window.postMessage("port", "*", event.ports); }); + +contextBridge.exposeInMainWorld("electron", { + getFilePath(file: File): string { + return webUtils.getPathForFile(file); + } +}); diff --git a/src/satellite.ts b/src/satellite.ts index d48a3f65..48cc21ac 100644 --- a/src/satellite.ts +++ b/src/satellite.ts @@ -1,17 +1,23 @@ import { AdvantageScopeAssets } from "./shared/AdvantageScopeAssets"; import NamedMessage from "./shared/NamedMessage"; import Preferences from "./shared/Preferences"; +import Selection, { SelectionMode } from "./shared/Selection"; import TabType, { getTabIcon } from "./shared/TabType"; +import ConsoleRenderer from "./shared/renderers/ConsoleRenderer"; +import DocumentationRenderer from "./shared/renderers/DocumentationRenderer"; +import JoysticksRenderer from "./shared/renderers/JoysticksRenderer"; +import LineGraphRenderer from "./shared/renderers/LineGraphRenderer"; +import MechanismRenderer from "./shared/renderers/MechanismRenderer"; +import MetadataRenderer from "./shared/renderers/MetadataRenderer"; +import OdometryRenderer from "./shared/renderers/OdometryRenderer"; +import PointsRenderer from "./shared/renderers/PointsRenderer"; +import StatisticsRenderer from "./shared/renderers/StatisticsRenderer"; +import SwerveRenderer from "./shared/renderers/SwerveRenderer"; +import TabRenderer from "./shared/renderers/TabRenderer"; +import TableRenderer from "./shared/renderers/TableRenderer"; +import ThreeDimensionRenderer from "./shared/renderers/ThreeDimensionRenderer"; +import VideoRenderer from "./shared/renderers/VideoRenderer"; import { htmlEncode } from "./shared/util"; -import JoysticksVisualizer from "./shared/visualizers/JoysticksVisualizer"; -import MechanismVisualizer from "./shared/visualizers/MechanismVisualizer"; -import OdometryVisualizer from "./shared/visualizers/OdometryVisualizer"; -import PointsVisualizer from "./shared/visualizers/PointsVisualizer"; -import SwerveVisualizer from "./shared/visualizers/SwerveVisualizer"; -import ThreeDimensionVisualizer from "./shared/visualizers/ThreeDimensionVisualizer"; -import ThreeDimensionVisualizerSwitching from "./shared/visualizers/ThreeDimensionVisualizerSwitching"; -import VideoVisualizer from "./shared/visualizers/VideoVisualizer"; -import Visualizer from "./shared/visualizers/Visualizer"; const MAX_ASPECT_RATIO = 5; const SAVE_PERIOD_MS = 250; @@ -21,13 +27,14 @@ declare global { assets: AdvantageScopeAssets | null; preferences: Preferences | null; isBattery: boolean; + selection: Selection; sendMainMessage: (name: string, data?: any) => void; } } window.isBattery = false; -let visualizer: Visualizer | null = null; +let renderer: TabRenderer | null = null; let type: TabType | null = null; let title: string | null = null; let messagePort: MessagePort | null = null; @@ -39,48 +46,54 @@ function updateVisualizer() { if (type === null) return; // Update visible elements - (document.getElementById("odometry") as HTMLElement).hidden = type !== TabType.Odometry; - (document.getElementById("threeDimension") as HTMLElement).hidden = type !== TabType.ThreeDimension; - (document.getElementById("video") as HTMLElement).hidden = type !== TabType.Video; - (document.getElementById("joysticks") as HTMLElement).hidden = type !== TabType.Joysticks; - (document.getElementById("swerve") as HTMLElement).hidden = type !== TabType.Swerve; - (document.getElementById("mechanism") as HTMLElement).hidden = type !== TabType.Mechanism; - (document.getElementById("points") as HTMLElement).hidden = type !== TabType.Points; - - // Create visualizer + for (let i = 0; i < document.body.childElementCount; i++) { + let element = document.getElementById("renderer" + i.toString()); + if (element !== null) { + element.hidden = type !== i; + } + } + + // Create renderer + let root = document.getElementById("renderer" + type.toString()) as HTMLElement; switch (type) { + case TabType.Documentation: + renderer = new DocumentationRenderer(root); + break; + case TabType.LineGraph: + renderer = new LineGraphRenderer(root, false); + break; case TabType.Odometry: - visualizer = new OdometryVisualizer( - document.getElementById("odometryCanvasContainer") as HTMLElement, - document.getElementById("odometryHeatmapContainer") as HTMLElement - ); + renderer = new OdometryRenderer(root); break; case TabType.ThreeDimension: - visualizer = new ThreeDimensionVisualizerSwitching( - document.body, - document.getElementById("threeDimensionCanvas") as HTMLCanvasElement, - document.getElementById("threeDimensionAnnotations") as HTMLElement, - document.getElementById("threeDimensionAlert") as HTMLElement - ); + renderer = new ThreeDimensionRenderer(root); + break; + case TabType.Table: + renderer = new TableRenderer(root, false); + break; + case TabType.Console: + renderer = new ConsoleRenderer(root, false); + break; + case TabType.Statistics: + renderer = new StatisticsRenderer(root); break; case TabType.Video: - visualizer = new VideoVisualizer(document.getElementsByClassName("video-image")[0] as HTMLImageElement); + renderer = new VideoRenderer(root); break; case TabType.Joysticks: - visualizer = new JoysticksVisualizer(document.getElementById("joysticksCanvas") as HTMLCanvasElement); + renderer = new JoysticksRenderer(root); break; case TabType.Swerve: - visualizer = new SwerveVisualizer(document.getElementsByClassName("swerve-canvas-container")[0] as HTMLElement); + renderer = new SwerveRenderer(root); break; case TabType.Mechanism: - visualizer = new MechanismVisualizer( - document.getElementsByClassName("mechanism-svg-container")[0] as HTMLElement - ); + renderer = new MechanismRenderer(root); break; case TabType.Points: - visualizer = new PointsVisualizer( - document.getElementsByClassName("points-background-container")[0] as HTMLElement - ); + renderer = new PointsRenderer(root); + break; + case TabType.Metadata: + renderer = new MetadataRenderer(root); break; } } @@ -118,7 +131,7 @@ window.addEventListener("message", (event) => { case "restore-state": type = message.data.type; updateVisualizer(); - visualizer?.restoreState(message.data.visualizer); + renderer?.restoreState(message.data.visualizer); break; case "render": @@ -127,27 +140,37 @@ window.addEventListener("message", (event) => { let newTitle = message.data.title; if (newTitle !== title) { titleElement.innerHTML = - (type ? getTabIcon(type) + " " : "") + htmlEncode(newTitle) + " — AdvantageScope"; + (type !== null ? getTabIcon(type) + " " : "") + htmlEncode(newTitle) + " — AdvantageScope"; title = newTitle; } // Render frame lastCommand = message.data.command; - if (visualizer) { - let aspectRatio = visualizer.render(message.data.command); + if (renderer) { + renderer.render(message.data.command); + let aspectRatio = renderer.getAspectRatio(); processAspectRatio(aspectRatio); + + // Update table range + if (type === TabType.Table) { + let tableRenderer = renderer as TableRenderer; + window.sendMainMessage("add-table-range", { + uuid: tableRenderer.UUID, + range: tableRenderer.getTimestampRange() + }); + } } break; case "set-3d-camera": if (type === TabType.ThreeDimension) { - (visualizer as ThreeDimensionVisualizer).set3DCamera(message.data); + (renderer as ThreeDimensionRenderer).set3DCamera(message.data); } break; case "edit-fov": if (type === TabType.ThreeDimension) { - (visualizer as ThreeDimensionVisualizer).setFov(message.data); + (renderer as ThreeDimensionRenderer).setFov(message.data); } break; @@ -160,10 +183,11 @@ window.addEventListener("message", (event) => { }); window.addEventListener("resize", () => { - if (visualizer === null || lastCommand === null) { + if (renderer === null || lastCommand === null) { return; } - let aspectRatio = visualizer.render(lastCommand); + renderer.render(lastCommand); + let aspectRatio = renderer.getAspectRatio(); if (aspectRatio) processAspectRatio(aspectRatio); }); @@ -178,6 +202,135 @@ function processAspectRatio(aspectRatio: number | null) { } } +window.addEventListener("beforeunload", () => { + if (type === TabType.Table) { + let tableRenderer = renderer as TableRenderer; + window.sendMainMessage("add-table-range", { + uuid: tableRenderer.UUID, + range: null + }); + } +}); + +window.addEventListener("keydown", (event) => { + if (event.target !== document.body) return; + switch (event.code) { + case "Space": + event.preventDefault(); + window.selection.togglePlayback(); + break; + + case "KeyL": + event.preventDefault(); + window.selection.toggleLock(); + break; + + case "ArrowLeft": + case "ArrowRight": + event.preventDefault(); + window.selection.stepCycle(event.code === "ArrowRight"); + break; + } +}); + setInterval(() => { - window.sendMainMessage("save-state", { type: type, visualizer: visualizer?.saveState() }); + window.sendMainMessage("save-state", { type: type, visualizer: renderer?.saveState() }); }, SAVE_PERIOD_MS); + +// Mock selection, send setter calls back to hub + +class MockSelection implements Selection { + getMode(): SelectionMode { + throw new Error("Method not implemented."); + } + + getHoveredTime(): number | null { + throw new Error("Method not implemented."); + } + + setHoveredTime(value: number | null): void { + window.sendMainMessage("call-selection-setter", { name: "setHoveredTime", args: [value] }); + } + + getSelectedTime(): number | null { + throw new Error("Method not implemented."); + } + + setSelectedTime(time: number): void { + window.sendMainMessage("call-selection-setter", { name: "setSelectedTime", args: [time] }); + } + + goIdle(): void { + window.sendMainMessage("call-selection-setter", { name: "goIdle", args: [] }); + } + + play(): void { + window.sendMainMessage("call-selection-setter", { name: "play", args: [] }); + } + + pause(): void { + window.sendMainMessage("call-selection-setter", { name: "pause", args: [] }); + } + + togglePlayback(): void { + window.sendMainMessage("call-selection-setter", { name: "togglePlayback", args: [] }); + } + + lock(): void { + window.sendMainMessage("call-selection-setter", { name: "lock", args: [] }); + } + + unlock(): void { + window.sendMainMessage("call-selection-setter", { name: "unlock", args: [] }); + } + + toggleLock(): void { + window.sendMainMessage("call-selection-setter", { name: "toggleLock", args: [] }); + } + + stepCycle(isForward: boolean): void { + window.sendMainMessage("call-selection-setter", { name: "stepCycle", args: [isForward] }); + } + + setLiveConnected(timeSupplier: () => number): void { + throw new Error("Method not implemented."); + } + + setLiveDisconnected(): void { + throw new Error("Method not implemented."); + } + + getCurrentLiveTime(): number | null { + throw new Error("Method not implemented."); + } + + getRenderTime(): number | null { + throw new Error("Method not implemented."); + } + + setPlaybackSpeed(speed: number): void { + throw new Error("Method not implemented."); + } + + setGrabZoomRange(range: [number, number]) { + window.sendMainMessage("call-selection-setter", { name: "setGrabZoomRange", args: [range] }); + } + + getGrabZoomRange(): [number, number] | null { + throw new Error("Method not implemented."); + } + + finishGrabZoom() { + window.sendMainMessage("call-selection-setter", { name: "finishGrabZoom" }); + } + + getTimelineRange(): [number, number] { + throw new Error("Method not implemented."); + } + + applyTimelineScroll(dx: number, dy: number, widthPixels: number): void { + window.sendMainMessage("call-selection-setter", { name: "applyTimelineScroll", args: [dx, dy, widthPixels] }); + } +} + +window.selection = new MockSelection(); diff --git a/src/shared/AdvantageScopeAssets.ts b/src/shared/AdvantageScopeAssets.ts index f66e3a25..352e1ce9 100644 --- a/src/shared/AdvantageScopeAssets.ts +++ b/src/shared/AdvantageScopeAssets.ts @@ -36,7 +36,7 @@ export interface Config3dField { name: string; path: string; - sourceUrl?: string; + sourceUrl?: string; // Unused starting in 2025 rotations: Config3d_Rotation[]; widthInches: number; heightInches: number; diff --git a/src/shared/Colors.ts b/src/shared/Colors.ts index cffdedd2..5ef350b6 100644 --- a/src/shared/Colors.ts +++ b/src/shared/Colors.ts @@ -1,23 +1,59 @@ -export const AllColors = [ - "#2b66a2", - "#e5b31b", - "#af2437", - "#80588e", - "#e48b32", - "#aacaee", - "#c0b487", - "#858584", - "#3b875a", - "#d993aa", - "#eb987e", - "#5d4f92", - "#a64b6b", - "#dbd345", - "#7e331f", - "#96b637", - "#5f4528", - "#d36134", - "#2e3b28" +import { hex, hsl } from "color-convert"; +import { SourceListOptionValueConfig } from "./SourceListConfig"; + +export const GraphColors: SourceListOptionValueConfig[] = [ + { key: "#2b66a2", display: "Blue" }, + { key: "#e5b31b", display: "Gold" }, + { key: "#af2437", display: "Red" }, + { key: "#80588e", display: "Purple" }, + { key: "#e48b32", display: "Orange" }, + { key: "#c0b487", display: "Tan" }, + { key: "#858584", display: "Gray" }, + { key: "#3b875a", display: "Green" }, + { key: "#d993aa", display: "Pink" }, + { key: "#5f4528", display: "Brown" } +]; + +export const NeonColors: SourceListOptionValueConfig[] = [ + { key: "#00ff00", display: "Green" }, + { key: "#ff0000", display: "Red" }, + { key: "#0000ff", display: "Blue" }, + { key: "#ff8c00", display: "Orange" }, + { key: "#00ffff", display: "Cyan" }, + { key: "#ffff00", display: "Yellow" }, + { key: "#ff00ff", display: "Magenta" } ]; -export const SimpleColors = ["#61a5f2", "#ebc542", "#3b875a"]; +export const NeonColors_RedStart: SourceListOptionValueConfig[] = [ + { key: "#ff0000", display: "Red" }, + { key: "#0000ff", display: "Blue" }, + { key: "#00ff00", display: "Green" }, + { key: "#ff8c00", display: "Orange" }, + { key: "#00ffff", display: "Cyan" }, + { key: "#ffff00", display: "Yellow" }, + { key: "#ff00ff", display: "Magenta" } +]; + +const DARK_MIN_LIGHTNESS = 65; +const LIGHT_MAX_LIGHTNESS = 45; + +/** + * Adjusts color brightness to ensure contrast with the background based on the application theme. + * + * @param color The hex color in the format "#??????" + * @param darkMode Whether the application is in dark mode (optional) + * @returns The adjusted hex color in the same format + */ +export function ensureThemeContrast(color: string, darkMode?: boolean): string { + if (darkMode === undefined) { + darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + } + + let hslVal = hex.hsl(color.slice(1)); + if (darkMode) { + hslVal[2] = Math.max(hslVal[2], DARK_MIN_LIGHTNESS); + } else { + hslVal[2] = Math.min(hslVal[2], LIGHT_MAX_LIGHTNESS); + } + return "#" + hsl.hex(hslVal); +} diff --git a/src/shared/HubState.ts b/src/shared/HubState.ts index 7d142940..52ead7d8 100644 --- a/src/shared/HubState.ts +++ b/src/shared/HubState.ts @@ -1,10 +1,8 @@ import TabType from "./TabType"; -import LoggableType from "./log/LoggableType"; -import { UnitConversionPreset } from "./units"; export interface HubState { sidebar: SidebarState; - tabs: TabGroupState; + tabs: TabsState; } export interface SidebarState { @@ -12,83 +10,16 @@ export interface SidebarState { expanded: string[]; } -export interface TabGroupState { +export interface TabsState { selected: number; tabs: TabState[]; } export interface TabState { type: TabType; - title?: string; -} - -export interface DocumentationState { - type: TabType.Documentation; - path: string; -} - -export interface LineGraphState { - type: TabType.LineGraph; - legendHeight: number; - legends: { - left: { - lockedRange: [number, number] | null; - unitConversion: UnitConversionPreset; - fields: { - key: string; - color: string; - show: boolean; - }[]; - }; - discrete: { - fields: { - key: string; - color: string; - show: boolean; - }[]; - }; - right: { - lockedRange: [number, number] | null; - unitConversion: UnitConversionPreset; - fields: { - key: string; - color: string; - show: boolean; - }[]; - }; - }; -} - -export interface TableState { - type: TabType.Table; - fields: string[]; -} - -export interface ConsoleState { - type: TabType.Console; - field: string | null; -} - -export interface StatisticsState { - type: TabType.Statistics; - fields: (string | null)[]; - selectionType: string; - selectionRangeMin: number; - selectionRangeMax: number; - measurementType: string; - measurementSampling: string; - measurementSamplingPeriod: number; - histogramMin: number; - histogramMax: number; - histogramStep: number; -} - -export interface TimelineVisualizerState { - type: TabType.Odometry | TabType.ThreeDimension | TabType.Video | TabType.Points | TabType.Joysticks; - uuid: string; - fields: ({ key: string; sourceTypeIndex: number; sourceType: LoggableType | string } | null)[]; - listFields: { type: string; key: string; sourceTypeIndex: number; sourceType: LoggableType | string }[][]; - options: { [id: string]: any }; - configHidden: boolean; - visualizer: any; + title: string; + controller: unknown; + controllerUUID: string; + renderer: unknown; + controlsHeight: number; } diff --git a/src/shared/LineGraphFilter.ts b/src/shared/LineGraphFilter.ts new file mode 100644 index 00000000..f1d0081b --- /dev/null +++ b/src/shared/LineGraphFilter.ts @@ -0,0 +1,7 @@ +enum LineGraphFilter { + None, + Differentiate, + Integrate +} + +export default LineGraphFilter; diff --git a/src/shared/Preferences.ts b/src/shared/Preferences.ts index 058718ec..c3c3377b 100644 --- a/src/shared/Preferences.ts +++ b/src/shared/Preferences.ts @@ -13,6 +13,7 @@ export default interface Preferences { tbaApiKey: string; userAssetsFolder: string | null; skipHootNonProWarning: boolean; + skipNumericArrayDeprecationWarning: boolean; skipFrcLogFolderDefault: boolean; usb?: boolean; } diff --git a/src/shared/Selection.ts b/src/shared/Selection.ts new file mode 100644 index 00000000..789424cc --- /dev/null +++ b/src/shared/Selection.ts @@ -0,0 +1,84 @@ +export default interface Selection { + /** Returns the current selection mode. */ + getMode(): SelectionMode; + + /** Gets the current the hovered time. */ + getHoveredTime(): number | null; + + /** Updates the hovered time. */ + setHoveredTime(value: number | null): void; + + /** Return the selected time based on the current mode. */ + getSelectedTime(): number | null; + + /** Updates the selected time based on the current mode. */ + setSelectedTime(time: number): void; + + /** Switches to idle if possible */ + goIdle(): void; + + /** Switches to playback mode. */ + play(): void; + + /** Exits playback and locked modes. */ + pause(): void; + + /** Switches between pausing and playback. */ + togglePlayback(): void; + + /** Switches to locked mode if possible. */ + lock(): void; + + /** Exits locked mode. */ + unlock(): void; + + /** Switches beteween locked and unlocked modes. */ + toggleLock(): void; + + /** Steps forward or backward by one cycle. */ + stepCycle(isForward: boolean): void; + + /** Records that the live connection has started. */ + setLiveConnected(timeSupplier: () => number): void; + + /** Records that the live connection has stopped. */ + setLiveDisconnected(): void; + + /** Returns the latest live timestamp if available. */ + getCurrentLiveTime(): number | null; + + /** Returns the time that should be displayed, for views that can only display a single sample. */ + getRenderTime(): number | null; + + /** Updates the playback speed. */ + setPlaybackSpeed(speed: number): void; + + /** Sets a new time range for an in-progress grab zoom. */ + setGrabZoomRange(range: [number, number]): void; + + /** Gets the time range to display for an in-progress grab zoom. */ + getGrabZoomRange(): [number, number] | null; + + /** Ends an in-progress grab zoom, applying the resulting zoom. */ + finishGrabZoom(): void; + + /** Returns the visible range for the timeline. */ + getTimelineRange(): [number, number]; + + /** Updates the timeline range based on a scroll event. */ + applyTimelineScroll(dx: number, dy: number, widthPixels: number): void; +} + +export enum SelectionMode { + /** Nothing is selected and playback is inactive. */ + Idle, + + /** A time is selected but playback is inactive. */ + Static, + + /** Historical playback is active. */ + Playback, + + /** Playback is locked to the live data. */ + Locked +} diff --git a/src/shared/SourceListConfig.ts b/src/shared/SourceListConfig.ts new file mode 100644 index 00000000..89f11359 --- /dev/null +++ b/src/shared/SourceListConfig.ts @@ -0,0 +1,77 @@ +export type SourceListConfig = { + title: string; + /** True advances type, string advances option */ + autoAdvance: boolean | string; + /** Should be false if parent types (arrays/structs) are supported directly */ + allowChildrenFromDrag: boolean; + /** If provided, remember types and options for fields */ + typeMemoryId?: string; + types: SourceListTypeConfig[]; +}; + +export type SourceListTypeConfig = { + key: string; + display: string; + symbol: string; + showInTypeName: boolean; + /** Option key or hex (starting with #) */ + color: string; + darkColor?: string; + sourceTypes: string[]; + /** Enable deprecation warning */ + numberArrayDeprecated?: boolean; + /** Identifies parents with shared children types */ + parentKey?: string; + /** Parent key this child is attached to */ + childOf?: string; + previewType?: + | "Rotation2d" + | "Translation2d" + | "Pose2d" + | "Transform2d" + | "Rotation3d" + | "Translation3d" + | "Pose3d" + | "Transform3d" + | "SwerveModuleState[]" + | "ChassisSpeeds" + | null; // Don't use preview + initialSelectionOption?: string; + showDocs: boolean; + options: SourceListOptionConfig[]; +}; + +export type SourceListOptionConfig = { + key: string; + display: string; + showInTypeName: boolean; + values: SourceListOptionValueConfig[]; +}; + +export type SourceListOptionValueConfig = { + key: string; + display: string; +}; + +export type SourceListState = SourceListItemState[]; + +export type SourceListItemState = { + type: string; + logKey: string; + logType: string; + visible: boolean; + options: { [key: string]: string }; +}; + +export type SourceListTypeMemory = { + // Memory ID + [key: string]: { + // Log key + [key: string]: SourceListTypeMemoryEntry; + }; +}; + +export type SourceListTypeMemoryEntry = { + type: string; + options: { [key: string]: string }; +}; diff --git a/src/shared/TabType.ts b/src/shared/TabType.ts index 4b24f641..3e2834ea 100644 --- a/src/shared/TabType.ts +++ b/src/shared/TabType.ts @@ -1,11 +1,11 @@ enum TabType { Documentation, LineGraph, + Odometry, + ThreeDimension, Table, Console, Statistics, - Odometry, - ThreeDimension, Video, Joysticks, Swerve, @@ -16,16 +16,6 @@ enum TabType { export default TabType; -export const TIMELINE_VIZ_TYPES: TabType[] = [ - TabType.Odometry, - TabType.ThreeDimension, - TabType.Video, - TabType.Points, - TabType.Joysticks, - TabType.Swerve, - TabType.Mechanism -]; - export function getAllTabTypes(): TabType[] { return Object.values(TabType).filter((tabType) => typeof tabType === "number") as TabType[]; } @@ -36,16 +26,16 @@ export function getDefaultTabTitle(type: TabType): string { return ""; case TabType.LineGraph: return "Line Graph"; + case TabType.Odometry: + return "Odometry"; + case TabType.ThreeDimension: + return "3D Field"; case TabType.Table: return "Table"; case TabType.Console: return "Console"; case TabType.Statistics: return "Statistics"; - case TabType.Odometry: - return "Odometry"; - case TabType.ThreeDimension: - return "3D Field"; case TabType.Video: return "Video"; case TabType.Joysticks: @@ -69,16 +59,16 @@ export function getTabIcon(type: TabType): string { return "📖"; case TabType.LineGraph: return "📉"; + case TabType.Odometry: + return "🗺"; + case TabType.ThreeDimension: + return "👀"; case TabType.Table: return "🔢"; case TabType.Console: return "💬"; case TabType.Statistics: return "📊"; - case TabType.Odometry: - return "🗺"; - case TabType.ThreeDimension: - return "👀"; case TabType.Video: return "🎬"; case TabType.Joysticks: @@ -88,10 +78,47 @@ export function getTabIcon(type: TabType): string { case TabType.Mechanism: return "⚙️"; case TabType.Points: - return "🔵"; + return "📍"; case TabType.Metadata: return "🔍"; default: return ""; } } + +export function getTabAccelerator(type: TabType): string { + if (type === TabType.Documentation) return ""; + return ( + "Alt+" + + (() => { + switch (type) { + case TabType.LineGraph: + return "G"; + case TabType.Odometry: + return "O"; + case TabType.ThreeDimension: + return "3"; + case TabType.Table: + return "T"; + case TabType.Console: + return "C"; + case TabType.Statistics: + return "S"; + case TabType.Video: + return "V"; + case TabType.Joysticks: + return "J"; + case TabType.Swerve: + return "D"; + case TabType.Mechanism: + return "M"; + case TabType.Points: + return "P"; + case TabType.Metadata: + return "I"; + default: + return ""; + } + })() + ); +} diff --git a/src/shared/geometry.ts b/src/shared/geometry.ts index 544045ed..3397b33f 100644 --- a/src/shared/geometry.ts +++ b/src/shared/geometry.ts @@ -1,8 +1,8 @@ -import * as THREE from "three"; -import { Quaternion } from "three"; import Log from "./log/Log"; -import { getOrDefault } from "./log/LogUtil"; +import { getOrDefault, getRobotStateRanges } from "./log/LogUtil"; import LoggableType from "./log/LoggableType"; +import { convert } from "./units"; +import { indexArray, jsonCopy, scaleValue } from "./util"; export type Translation2d = [number, number]; // meters (x, y) export type Rotation2d = number; // radians @@ -10,6 +10,12 @@ export type Pose2d = { translation: Translation2d; rotation: Rotation2d; }; +export const Translation2dZero: Translation2d = [0, 0]; +export const Rotation2dZero: Rotation2d = 0; +export const Pose2dZero: Pose2d = { + translation: Translation2dZero, + rotation: Rotation2dZero +}; export type Translation3d = [number, number, number]; // meters (x, y, z) export type Rotation3d = [number, number, number, number]; // radians (w, x, y, z) @@ -17,282 +23,633 @@ export type Pose3d = { translation: Translation3d; rotation: Rotation3d; }; +export const Translation3dZero: Translation3d = [0, 0, 0]; +export const Rotation3dZero: Rotation3d = [1, 0, 0, 0]; +export const Pose3dZero: Pose3d = { + translation: Translation3dZero, + rotation: Rotation3dZero +}; + +export type AnnotatedPose2d = { + pose: Pose2d; + annotation: PoseAnnotations; +}; +export type AnnotatedPose3d = { + pose: Pose3d; + annotation: PoseAnnotations; +}; +export type PoseAnnotations = { + is2DSource: boolean; + zebraTeam?: number; + zebraAlliance?: "blue" | "red"; + aprilTagId?: number; + visionColor?: string; +}; + +export type SwerveState = { speed: number; angle: Rotation2d }; +export type ChassisSpeeds = { vx: number; vy: number; omega: number }; export const APRIL_TAG_36H11_COUNT = 587; export const APRIL_TAG_16H5_COUNT = 30; +export const APRIL_TAG_36H11_SIZE = convert(8.125, "inches", "meters"); +export const APRIL_TAG_16H5_SIZE = convert(8, "inches", "meters"); +export const HEATMAP_DT = 0.25; -export type AprilTag = { - id: number | null; - pose: Pose3d; -}; +// FORMAT CONVERSION UTILITIES + +export function translation2dTo3d(input: Translation2d): Translation3d { + return [...input, 0]; +} -export function translation2dTo3d(input: Translation2d, z: number = 0): Translation3d { - return [...input, z]; +export function translation3dTo2d(input: Translation3d): Translation2d { + return [input[0], input[1]]; } export function rotation2dTo3d(input: Rotation2d): Rotation3d { - let quaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), input); - return [quaternion.w, quaternion.x, quaternion.y, quaternion.z]; + // https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles#Euler_angles_to_quaternion_conversion + return [Math.cos(input * 0.5), 0, 0, Math.sin(input * 0.5)]; } export function rotation3dTo2d(input: Rotation3d): Rotation2d { - const w = input[0]; - const x = input[1]; - const y = input[2]; - const z = input[3]; - // https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles#Quaternion_to_Euler_angles_(in_3-2-1_sequence)_conversion - return Math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)); + return Math.atan2( + 2.0 * (input[0] * input[3] + input[1] * input[2]), + 1.0 - 2.0 * (input[2] * input[2] + input[3] * input[3]) + ); +} + +export function rotation3dToRPY(input: Rotation3d): [number, number, number] { + let w = input[0]; + let x = input[1]; + let y = input[2]; + let z = input[3]; + + let roll = 0; + let pitch = 0; + let yaw = 0; + + // wpimath/algorithms.md + { + let cxcy = 1.0 - 2.0 * (x * x + y * y); + let sxcy = 2.0 * (w * x + y * z); + let cy_sq = cxcy * cxcy + sxcy * sxcy; + if (cy_sq > 1e-20) { + roll = Math.atan2(sxcy, cxcy); + } + } + { + let ratio = 2.0 * (w * y - z * x); + if (Math.abs(ratio) >= 1.0) { + pitch = (Math.PI / 2.0) * Math.sign(ratio); + } else { + pitch = Math.asin(ratio); + } + } + { + let cycz = 1.0 - 2.0 * (y * y + z * z); + let cysz = 2.0 * (w * z + x * y); + let cy_sq = cycz * cycz + cysz * cysz; + if (cy_sq > 1e-20) { + yaw = Math.atan2(cysz, cycz); + } else { + yaw = Math.atan2(2.0 * w * z, w * w - z * z); + } + } + return [roll, pitch, yaw]; } -export function pose2dTo3d(input: Pose2d, z: number = 0): Pose3d { +export function pose2dTo3d(input: Pose2d): Pose3d { return { - translation: translation2dTo3d(input.translation, z), + translation: translation2dTo3d(input.translation), rotation: rotation2dTo3d(input.rotation) }; } -export function pose2dArrayTo3d(input: Pose2d[], z: number = 0): Pose3d[] { - return input.map((pose) => pose2dTo3d(pose, z)); +export function pose3dTo2d(input: Pose3d): Pose2d { + return { + translation: translation3dTo2d(input.translation), + rotation: rotation3dTo2d(input.rotation) + }; } -export function rotation3dToQuaternion(input: Rotation3d): THREE.Quaternion { - return new Quaternion(input[1], input[2], input[3], input[0]); +export function annotatedPose2dTo3d(input: AnnotatedPose2d): AnnotatedPose3d { + return { + pose: { + translation: translation2dTo3d(input.pose.translation), + rotation: rotation2dTo3d(input.pose.rotation) + }, + annotation: input.annotation + }; } -export function quaternionToRotation3d(input: THREE.Quaternion): Rotation3d { - return [input.w, input.x, input.y, input.z]; +export function annotatedPose3dTo2d(input: AnnotatedPose3d): AnnotatedPose2d { + return { + pose: { + translation: translation3dTo2d(input.pose.translation), + rotation: rotation3dTo2d(input.pose.rotation) + }, + annotation: input.annotation + }; } // LOG READING UTILITIES -export function numberArrayToPose2dArray(array: number[], distanceConversion = 1, rotationConversion = 1): Pose2d[] { - let poses: Pose2d[] = []; - if (array.length === 2) { - poses.push({ - translation: [array[0] * distanceConversion, array[1] * distanceConversion], - rotation: 0 - }); - } else { - for (let i = 0; i < array.length; i += 3) { - poses.push({ - translation: [array[i] * distanceConversion, array[i + 1] * distanceConversion], - rotation: array[i + 2] * rotationConversion - }); - } - } - return poses; -} - -export function logReadNumberArrayToPose2dArray( +export function grabPosesAuto( log: Log, key: string, + logType: string, timestamp: number, - distanceConversion = 1, - rotationConversion = 1 -): Pose2d[] { - return numberArrayToPose2dArray( - getOrDefault(log, key, LoggableType.NumberArray, timestamp, []), - distanceConversion, - rotationConversion - ); -} - -export function logReadPose2d(log: Log, key: string, timestamp: number, distanceConversion = 1): Pose2d | null { - const x = getOrDefault(log, key + "/translation/x", LoggableType.Number, timestamp, null); - const y = getOrDefault(log, key + "/translation/y", LoggableType.Number, timestamp, null); - if (x === null || y === null) { - return null; - } else { - return { - translation: [x * distanceConversion, y * distanceConversion], - rotation: getOrDefault(log, key + "/rotation/value", LoggableType.Number, timestamp, 0) - }; + uuid?: string, + numberArrayFormat?: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d", + numberArrayUnits?: "radians" | "degrees", + zebraOrigin?: "blue" | "red", + zebraFieldWidth?: number, + zebraFieldHeight?: number +): AnnotatedPose3d[] { + switch (logType) { + case "Number": + return grabNumberRotation(log, key, timestamp, numberArrayUnits, uuid); + case "NumberArray": + if (numberArrayFormat !== undefined) { + return grabNumberArray(log, key, timestamp, numberArrayFormat, numberArrayUnits, uuid); + } else { + return []; + } + case "Rotation2d": + return grabRotation2d(log, key, timestamp, uuid); + case "Rotation3d": + return grabRotation3d(log, key, timestamp, uuid); + case "Rotation2d[]": + return grabRotation2dArray(log, key, timestamp, uuid); + case "Rotation3d[]": + return grabRotation3dArray(log, key, timestamp, uuid); + case "Translation2d": + return grabTranslation2d(log, key, timestamp, uuid); + case "Translation3d": + return grabTranslation3d(log, key, timestamp, uuid); + case "Translation2d[]": + return grabTranslation2dArray(log, key, timestamp, uuid); + case "Translation3d[]": + return grabTranslation3dArray(log, key, timestamp, uuid); + case "Pose2d": + case "Transform2d": + return grabPose2d(log, key, timestamp, uuid); + case "Pose3d": + case "Transform3d": + return grabPose3d(log, key, timestamp, uuid); + case "Pose2d[]": + case "Transform2d[]": + return grabPose2dArray(log, key, timestamp, uuid); + case "Pose3d[]": + case "Transform3d[]": + return grabPose3dArray(log, key, timestamp, uuid); + case "Trajectory": + return grabTrajectory(log, key, timestamp, uuid); + case "ZebraTranslation": + if (zebraOrigin !== undefined && zebraFieldWidth !== undefined && zebraFieldHeight !== undefined) { + return grabZebraTranslation(log, key, timestamp, zebraOrigin, zebraFieldWidth, zebraFieldHeight, uuid); + } else { + return []; + } + default: + return []; } } -export function logReadPose2dArray(log: Log, key: string, timestamp: number, distanceConversion = 1): Pose2d[] { - let length = getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0); - let poses: Pose2d[] = []; - for (let i = 0; i < length; i++) { - poses.push({ - translation: [ - getOrDefault(log, key + "/" + i.toString() + "/translation/x", LoggableType.Number, timestamp, 0) * - distanceConversion, - getOrDefault(log, key + "/" + i.toString() + "/translation/y", LoggableType.Number, timestamp, 0) * - distanceConversion - ], - rotation: getOrDefault(log, key + "/" + i.toString() + "/rotation/value", LoggableType.Number, timestamp, 0) - }); - } - return poses; -} - -export function logReadTranslation2dToPose2d( +export function grabNumberRotation( log: Log, key: string, timestamp: number, - distanceConversion = 1 -): Pose2d | null { - const x = getOrDefault(log, key + "/x", LoggableType.Number, timestamp, null); - const y = getOrDefault(log, key + "/y", LoggableType.Number, timestamp, null); - if (x === null || y === null) { - return null; - } else { - return { - translation: [x * distanceConversion, y * distanceConversion], - rotation: 0 - }; + unit?: "radians" | "degrees", + uuid?: string +): AnnotatedPose3d[] { + let value = getOrDefault(log, key, LoggableType.Number, timestamp, 0, uuid); + if (unit === "degrees") { + value = convert(value, "degrees", "radians"); } + return [ + { + pose: { + translation: Translation3dZero, + rotation: rotation2dTo3d(value) + }, + annotation: { + is2DSource: true + } + } + ]; } -export function logReadTranslation2dArrayToPose2dArray( +export function grabNumberArray( log: Log, key: string, timestamp: number, - distanceConversion = 1 -): Pose2d[] { - let length = getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0); - let poses: Pose2d[] = []; - for (let i = 0; i < length; i++) { - poses.push({ - translation: [ - getOrDefault(log, key + "/" + i.toString() + "/x", LoggableType.Number, timestamp, 0) * distanceConversion, - getOrDefault(log, key + "/" + i.toString() + "/y", LoggableType.Number, timestamp, 0) * distanceConversion - ], - rotation: 0 - }); + format: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d", + unit?: "radians" | "degrees", + uuid?: string +): AnnotatedPose3d[] { + let value = getOrDefault(log, key, LoggableType.NumberArray, timestamp, [], uuid); + let poses: AnnotatedPose3d[] = []; + let finalUnit: "radians" | "degrees" = unit === "degrees" ? "degrees" : "radians"; + switch (format) { + case "Translation2d": + for (let i = 0; i < value.length - 1; i += 2) { + poses.push({ + pose: { + translation: translation2dTo3d([value[i], value[i + 1]]), + rotation: Rotation3dZero + }, + annotation: { + is2DSource: true + } + }); + } + break; + case "Translation3d": + for (let i = 0; i < value.length - 2; i += 3) { + poses.push({ + pose: { + translation: [value[i], value[i + 1], value[i + 2]], + rotation: Rotation3dZero + }, + annotation: { + is2DSource: false + } + }); + } + break; + case "Pose2d": + for (let i = 0; i < value.length - 2; i += 3) { + poses.push({ + pose: pose2dTo3d({ + translation: [value[i], value[i + 1]], + rotation: convert(value[i + 2], finalUnit, "radians") + }), + annotation: { + is2DSource: true + } + }); + } + break; + case "Pose3d": + for (let i = 0; i < value.length - 6; i += 7) { + poses.push({ + pose: { + translation: [value[i], value[i + 1], value[i + 2]], + rotation: [value[i + 3], value[i + 4], value[i + 5], value[i + 6]] + }, + annotation: { + is2DSource: false + } + }); + } + break; } return poses; } -export function logReadTrajectoryToPose2dArray( - log: Log, - key: string, - timestamp: number, - distanceConversion = 1 -): Pose2d[] { - let length = getOrDefault(log, key + "/states/length", LoggableType.Number, timestamp, 0); - let poses: Pose2d[] = []; - for (let i = 0; i < length; i++) { - poses.push({ - translation: [ - getOrDefault(log, key + "/states/" + i.toString() + "/pose/translation/x", LoggableType.Number, timestamp, 0) * - distanceConversion, - getOrDefault(log, key + "/states/" + i.toString() + "/pose/translation/y", LoggableType.Number, timestamp, 0) * - distanceConversion - ], - rotation: getOrDefault( - log, - key + "/states/" + i.toString() + "/pose/rotation/value", - LoggableType.Number, - timestamp, - 0 - ) - }); - } - return poses; +export function grabRotation2d(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return [ + { + pose: { + translation: Translation3dZero, + rotation: rotation2dTo3d(getOrDefault(log, key + "/value", LoggableType.Number, timestamp, 0, uuid)) + }, + annotation: { + is2DSource: true + } + } + ]; } -export function logReadNumberArrayToPose3dArray( +export function grabRotation3d(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return [ + { + pose: { + translation: Translation3dZero, + rotation: [ + getOrDefault(log, key + "/q/w", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/q/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/q/y", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/q/z", LoggableType.Number, timestamp, 0, uuid) + ] + }, + annotation: { is2DSource: false } + } + ]; +} + +export function grabRotation2dArray(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabRotation2d(log, key + "/" + index.toString(), timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabRotation3dArray(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabRotation3d(log, key + "/" + index.toString(), timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabTranslation2d(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return [ + { + pose: { + translation: translation2dTo3d([ + getOrDefault(log, key + "/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/y", LoggableType.Number, timestamp, 0, uuid) + ]), + rotation: Rotation3dZero + }, + annotation: { is2DSource: true } + } + ]; +} + +export function grabTranslation3d(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return [ + { + pose: { + translation: [ + getOrDefault(log, key + "/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/y", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/z", LoggableType.Number, timestamp, 0, uuid) + ], + rotation: Rotation3dZero + }, + annotation: { is2DSource: false } + } + ]; +} + +export function grabTranslation2dArray(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabTranslation2d(log, key + "/" + index.toString(), timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabTranslation3dArray(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabTranslation3d(log, key + "/" + index.toString(), timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabPose2d(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return [ + { + pose: pose2dTo3d({ + translation: [ + getOrDefault(log, key + "/translation/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/translation/y", LoggableType.Number, timestamp, 0, uuid) + ], + rotation: getOrDefault(log, key + "/rotation/value", LoggableType.Number, timestamp, 0, uuid) + }), + annotation: { is2DSource: true } + } + ]; +} + +export function grabPose3d(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return [ + { + pose: { + translation: [ + getOrDefault(log, key + "/translation/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/translation/y", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/translation/z", LoggableType.Number, timestamp, 0, uuid) + ], + rotation: [ + getOrDefault(log, key + "/rotation/q/w", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/rotation/q/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/rotation/q/y", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/rotation/q/z", LoggableType.Number, timestamp, 0, uuid) + ] + }, + annotation: { is2DSource: false } + } + ]; +} + +export function grabPose2dArray(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabPose2d(log, key + "/" + index.toString(), timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabPose3dArray(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabPose3d(log, key + "/" + index.toString(), timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabTrajectory(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/states/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabPose3d(log, key + "/states/" + index.toString() + "/pose", timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabAprilTag(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return [ + { + pose: { + translation: [ + getOrDefault(log, key + "/pose/translation/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/pose/translation/y", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/pose/translation/z", LoggableType.Number, timestamp, 0, uuid) + ], + rotation: [ + getOrDefault(log, key + "/pose/rotation/q/w", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/pose/rotation/q/x", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/pose/rotation/q/y", LoggableType.Number, timestamp, 0, uuid), + getOrDefault(log, key + "/pose/rotation/q/z", LoggableType.Number, timestamp, 0, uuid) + ] + }, + annotation: { + aprilTagId: getOrDefault(log, key + "/ID", LoggableType.Number, timestamp, undefined, uuid), + is2DSource: false + } + } + ]; +} + +export function grabAprilTagArray(log: Log, key: string, timestamp: number, uuid?: string): AnnotatedPose3d[] { + return indexArray(getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid)).reduce( + (array, index) => array.concat(grabAprilTag(log, key + "/" + index.toString(), timestamp)), + [] as AnnotatedPose3d[] + ); +} + +export function grabZebraTranslation( log: Log, key: string, timestamp: number, - distanceConversion = 1 -): Pose3d[] { - let poses: Pose3d[] = []; - let array = getOrDefault(log, key, LoggableType.NumberArray, timestamp, []); - for (let i = 0; i < array.length; i += 7) { - poses.push({ - translation: [ - array[i] * distanceConversion, - array[i + 1] * distanceConversion, - array[i + 2] * distanceConversion - ], - rotation: [array[i + 3], array[i + 4], array[i + 5], array[i + 6]] - }); + origin: "blue" | "red", + fieldWidth: number, + fieldHeight: number, + uuid?: string +): AnnotatedPose3d[] { + let x: number | null = null; + let y: number | null = null; + { + let xData = window.log.getNumber(key + "/x", timestamp, timestamp); + if (xData !== undefined && xData.values.length > 0) { + if (xData.values.length === 1) { + x = xData.values[0]; + } else { + x = scaleValue(timestamp, [xData.timestamps[0], xData.timestamps[1]], [xData.values[0], xData.values[1]]); + } + } } - return poses; -} - -export function logReadPose3d(log: Log, key: string, timestamp: number, distanceConversion = 1): Pose3d | null { - const x = getOrDefault(log, key + "/translation/x", LoggableType.Number, timestamp, null); - const y = getOrDefault(log, key + "/translation/y", LoggableType.Number, timestamp, null); - const z = getOrDefault(log, key + "/translation/z", LoggableType.Number, timestamp, null); - const qw = getOrDefault(log, key + "/rotation/q/w", LoggableType.Number, timestamp, null); - const qx = getOrDefault(log, key + "/rotation/q/x", LoggableType.Number, timestamp, null); - const qy = getOrDefault(log, key + "/rotation/q/y", LoggableType.Number, timestamp, null); - const qz = getOrDefault(log, key + "/rotation/q/z", LoggableType.Number, timestamp, null); - if (x === null || y === null || z === null || qw === null || qx === null || qy === null || qz === null) { - return null; - } else { - return { - translation: [x * distanceConversion, y * distanceConversion, z * distanceConversion], - rotation: [qw, qx, qy, qz] - }; + { + let yData = window.log.getNumber(key + "/y", timestamp, timestamp); + if (yData !== undefined && yData.values.length > 0) { + if (yData.values.length === 1) { + y = yData.values[0]; + } else { + y = scaleValue(timestamp, [yData.timestamps[0], yData.timestamps[1]], [yData.values[0], yData.values[1]]); + } + } } -} + if (x === null || y === null) return []; + x = convert(x, "feet", "meters"); + y = convert(y, "feet", "meters"); -export function logReadPose3dArray(log: Log, key: string, timestamp: number, distanceConversion = 1): Pose3d[] { - let length = getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0); - let poses: Pose3d[] = []; - for (let i = 0; i < length; i++) { - poses.push({ - translation: [ - getOrDefault(log, key + "/" + i.toString() + "/translation/x", LoggableType.Number, timestamp, 0) * - distanceConversion, - getOrDefault(log, key + "/" + i.toString() + "/translation/y", LoggableType.Number, timestamp, 0) * - distanceConversion, - getOrDefault(log, key + "/" + i.toString() + "/translation/z", LoggableType.Number, timestamp, 0) * - distanceConversion - ], - rotation: [ - getOrDefault(log, key + "/" + i.toString() + "/rotation/q/w", LoggableType.Number, timestamp, 0), - getOrDefault(log, key + "/" + i.toString() + "/rotation/q/x", LoggableType.Number, timestamp, 0), - getOrDefault(log, key + "/" + i.toString() + "/rotation/q/y", LoggableType.Number, timestamp, 0), - getOrDefault(log, key + "/" + i.toString() + "/rotation/q/z", LoggableType.Number, timestamp, 0) - ] - }); + let alliance: "blue" | "red" = + getOrDefault(log, key + "/alliance", LoggableType.String, Infinity, 0, uuid) === "red" ? "red" : "blue"; // Read alliance from end of log + let splitKey = key.split("FRC"); + let teamNumber = splitKey.length > 1 ? Number(splitKey[splitKey.length - 1]) : undefined; + + // Zebra always uses red origin, convert translation + if (origin === "blue") { + x = fieldWidth - x; + y = fieldHeight - y; } - return poses; + return [ + { + pose: pose2dTo3d({ + translation: [x, y], + rotation: Rotation2dZero + }), + annotation: { + zebraAlliance: alliance, + zebraTeam: teamNumber, + is2DSource: true + } + } + ]; } -export function logReadTranslation3dToPose3d( +export function grabHeatmapData( log: Log, key: string, - timestamp: number, - distanceConversion = 1 -): Pose3d | null { - const x = getOrDefault(log, key + "/x", LoggableType.Number, timestamp, null); - const y = getOrDefault(log, key + "/y", LoggableType.Number, timestamp, null); - const z = getOrDefault(log, key + "/z", LoggableType.Number, timestamp, null); - if (x === null || y === null || z === null) { - return null; - } else { - return { - translation: [x * distanceConversion, y * distanceConversion, z * distanceConversion], - rotation: [0, 0, 0, 0] - }; + logType: string, + timeRange: "enabled" | "auto" | "teleop" | "teleop-no-endgame" | "full", + uuid?: string, + numberArrayFormat?: "Translation2d" | "Translation3d" | "Pose2d" | "Pose3d", + numberArrayUnits?: "radians" | "degrees", + zebraOrigin?: "blue" | "red", + zebraFieldWidth?: number, + zebraFieldHeight?: number +): AnnotatedPose3d[] { + let poses: AnnotatedPose3d[] = []; + let isFullLog = timeRange === "full"; + let stateRanges = isFullLog ? null : getRobotStateRanges(log); + let isValid = (timestamp: number) => { + if (isFullLog) return true; + if (stateRanges === null) return false; + let currentRange = stateRanges.findLast((range) => range.start <= timestamp); + switch (timeRange) { + case "enabled": + return currentRange?.mode !== "disabled"; + case "auto": + return currentRange?.mode === "auto"; + case "teleop": + return currentRange?.mode === "teleop"; + case "teleop-no-endgame": + return currentRange?.mode === "teleop" && currentRange?.end !== undefined && currentRange?.end - timestamp > 30; + } + }; + for (let sampleTime = log.getTimestampRange()[0]; sampleTime < log.getTimestampRange()[1]; sampleTime += HEATMAP_DT) { + if (!isValid(sampleTime)) continue; + poses = poses.concat( + grabPosesAuto( + log, + key, + logType, + sampleTime, + uuid, + numberArrayFormat, + numberArrayUnits, + zebraOrigin, + zebraFieldWidth, + zebraFieldHeight + ) + ); } + return poses; } -export function logReadTranslation3dArrayToPose3dArray( +export function grabSwerveStates( log: Log, key: string, + logType: string, timestamp: number, - distanceConversion = 1 -): Pose3d[] { - let length = getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0); - let poses: Pose3d[] = []; - for (let i = 0; i < length; i++) { - poses.push({ - translation: [ - getOrDefault(log, key + "/" + i.toString() + "/x", LoggableType.Number, timestamp, 0) * distanceConversion, - getOrDefault(log, key + "/" + i.toString() + "/y", LoggableType.Number, timestamp, 0) * distanceConversion, - getOrDefault(log, key + "/" + i.toString() + "/z", LoggableType.Number, timestamp, 0) * distanceConversion - ], - rotation: [0, 0, 0, 0] - }); + arrangement?: string, + rotationUnits: "radians" | "degrees" = "radians", + uuid?: string +): SwerveState[] { + let states: SwerveState[] = []; + switch (logType) { + case "NumberArray": + { + let value: number[] = getOrDefault(log, key, LoggableType.NumberArray, timestamp, [], uuid); + for (let i = 0; i < value.length - 1; i += 2) { + states.push({ + speed: value[i + 1], + angle: convert(value[i], rotationUnits, "radians") + }); + } + } + break; + + case "SwerveModuleState[]": + { + let length = getOrDefault(log, key + "/length", LoggableType.Number, timestamp, 0, uuid); + for (let i = 0; i < length; i++) { + states.push({ + speed: getOrDefault(log, key + "/" + i.toString() + "/speed", LoggableType.Number, timestamp, 0, uuid), + angle: getOrDefault(log, key + "/" + i.toString() + "/angle/value", LoggableType.Number, timestamp, 0, uuid) + }); + } + } + break; } - return poses; + + // Apply arrangement + if (states.length === 4 && arrangement !== undefined) { + let originalStates = jsonCopy(states); + arrangement + .split(",") + .map((x) => Number(x)) + .forEach((sourceIndex, targetIndex) => { + states[targetIndex] = originalStates[sourceIndex]; + }); + } + + return states; +} + +export function grabChassiSpeeds(log: Log, key: string, timestamp: number, uuid?: string): ChassisSpeeds { + return { + vx: getOrDefault(log, key + "/vx", LoggableType.Number, timestamp, 0, uuid), + vy: getOrDefault(log, key + "/vy", LoggableType.Number, timestamp, 0, uuid), + omega: getOrDefault(log, key + "/omega", LoggableType.Number, timestamp, 0, uuid) + }; } diff --git a/src/shared/log/Log.ts b/src/shared/log/Log.ts index 7f398839..a0fa9aab 100644 --- a/src/shared/log/Log.ts +++ b/src/shared/log/Log.ts @@ -103,6 +103,15 @@ export default class Log { return Object.keys(this.fields).filter((field) => !this.isGenerated(field)).length; } + /** Returns the internal field object for a key. Prefer other methods when possible. */ + getField(key: string): LogField | null { + if (key in this.fields) { + return this.fields[key]; + } else { + return null; + } + } + /** Returns the constant field type. */ getType(key: string): LoggableType | null { if (key in this.fields) { @@ -285,43 +294,43 @@ export default class Log { } /** Reads a set of generic values from the field. */ - getRange(key: string, start: number, end: number): LogValueSetAny | undefined { - if (key in this.fields) return this.fields[key].getRange(start, end); + getRange(key: string, start: number, end: number, uuid?: string): LogValueSetAny | undefined { + if (key in this.fields) return this.fields[key].getRange(start, end, uuid); } /** Reads a set of Raw values from the field. */ - getRaw(key: string, start: number, end: number): LogValueSetRaw | undefined { - if (key in this.fields) return this.fields[key].getRaw(start, end); + getRaw(key: string, start: number, end: number, uuid?: string): LogValueSetRaw | undefined { + if (key in this.fields) return this.fields[key].getRaw(start, end, uuid); } /** Reads a set of Boolean values from the field. */ - getBoolean(key: string, start: number, end: number): LogValueSetBoolean | undefined { - if (key in this.fields) return this.fields[key].getBoolean(start, end); + getBoolean(key: string, start: number, end: number, uuid?: string): LogValueSetBoolean | undefined { + if (key in this.fields) return this.fields[key].getBoolean(start, end, uuid); } /** Reads a set of Number values from the field. */ - getNumber(key: string, start: number, end: number): LogValueSetNumber | undefined { - if (key in this.fields) return this.fields[key].getNumber(start, end); + getNumber(key: string, start: number, end: number, uuid?: string): LogValueSetNumber | undefined { + if (key in this.fields) return this.fields[key].getNumber(start, end, uuid); } /** Reads a set of String values from the field. */ - getString(key: string, start: number, end: number): LogValueSetString | undefined { - if (key in this.fields) return this.fields[key].getString(start, end); + getString(key: string, start: number, end: number, uuid?: string): LogValueSetString | undefined { + if (key in this.fields) return this.fields[key].getString(start, end, uuid); } /** Reads a set of BooleanArray values from the field. */ - getBooleanArray(key: string, start: number, end: number): LogValueSetBooleanArray | undefined { - if (key in this.fields) return this.fields[key].getBooleanArray(start, end); + getBooleanArray(key: string, start: number, end: number, uuid?: string): LogValueSetBooleanArray | undefined { + if (key in this.fields) return this.fields[key].getBooleanArray(start, end, uuid); } /** Reads a set of NumberArray values from the field. */ - getNumberArray(key: string, start: number, end: number): LogValueSetNumberArray | undefined { - if (key in this.fields) return this.fields[key].getNumberArray(start, end); + getNumberArray(key: string, start: number, end: number, uuid?: string): LogValueSetNumberArray | undefined { + if (key in this.fields) return this.fields[key].getNumberArray(start, end, uuid); } /** Reads a set of StringArray values from the field. */ - getStringArray(key: string, start: number, end: number): LogValueSetStringArray | undefined { - if (key in this.fields) return this.fields[key].getStringArray(start, end); + getStringArray(key: string, start: number, end: number, uuid?: string): LogValueSetStringArray | undefined { + if (key in this.fields) return this.fields[key].getStringArray(start, end, uuid); } /** Writes a new Raw value to the field. */ diff --git a/src/shared/log/LogField.ts b/src/shared/log/LogField.ts index 9f34a103..f07c4915 100644 --- a/src/shared/log/LogField.ts +++ b/src/shared/log/LogField.ts @@ -23,6 +23,8 @@ export default class LogField { // Toggles when first value is removed, useful for creating striping effects that persist as data is updated private stripingReference = false; + private getRangeCache: { [id: string]: number } = {}; + constructor(type: LoggableType) { this.type = type; } @@ -54,19 +56,50 @@ export default class LogField { } } - /** Returns the values in the specified timestamp range. */ - getRange(start: number, end: number): LogValueSetAny { + /** Returns the values in the specified timestamp range. + * + * If a UUID is provided, requests for single timestamps will cache + * the timestamp index to make searches of chronological data faster. + */ + getRange(start: number, end: number, uuid?: string): LogValueSetAny { let timestamps: number[]; let values: any[]; - let startValueIndex = this.data.timestamps.findIndex((x) => x > start); + let cacheIndex: number | null = null; + if (start === end && uuid !== undefined && uuid in this.getRangeCache) { + cacheIndex = this.getRangeCache[uuid]; + } + + let startValueIndex = -1; + if ( + cacheIndex !== null && + cacheIndex < this.data.timestamps.length && + !(this.data.timestamps[cacheIndex] > start) + ) { + // Search from previous location + let rawIndex = this.data.timestamps.slice(cacheIndex).findIndex((x) => x > start); + if (rawIndex !== -1) startValueIndex = rawIndex + cacheIndex; + } + if (startValueIndex === -1) { + // Search from start + startValueIndex = this.data.timestamps.findIndex((x) => x > start); + } if (startValueIndex === -1) { startValueIndex = this.data.timestamps.length - 1; } else if (startValueIndex !== 0) { startValueIndex -= 1; } - let endValueIndex = this.data.timestamps.findIndex((x) => x >= end); + let endValueIndex = -1; + if (cacheIndex !== null && cacheIndex < this.data.timestamps.length && !(this.data.timestamps[cacheIndex] >= end)) { + // Search from previous location + let rawIndex = this.data.timestamps.slice(cacheIndex).findIndex((x) => x >= end); + if (rawIndex !== -1) endValueIndex = rawIndex + cacheIndex; + } + if (endValueIndex === -1) { + // Search from start + endValueIndex = this.data.timestamps.findIndex((x) => x >= end); + } if (endValueIndex === -1 || endValueIndex === this.data.timestamps.length - 1) { // Extend to end of timestamps timestamps = this.data.timestamps.slice(startValueIndex); @@ -75,41 +108,46 @@ export default class LogField { timestamps = this.data.timestamps.slice(startValueIndex, endValueIndex + 1); values = this.data.values.slice(startValueIndex, endValueIndex + 1); } + + if (start === end && uuid !== undefined) { + this.getRangeCache[uuid] = startValueIndex; + } + return { timestamps: timestamps, values: values }; } /** Reads a set of Raw values from the field. */ - getRaw(start: number, end: number): LogValueSetRaw | undefined { + getRaw(start: number, end: number, uuid?: string): LogValueSetRaw | undefined { if (this.type === LoggableType.Raw) return this.getRange(start, end); } /** Reads a set of Boolean values from the field. */ - getBoolean(start: number, end: number): LogValueSetBoolean | undefined { + getBoolean(start: number, end: number, uuid?: string): LogValueSetBoolean | undefined { if (this.type === LoggableType.Boolean) return this.getRange(start, end); } /** Reads a set of Number values from the field. */ - getNumber(start: number, end: number): LogValueSetNumber | undefined { + getNumber(start: number, end: number, uuid?: string): LogValueSetNumber | undefined { if (this.type === LoggableType.Number) return this.getRange(start, end); } /** Reads a set of String values from the field. */ - getString(start: number, end: number): LogValueSetString | undefined { + getString(start: number, end: number, uuid?: string): LogValueSetString | undefined { if (this.type === LoggableType.String) return this.getRange(start, end); } /** Reads a set of BooleanArray values from the field. */ - getBooleanArray(start: number, end: number): LogValueSetBooleanArray | undefined { + getBooleanArray(start: number, end: number, uuid?: string): LogValueSetBooleanArray | undefined { if (this.type === LoggableType.BooleanArray) return this.getRange(start, end); } /** Reads a set of NumberArray values from the field. */ - getNumberArray(start: number, end: number): LogValueSetNumberArray | undefined { + getNumberArray(start: number, end: number, uuid?: string): LogValueSetNumberArray | undefined { if (this.type === LoggableType.NumberArray) return this.getRange(start, end); } /** Reads a set of StringArray values from the field. */ - getStringArray(start: number, end: number): LogValueSetStringArray | undefined { + getStringArray(start: number, end: number, uuid?: string): LogValueSetStringArray | undefined { if (this.type === LoggableType.StringArray) return this.getRange(start, end); } diff --git a/src/shared/log/LogUtil.ts b/src/shared/log/LogUtil.ts index ed5d6a59..0061713e 100644 --- a/src/shared/log/LogUtil.ts +++ b/src/shared/log/LogUtil.ts @@ -1,12 +1,12 @@ import Fuse from "fuse.js"; -import { Rotation2d, Translation2d } from "../geometry"; import MatchInfo, { MatchType } from "../MatchInfo"; +import { Rotation2d, Translation2d } from "../geometry"; import { convert } from "../units"; -import { arraysEqual } from "../util"; +import { arraysEqual, jsonCopy } from "../util"; import Log from "./Log"; import LogFieldTree from "./LogFieldTree"; -import LoggableType from "./LoggableType"; import { LogValueSetBoolean } from "./LogValueSets"; +import LoggableType from "./LoggableType"; export const TYPE_KEY = ".type"; export const STRUCT_PREFIX = "struct:"; @@ -25,6 +25,13 @@ export const ENABLED_KEYS = withMergedKeys([ "/DSLog/Status/DSDisabled", "RobotEnable" // Phoenix ]); +export const AUTONOMOUS_KEYS = withMergedKeys([ + "/DriverStation/Autonomous", + "NT:/AdvantageKit/DriverStation/Autonomous", + "DS:autonomous", + "NT:/FMSInfo/FMSControlData", + "/DSLog/Status/DSTeleop" +]); export const ALLIANCE_KEYS = withMergedKeys([ "/DriverStation/AllianceStation", "NT:/AdvantageKit/DriverStation/AllianceStation", @@ -92,14 +99,23 @@ export function getLogValueText(value: any, type: LoggableType): string { textArray.push((byte & 0xff).toString(16).padStart(2, "0")); }); return textArray.join("-"); + } else if (Array.isArray(value)) { + return "[" + value.map((x) => JSON.stringify(x)).join(", ") + "]"; } else { return JSON.stringify(value); } } -export function getOrDefault(log: Log, key: string, type: LoggableType, timestamp: number, defaultValue: any): any { +export function getOrDefault( + log: Log, + key: string, + type: LoggableType, + timestamp: number, + defaultValue: any, + uuid?: string +): any { if (log.getType(key) === type) { - let logData = log.getRange(key, timestamp, timestamp); + let logData = log.getRange(key, timestamp, timestamp, uuid); if (logData !== undefined && logData.values.length > 0 && logData.timestamps[0] <= timestamp) { return logData.values[0]; } @@ -186,6 +202,80 @@ export function getEnabledData(log: Log): LogValueSetBoolean | null { return enabledData; } +export function getAutonomousData(log: Log): LogValueSetBoolean | null { + let autonomousKey = AUTONOMOUS_KEYS.find((key) => log.getFieldKeys().includes(key)); + if (!autonomousKey) return null; + let autonomousData: LogValueSetBoolean | null = null; + if (autonomousKey.endsWith("FMSControlData")) { + let tempAutoData = log.getNumber(autonomousKey, -Infinity, Infinity); + if (tempAutoData) { + autonomousData = { + timestamps: tempAutoData.timestamps, + values: tempAutoData.values.map((controlWord) => ((controlWord >> 1) & 1) !== 0) + }; + } + } else { + let tempAutoData = log.getBoolean(autonomousKey, -Infinity, Infinity); + if (!tempAutoData) return null; + autonomousData = tempAutoData; + if (autonomousKey.endsWith("DSTeleop")) { + autonomousData = { + timestamps: autonomousData.timestamps, + values: autonomousData.values.map((value) => !value) + }; + } + } + return autonomousData; +} + +export function getRobotStateRanges(log: Log): { start: number; end?: number; mode: "disabled" | "auto" | "teleop" }[] { + let enabledData = getEnabledData(log); + let autoData = getAutonomousData(log); + if (!enabledData || !autoData) return []; + + // Combine enabled and auto data + let allTimestamps = [...enabledData.timestamps, ...autoData.timestamps]; + allTimestamps = [...new Set(allTimestamps)]; + allTimestamps.sort((a, b) => Number(a) - Number(b)); + let combined: { timestamp: number; enabled: boolean; auto: boolean }[] = []; + allTimestamps.forEach((timestamp) => { + let enabled = enabledData!.values.findLast((_, index) => enabledData!.timestamps[index] <= timestamp); + let auto = autoData!.values.findLast((_, index) => autoData!.timestamps[index] <= timestamp); + if (enabled === undefined) enabled = false; + if (auto === undefined) auto = false; + combined.push({ + timestamp: timestamp, + enabled: enabled, + auto: auto + }); + }); + + // Get ranges + let ranges: { start: number; end?: number; mode: "disabled" | "auto" | "teleop" }[] = []; + combined.forEach((sample, index) => { + let end: number | undefined = undefined; + if (sample.enabled) { + if (index < combined.length - 1) { + end = combined[index + 1].timestamp; + } + ranges.push({ + start: sample.timestamp, + end: end, + mode: sample.auto ? "auto" : "teleop" + }); + } else { + let endSample = combined.find((endSample) => endSample.timestamp > sample.timestamp && endSample.enabled); + if (endSample) end = endSample.timestamp; + ranges.push({ + start: sample.timestamp, + end: end, + mode: "disabled" + }); + } + }); + return ranges; +} + export function getIsRedAlliance(log: Log, time: number): boolean { let allianceKey = ALLIANCE_KEYS.find((key) => log.getFieldKeys().includes(key)); if (!allianceKey) return false; @@ -194,7 +284,10 @@ export function getIsRedAlliance(log: Log, time: number): boolean { // Integer value (station) from AdvantageKit let tempAllianceData = log.getNumber(allianceKey, time, time); if (tempAllianceData && tempAllianceData.values.length > 0) { - return tempAllianceData.values[tempAllianceData.values.length - 1] <= 3; + return ( + tempAllianceData.values[tempAllianceData.values.length - 1] <= 3 && + tempAllianceData.values[tempAllianceData.values.length - 1] > 0 + ); } } else { // Boolean value from NT @@ -261,12 +354,14 @@ export interface JoystickState { povs: number[]; } +export const BlankJoystickState: JoystickState = { + buttons: [], + axes: [], + povs: [] +}; + export function getJoystickState(log: Log, joystickId: number, time: number): JoystickState { - let state: JoystickState = { - buttons: [], - axes: [], - povs: [] - }; + let state = jsonCopy(BlankJoystickState); if (joystickId < 0 || joystickId > 5 || joystickId % 1 !== 0) return state; // Find joystick table diff --git a/src/hub/tabControllers/ConsoleController.ts b/src/shared/renderers/ConsoleRenderer.ts similarity index 50% rename from src/hub/tabControllers/ConsoleController.ts rename to src/shared/renderers/ConsoleRenderer.ts index 97ff1afe..467c65be 100644 --- a/src/hub/tabControllers/ConsoleController.ts +++ b/src/shared/renderers/ConsoleRenderer.ts @@ -1,14 +1,9 @@ -import { ConsoleState } from "../../shared/HubState"; -import LoggableType from "../../shared/log/LoggableType"; -import { LogValueSetString } from "../../shared/log/LogValueSets"; -import TabType from "../../shared/TabType"; -import { formatTimeWithMS, htmlEncode } from "../../shared/util"; import { SelectionMode } from "../Selection"; -import TabController from "../TabController"; +import LogField from "../log/LogField"; +import { arraysEqual, formatTimeWithMS, htmlEncode } from "../util"; +import TabRenderer from "./TabRenderer"; -export default class ConsoleController implements TabController { - private CONTENT: HTMLElement; - private DRAG_HIGHLIGHT: HTMLElement; +export default class ConsoleRenderer implements TabRenderer { private TABLE_CONTAINER: HTMLElement; private TABLE_BODY: HTMLElement; private JUMP_INPUT: HTMLInputElement; @@ -16,63 +11,48 @@ export default class ConsoleController implements TabController { private FILTER_INPUT: HTMLInputElement; private FIELD_CELL: HTMLElement; private FIELD_TEXT: HTMLElement; + private FIELD_DELETE: HTMLButtonElement; + private HAND_ICON: HTMLElement; - private field: string | null = null; + private hasController: boolean; + private key: string | null = null; + private keyAvailable = false; + private timestamps: number[] = []; + private values: string[] = []; + private renderedTimestamps: number[] = []; + private renderedValues: string[] = []; private lastScrollPosition: number | null = null; - private lastData: LogValueSetString = { - timestamps: [], - values: [] - }; + private selectionMode: SelectionMode = SelectionMode.Idle; + private selectedTime: number | null = null; + private hoveredTime: number | null = null; - constructor(content: HTMLElement) { - this.CONTENT = content; - this.DRAG_HIGHLIGHT = content.getElementsByClassName("console-table-drag-highlight")[0] as HTMLElement; - this.TABLE_CONTAINER = content.getElementsByClassName("console-table-container")[0] as HTMLElement; + constructor(root: HTMLElement, hasController: boolean) { + this.hasController = hasController; + this.TABLE_CONTAINER = root.getElementsByClassName("console-table-container")[0] as HTMLElement; this.TABLE_BODY = this.TABLE_CONTAINER.firstElementChild?.firstElementChild as HTMLElement; this.JUMP_INPUT = this.TABLE_BODY.firstElementChild?.firstElementChild?.firstElementChild as HTMLInputElement; this.JUMP_BUTTON = this.TABLE_BODY.firstElementChild?.firstElementChild?.lastElementChild as HTMLInputElement; this.FILTER_INPUT = this.TABLE_BODY.firstElementChild?.lastElementChild?.lastElementChild as HTMLInputElement; this.FIELD_CELL = this.TABLE_BODY.firstElementChild?.lastElementChild as HTMLElement; - this.FIELD_TEXT = this.FIELD_CELL.firstElementChild as HTMLElement; - - // Drag handling - window.addEventListener("drag-update", (event) => { - if (this.CONTENT.hidden) return; - let dragData = (event as CustomEvent).detail; - let rect = this.CONTENT.getBoundingClientRect(); - let active = - dragData.x > rect.left && dragData.x < rect.right && dragData.y > rect.top && dragData.y < rect.bottom; - let validType = window.log.getType(dragData.data.fields[0]) === LoggableType.String; - this.DRAG_HIGHLIGHT.hidden = true; - if (active && validType) { - if (dragData.end) { - this.field = dragData.data.fields[0]; - this.updateData(); - } else { - this.DRAG_HIGHLIGHT.hidden = false; - } - } - }); - this.FIELD_CELL.addEventListener("contextmenu", () => { - this.field = null; - this.updateData(); - }); + this.FIELD_TEXT = this.FIELD_CELL.firstElementChild?.firstElementChild as HTMLElement; + this.FIELD_DELETE = this.FIELD_CELL.firstElementChild?.lastElementChild as HTMLButtonElement; + this.HAND_ICON = root.getElementsByClassName("large-table-hand-icon")[0] as HTMLElement; // Jump input handling let jump = () => { // Determine target time let targetTime = Number(this.JUMP_INPUT.value); if (this.JUMP_INPUT.value === "") { - if (window.selection.getMode() !== SelectionMode.Idle) { - targetTime = window.selection.getSelectedTime() as number; + if (this.selectionMode !== SelectionMode.Idle) { + targetTime = this.selectedTime as number; } else { targetTime = 0; } } // Find target row - let targetRow = this.lastData.timestamps.findIndex((value) => value > targetTime); - if (targetRow === -1) targetRow = this.lastData.timestamps.length; + let targetRow = this.timestamps.findIndex((value) => value > targetTime); + if (targetRow === -1) targetRow = this.timestamps.length; if (targetRow < 1) targetRow = 1; targetRow -= 1; this.TABLE_CONTAINER.scrollTop = Array.from(this.TABLE_BODY.children).reduce((totalHeight, row, rowIndex) => { @@ -89,47 +69,61 @@ export default class ConsoleController implements TabController { this.JUMP_BUTTON.addEventListener("click", jump); this.FILTER_INPUT.addEventListener("input", () => this.updateData()); + // Delete button handling + this.FIELD_DELETE.addEventListener("click", () => { + root.dispatchEvent(new CustomEvent("close-field")); + }); + // Update field text this.updateData(); } - saveState(): ConsoleState { - return { - type: TabType.Console, - field: this.field - }; + saveState(): unknown { + return null; } - restoreState(state: ConsoleState) { - this.field = state.field; - this.updateData(); - } + restoreState(state: unknown): void {} - refresh() { - this.updateData(); + getAspectRatio(): number | null { + return null; } - newAssets() {} + render(command: ConsoleRendererCommand): void { + // Update selection state + this.selectionMode = command.selectionMode; + this.selectedTime = command.selectedTime; + this.hoveredTime = command.hoveredTime; - getActiveFields(): string[] { - if (this.field === null) { - return []; - } else { - return [this.field]; + // Get data from field + let field = command.keyAvailable ? LogField.fromSerialized(command.serialized) : null; + let fieldData = field === null ? undefined : field.getString(-Infinity, Infinity); + let newTimestamps = fieldData === undefined ? [] : fieldData.timestamps; + let newValues = fieldData === undefined ? [] : fieldData.values; + + // Update values + if ( + command.key !== this.key || + command.keyAvailable !== this.keyAvailable || + !arraysEqual(newTimestamps, this.timestamps) || + !arraysEqual(newValues, this.values) + ) { + this.key = command.key; + this.keyAvailable = command.keyAvailable; + this.timestamps = newTimestamps; + this.values = newValues; + this.updateData(); } - } - periodic() { // Update highlights this.updateHighlights(); // Update placeholder for jump input - let selectedTime = window.selection.getSelectedTime(); + let selectedTime = this.selectedTime; let placeholder = selectedTime === null ? 0 : selectedTime; this.JUMP_INPUT.placeholder = formatTimeWithMS(placeholder); // Scroll to bottom if locked - if (window.selection.getMode() === SelectionMode.Locked) { + if (this.selectionMode === SelectionMode.Locked) { if (this.lastScrollPosition !== null && this.TABLE_CONTAINER.scrollTop < this.lastScrollPosition) { window.selection.unlock(); } else { @@ -142,49 +136,50 @@ export default class ConsoleController implements TabController { /** Updates the field text and data. */ updateData() { // Update field text - if (this.field === null) { - this.FIELD_TEXT.innerText = ""; + if (this.key === null) { + this.FIELD_TEXT.innerText = ""; this.FIELD_TEXT.style.textDecoration = ""; - } else if (!window.log.getFieldKeys().includes(this.field)) { - this.FIELD_TEXT.innerText = this.field; + this.FIELD_DELETE.hidden = true; + } else if (!this.keyAvailable) { + this.FIELD_TEXT.innerText = this.key; this.FIELD_TEXT.style.textDecoration = "line-through"; + this.FIELD_DELETE.hidden = !this.hasController; } else { - this.FIELD_TEXT.innerText = this.field; + this.FIELD_TEXT.innerText = this.key; this.FIELD_TEXT.style.textDecoration = ""; + this.FIELD_DELETE.hidden = !this.hasController; } + // Update hand icon + let showHand = this.hasController && !this.keyAvailable; + this.HAND_ICON.style.transition = showHand ? "opacity 1s ease-in 1s" : ""; + this.HAND_ICON.style.opacity = showHand ? "0.15" : "0"; + // Get data - let logData: LogValueSetString = { - timestamps: [], - values: [] - }; - if (this.field !== null) { - let logDataTemp = window.log.getString(this.field, -Infinity, Infinity); - if (logDataTemp) logData = logDataTemp; - } + let timestamps = this.timestamps; + let values = this.values; const filter = this.FILTER_INPUT.value.toLowerCase(); if (filter.length > 0) { - let filteredLogData: LogValueSetString = { - timestamps: [], - values: [] - }; - for (let i = 0; i < logData.timestamps.length; i++) { - let value = logData.values[i]; + let filteredTimestamps: number[] = []; + let filteredValues: string[] = []; + for (let i = 0; i < timestamps.length; i++) { + let value = values[i]; if (value.toLowerCase().includes(filter)) { - filteredLogData.timestamps.push(logData.timestamps[i]); - filteredLogData.values.push(value); + filteredTimestamps.push(timestamps[i]); + filteredValues.push(value); } } - logData = filteredLogData; + timestamps = filteredTimestamps; + values = filteredValues; } // Clear extra rows - while (this.TABLE_BODY.children.length - 1 > logData.timestamps.length) { + while (this.TABLE_BODY.children.length - 1 > timestamps.length) { this.TABLE_BODY.removeChild(this.TABLE_BODY.lastElementChild!); } // Add new rows - while (this.TABLE_BODY.children.length - 1 < logData.timestamps.length) { + while (this.TABLE_BODY.children.length - 1 < timestamps.length) { let row = document.createElement("tr"); this.TABLE_BODY.appendChild(row); let timestampCell = document.createElement("td"); @@ -195,14 +190,14 @@ export default class ConsoleController implements TabController { // Bind selection controls row.addEventListener("mouseenter", () => { let rowIndex = Array.from(this.TABLE_BODY.children).indexOf(row); - window.selection.setHoveredTime(this.lastData.timestamps[rowIndex - 1]); + window.selection.setHoveredTime(this.renderedTimestamps[rowIndex - 1]); }); row.addEventListener("mouseleave", () => { window.selection.setHoveredTime(null); }); row.addEventListener("click", () => { let rowIndex = Array.from(this.TABLE_BODY.children).indexOf(row); - window.selection.setSelectedTime(this.lastData.timestamps[rowIndex - 1]); + window.selection.setSelectedTime(this.renderedTimestamps[rowIndex - 1]); }); row.addEventListener("contextmenu", () => { window.selection.goIdle(); @@ -210,55 +205,48 @@ export default class ConsoleController implements TabController { } // Update values - for (let i = 0; i < logData.values.length; i++) { + for (let i = 0; i < values.length; i++) { // Check if value has changed let hasChanged = false; - if (i > this.lastData.timestamps.length) { + if (i > this.renderedTimestamps.length) { hasChanged = true; // New row - } else if ( - logData.timestamps[i] !== this.lastData.timestamps[i] || - logData.values[i] !== this.lastData.values[i] - ) { + } else if (this.renderedTimestamps[i] !== timestamps[i] || this.renderedValues[i] !== values[i]) { hasChanged = true; // Data has changed } // Update cell contents if (hasChanged) { let row = this.TABLE_BODY.children[i + 1]; - (row.children[0] as HTMLElement).innerText = formatTimeWithMS(logData.timestamps[i]); - (row.children[1] as HTMLElement).innerHTML = htmlEncode(logData.values[i]).replace("\n", "
"); + (row.children[0] as HTMLElement).innerText = formatTimeWithMS(timestamps[i]); + (row.children[1] as HTMLElement).innerHTML = htmlEncode(values[i]).replace("\n", "
"); } } - - // Update last timestamps - this.lastData = { - timestamps: [...logData.timestamps], - values: [...logData.values] - }; + this.renderedTimestamps = timestamps; + this.renderedValues = values; } /** Updates highlighted times (selected & hovered). */ private updateHighlights() { - if (this.lastData.timestamps.length === 0) return; + if (this.timestamps.length === 0) return; let highlight = (time: number | null, className: string) => { Array.from(this.TABLE_BODY.children).forEach((row) => row.classList.remove(className)); if (time) { - let target = this.lastData.timestamps.findIndex((value) => value > time); - if (target === -1) target = this.lastData.timestamps.length; + let target = this.renderedTimestamps.findIndex((value) => value > time); + if (target === -1) target = this.renderedTimestamps.length; if (target < 1) target = 1; target -= 1; this.TABLE_BODY.children[target + 1].classList.add(className); } }; - switch (window.selection.getMode()) { + switch (this.selectionMode) { case SelectionMode.Idle: highlight(null, "selected"); - highlight(window.selection.getHoveredTime(), "hovered"); + highlight(this.hoveredTime, "hovered"); break; case SelectionMode.Static: case SelectionMode.Playback: - highlight(window.selection.getSelectedTime(), "selected"); - highlight(window.selection.getHoveredTime(), "hovered"); + highlight(this.selectedTime, "selected"); + highlight(this.hoveredTime, "hovered"); break; case SelectionMode.Locked: Array.from(this.TABLE_BODY.children).forEach((row) => row.classList.remove("selected")); @@ -267,3 +255,13 @@ export default class ConsoleController implements TabController { } } } + +export type ConsoleRendererCommand = { + key: string | null; + keyAvailable: boolean; + serialized: any; + + selectionMode: SelectionMode; + selectedTime: number | null; + hoveredTime: number | null; +}; diff --git a/src/hub/tabControllers/DocumentationController.ts b/src/shared/renderers/DocumentationRenderer.ts similarity index 88% rename from src/hub/tabControllers/DocumentationController.ts rename to src/shared/renderers/DocumentationRenderer.ts index 95e6df40..5a50d7ce 100644 --- a/src/hub/tabControllers/DocumentationController.ts +++ b/src/shared/renderers/DocumentationRenderer.ts @@ -2,11 +2,9 @@ import hljs from "highlight.js/lib/core"; import cpp from "highlight.js/lib/languages/cpp"; import java from "highlight.js/lib/languages/java"; import { Remarkable } from "remarkable"; -import { DocumentationState } from "../../shared/HubState"; -import TabType from "../../shared/TabType"; -import TabController from "../TabController"; +import TabRenderer from "./TabRenderer"; -export default class DocumentationController implements TabController { +export default class DocumentationRenderer implements TabRenderer { private CONTAINER: HTMLElement; private TEXT: HTMLElement; private remarkable = new Remarkable({ html: true }); @@ -24,27 +22,21 @@ export default class DocumentationController implements TabController { this.loadMarkdown(this.markdownPath); } - saveState(): DocumentationState { - return { - type: TabType.Documentation, - path: this.markdownPath - }; + saveState(): unknown { + return this.markdownPath; } - restoreState(state: DocumentationState) { - this.markdownPath = state.path; - this.loadMarkdown(this.markdownPath); + restoreState(state: unknown): void { + if (typeof state === "string") { + this.loadMarkdown(state); + } } - refresh() {} - - newAssets() {} - - getActiveFields(): string[] { - return []; + getAspectRatio(): number | null { + return null; } - periodic() { + render(command: unknown): void { // Update screenshot on index page if (this.isIndex) { let images = this.TEXT.getElementsByTagName("img"); diff --git a/src/shared/renderers/Heatmap.ts b/src/shared/renderers/Heatmap.ts new file mode 100644 index 00000000..f8640140 --- /dev/null +++ b/src/shared/renderers/Heatmap.ts @@ -0,0 +1,117 @@ +import h337 from "heatmap.js"; +import { Translation2d } from "../geometry"; +import { scaleValue } from "../util"; + +export default class Heatmap { + private static HEATMAP_GRID_SIZE = 0.1; + private static HEATMAP_RADIUS = 0.1; // Fraction of field height + + private container: HTMLElement; + private heatmap: h337.Heatmap<"value", "x", "y"> | null = null; + private lastPixelDimensions: [number, number] = [0, 0]; + private lastFieldDimensions: [number, number] = [0, 0]; + private lastFlipped = false; + private lastTranslationsStr = ""; + + constructor(container: HTMLElement) { + this.container = container; + } + + getCanvas(): HTMLCanvasElement | null { + let canvas = this.container.getElementsByTagName("canvas"); + if (canvas.length === 0) { + return null; + } else { + return canvas[0]; + } + } + + update( + translations: Translation2d[], + pixelDimensions: [number, number], + fieldDimensions: [number, number], + flipped = false + ) { + // Recreate heatmap canvas + let newHeatmapInstance = false; + if ( + pixelDimensions[0] !== this.lastPixelDimensions[0] || + pixelDimensions[1] !== this.lastPixelDimensions[1] || + fieldDimensions[0] !== this.lastFieldDimensions[0] || + fieldDimensions[1] !== this.lastFieldDimensions[1] || + flipped !== this.lastFlipped + ) { + newHeatmapInstance = true; + this.lastPixelDimensions = pixelDimensions; + this.lastFieldDimensions = fieldDimensions; + this.lastFlipped = flipped; + while (this.container.firstChild) { + this.container.removeChild(this.container.firstChild); + } + this.container.style.width = pixelDimensions[0].toString() + "px"; + this.container.style.height = pixelDimensions[1].toString() + "px"; + this.heatmap = h337.create({ + container: this.container, + radius: pixelDimensions[1] * Heatmap.HEATMAP_RADIUS, + maxOpacity: 0.75 + }); + } + + // Update heatmap data + let translationsStr = JSON.stringify(translations); + if (translationsStr !== this.lastTranslationsStr || newHeatmapInstance) { + this.lastTranslationsStr = translationsStr; + let grid: number[][] = []; + for (let x = 0; x < fieldDimensions[0] + Heatmap.HEATMAP_GRID_SIZE; x += Heatmap.HEATMAP_GRID_SIZE) { + let column: number[] = []; + grid.push(column); + for (let y = 0; y < fieldDimensions[1] + Heatmap.HEATMAP_GRID_SIZE; y += Heatmap.HEATMAP_GRID_SIZE) { + column.push(0); + } + } + + translations.forEach((translation) => { + let gridX = Math.floor(translation[0] / Heatmap.HEATMAP_GRID_SIZE); + let gridY = Math.floor(translation[1] / Heatmap.HEATMAP_GRID_SIZE); + if (gridX >= 0 && gridY >= 0 && gridX < grid.length && gridY < grid[0].length) { + grid[gridX][gridY] += 1; + } + }); + + let heatmapData: { x: number; y: number; value: number }[] = []; + let x = Heatmap.HEATMAP_GRID_SIZE / 2; + let y: number; + let maxValue = 0; + grid.forEach((column) => { + x += Heatmap.HEATMAP_GRID_SIZE; + y = Heatmap.HEATMAP_GRID_SIZE / 2; + column.forEach((gridValue) => { + y += Heatmap.HEATMAP_GRID_SIZE; + let coordinates = [ + scaleValue(x, [0, fieldDimensions[0]], [0, pixelDimensions[0]]), + scaleValue(y, [0, fieldDimensions[1]], [pixelDimensions[1], 0]) + ]; + if (flipped) { + coordinates[0] = pixelDimensions[0] - coordinates[0]; + coordinates[1] = pixelDimensions[1] - coordinates[1]; + } + coordinates[0] = Math.round(coordinates[0]); + coordinates[1] = Math.round(coordinates[1]); + maxValue = Math.max(maxValue, gridValue); + if (gridValue > 0) { + heatmapData.push({ + x: coordinates[0], + y: coordinates[1], + value: gridValue + }); + } + }); + }); + this.heatmap?.setData({ + min: 0, + max: maxValue, + data: heatmapData + }); + } + } +} diff --git a/src/shared/visualizers/JoysticksVisualizer.ts b/src/shared/renderers/JoysticksRenderer.ts similarity index 88% rename from src/shared/visualizers/JoysticksVisualizer.ts rename to src/shared/renderers/JoysticksRenderer.ts index 5c89368f..36090717 100644 --- a/src/shared/visualizers/JoysticksVisualizer.ts +++ b/src/shared/renderers/JoysticksRenderer.ts @@ -1,8 +1,8 @@ import { JoystickState } from "../log/LogUtil"; import { scaleValue } from "../util"; -import Visualizer from "./Visualizer"; +import TabRenderer from "./TabRenderer"; -export default class JoysticksVisualizer implements Visualizer { +export default class JoysticksRenderer implements TabRenderer { private CANVAS: HTMLCanvasElement; private IMAGES: HTMLImageElement[] = []; @@ -10,27 +10,25 @@ export default class JoysticksVisualizer implements Visualizer { private BLACK_COLOR = "#222222"; private WHITE_COLOR = "#eeeeee"; - constructor(canvas: HTMLCanvasElement) { - this.CANVAS = canvas; - for (let i = 0; i < 3; i++) { + constructor(root: HTMLElement) { + this.CANVAS = root.getElementsByTagName("canvas")[0] as HTMLCanvasElement; + for (let i = 0; i < 6; i++) { let image = document.createElement("img"); this.IMAGES.push(image); - canvas.appendChild(image); } } - saveState() { + saveState(): unknown { return null; } - restoreState(): void {} + restoreState(state: unknown): void {} - render( - command: { - layoutTitle: string; - state: JoystickState; - }[] - ): number | null { + getAspectRatio(): number | null { + return null; + } + + render(command: JoysticksRendererCommand): void { // Set up canvas let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; let canvasWidth = this.CANVAS.clientWidth; @@ -43,7 +41,7 @@ export default class JoysticksVisualizer implements Visualizer { // Iterate over joysticks command.forEach((joystick, index) => { - let config = window.assets?.joysticks.find((joystickConfig) => joystickConfig.name === joystick.layoutTitle); + let config = window.assets?.joysticks.find((joystickConfig) => joystickConfig.name === joystick.layout); // Update image element let imageElement = this.IMAGES[index]; @@ -75,16 +73,50 @@ export default class JoysticksVisualizer implements Visualizer { joystickRegionTop = 0; break; case 3: - if (index === 2) { + joystickRegionHeight = canvasHeight / 2; + if (index < 2) { + joystickRegionWidth = canvasWidth / 2; + joystickRegionLeft = joystickRegionWidth * index; + joystickRegionTop = 0; + } else { joystickRegionWidth = canvasWidth; - joystickRegionHeight = canvasHeight / 2; joystickRegionLeft = 0; joystickRegionTop = canvasHeight / 2; + } + break; + case 4: + joystickRegionWidth = canvasWidth / 2; + joystickRegionHeight = canvasHeight / 2; + if (index < 2) { + joystickRegionLeft = joystickRegionWidth * index; + joystickRegionTop = 0; } else { + joystickRegionLeft = joystickRegionWidth * (index - 2); + joystickRegionTop = joystickRegionHeight; + } + break; + case 5: + joystickRegionHeight = canvasHeight / 2; + if (index < 2) { joystickRegionWidth = canvasWidth / 2; - joystickRegionHeight = canvasHeight / 2; joystickRegionLeft = joystickRegionWidth * index; joystickRegionTop = 0; + } else { + joystickRegionWidth = canvasWidth / 3; + joystickRegionLeft = joystickRegionWidth * (index - 2); + joystickRegionTop = joystickRegionHeight; + } + break; + case 6: + joystickRegionHeight = canvasHeight / 2; + if (index < 3) { + joystickRegionWidth = canvasWidth / 3; + joystickRegionLeft = joystickRegionWidth * index; + joystickRegionTop = 0; + } else { + joystickRegionWidth = canvasWidth / 3; + joystickRegionLeft = joystickRegionWidth * (index - 3); + joystickRegionTop = joystickRegionHeight; } break; default: @@ -404,7 +436,7 @@ export default class JoysticksVisualizer implements Visualizer { context.fillStyle = isLight ? this.BLACK_COLOR : this.WHITE_COLOR; context.fillText("No joysticks selected.", canvasWidth / 2, canvasHeight / 2); } - - return null; } } + +export type JoysticksRendererCommand = { layout: string; state: JoystickState }[]; diff --git a/src/shared/renderers/LineGraphRenderer.ts b/src/shared/renderers/LineGraphRenderer.ts new file mode 100644 index 00000000..b251faa2 --- /dev/null +++ b/src/shared/renderers/LineGraphRenderer.ts @@ -0,0 +1,601 @@ +import ScrollSensor from "../../hub/ScrollSensor"; +import { SelectionMode } from "../Selection"; +import { ValueScaler, calcAxisStepSize, clampValue, cleanFloat, scaleValue, shiftColor } from "../util"; +import TabRenderer from "./TabRenderer"; + +export default class LineGraphRenderer implements TabRenderer { + private Y_STEP_TARGET_PX = 50; + private X_STEP_TARGET_PX = 100; + private MAX_DECIMAL_VALUE = 1e9; // After this, stop trying to display fractional values + + private ROOT: HTMLElement; + private CANVAS: HTMLCanvasElement; + private SCROLL_OVERLAY: HTMLElement; + + private hasController: boolean; + private scrollSensor: ScrollSensor; + private mouseDownX = 0; + private grabZoomActive = false; + private grabZoomStartTime = 0; + private lastCursorX: number | null = null; + private lastHoveredTime: number | null = null; + private didClearHoveredTime = false; + + constructor(root: HTMLElement, hasController: boolean) { + this.hasController = hasController; + this.ROOT = root; + this.CANVAS = root.getElementsByClassName("line-graph-canvas")[0] as HTMLCanvasElement; + this.SCROLL_OVERLAY = root.getElementsByClassName("line-graph-scroll")[0] as HTMLCanvasElement; + + // Hover handling + this.SCROLL_OVERLAY.addEventListener("mousemove", (event) => { + this.lastCursorX = event.clientX - this.ROOT.getBoundingClientRect().x; + }); + this.SCROLL_OVERLAY.addEventListener("mouseleave", () => { + this.lastCursorX = null; + window.selection.setHoveredTime(null); + }); + + // Selection handling + this.SCROLL_OVERLAY.addEventListener("mousedown", (event) => { + this.mouseDownX = event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x; + if (event.shiftKey && this.lastHoveredTime !== null) { + this.grabZoomActive = true; + this.grabZoomStartTime = this.lastHoveredTime; + } + }); + this.SCROLL_OVERLAY.addEventListener("mousemove", () => { + if (this.grabZoomActive && this.lastHoveredTime !== null) { + window.selection.setGrabZoomRange([this.grabZoomStartTime, this.lastHoveredTime]); + } + }); + this.SCROLL_OVERLAY.addEventListener("mouseup", () => { + if (this.grabZoomActive) { + window.selection.finishGrabZoom(); + this.grabZoomActive = false; + } + }); + this.SCROLL_OVERLAY.addEventListener("click", (event) => { + if (Math.abs(event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x - this.mouseDownX) <= 5) { + let hoveredTime = this.lastHoveredTime; + if (hoveredTime) { + window.selection.setSelectedTime(hoveredTime); + } + } + }); + this.SCROLL_OVERLAY.addEventListener("contextmenu", () => { + window.selection.goIdle(); + }); + + // Scroll handling + this.scrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { + if (root.hidden) return; + window.selection.applyTimelineScroll(dx, dy, this.SCROLL_OVERLAY.clientWidth); + }); + } + + getAspectRatio(): number | null { + return null; + } + + render(command: LineGraphRendererCommand): void { + this.scrollSensor.periodic(); + + // Initial setup and scaling + const timeRange = command.timeRange; + const devicePixelRatio = window.devicePixelRatio; + let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; + let width = this.CANVAS.clientWidth; + let height = this.CANVAS.clientHeight; + let light = !window.matchMedia("(prefers-color-scheme: dark)").matches; + this.CANVAS.width = width * devicePixelRatio; + this.CANVAS.height = height * devicePixelRatio; + context.scale(devicePixelRatio, devicePixelRatio); + context.clearRect(0, 0, width, height); + context.font = "12px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont"; + + // Calculate vertical layout (based on discrete fields) + let graphTop = this.hasController ? 8 : 20; + let graphHeight = height - graphTop - 35; + if (graphHeight < 1) graphHeight = 1; + let graphHeightOpen = + graphHeight - command.discreteFields.length * 20 - (command.discreteFields.length > 0 ? 5 : 0); + if (graphHeightOpen < 1) graphHeightOpen = 1; + + // Calculate Y step sizes + let leftStepSize = calcAxisStepSize(command.leftRange, graphHeightOpen, this.Y_STEP_TARGET_PX); + let rightStepSize = calcAxisStepSize(command.rightRange, graphHeightOpen, this.Y_STEP_TARGET_PX); + + // Calculate horizontal layout + let getTextWidth = (range: [number, number], stepSize: number): number => { + let length = 0; + let value = Math.floor(range[1] / stepSize) * stepSize; + while (value > range[0]) { + length = Math.max(length, context.measureText(cleanFloat(value).toString()).width); + value -= stepSize; + } + return Math.ceil(length / 10) * 10; + }; + let graphLeft = 25 + (command.showLeftAxis ? getTextWidth(command.leftRange, leftStepSize) : 0); + let graphRight = 25 + (command.showRightAxis ? getTextWidth(command.rightRange, rightStepSize) : 0); + let graphWidth = width - graphLeft - graphRight; + if (graphWidth < 1) graphWidth = 1; + + // Calculate X step size + let timeStepSize = calcAxisStepSize(command.timeRange, graphWidth, this.X_STEP_TARGET_PX); + + // Update scroll layout + this.SCROLL_OVERLAY.style.left = graphLeft.toString() + "px"; + this.SCROLL_OVERLAY.style.right = graphRight.toString() + "px"; + + // Render discrete data + let discreteBorders: number[] = []; + context.globalAlpha = 1; + context.textAlign = "left"; + context.textBaseline = "middle"; + context.lineWidth = 1; + context.lineCap = "round"; + command.discreteFields.forEach((field, renderIndex) => { + context.beginPath(); + let toggle = field.toggleReference; + let skippedSamples = 0; + discreteBorders = discreteBorders.concat(field.timestamps); + for (let i = 0; i < field.timestamps.length; i++) { + i += skippedSamples; + if (i >= field.timestamps.length) break; + + let startX = scaleValue(field.timestamps[i], timeRange, [graphLeft, graphLeft + graphWidth]); + let endX: number; + if (i === field.timestamps.length - 1) { + endX = graphLeft + graphWidth; + } else { + skippedSamples = 0; + while ( + (endX = scaleValue(field.timestamps[i + skippedSamples + 1], timeRange, [ + graphLeft, + graphLeft + graphWidth + ])) - + startX < + 1 / devicePixelRatio + ) { + skippedSamples++; + toggle = !toggle; + } + } + if (endX > graphLeft + graphWidth) endX = graphLeft + graphWidth; + let topY = graphTop + graphHeight - 20 - renderIndex * 20; + + // Draw shape + toggle = !toggle; + if (field.type === "stripes") { + context.fillStyle = toggle ? shiftColor(field.color, -30) : shiftColor(field.color, 30); + context.fillRect(startX, topY, endX - startX, 15); + } else { + let startY = toggle ? topY + 15 : topY; + let endY = toggle ? topY : topY + 15; + context.moveTo(startX, startY); + context.lineTo(startX, endY); + context.lineTo(endX, endY); + } + + // Draw text + let adjustedStartX = startX < graphLeft ? graphLeft : startX; + if (endX - adjustedStartX > 10) { + if (field.type === "stripes") { + context.fillStyle = toggle ? shiftColor(field.color, 130) : shiftColor(field.color, -130); + } else { + context.fillStyle = field.color; + } + context.fillText( + field.values[i + skippedSamples], + adjustedStartX + 5, + topY + 15 / 2, + endX - adjustedStartX - 10 + ); + } + } + if (field.type === "graph") { + context.strokeStyle = field.color; + context.stroke(); + } + }); + + // Render continuous data + const xScaler = new ValueScaler(timeRange, [graphLeft, graphLeft + graphWidth]); + let drawNumericFields = (fields: LineGraphRendererCommand_NumericField[], yRange: [number, number]) => { + const yScaler = new ValueScaler(yRange, [graphTop + graphHeightOpen, graphTop]); + fields.forEach((field) => { + context.strokeStyle = field.color; + context.fillStyle = field.color; + context.lineCap = "round"; + switch (field.size) { + case "normal": + context.lineWidth = 1; + break; + case "bold": + context.lineWidth = 3; + break; + case "verybold": + context.lineWidth = 6; + break; + } + + switch (field.type) { + case "stepped": + context.beginPath(); + context.moveTo(graphLeft + graphWidth, yScaler.calculate(field.values[field.values.length - 1])); + let i = field.values.length - 1; + while (true) { + let x = xScaler.calculate(field.timestamps[i]); + + // Render start of current data point + let value = field.values[i]; + context.lineTo(x, yScaler.calculate(value)); + + // Find previous data point and vertical range + let currentX = Math.floor(x * devicePixelRatio); + let newX = currentX; + let vertRange = [value, value]; + do { + i--; + let value = field.values[i]; + if (value < vertRange[0]) vertRange[0] = value; + if (value > vertRange[1]) vertRange[1] = value; + newX = Math.floor(xScaler.calculate(field.timestamps[i]) * devicePixelRatio); + } while (i >= 0 && newX >= currentX); // Compile values to vertical range until the pixel changes + if (i < 0) break; + + // Render vertical range + context.moveTo(x, yScaler.calculate(vertRange[0])); + context.lineTo(x, yScaler.calculate(vertRange[1])); + + // Move to end of previous data point + context.moveTo(x, yScaler.calculate(field.values[i])); + } + context.stroke(); + break; + + case "smooth": + context.beginPath(); + for (let i = 0; i < field.timestamps.length; i++) { + let x = xScaler.calculate(field.timestamps[i]); + let y = yScaler.calculate(field.values[i]); + if (i === 0) { + context.moveTo(x, y); + } else { + context.lineTo(x, y); + } + } + context.stroke(); + break; + + case "points": + let radius = field.size === "normal" ? 1 : 2; + for (let i = 0; i < field.timestamps.length; i++) { + let x = xScaler.calculate(field.timestamps[i]); + let y = yScaler.calculate(field.values[i]); + context.beginPath(); + context.arc(x, y, radius, 0, Math.PI * 2); + context.fill(); + } + break; + } + }); + }; + if (command.priorityAxis === "left") { + drawNumericFields(command.rightFields, command.rightRange); + drawNumericFields(command.leftFields, command.leftRange); + } else { + drawNumericFields(command.leftFields, command.leftRange); + drawNumericFields(command.rightFields, command.rightRange); + } + + // Update hovered time based on graph layout + if (this.lastCursorX === null || this.lastCursorX < graphLeft || this.lastCursorX > graphLeft + graphWidth) { + if (!this.didClearHoveredTime) { + window.selection.setHoveredTime(null); + this.didClearHoveredTime = true; + } + } else { + let hoveredTime = scaleValue(this.lastCursorX, [graphLeft, graphLeft + graphWidth], command.timeRange); + let nearestDiscreteBorder = discreteBorders.reduce((prev, border) => { + if (Math.abs(hoveredTime - border) < Math.abs(hoveredTime - prev)) { + return border; + } else { + return prev; + } + }, Infinity); + if (isFinite(nearestDiscreteBorder)) { + let nearestDiscreteBorderX = scaleValue(nearestDiscreteBorder, command.timeRange, [ + graphLeft, + graphLeft + graphWidth + ]); + if (Math.abs(nearestDiscreteBorderX - this.lastCursorX) < 5) { + hoveredTime = nearestDiscreteBorder; + } + } + window.selection.setHoveredTime(hoveredTime); + this.didClearHoveredTime = false; + command.hoveredTime = hoveredTime; + } + + // Draw grab zoom range + if (command.grabZoomRange !== null) { + let startX = scaleValue(command.grabZoomRange[0], command.timeRange, [graphLeft, graphLeft + graphWidth]); + let endX = scaleValue(command.grabZoomRange[1], command.timeRange, [graphLeft, graphLeft + graphWidth]); + + context.globalAlpha = 0.25; + context.fillStyle = "yellow"; + context.fillRect(startX, graphTop, endX - startX, graphHeight); + context.globalAlpha = 1; + } + + // Use similar logic as main axes but with an extra decimal point of precision to format the popup timestamps + let formatMarkedTimestampText = (time: number): string => { + let fractionDigits = Math.max(0, -Math.floor(Math.log10(timeStepSize / 10))); + return time.toFixed(fractionDigits) + "s"; + }; + + // Write formatted timestamp popups to graph view + let writeCenteredTime = (text: string, x: number, alpha: number, drawRect: boolean) => { + context.globalAlpha = alpha; + context.strokeStyle = light ? "#222" : "#eee"; + context.fillStyle = light ? "#222" : "#eee"; + let textSize = context.measureText(text); + context.clearRect( + x - textSize.actualBoundingBoxLeft - 5, + graphTop, + textSize.width + 10, + textSize.actualBoundingBoxDescent + 10 + ); + if (drawRect) { + context.strokeRect( + x - textSize.actualBoundingBoxLeft - 5, + graphTop, + textSize.width + 10, + textSize.actualBoundingBoxDescent + 10 + ); + } + + context.fillText(text, x, graphTop + 5); + context.globalAlpha = 1; + }; + + // Draw a vertical dotted line at the time + let markTime = (time: number, alpha: number) => { + if (time >= timeRange[0] && time <= timeRange[1]) { + context.globalAlpha = alpha; + context.lineWidth = 1; + context.setLineDash([5, 5]); + context.strokeStyle = light ? "#222" : "#eee"; + context.fillStyle = light ? "#222" : "#eee"; + + let x = scaleValue(time, timeRange, [graphLeft, graphLeft + graphWidth]); + context.beginPath(); + context.moveTo(x, graphTop); + context.lineTo(x, graphTop + graphHeight); + context.stroke(); + context.setLineDash([]); + context.globalAlpha = 1; + } + }; + + // Render selected times + context.textBaseline = "top"; + context.textAlign = "center"; + let selectedX = + command.selectedTime === null + ? null + : scaleValue(command.selectedTime, timeRange, [graphLeft, graphLeft + graphWidth]); + let hoveredX = + command.hoveredTime === null + ? null + : scaleValue(command.hoveredTime, timeRange, [graphLeft, graphLeft + graphWidth]); + let selectedText = command.selectedTime === null ? null : formatMarkedTimestampText(command.selectedTime); + let hoveredText = command.hoveredTime === null ? null : formatMarkedTimestampText(command.hoveredTime); + if (command.hoveredTime !== null) markTime(command.hoveredTime!, 0.35); + if (command.selectionMode === SelectionMode.Static || command.selectionMode === SelectionMode.Playback) { + // There is a valid selected time + command.selectedTime = command.selectedTime as number; + selectedX = selectedX as number; + selectedText = selectedText as string; + markTime(command.selectedTime!, 1); + if (command.hoveredTime !== null && command.hoveredTime !== command.selectedTime) { + // Write both selected and hovered time, figure out layout + command.hoveredTime = command.hoveredTime as number; + hoveredX = hoveredX as number; + hoveredText = hoveredText as string; + + let deltaText = "\u0394" + formatMarkedTimestampText(command.hoveredTime - command.selectedTime); + let xSpace = clampValue(selectedX, graphLeft, graphLeft + graphWidth) - hoveredX; + let textHalfWidths = + (context.measureText(selectedText).width + 10) / 2 + (context.measureText(hoveredText).width + 10) / 2 + 4; + let deltaTextMetrics = context.measureText(deltaText); + let deltaWidth = deltaTextMetrics.width + 10 + 4; + let offsetAmount = textHalfWidths - Math.abs(xSpace); + let doesDeltaFit = deltaWidth <= Math.abs(xSpace); + if (doesDeltaFit) { + // Enough space for delta text + offsetAmount = textHalfWidths + deltaWidth - Math.abs(xSpace); + + // Draw connecting line between two cursors, overlapping parts will be automatically cleared + let centerY = (deltaTextMetrics.actualBoundingBoxDescent + 10) / 2 + graphTop; + context.globalAlpha = 0.35; + context.lineWidth = 1; + context.setLineDash([]); + context.strokeStyle = light ? "#222" : "#eee"; + context.beginPath(); + context.moveTo(selectedX, centerY); + context.lineTo(hoveredX, centerY); + context.stroke(); + context.globalAlpha = 1; + + // Draw delta text + let deltaX = (selectedX + hoveredX) / 2; + if (command.selectedTime < timeRange[0]) { + deltaX = Math.max(deltaX, graphLeft + deltaWidth / 2 - 2); + } else if (command.selectedTime > timeRange[1]) { + deltaX = Math.min(deltaX, graphLeft + graphWidth - deltaWidth / 2 + 2); + } + writeCenteredTime(deltaText, deltaX, 0.35, false); + } + if (offsetAmount > 0) { + selectedX = selectedX + (offsetAmount / 2) * (selectedX < hoveredX ? -1 : 1); + hoveredX = hoveredX - (offsetAmount / 2) * (selectedX < hoveredX ? -1 : 1); + } + writeCenteredTime(selectedText, selectedX, 1, true); + writeCenteredTime(hoveredText, hoveredX, 0.35, true); + } else { + // No valid hovered time, only write selected time + writeCenteredTime(selectedText, selectedX, 1, true); + } + } else if (command.hoveredTime !== null) { + // No valid selected time, only write hovered time + writeCenteredTime(hoveredText!, hoveredX!, 0.35, true); + } + this.lastHoveredTime = command.hoveredTime; + + // Clear overflow & draw graph outline + context.lineWidth = 1; + context.strokeStyle = light ? "#222" : "#eee"; + context.clearRect(0, 0, width, graphTop); + context.clearRect(0, graphTop + graphHeight, width, height - graphTop - graphHeight); + context.clearRect(0, graphTop, graphLeft, graphHeight); + context.clearRect(graphLeft + graphWidth, graphTop, width - graphLeft - graphWidth, graphHeight); + context.strokeRect(graphLeft, graphTop, graphWidth, graphHeight); + + // Render Y axes + context.lineWidth = 1; + context.strokeStyle = light ? "#222" : "#eee"; + context.fillStyle = light ? "#222" : "#eee"; + context.textBaseline = "middle"; + + if (command.showLeftAxis) { + context.textAlign = "right"; + let stepPos = Math.floor(command.leftRange[1] / leftStepSize) * leftStepSize; + while (true) { + let y = scaleValue(stepPos, command.leftRange, [graphTop + graphHeightOpen, graphTop]); + if (y > graphTop + graphHeight) break; + + context.globalAlpha = 1; + if (Math.abs(stepPos) < this.MAX_DECIMAL_VALUE || stepPos % 1 === 0) { + let value = Math.abs(stepPos) < this.MAX_DECIMAL_VALUE ? cleanFloat(stepPos) : Math.round(stepPos); + context.fillText(value.toString(), graphLeft - 15, y); + context.beginPath(); + context.moveTo(graphLeft, y); + context.lineTo(graphLeft - 5, y); + context.stroke(); + } + + if (command.priorityAxis === "left") { + context.globalAlpha = 0.1; + context.beginPath(); + context.moveTo(graphLeft, y); + context.lineTo(graphLeft + graphWidth, y); + context.stroke(); + } + + stepPos -= leftStepSize; + } + } + + if (command.showRightAxis) { + context.textAlign = "left"; + let stepPos = Math.floor(command.rightRange[1] / rightStepSize) * rightStepSize; + while (true) { + let y = scaleValue(stepPos, command.rightRange, [graphTop + graphHeightOpen, graphTop]); + if (y > graphTop + graphHeight) break; + + context.globalAlpha = 1; + if (Math.abs(stepPos) < this.MAX_DECIMAL_VALUE || stepPos % 1 === 0) { + let value = Math.abs(stepPos) < this.MAX_DECIMAL_VALUE ? cleanFloat(stepPos) : Math.round(stepPos); + context.fillText(value.toString(), graphLeft + graphWidth + 15, y); + context.beginPath(); + context.moveTo(graphLeft + graphWidth, y); + context.lineTo(graphLeft + graphWidth + 5, y); + context.stroke(); + } + + if (command.priorityAxis === "right") { + context.globalAlpha = 0.1; + context.beginPath(); + context.moveTo(graphLeft, y); + context.lineTo(graphLeft + graphWidth, y); + context.stroke(); + } + + stepPos -= rightStepSize; + } + } + + // Render x axis + context.textAlign = "center"; + let stepPos = Math.ceil(cleanFloat(timeRange[0] / timeStepSize)) * timeStepSize; + while (true) { + let x = scaleValue(stepPos, timeRange, [graphLeft, graphLeft + graphWidth]); + + // Clean up final x (scroll can cause rounding problems) + if (x - graphLeft - graphWidth > 1) { + break; + } else if (x - graphLeft - graphWidth > 0) { + x = graphLeft + graphWidth; + } + + let text = cleanFloat(stepPos).toString() + "s"; + + context.globalAlpha = 1; + context.fillText(text, x, graphTop + graphHeight + 15); + context.beginPath(); + context.moveTo(x, graphTop + graphHeight); + context.lineTo(x, graphTop + graphHeight + 5); + context.stroke(); + + context.globalAlpha = 0.1; + context.beginPath(); + context.moveTo(x, graphTop); + context.lineTo(x, graphTop + graphHeight); + context.stroke(); + + stepPos += timeStepSize; + } + } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} +} + +export type LineGraphRendererCommand = { + timeRange: [number, number]; + selectionMode: SelectionMode; + selectedTime: number | null; + hoveredTime: number | null; + grabZoomRange: [number, number] | null; + + leftRange: [number, number]; + rightRange: [number, number]; + showLeftAxis: boolean; + showRightAxis: boolean; + priorityAxis: "left" | "right"; + leftFields: LineGraphRendererCommand_NumericField[]; + rightFields: LineGraphRendererCommand_NumericField[]; + discreteFields: LineGraphRendererCommand_DiscreteField[]; +}; + +export type LineGraphRendererCommand_NumericField = { + timestamps: number[]; + values: number[]; + color: string; + type: "smooth" | "stepped" | "points"; + size: "normal" | "bold" | "verybold"; +}; + +export type LineGraphRendererCommand_DiscreteField = { + timestamps: number[]; + values: string[]; + color: string; + type: "stripes" | "graph"; + toggleReference: boolean; +}; diff --git a/src/shared/visualizers/MechanismVisualizer.ts b/src/shared/renderers/MechanismRenderer.ts similarity index 77% rename from src/shared/visualizers/MechanismVisualizer.ts rename to src/shared/renderers/MechanismRenderer.ts index df71f58a..bfd98822 100644 --- a/src/shared/visualizers/MechanismVisualizer.ts +++ b/src/shared/renderers/MechanismRenderer.ts @@ -1,25 +1,31 @@ import { Translation2d } from "../geometry"; import { MechanismState } from "../log/LogUtil"; -import Visualizer from "./Visualizer"; +import TabRenderer from "./TabRenderer"; -export default class MechanismVisualizer implements Visualizer { +export default class MechanismRenderer implements TabRenderer { private CONTAINER: HTMLElement; private SVG: HTMLElement; - constructor(container: HTMLElement) { - this.CONTAINER = container; - this.SVG = container.firstElementChild as HTMLElement; + private aspectRatio = 1; + + constructor(root: HTMLElement) { + this.CONTAINER = root.getElementsByClassName("mechanism-svg-container")[0] as HTMLElement; + this.SVG = root.getElementsByClassName("mechanism-svg")[0] as HTMLElement; } - saveState() { + saveState(): unknown { return null; } - restoreState(): void {} + restoreState(state: unknown): void {} + + getAspectRatio(): number | null { + return this.aspectRatio; + } - render(command: MechanismState | null): number | null { + render(command: MechanismRendererCommand): void { this.SVG.style.display = command === null ? "none" : "initial"; - if (command === null) return null; + if (command === null) return; // Update svg size and background let renderWidth = 0; @@ -34,6 +40,7 @@ export default class MechanismVisualizer implements Visualizer { this.SVG.setAttribute("width", renderWidth.toString()); this.SVG.setAttribute("height", renderHeight.toString()); this.SVG.style.backgroundColor = command.backgroundColor; + this.aspectRatio = command.dimensions[0] / command.dimensions[1]; // Remove old elements while (this.SVG.firstChild) { @@ -69,7 +76,7 @@ export default class MechanismVisualizer implements Visualizer { circle.style.fill = lineData.color; }); }); - - return command.dimensions[0] / command.dimensions[1]; } } + +export type MechanismRendererCommand = MechanismState | null; diff --git a/src/hub/tabControllers/MetadataController.ts b/src/shared/renderers/MetadataRenderer.ts similarity index 56% rename from src/hub/tabControllers/MetadataController.ts rename to src/shared/renderers/MetadataRenderer.ts index 1aba1ca6..74fe5152 100644 --- a/src/hub/tabControllers/MetadataController.ts +++ b/src/shared/renderers/MetadataRenderer.ts @@ -1,63 +1,26 @@ -import { TabState } from "../../shared/HubState"; -import { MERGE_PREFIX, METADATA_KEYS } from "../../shared/log/LogUtil"; -import TabType from "../../shared/TabType"; -import TabController from "../TabController"; +import { MERGE_PREFIX } from "../log/LogUtil"; +import TabRenderer from "./TabRenderer"; -export default class MetadataController implements TabController { +export default class MetadataRenderer implements TabRenderer { private NO_DATA_ALERT: HTMLElement; private TABLE_CONTAINER: HTMLElement; private TABLE_BODY: HTMLElement; private lastDataString: string = ""; - constructor(content: HTMLElement) { - this.NO_DATA_ALERT = content.getElementsByClassName("tab-centered")[0] as HTMLElement; - this.TABLE_CONTAINER = content.getElementsByClassName("metadata-table-container")[0] as HTMLElement; - this.TABLE_BODY = content.getElementsByClassName("metadata-table")[0].firstElementChild as HTMLElement; - this.refresh(); + constructor(root: HTMLElement) { + this.NO_DATA_ALERT = root.getElementsByClassName("tab-centered")[0] as HTMLElement; + this.TABLE_CONTAINER = root.getElementsByClassName("metadata-table-container")[0] as HTMLElement; + this.TABLE_BODY = root.getElementsByClassName("metadata-table")[0].firstElementChild as HTMLElement; } - saveState(): TabState { - return { type: TabType.Metadata }; + getAspectRatio(): number | null { + return null; } - restoreState(state: TabState) {} - - getActiveFields(): string[] { - return METADATA_KEYS; - } - - periodic() {} - - refresh() { - // Get data - let data: { [id: string]: { generic: string | null; real: string | null; replay: string | null } } = {}; - window.log.getFieldKeys().forEach((key) => { - METADATA_KEYS.forEach((metadataKey) => { - if (key.startsWith(metadataKey)) { - let cleanKey = key.slice(metadataKey.length); - if (key.startsWith("/" + MERGE_PREFIX)) { - cleanKey = key.slice(0, key.indexOf("/", MERGE_PREFIX.length + 1)) + cleanKey; - } - if (!(cleanKey in data)) { - data[cleanKey] = { generic: null, real: null, replay: null }; - } - let logData = window.log.getString(key, Infinity, Infinity); - if (logData) { - if (metadataKey.includes("RealMetadata")) { - data[cleanKey]["real"] = logData.values[0]; - } else if (metadataKey.includes("ReplayMetadata")) { - data[cleanKey]["replay"] = logData.values[0]; - } else { - data[cleanKey]["generic"] = logData.values[0]; - } - } - } - }); - }); - + render(command: MetadataRendererCommand): void { // Exit if nothing has changed - let dataString = JSON.stringify(data); + let dataString = JSON.stringify(command); if (dataString === this.lastDataString) { return; } @@ -74,7 +37,7 @@ export default class MetadataController implements TabController { // Update headers let visibleTypes: Set<"generic" | "real" | "replay"> = new Set(); let visibleTypesArr: ("generic" | "real" | "replay")[] = []; - Object.values(data).forEach((value) => { + Object.values(command).forEach((value) => { if (value.generic !== null) visibleTypes.add("generic"); if (value.real !== null) visibleTypes.add("real"); if (value.replay !== null) visibleTypes.add("replay"); @@ -93,7 +56,7 @@ export default class MetadataController implements TabController { } // Add rows - let keys = Object.keys(data); + let keys = Object.keys(command); keys.sort(); keys.sort((a, b) => { if (a.startsWith("/" + MERGE_PREFIX)) a = a.slice(a.indexOf("/", MERGE_PREFIX.length + 1)); @@ -113,7 +76,7 @@ export default class MetadataController implements TabController { if (i === 0) { cell.innerText = key.substring(1); } else { - let value = data[key][visibleTypesArr[visibleTypesIdx]]; + let value = command[key][visibleTypesArr[visibleTypesIdx]]; if (value !== null) { cell.innerText = value; } else { @@ -131,5 +94,13 @@ export default class MetadataController implements TabController { this.TABLE_CONTAINER.hidden = !showTable; } - newAssets() {} + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} } + +export type MetadataRendererCommand = { + [id: string]: { generic: string | null; real: string | null; replay: string | null }; +}; diff --git a/src/shared/renderers/OdometryRenderer.ts b/src/shared/renderers/OdometryRenderer.ts new file mode 100644 index 00000000..a4fd3cba --- /dev/null +++ b/src/shared/renderers/OdometryRenderer.ts @@ -0,0 +1,487 @@ +import { AnnotatedPose2d, Pose2d, SwerveState, Translation2d } from "../geometry"; +import { convert } from "../units"; +import { scaleValue, transformPx } from "../util"; +import Heatmap from "./Heatmap"; +import TabRenderer from "./TabRenderer"; + +export default class OdometryRenderer implements TabRenderer { + private CONTAINER: HTMLElement; + private CANVAS: HTMLCanvasElement; + private IMAGE: HTMLImageElement; + private HEATMAP_CONTAINER: HTMLElement; + + private heatmap: Heatmap; + private lastImageSource = ""; + private aspectRatio = 1; + + constructor(root: HTMLElement) { + this.CONTAINER = root.getElementsByClassName("odometry-canvas-container")[0] as HTMLElement; + this.CANVAS = root.getElementsByClassName("odometry-canvas")[0] as HTMLCanvasElement; + this.IMAGE = document.createElement("img"); + this.HEATMAP_CONTAINER = root.getElementsByClassName("odometry-heatmap-container")[0] as HTMLElement; + this.heatmap = new Heatmap(this.HEATMAP_CONTAINER); + } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} + + getAspectRatio(): number | null { + return this.aspectRatio; + } + + render(command: OdometryRendererCommand): void { + // Set up canvas + let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; + let isVertical = command.orientation === Orientation.DEG_90 || command.orientation === Orientation.DEG_270; + let width = isVertical ? this.CONTAINER.clientHeight : this.CONTAINER.clientWidth; + let height = isVertical ? this.CONTAINER.clientWidth : this.CONTAINER.clientHeight; + this.CANVAS.style.width = width.toString() + "px"; + this.CANVAS.style.height = height.toString() + "px"; + this.CANVAS.width = width * window.devicePixelRatio; + this.CANVAS.height = height * window.devicePixelRatio; + context.scale(window.devicePixelRatio, window.devicePixelRatio); + context.clearRect(0, 0, width, height); + context.lineCap = "round"; + context.lineJoin = "round"; + + // Set canvas transform + switch (command.orientation) { + case Orientation.DEG_0: + this.CANVAS.style.transform = "translate(-50%, -50%) rotate(0deg)"; + break; + case Orientation.DEG_90: + this.CANVAS.style.transform = "translate(-50%, -50%) rotate(-90deg)"; + break; + case Orientation.DEG_180: + this.CANVAS.style.transform = "translate(-50%, -50%) rotate(180deg)"; + break; + case Orientation.DEG_270: + this.CANVAS.style.transform = "translate(-50%, -50%) rotate(90deg)"; + break; + } + + // Get game data and update image element + let gameData = window.assets?.field2ds.find((game) => game.name === command.game); + if (!gameData) return; + if (gameData.path !== this.lastImageSource) { + this.lastImageSource = gameData.path; + this.IMAGE.src = gameData.path; + } + if (!(this.IMAGE.width > 0 && this.IMAGE.height > 0)) return; + + // Determine if objects are flipped + let objectsFlipped = command.origin === "red"; + + // Render background + let fieldWidth = gameData.bottomRight[0] - gameData.topLeft[0]; + let fieldHeight = gameData.bottomRight[1] - gameData.topLeft[1]; + + let topMargin = gameData.topLeft[1]; + let bottomMargin = this.IMAGE.height - gameData.bottomRight[1]; + let leftMargin = gameData.topLeft[0]; + let rightMargin = this.IMAGE.width - gameData.bottomRight[0]; + + let margin = Math.min(topMargin, bottomMargin, leftMargin, rightMargin); + let extendedFieldWidth = fieldWidth + margin * 2; + let extendedFieldHeight = fieldHeight + margin * 2; + let constrainHeight = width / height > extendedFieldWidth / extendedFieldHeight; + let imageScalar: number; + if (constrainHeight) { + imageScalar = height / extendedFieldHeight; + } else { + imageScalar = width / extendedFieldWidth; + } + let fieldCenterX = fieldWidth * 0.5 + gameData.topLeft[0]; + let fieldCenterY = fieldHeight * 0.5 + gameData.topLeft[1]; + let renderValues = [ + Math.floor(width * 0.5 - fieldCenterX * imageScalar), // X (normal) + Math.floor(height * 0.5 - fieldCenterY * imageScalar), // Y (normal) + Math.ceil(width * -0.5 - fieldCenterX * imageScalar), // X (flipped) + Math.ceil(height * -0.5 - fieldCenterY * imageScalar), // Y (flipped) + this.IMAGE.width * imageScalar, // Width + this.IMAGE.height * imageScalar // Height + ]; + context.drawImage(this.IMAGE, renderValues[0], renderValues[1], renderValues[4], renderValues[5]); + this.aspectRatio = isVertical ? fieldHeight / fieldWidth : fieldWidth / fieldHeight; + + // Calculate field edges + let canvasFieldLeft = renderValues[0] + gameData.topLeft[0] * imageScalar; + let canvasFieldTop = renderValues[1] + gameData.topLeft[1] * imageScalar; + let canvasFieldWidth = fieldWidth * imageScalar; + let canvasFieldHeight = fieldHeight * imageScalar; + let pixelsPerInch = (canvasFieldHeight / gameData.heightInches + canvasFieldWidth / gameData.widthInches) / 2; + let robotLengthPixels = pixelsPerInch * command.size; + + // Convert translation to pixel coordinates + let calcCoordinates = (translation: Translation2d): [number, number] => { + if (!gameData) return [0, 0]; + let positionInches = [convert(translation[0], "meters", "inches"), convert(translation[1], "meters", "inches")]; + + positionInches[1] = gameData.heightInches - positionInches[1]; // Positive y is flipped on the canvas + + let positionPixels: [number, number] = [ + positionInches[0] * (canvasFieldWidth / gameData.widthInches), + positionInches[1] * (canvasFieldHeight / gameData.heightInches) + ]; + if (objectsFlipped) { + positionPixels[0] = canvasFieldLeft + canvasFieldWidth - positionPixels[0]; + positionPixels[1] = canvasFieldTop + canvasFieldHeight - positionPixels[1]; + } else { + positionPixels[0] += canvasFieldLeft; + positionPixels[1] += canvasFieldTop; + } + return positionPixels; + }; + + // Function to draw robot + let drawRobot = ( + pose: Pose2d, + swerveStates: { + values: SwerveState[]; + color: string; + }[], + ghostColor?: string + ) => { + let centerPos = calcCoordinates(pose.translation); + let rotation = pose.rotation; + if (objectsFlipped) rotation += Math.PI; + + // Render robot + context.fillStyle = ghostColor !== undefined ? ghostColor : "#222"; + context.strokeStyle = ghostColor !== undefined ? ghostColor : command.bumpers; + context.lineWidth = 3 * pixelsPerInch; + let backLeft = transformPx(centerPos, rotation, [robotLengthPixels * -0.5, robotLengthPixels * 0.5]); + let frontLeft = transformPx(centerPos, rotation, [robotLengthPixels * 0.5, robotLengthPixels * 0.5]); + let frontRight = transformPx(centerPos, rotation, [robotLengthPixels * 0.5, robotLengthPixels * -0.5]); + let backRight = transformPx(centerPos, rotation, [robotLengthPixels * -0.5, robotLengthPixels * -0.5]); + context.beginPath(); + context.moveTo(frontLeft[0], frontLeft[1]); + context.lineTo(frontRight[0], frontRight[1]); + context.lineTo(backRight[0], backRight[1]); + context.lineTo(backLeft[0], backLeft[1]); + context.closePath(); + if (ghostColor === undefined) { + context.fill(); + context.stroke(); + } else { + context.globalAlpha = 0.2; + context.fill(); + context.globalAlpha = 1; + context.stroke(); + } + + // Render arrow + context.strokeStyle = "white"; + context.lineWidth = 1.5 * pixelsPerInch; + let arrowBack = transformPx(centerPos, rotation, [robotLengthPixels * -0.3, 0]); + let arrowFront = transformPx(centerPos, rotation, [robotLengthPixels * 0.3, 0]); + let arrowLeft = transformPx(centerPos, rotation, [robotLengthPixels * 0.15, robotLengthPixels * 0.15]); + let arrowRight = transformPx(centerPos, rotation, [robotLengthPixels * 0.15, robotLengthPixels * -0.15]); + context.beginPath(); + context.moveTo(arrowBack[0], arrowBack[1]); + context.lineTo(arrowFront[0], arrowFront[1]); + context.lineTo(arrowLeft[0], arrowLeft[1]); + context.moveTo(arrowFront[0], arrowFront[1]); + context.lineTo(arrowRight[0], arrowRight[1]); + context.stroke(); + + // Render swerve states + [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1] + ].forEach((corner, index) => { + let moduleCenterPx = transformPx(centerPos, rotation, [ + (robotLengthPixels / 2) * corner[0], + (robotLengthPixels / 2) * corner[1] + ]); + + // Draw module data + let drawModuleData = (state: SwerveState, color: string) => { + let fullRotation = rotation + state.angle; + context.strokeStyle = color; + + // Draw speed + if (Math.abs(state.speed) <= 0.001) return; + let vectorSpeed = state.speed / 5; + let vectorRotation = fullRotation; + if (state.speed < 0) { + vectorSpeed *= -1; + vectorRotation += Math.PI; + } + if (vectorSpeed < 0.05) return; + let vectorLength = pixelsPerInch * 36 * vectorSpeed; + let arrowBack = transformPx(moduleCenterPx, vectorRotation, [0, 0]); + let arrowFront = transformPx(moduleCenterPx, vectorRotation, [vectorLength, 0]); + let arrowLeft = transformPx(moduleCenterPx, vectorRotation, [ + vectorLength - pixelsPerInch * 4, + pixelsPerInch * 4 + ]); + let arrowRight = transformPx(moduleCenterPx, vectorRotation, [ + vectorLength - pixelsPerInch * 4, + pixelsPerInch * -4 + ]); + context.beginPath(); + context.moveTo(...arrowBack); + context.lineTo(...arrowFront); + context.moveTo(...arrowLeft); + context.lineTo(...arrowFront); + context.lineTo(...arrowRight); + context.stroke(); + }; + swerveStates.forEach((set) => { + if (index < set.values.length) { + drawModuleData(set.values[index], set.color); + } + }); + }); + }; + + // Update heatmap data + let heatmapTranslations: Translation2d[] = []; + command.objects + .filter((object) => object.type === "heatmap") + .forEach((object) => { + heatmapTranslations = heatmapTranslations.concat(object.poses.map((pose) => pose.pose.translation)); + }); + this.heatmap.update( + heatmapTranslations, + [canvasFieldWidth, canvasFieldHeight], + [convert(gameData.widthInches, "inches", "meters"), convert(gameData.heightInches, "inches", "meters")], + objectsFlipped + ); + let heatmapCanvas = this.heatmap.getCanvas(); + if (heatmapCanvas !== null) { + context.drawImage(heatmapCanvas, canvasFieldLeft, canvasFieldTop); + } + + // Draw objects + const renderingOrder = ["trajectory", "robot", "ghost", "arrow", "zebra"]; + command.objects + .toSorted((objA, objB) => renderingOrder.indexOf(objA.type) - renderingOrder.indexOf(objB.type)) + .forEach((object) => { + switch (object.type) { + case "trajectory": + context.strokeStyle = "orange"; + context.lineWidth = 2 * pixelsPerInch; + context.lineCap = "round"; + context.lineJoin = "round"; + context.beginPath(); + let firstPoint = true; + object.poses.forEach((pose) => { + if (firstPoint) { + context.moveTo(...calcCoordinates(pose.pose.translation)); + firstPoint = false; + } else { + context.lineTo(...calcCoordinates(pose.pose.translation)); + } + }); + context.stroke(); + break; + case "robot": + object.poses.forEach((pose, index) => { + // Draw trails + let trailCoordinates: [number, number][] = []; + object.trails[index].forEach((translation: Translation2d) => { + let coordinates = calcCoordinates(translation); + trailCoordinates.push(coordinates); + }); + context.strokeStyle = "rgba(170, 170, 170)"; + context.lineCap = "round"; + context.lineJoin = "round"; + trailCoordinates.forEach((position, index) => { + if (index === 0) return; + let previous = trailCoordinates[index - 1]; + let current = position; + let lineWidth = 1 - Math.abs(index - trailCoordinates.length / 2) / (trailCoordinates.length / 2); + if (lineWidth > 0.75) { + lineWidth = 1; + } else { + lineWidth = scaleValue(lineWidth, [0, 0.75], [0, 1]); + } + let lineWidthPixels = lineWidth * pixelsPerInch; + context.lineWidth = lineWidthPixels; + + context.beginPath(); + context.moveTo(previous[0], previous[1]); + context.lineTo(current[0], current[1]); + context.stroke(); + }); + + // Draw vision targets + let robotPos = calcCoordinates(pose.pose.translation); + object.visionTargets.forEach((target: AnnotatedPose2d) => { + context.strokeStyle = + target.annotation.visionColor === undefined ? "#00ff00" : target.annotation.visionColor; + context.lineWidth = 1 * pixelsPerInch; // 1 inch + context.beginPath(); + context.moveTo(robotPos[0], robotPos[1]); + context.lineTo(...calcCoordinates(target.pose.translation)); + context.stroke(); + }); + + // Draw main object + drawRobot(pose.pose, object.swerveStates); + }); + break; + case "ghost": + object.poses.forEach((pose) => { + // Draw vision targets + let robotPos = calcCoordinates(pose.pose.translation); + object.visionTargets.forEach((target: AnnotatedPose2d) => { + context.strokeStyle = + target.annotation.visionColor === undefined ? "#00ff00" : target.annotation.visionColor; + context.lineWidth = 1 * pixelsPerInch; // 1 inch + context.beginPath(); + context.moveTo(robotPos[0], robotPos[1]); + context.lineTo(...calcCoordinates(target.pose.translation)); + context.stroke(); + }); + + // Draw main object + drawRobot(pose.pose, object.swerveStates, object.color); + }); + break; + case "arrow": + let offsetIndex = ["front", "center", "back"].indexOf(object.position); + object.poses.forEach((pose) => { + let position = calcCoordinates(pose.pose.translation); + let rotation = pose.pose.rotation; + if (objectsFlipped) rotation += Math.PI; + + context.strokeStyle = "white"; + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = 1.5 * pixelsPerInch; + let arrowBack = transformPx(position, rotation, [robotLengthPixels * (-0.6 + 0.3 * offsetIndex), 0]); + let arrowFront = transformPx(position, rotation, [robotLengthPixels * (0.3 * offsetIndex), 0]); + let arrowLeft = transformPx(position, rotation, [ + robotLengthPixels * (-0.15 + 0.3 * offsetIndex), + robotLengthPixels * 0.15 + ]); + let arrowRight = transformPx(position, rotation, [ + robotLengthPixels * (-0.15 + 0.3 * offsetIndex), + robotLengthPixels * -0.15 + ]); + let crossbarLeft = transformPx(position, rotation, [ + 0, + robotLengthPixels * (offsetIndex === 0 ? 0.15 : 0.1) + ]); + let crossbarRight = transformPx(position, rotation, [ + 0, + robotLengthPixels * -(offsetIndex === 0 ? 0.15 : 0.1) + ]); + context.beginPath(); + context.moveTo(arrowBack[0], arrowBack[1]); + context.lineTo(arrowFront[0], arrowFront[1]); + context.lineTo(arrowLeft[0], arrowLeft[1]); + context.moveTo(arrowFront[0], arrowFront[1]); + context.lineTo(arrowRight[0], arrowRight[1]); + context.stroke(); + context.beginPath(); + context.moveTo(crossbarLeft[0], crossbarLeft[1]); + context.lineTo(crossbarRight[0], crossbarRight[1]); + context.stroke(); + }); + break; + case "zebra": + context.font = + Math.round(12 * pixelsPerInch).toString() + + "px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont"; + object.poses.forEach((pose) => { + let coordinates = calcCoordinates(pose.pose.translation); + + if (!pose.annotation.zebraAlliance) return; + context.fillStyle = pose.annotation.zebraAlliance; + context.strokeStyle = "white"; + context.lineWidth = 2 * pixelsPerInch; + context.beginPath(); + context.arc(coordinates[0], coordinates[1], 6 * pixelsPerInch, 0, Math.PI * 2); + context.fill(); + context.stroke(); + + if (!pose.annotation.zebraTeam) return; + context.fillStyle = "white"; + context.textAlign = "center"; + context.fillText( + pose.annotation.zebraTeam.toString(), + coordinates[0], + coordinates[1] - 15 * pixelsPerInch + ); + }); + break; + } + }); + } +} + +export enum Orientation { + DEG_0 = 0, + DEG_90 = 1, + DEG_180 = 2, + DEG_270 = 3 +} + +export type OdometryRendererCommand = { + game: string; + bumpers: "blue" | "red"; + origin: "blue" | "red"; + orientation: Orientation; + size: 30 | 27 | 24; + objects: OdometryRendererCommand_AnyObj[]; +}; + +export type OdometryRendererCommand_AnyObj = + | OdometryRendererCommand_RobotObj + | OdometryRendererCommand_GhostObj + | OdometryRendererCommand_TrajectoryObj + | OdometryRendererCommand_HeatmapObj + | OdometryRendererCommand_ArrowObj + | OdometryRendererCommand_ZebraMarkerObj; + +export type OdometryRendererCommand_RobotObj = { + type: "robot"; + poses: AnnotatedPose2d[]; + trails: Translation2d[][]; + visionTargets: AnnotatedPose2d[]; + swerveStates: { + values: SwerveState[]; + color: string; + }[]; +}; + +export type OdometryRendererCommand_GhostObj = { + type: "ghost"; + poses: AnnotatedPose2d[]; + color: string; + visionTargets: AnnotatedPose2d[]; + swerveStates: { + values: SwerveState[]; + color: string; + }[]; +}; + +export type OdometryRendererCommand_TrajectoryObj = { + type: "trajectory"; + poses: AnnotatedPose2d[]; +}; + +export type OdometryRendererCommand_HeatmapObj = { + type: "heatmap"; + poses: AnnotatedPose2d[]; +}; + +export type OdometryRendererCommand_ArrowObj = { + type: "arrow"; + poses: AnnotatedPose2d[]; + position: "center" | "back" | "front"; +}; + +export type OdometryRendererCommand_ZebraMarkerObj = { + type: "zebra"; + poses: AnnotatedPose2d[]; +}; diff --git a/src/shared/renderers/PointsRenderer.ts b/src/shared/renderers/PointsRenderer.ts new file mode 100644 index 00000000..e9a12707 --- /dev/null +++ b/src/shared/renderers/PointsRenderer.ts @@ -0,0 +1,122 @@ +import { GraphColors } from "../Colors"; +import TabRenderer from "./TabRenderer"; + +export default class PointsRenderer implements TabRenderer { + private CONTAINER: HTMLElement; + private BACKGROUND: HTMLElement; + private TEMPLATES: HTMLElement; + + private aspectRatio = 1; + + constructor(root: HTMLElement) { + this.CONTAINER = root.firstElementChild as HTMLElement; + this.BACKGROUND = this.CONTAINER.children[0] as HTMLElement; + this.TEMPLATES = this.CONTAINER.children[1] as HTMLElement; + } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} + + getAspectRatio(): number | null { + return this.aspectRatio; + } + + render(command: PointsRendererCommand): void { + // Update background size + let containerWidth = this.CONTAINER.getBoundingClientRect().width; + let containerHeight = this.CONTAINER.getBoundingClientRect().height; + let targetWidth = command.dimensions[0]; + let targetHeight = command.dimensions[1]; + if (targetWidth < 1) targetWidth = 1; + if (targetHeight < 1) targetHeight = 1; + this.aspectRatio = targetWidth / targetHeight; + + let finalWidth: number; + let finalHeight: number; + if (targetWidth / targetHeight < containerWidth / containerHeight) { + finalHeight = containerHeight; + finalWidth = containerHeight * (targetWidth / targetHeight); + } else { + finalWidth = containerWidth; + finalHeight = containerWidth * (targetHeight / targetWidth); + } + + this.BACKGROUND.style.width = Math.ceil(finalWidth + 1).toString() + "px"; + this.BACKGROUND.style.height = Math.ceil(finalHeight + 1).toString() + "px"; + + // Clear old points + while (this.BACKGROUND.firstChild) { + this.BACKGROUND.removeChild(this.BACKGROUND.firstChild); + } + + // Render new points + command.sets.forEach((set) => { + set.points.forEach((position, index) => { + if ( + position[0] < 0 || + position[0] > command.dimensions[0] || + position[1] < 0 || + position[1] > command.dimensions[1] + ) { + return; + } + position[0] = (position[0] / command.dimensions[0]) * finalWidth; + position[1] = (position[1] / command.dimensions[1]) * finalHeight; + + // Create point + let point: HTMLElement | null = null; + switch (set.shape) { + case "plus": + point = this.TEMPLATES.children[0].cloneNode(true) as HTMLElement; + break; + case "cross": + point = this.TEMPLATES.children[1].cloneNode(true) as HTMLElement; + break; + case "circle": + point = this.TEMPLATES.children[2].cloneNode(true) as HTMLElement; + break; + } + if (!point) return; + switch (set.size) { + case "large": + point.style.transform = "translate(-50%,-50%) scale(1, 1)"; + break; + case "medium": + point.style.transform = "translate(-50%,-50%) scale(0.5, 0.5)"; + break; + case "small": + point.style.transform = "translate(-50%,-50%) scale(0.25, 0.25)"; + break; + } + + // Set color + let color = ""; + if (set.groupSize < 1) { + color = window.matchMedia("(prefers-color-scheme: dark)").matches ? "white" : "black"; + } else { + color = GraphColors[Math.floor(index / set.groupSize) % GraphColors.length].key; + } + point.style.fill = color; + point.style.stroke = color; + + // Set coordinates and append + point.style.left = position[0].toString() + "px"; + point.style.top = position[1].toString() + "px"; + this.BACKGROUND.appendChild(point); + }); + }); + } +} + +export type PointsRendererCommand = { + dimensions: [number, number]; + sets: { + points: [number, number][]; + shape: "plus" | "cross" | "circle"; + size: "small" | "medium" | "large"; + groupSize: number; + }[]; +}; diff --git a/src/shared/renderers/StatisticsRenderer.ts b/src/shared/renderers/StatisticsRenderer.ts new file mode 100644 index 00000000..ff8602cf --- /dev/null +++ b/src/shared/renderers/StatisticsRenderer.ts @@ -0,0 +1,240 @@ +import { Chart, ChartDataset, LegendOptions, LinearScaleOptions, TooltipCallbacks, registerables } from "chart.js"; +import { ensureThemeContrast } from "../Colors"; +import { cleanFloat } from "../util"; +import TabRenderer from "./TabRenderer"; + +export default class StatisticsRenderer implements TabRenderer { + private static registeredChart = false; + + private VALUES_TABLE_CONTAINER: HTMLElement; + private VALUES_TABLE_BODY: HTMLElement; + private HISTOGRAM_CONTAINER: HTMLElement; + + private changeCounter = -1; + private firstRender = true; + private lastIsLight: boolean | null = null; + private histogram: Chart; + + /** Registers all Chart.js elements. */ + private static registerChart() { + if (!this.registeredChart) { + this.registeredChart = true; + Chart.register(...registerables); + } + } + + constructor(root: HTMLElement) { + this.VALUES_TABLE_CONTAINER = root.getElementsByClassName("stats-values-container")[0] as HTMLElement; + this.VALUES_TABLE_BODY = this.VALUES_TABLE_CONTAINER.firstElementChild?.firstElementChild as HTMLElement; + this.HISTOGRAM_CONTAINER = root.getElementsByClassName("stats-histogram-container")[0] as HTMLElement; + + // Create chart + StatisticsRenderer.registerChart(); + this.histogram = new Chart(this.HISTOGRAM_CONTAINER.firstElementChild as HTMLCanvasElement, { + type: "bar", + data: { + labels: [], + datasets: [] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 0 + }, + plugins: { + legend: { + onClick: () => {} + } + }, + scales: { + x: { + type: "linear", + stacked: true, + offset: false, + grid: { + offset: false + }, + ticks: { + stepSize: 1 + } + }, + y: { + stacked: true, + grace: 0.1 + } + } + } + }); + } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} + + getAspectRatio(): number | null { + return null; + } + + render(command: StatisticsRendererCommand): void { + // Update histogram layout + this.HISTOGRAM_CONTAINER.style.left = (this.VALUES_TABLE_CONTAINER.offsetWidth + 10).toString() + "px"; + + // Update histogram colors + const isLight = !window.matchMedia("(prefers-color-scheme: dark)").matches; + if (isLight !== this.lastIsLight) { + this.lastIsLight = isLight; + (this.histogram.options.plugins!.legend as LegendOptions<"bar">).labels.color = isLight ? "#222" : "#eee"; + let xAxisOptions = this.histogram.options.scales!.x as LinearScaleOptions; + let yAxisOptions = this.histogram.options.scales!.y as LinearScaleOptions; + xAxisOptions.ticks.color = isLight ? "#222" : "#eee"; + yAxisOptions.ticks.color = isLight ? "#222" : "#eee"; + xAxisOptions.border.color = isLight ? "#222" : "#eee"; + yAxisOptions.border.color = isLight ? "#222" : "#eee"; + xAxisOptions.grid.color = isLight ? "#eee" : "#333"; + yAxisOptions.grid.color = isLight ? "#eee" : "#333"; + this.histogram.update(); + } + + // Update data + if (command.changeCounter !== this.changeCounter || this.firstRender) { + this.firstRender = false; + this.changeCounter = command.changeCounter; + + // Clear values + while (this.VALUES_TABLE_BODY.firstChild) { + this.VALUES_TABLE_BODY.removeChild(this.VALUES_TABLE_BODY.firstChild); + } + + // Add a new section header + let addSection = (title: string) => { + let row = document.createElement("tr"); + this.VALUES_TABLE_BODY.appendChild(row); + row.classList.add("section"); + let cell = document.createElement("td"); + row.appendChild(cell); + cell.colSpan = Math.max(1 + command.fields.length, 2); + cell.innerText = title; + }; + + // Add a new row with data + let addValues = (title: string, digits: number, getValue: (stats: StatisticsRendererCommand_Stats) => number) => { + let row = document.createElement("tr"); + this.VALUES_TABLE_BODY.appendChild(row); + row.classList.add("values"); + let titleCell = document.createElement("td"); + row.appendChild(titleCell); + titleCell.innerText = title; + command.fields.forEach((field) => { + let color = ensureThemeContrast(field.color); + let valueCell = document.createElement("td"); + row.appendChild(valueCell); + let value = getValue(field.stats); + if (isNaN(value)) { + valueCell.innerText = "-"; + } else { + valueCell.innerText = value.toFixed(digits); + } + valueCell.style.color = color; + }); + if (command.fields.length === 0) { + let valueCell = document.createElement("td"); + row.appendChild(valueCell); + valueCell.innerText = "-"; + } + }; + + // Add all rows + addSection("Summary"); + addValues("Count", 0, (x) => x.count); + addValues("Min", 3, (x) => x.min); + addValues("Max", 3, (x) => x.max); + addSection("Center"); + addValues("Mean", 3, (x) => x.mean); + addValues("Median", 3, (x) => x.median); + addValues("Mode", 3, (x) => x.mode); + addValues("Geometric Mean", 3, (x) => x.geometricMean); + addValues("Harmonic Mean", 3, (x) => x.harmonicMean); + addValues("Quadratic Mean", 3, (x) => x.quadraticMean); + addSection("Spread"); + addValues("Standard Deviation", 3, (x) => x.standardDeviation); + addValues("Median Absolute Deviation", 3, (x) => x.medianAbsoluteDeviation); + addValues("Interquartile Range", 3, (x) => x.interquartileRange); + addValues("Skewness", 3, (x) => x.skewness); + addSection("Percentiles"); + addValues("1st Percentile", 3, (x) => x.percentile01); + addValues("5th Percentile", 3, (x) => x.percentile05); + addValues("10th Percentile", 3, (x) => x.percentile10); + addValues("25th Percentile", 3, (x) => x.percentile25); + addValues("50th Percentile", 3, (x) => x.percentile50); + addValues("75th Percentile", 3, (x) => x.percentile75); + addValues("90th Percentile", 3, (x) => x.percentile90); + addValues("95th Percentile", 3, (x) => x.percentile95); + addValues("99th Percentile", 3, (x) => x.percentile99); + + // Update histogram data + this.histogram.data.labels = command.bins.map((value) => value + command.stepSize / 2); + this.histogram.data.datasets = command.fields.map((field) => { + const dataset: ChartDataset = { + label: field.title.length > 20 ? "..." + field.title.slice(-20) : field.title, + data: field.histogramCounts, + backgroundColor: ensureThemeContrast(field.color), + barPercentage: 1, + categoryPercentage: 1 + }; + return dataset; + }); + (this.histogram.options.scales!.x as LinearScaleOptions).ticks.stepSize = command.stepSize; + (this.histogram.options.plugins!.tooltip!.callbacks as TooltipCallbacks<"bar">).title = (items) => { + if (items.length < 1) { + return ""; + } + const item = items[0]; + const x = item.parsed.x; + const min = x - command.stepSize / 2; + const max = x + command.stepSize / 2; + return cleanFloat(min).toString() + " to " + cleanFloat(max).toString(); + }; + this.histogram.update(); + } + } +} + +export type StatisticsRendererCommand = { + changeCounter: number; + bins: number[]; + stepSize: number; + fields: { + title: string; + color: string; + histogramCounts: number[]; + stats: StatisticsRendererCommand_Stats; + }[]; +}; + +export type StatisticsRendererCommand_Stats = { + count: number; + min: number; + max: number; + mean: number; + median: number; + mode: number; + geometricMean: number; + harmonicMean: number; + quadraticMean: number; + standardDeviation: number; + medianAbsoluteDeviation: number; + interquartileRange: number; + skewness: number; + percentile01: number; + percentile05: number; + percentile10: number; + percentile25: number; + percentile50: number; + percentile75: number; + percentile90: number; + percentile95: number; + percentile99: number; +}; diff --git a/src/shared/renderers/SwerveRenderer.ts b/src/shared/renderers/SwerveRenderer.ts new file mode 100644 index 00000000..2733432c --- /dev/null +++ b/src/shared/renderers/SwerveRenderer.ts @@ -0,0 +1,254 @@ +import { ChassisSpeeds, Rotation2d, SwerveState } from "../geometry"; +import { transformPx, wrapRadians } from "../util"; +import TabRenderer from "./TabRenderer"; + +export default class SwerveRenderer implements TabRenderer { + private CONTAINER: HTMLElement; + private CANVAS: HTMLCanvasElement; + + private BLACK_COLOR = "#222222"; + private WHITE_COLOR = "#eeeeee"; + + constructor(root: HTMLElement) { + this.CONTAINER = root.firstElementChild as HTMLElement; + this.CANVAS = this.CONTAINER.firstElementChild as HTMLCanvasElement; + } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} + + getAspectRatio(): number | null { + return 1; + } + + render(command: SwerveRendererCommand): void { + // Update canvas size + let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; + let size = Math.min(this.CONTAINER.clientWidth, this.CONTAINER.clientHeight); + this.CANVAS.style.width = size.toString() + "px"; + this.CANVAS.style.height = size.toString() + "px"; + this.CANVAS.width = size * window.devicePixelRatio; + this.CANVAS.height = size * window.devicePixelRatio; + context.scale(window.devicePixelRatio, window.devicePixelRatio); + context.clearRect(0, 0, size, size); + context.lineCap = "round"; + context.lineJoin = "round"; + let centerPx: [number, number] = [size / 2, size / 2]; + let isLight = !window.matchMedia("(prefers-color-scheme: dark)").matches; + let strokeColor = isLight ? this.BLACK_COLOR : this.WHITE_COLOR; + + // Calculate component dimensions + let frameWidthPx = size * 0.3 * Math.min(command.frameAspectRatio, 1); + let frameHeightPx = (size * 0.3) / Math.max(command.frameAspectRatio, 1); + let moduleRadiusPx = size * 0.05; + let fullVectorPx = size * 0.25; + + // Draw frame + context.strokeStyle = strokeColor; + context.lineWidth = 4; + context.beginPath(); + context.moveTo(...transformPx(centerPx, command.rotation, [frameHeightPx / 2, frameWidthPx / 2 - moduleRadiusPx])); + context.lineTo(...transformPx(centerPx, command.rotation, [frameHeightPx / 2, -frameWidthPx / 2 + moduleRadiusPx])); + context.stroke(); + context.beginPath(); + context.moveTo(...transformPx(centerPx, command.rotation, [frameHeightPx / 2 - moduleRadiusPx, -frameWidthPx / 2])); + context.lineTo( + ...transformPx(centerPx, command.rotation, [-frameHeightPx / 2 + moduleRadiusPx, -frameWidthPx / 2]) + ); + context.stroke(); + context.beginPath(); + context.moveTo(...transformPx(centerPx, command.rotation, [frameHeightPx / 2 - moduleRadiusPx, frameWidthPx / 2])); + context.lineTo(...transformPx(centerPx, command.rotation, [-frameHeightPx / 2 + moduleRadiusPx, frameWidthPx / 2])); + context.stroke(); + context.beginPath(); + context.moveTo(...transformPx(centerPx, command.rotation, [-frameHeightPx / 2, frameWidthPx / 2 - moduleRadiusPx])); + context.lineTo( + ...transformPx(centerPx, command.rotation, [-frameHeightPx / 2, -frameWidthPx / 2 + moduleRadiusPx]) + ); + context.stroke(); + + // Draw arrow on robot + context.strokeStyle = strokeColor; + context.lineWidth = 4; + const hasSpeeds = command.speeds.length > 0; + let arrowBack = transformPx( + centerPx, + command.rotation, + hasSpeeds ? [frameHeightPx * 0.5, 0] : [frameHeightPx * -0.3, 0] + ); + let arrowFront = transformPx( + centerPx, + command.rotation, + hasSpeeds ? [frameHeightPx * 0.8, 0] : [frameHeightPx * 0.3, 0] + ); + let arrowLeft = transformPx( + centerPx, + command.rotation, + hasSpeeds ? [frameHeightPx * 0.65, frameWidthPx * 0.15] : [frameHeightPx * 0.15, frameWidthPx * 0.15] + ); + let arrowRight = transformPx( + centerPx, + command.rotation, + hasSpeeds ? [frameHeightPx * 0.65, frameWidthPx * -0.15] : [frameHeightPx * 0.15, frameWidthPx * -0.15] + ); + context.beginPath(); + context.moveTo(...arrowBack); + context.lineTo(...arrowFront); + context.moveTo(...arrowLeft); + context.lineTo(...arrowFront); + context.lineTo(...arrowRight); + context.stroke(); + + // Draw modules + [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1] + ].forEach((corner, index) => { + let moduleCenterPx = transformPx(centerPx, command.rotation, [ + (frameHeightPx / 2) * corner[0], + (frameWidthPx / 2) * corner[1] + ]); + + // Draw module data + let drawModuleData = (state: SwerveState, color: string) => { + let fullRotation = command.rotation + state.angle; + context.fillStyle = color; + context.strokeStyle = color; + context.lineWidth = 4; + + // Draw rotation + context.beginPath(); + if (command.states.length >= 2) { + context.moveTo(...moduleCenterPx); + } else { + context.moveTo(...transformPx(moduleCenterPx, fullRotation, [moduleRadiusPx, 0])); + } + context.arc( + ...moduleCenterPx, + moduleRadiusPx, + -wrapRadians(fullRotation - (5 * Math.PI) / 6), + -wrapRadians(fullRotation + (5 * Math.PI) / 6) + ); + context.closePath(); + context.fill(); + + // Draw speed + if (Math.abs(state.speed) <= 0.001) return; + let vectorSpeed = state.speed; + let vectorRotation = fullRotation; + if (state.speed < 0) { + vectorSpeed *= -1; + vectorRotation += Math.PI; + } + if (vectorSpeed < 0.05) return; + let vectorLength = fullVectorPx * vectorSpeed; + let arrowBack = transformPx(moduleCenterPx, vectorRotation, [moduleRadiusPx, 0]); + let arrowFront = transformPx(moduleCenterPx, vectorRotation, [moduleRadiusPx + vectorLength, 0]); + let arrowLeft = transformPx(moduleCenterPx, vectorRotation, [ + moduleRadiusPx + vectorLength - moduleRadiusPx * 0.4, + moduleRadiusPx * 0.4 + ]); + let arrowRight = transformPx(moduleCenterPx, vectorRotation, [ + moduleRadiusPx + vectorLength - moduleRadiusPx * 0.4, + moduleRadiusPx * -0.4 + ]); + context.beginPath(); + context.moveTo(...arrowBack); + context.lineTo(...arrowFront); + context.moveTo(...arrowLeft); + context.lineTo(...arrowFront); + context.lineTo(...arrowRight); + context.stroke(); + }; + command.states.forEach((set) => { + if (index < set.values.length) { + drawModuleData(set.values[index], set.color); + } + }); + + // Draw module outline + context.strokeStyle = strokeColor; + context.lineWidth = 4; + context.beginPath(); + context.arc(...moduleCenterPx, moduleRadiusPx, 0, Math.PI * 2); + context.stroke(); + }); + + // Draw chassis speeds + command.speeds.forEach((speed) => { + context.strokeStyle = speed.color; + context.lineWidth = 4; + + // Linear speed + let angle = Math.atan2(speed.value.vy, speed.value.vx); + let length = Math.hypot(speed.value.vx, speed.value.vy); + if (length < 0.05) return; + length *= fullVectorPx; + + let arrowBack = transformPx(centerPx, command.rotation + angle, [0, 0]); + let arrowFront = transformPx(centerPx, command.rotation + angle, [length, 0]); + let arrowLeft = transformPx(centerPx, command.rotation + angle, [ + length - moduleRadiusPx * 0.4, + moduleRadiusPx * 0.4 + ]); + let arrowRight = transformPx(centerPx, command.rotation + angle, [ + length - moduleRadiusPx * 0.4, + moduleRadiusPx * -0.4 + ]); + context.beginPath(); + context.moveTo(...arrowBack); + context.lineTo(...arrowFront); + context.moveTo(...arrowLeft); + context.lineTo(...arrowFront); + context.lineTo(...arrowRight); + context.stroke(); + + // Angular speed + if (Math.abs(speed.value.omega) > 0.1) { + context.beginPath(); + context.arc( + centerPx[0], + centerPx[1], + frameWidthPx * 0.25, + -command.rotation, + -(command.rotation + speed.value.omega), + speed.value.omega > 0 + ); + let arrowFront = transformPx(centerPx, command.rotation + speed.value.omega, [frameWidthPx * 0.25, 0]); + let arrowLeft = transformPx( + centerPx, + command.rotation + speed.value.omega - 0.3 * Math.sign(speed.value.omega), + [frameWidthPx * 0.25 - moduleRadiusPx * 0.4, 0] + ); + let arrowRight = transformPx( + centerPx, + command.rotation + speed.value.omega - 0.3 * Math.sign(speed.value.omega), + [frameWidthPx * 0.25 + moduleRadiusPx * 0.4, 0] + ); + context.lineTo(...arrowFront); + context.moveTo(...arrowLeft); + context.lineTo(...arrowFront); + context.lineTo(...arrowRight); + context.stroke(); + } + }); + } +} + +export type SwerveRendererCommand = { + rotation: Rotation2d; + frameAspectRatio: number; + states: { + values: SwerveState[]; + color: string; + }[]; + speeds: { + value: ChassisSpeeds; + color: string; + }[]; +}; diff --git a/src/shared/renderers/TabRenderer.ts b/src/shared/renderers/TabRenderer.ts new file mode 100644 index 00000000..b81209cf --- /dev/null +++ b/src/shared/renderers/TabRenderer.ts @@ -0,0 +1,27 @@ +export default interface TabRenderer { + /** Returns the current state. */ + saveState(): unknown; + + /** Restores to the provided state. */ + restoreState(state: unknown): void; + + /** Get the desired window aspect ratio for satellites. */ + getAspectRatio(): number | null; + + /** Called once per frame. */ + render(command: unknown): void; +} + +export class NoopRenderer implements TabRenderer { + saveState(): unknown { + return null; + } + + restoreState(): void {} + + getAspectRatio(): number | null { + return null; + } + + render(): void {} +} diff --git a/src/hub/tabControllers/TableController.ts b/src/shared/renderers/TableRenderer.ts similarity index 59% rename from src/hub/tabControllers/TableController.ts rename to src/shared/renderers/TableRenderer.ts index 82fc404d..aebcde0c 100644 --- a/src/hub/tabControllers/TableController.ts +++ b/src/shared/renderers/TableRenderer.ts @@ -1,49 +1,49 @@ -import { TableState } from "../../shared/HubState"; -import LoggableType from "../../shared/log/LoggableType"; -import { getLogValueText } from "../../shared/log/LogUtil"; -import { LogValueSetAny } from "../../shared/log/LogValueSets"; -import TabType from "../../shared/TabType"; -import { arraysEqual, createUUID, formatTimeWithMS } from "../../shared/util"; import { SelectionMode } from "../Selection"; -import TabController from "../TabController"; +import { getLogValueText } from "../log/LogUtil"; +import { LogValueSetAny } from "../log/LogValueSets"; +import LoggableType from "../log/LoggableType"; +import { arraysEqual, createUUID, formatTimeWithMS } from "../util"; +import TabRenderer from "./TabRenderer"; -export default class TableController implements TabController { - private CONTENT: HTMLElement; - private NO_DATA_ALERT: HTMLElement; +export default class TableRenderer implements TabRenderer { + readonly UUID = createUUID(); + + private ROOT: HTMLElement; private HEADER_TEMPLATE: HTMLElement; private TABLE_CONTAINER: HTMLElement; private TABLE_BODY: HTMLElement; - private DRAG_HIGHLIGHT: HTMLElement; private INPUT_FIELD: HTMLInputElement; + private HAND_ICON: HTMLElement; - private UUID = createUUID(); private ROW_HEIGHT_PX = 25; // May be adjusted later based on platform private DATA_ROW_BUFFER = 15; + private hasController: boolean; private fillerUpper: HTMLElement; private dataRows: HTMLElement[] = []; private dataRowTimestamps: number[] = []; private fillerLower: HTMLElement; - private fields: string[] = []; private timestamps: number[] = []; - private lastLogFieldList: string[] = []; + private lastFields: string[] = []; + private lastFieldsAvailable: boolean[] = []; private lastScrollPosition: number | null = null; private hoverCursorY: number | null = null; - - constructor(content: HTMLElement) { - this.CONTENT = content; - this.NO_DATA_ALERT = content.getElementsByClassName("tab-centered")[0] as HTMLElement; - this.HEADER_TEMPLATE = content.getElementsByClassName("data-table-header-template")[0] as HTMLElement; - this.TABLE_CONTAINER = content.getElementsByClassName("data-table-container")[0] as HTMLElement; - this.TABLE_BODY = content.getElementsByClassName("data-table")[0].firstElementChild as HTMLElement; - this.DRAG_HIGHLIGHT = content.getElementsByClassName("data-table-drag-highlight")[0] as HTMLElement; - this.INPUT_FIELD = content.getElementsByClassName("data-table-jump-input")[0] as HTMLInputElement; - - // Drag handling - window.addEventListener("drag-update", (event) => { - this.handleDrag((event as CustomEvent).detail); - }); + private didClearHoveredTime = false; + private timestampRange: [number, number] | null = null; + + private selectionMode: SelectionMode = SelectionMode.Idle; + private selectedTime: number | null = null; + private hoveredTime: number | null = null; + + constructor(root: HTMLElement, hasController: boolean) { + this.ROOT = root; + this.hasController = hasController; + this.HEADER_TEMPLATE = root.getElementsByClassName("data-table-header-template")[0] as HTMLElement; + this.TABLE_CONTAINER = root.getElementsByClassName("data-table-container")[0] as HTMLElement; + this.TABLE_BODY = root.getElementsByClassName("data-table")[0].firstElementChild as HTMLElement; + this.INPUT_FIELD = root.getElementsByClassName("data-table-jump-input")[0] as HTMLInputElement; + this.HAND_ICON = root.getElementsByClassName("large-table-hand-icon")[0] as HTMLElement; // Create filler elements { @@ -63,8 +63,8 @@ export default class TableController implements TabController { let jump = () => { let targetTime = Number(this.INPUT_FIELD.value); if (this.INPUT_FIELD.value === "") { - if (window.selection.getMode() !== SelectionMode.Idle) { - targetTime = window.selection.getSelectedTime() as number; + if (this.selectionMode !== SelectionMode.Idle) { + targetTime = this.selectedTime as number; } else { targetTime = 0; window.selection.setSelectedTime(0); @@ -72,12 +72,13 @@ export default class TableController implements TabController { } else { window.selection.setSelectedTime(targetTime); } + this.selectedTime = targetTime; this.scrollToSelected(); }; this.INPUT_FIELD.addEventListener("keydown", (event) => { if (event.code === "Enter") jump(); }); - content.getElementsByClassName("data-table-jump-button")[0].addEventListener("click", jump); + root.getElementsByClassName("data-table-jump-button")[0].addEventListener("click", jump); // Bind hover controls this.TABLE_BODY.addEventListener("mousemove", (event) => { @@ -86,75 +87,9 @@ export default class TableController implements TabController { this.TABLE_BODY.addEventListener("mouseleave", () => { this.hoverCursorY = null; }); - } - - saveState(): TableState { - return { type: TabType.Table, fields: this.fields }; - } - - restoreState(state: TableState) { - this.fields = state.fields; - this.updateFields(); - } - - refresh() { - // Check if field list changed - let fieldList = window.log.getFieldKeys(); - if (arraysEqual(fieldList, this.lastLogFieldList)) return; - this.lastLogFieldList = fieldList; - this.updateFields(); - } - - newAssets() {} - - /** Processes a drag event, including adding a field if necessary. */ - private handleDrag(dragData: any) { - if (this.CONTENT.hidden) return; - // Remove empty fields - let dragFields = dragData.data.fields as string[]; - dragFields = dragFields.filter((field) => window.log.getType(field) !== LoggableType.Empty); - if (dragFields.length === 0) return; - - // Find selected section - let header = this.TABLE_BODY.firstElementChild as HTMLElement; - let tableBox = this.TABLE_CONTAINER.getBoundingClientRect(); - let selected: number | null = null; - let selectedX: number | null = null; - if (dragData.y > tableBox.y) { - for (let i = 0; i < header.childElementCount; i++) { - let targetX = 0; - if (i === 0 && this.fields.length > 0) { - targetX = header.children[1].getBoundingClientRect().left; - } else { - targetX = header.children[i].getBoundingClientRect().right; - } - if (targetX < (header.firstElementChild as HTMLElement).getBoundingClientRect().right) continue; - let leftBound = i === 0 ? tableBox.x : targetX - header.children[i].getBoundingClientRect().width / 2; - let rightBound = - i === header.childElementCount - 1 - ? Infinity - : targetX + header.children[i + 1].getBoundingClientRect().width / 2; - if (leftBound < dragData.x && rightBound > dragData.x) { - selected = i; - selectedX = targetX; - } - } - } - - // Update highlight or add field - if (dragData.end) { - this.DRAG_HIGHLIGHT.hidden = true; - if (selected !== null) { - this.fields.splice(selected, 0, ...dragFields); - this.updateFields(); - } - } else { - this.DRAG_HIGHLIGHT.hidden = selected === null; - if (selected !== null && selectedX !== null) { - this.DRAG_HIGHLIGHT.style.left = (selectedX - tableBox.x - 12.5).toString() + "px"; - } - } + // Initialize columns + this.updateFields([]); } /** Scrolls such that the selected time is in view. @@ -162,9 +97,8 @@ export default class TableController implements TabController { * @returns Whether the position was updated */ private scrollToSelected() { - const selectedTime = window.selection.getSelectedTime(); - if (selectedTime !== null) { - let targetRow = this.timestamps.findLastIndex((timestamp) => timestamp <= selectedTime); + if (this.selectedTime !== null) { + let targetRow = this.timestamps.findLastIndex((timestamp) => timestamp <= this.selectedTime!); if (targetRow === -1) targetRow = 0; const visibleHeight = this.TABLE_CONTAINER.clientHeight - this.TABLE_BODY.firstElementChild!.clientHeight; @@ -181,9 +115,11 @@ export default class TableController implements TabController { } /** Updates the table based on the current field list */ - private updateFields() { - // Update no data alert - this.NO_DATA_ALERT.hidden = this.fields.length > 0; + private updateFields(fields: TableRendererCommand["fields"]) { + // Update hand icon + let showHand = this.hasController && (fields.length === 0 || fields.every((field) => !field.isAvailable)); + this.HAND_ICON.style.transition = showHand ? "opacity 1s ease-in 1s" : ""; + this.HAND_ICON.style.opacity = showHand ? "0.15" : "0"; // Clear old header cells let header = this.TABLE_BODY.firstElementChild as HTMLElement; @@ -192,29 +128,31 @@ export default class TableController implements TabController { } // Add new header cells - this.fields.forEach((field, index) => { + fields.forEach((field, index) => { let cell = document.createElement("th"); Array.from(this.HEADER_TEMPLATE.children).forEach((element) => { cell.appendChild(element.cloneNode(true)); }); header.appendChild(cell); - let textElement = (cell.firstElementChild as HTMLElement).firstElementChild as HTMLElement; - let closeButton = cell.lastElementChild as HTMLElement; - if (!window.log.getFieldKeys().includes(field)) { + let keyContainer = cell.firstElementChild as HTMLElement; + let textElement = keyContainer.firstElementChild as HTMLElement; + if (!field.isAvailable) { textElement.style.textDecoration = "line-through"; } - cell.title = field; - textElement.innerText = field; + cell.title = field.key; + textElement.innerText = field.key; + + let closeButton = cell.lastElementChild as HTMLElement; closeButton.title = ""; - closeButton.addEventListener("click", () => { - this.fields.splice(index, 1); - this.updateFields(); - }); + closeButton.hidden = !this.hasController; + if (this.hasController) { + keyContainer.classList.add("has-close-button"); + closeButton.addEventListener("click", () => { + this.ROOT.dispatchEvent(new CustomEvent("close-field", { detail: index })); + }); + } }); - - // Update timestamps - this.timestamps = window.log.getTimestamps(this.fields, this.UUID); } /** Updates highlighted times (selected & hovered). */ @@ -229,15 +167,15 @@ export default class TableController implements TabController { } } }; - switch (window.selection.getMode()) { + switch (this.selectionMode) { case SelectionMode.Idle: highlight(null, "selected"); - highlight(window.selection.getHoveredTime(), "hovered"); + highlight(this.hoveredTime, "hovered"); break; case SelectionMode.Static: case SelectionMode.Playback: - highlight(window.selection.getSelectedTime(), "selected"); - highlight(window.selection.getHoveredTime(), "hovered"); + highlight(this.selectedTime, "selected"); + highlight(this.hoveredTime, "hovered"); break; case SelectionMode.Locked: Array.from(this.TABLE_BODY.children).forEach((row) => row.classList.remove("selected")); @@ -247,17 +185,35 @@ export default class TableController implements TabController { break; } } - highlight(window.selection.getHoveredTime(), "hovered"); + highlight(this.hoveredTime, "hovered"); break; } } - getActiveFields(): string[] { - return this.fields; + getAspectRatio(): number | null { + return null; + } + + getTimestampRange(): [number, number] | null { + return this.timestampRange; } - periodic() { + render(command: TableRendererCommand): void { let initialScrollPosition = this.TABLE_CONTAINER.scrollTop; + this.selectionMode = command.selectionMode; + this.selectedTime = command.selectedTime; + this.hoveredTime = command.hoveredTime; + + // Check if fields have changed + { + let fields = command.fields.map((field) => field.key); + let fieldsAvailable = command.fields.map((field) => field.isAvailable); + if (!arraysEqual(fields, this.lastFields) || !arraysEqual(fieldsAvailable, this.lastFieldsAvailable)) { + this.updateFields(command.fields); + this.lastFields = fields; + this.lastFieldsAvailable = fieldsAvailable; + } + } // Update data row count const dataRowCount = Math.ceil(this.TABLE_CONTAINER.clientHeight / this.ROW_HEIGHT_PX) + this.DATA_ROW_BUFFER * 2; @@ -292,12 +248,11 @@ export default class TableController implements TabController { } // Update timestamps, count removed rows from start - let newTimestamps = window.log.getTimestamps(this.fields, this.UUID); - let removedRows = this.timestamps.findIndex((timestamp) => timestamp === newTimestamps[1]) - 1; + let removedRows = this.timestamps.findIndex((timestamp) => timestamp === command.timestamps[1]) - 1; if (removedRows > 0) { this.TABLE_CONTAINER.scrollTop = initialScrollPosition - removedRows * this.ROW_HEIGHT_PX; } - this.timestamps = newTimestamps; + this.timestamps = command.timestamps; // Update element heights let dataRowStart: number; @@ -338,32 +293,29 @@ export default class TableController implements TabController { this.dataRowTimestamps.push(this.timestamps[i]); cellText.push([formatTimeWithMS(this.timestamps[i])]); } - let availableFields = window.log.getFieldKeys(); - this.fields.forEach((field) => { - if (!availableFields.includes(field)) { + this.timestampRange = + this.dataRowTimestamps.length > 0 + ? [this.dataRowTimestamps[0], this.dataRowTimestamps[this.dataRowTimestamps.length - 1]] + : null; + command.fields.forEach((field) => { + if (!field.isAvailable) { for (let i = dataRowStart; i < dataRowEnd; i++) { cellText[i - dataRowStart].push("null"); } - return; - } - let data = window.log.getRange( - field, - this.timestamps[dataRowStart], - this.timestamps[dataRowEnd] - ) as LogValueSetAny; - for (let i = dataRowStart; i < dataRowEnd; i++) { - let nextIndex = data.timestamps.findIndex((value) => value > this.timestamps[i]); - if (nextIndex === -1) nextIndex = data?.timestamps.length; - if (nextIndex === 0) { - cellText[i - dataRowStart].push("null"); - } else { - let value = data.values[nextIndex - 1]; - let type = window.log.getType(field)!; - let text = getLogValueText(value, type); - if (type === LoggableType.Boolean) { - text = (value ? "🟩" : "🟥") + " " + text; + } else { + for (let i = dataRowStart; i < dataRowEnd; i++) { + let nextIndex = field.data!.timestamps.findIndex((value) => value > this.timestamps[i]); + if (nextIndex === -1) nextIndex = field.data!.timestamps.length; + if (nextIndex === 0 || field.type === null) { + cellText[i - dataRowStart].push("null"); + } else { + let value = field.data!.values[nextIndex - 1]; + let text = getLogValueText(value, field.type); + if (field.type === LoggableType.Boolean) { + text = (value ? "🟩" : "🟥") + " " + text; + } + cellText[i - dataRowStart].push(text); } - cellText[i - dataRowStart].push(text); } } }); @@ -387,7 +339,7 @@ export default class TableController implements TabController { }); // Scroll automatically based on selection mode - switch (window.selection.getMode()) { + switch (this.selectionMode) { case SelectionMode.Playback: this.scrollToSelected(); break; @@ -409,14 +361,34 @@ export default class TableController implements TabController { let rect = row.getBoundingClientRect(); if (this.hoverCursorY! >= rect.top && this.hoverCursorY! < rect.bottom) { window.selection.setHoveredTime(this.dataRowTimestamps[index]); + this.didClearHoveredTime = false; } }); - } else { + } else if (!this.didClearHoveredTime) { window.selection.setHoveredTime(null); + this.didClearHoveredTime = true; } this.updateHighlights(); - let selectedTime = window.selection.getSelectedTime(); - let placeholder = selectedTime === null ? 0 : selectedTime; + let placeholder = this.selectedTime === null ? 0 : this.selectedTime; this.INPUT_FIELD.placeholder = formatTimeWithMS(placeholder); } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} } + +export type TableRendererCommand = { + timestamps: number[]; + fields: { + key: string; + isAvailable: boolean; + data: LogValueSetAny | null; + type: LoggableType | null; + }[]; + selectionMode: SelectionMode; + selectedTime: number | null; + hoveredTime: number | null; +}; diff --git a/src/shared/renderers/ThreeDimensionRenderer.ts b/src/shared/renderers/ThreeDimensionRenderer.ts new file mode 100644 index 00000000..15b3e58f --- /dev/null +++ b/src/shared/renderers/ThreeDimensionRenderer.ts @@ -0,0 +1,183 @@ +import { AnnotatedPose3d, SwerveState } from "../geometry"; +import { MechanismState } from "../log/LogUtil"; +import TabRenderer from "./TabRenderer"; +import ThreeDimensionRendererImpl from "./ThreeDimensionRendererImpl"; + +export default class ThreeDimensionRenderer implements TabRenderer { + private CANVAS: HTMLCanvasElement; + private CANVAS_CONTAINER: HTMLElement; + private ANNOTATIONS_DIV: HTMLElement; + private ALERT: HTMLElement; + private SPINNER: HTMLElement; + + private implementation: ThreeDimensionRendererImpl | null = null; + private lastMode: "cinematic" | "standard" | "low-power" | null = null; + + constructor(root: HTMLElement) { + this.CANVAS = root.getElementsByClassName("three-dimension-canvas")[0] as HTMLCanvasElement; + this.CANVAS_CONTAINER = root.getElementsByClassName("three-dimension-canvas-container")[0] as HTMLElement; + this.ANNOTATIONS_DIV = root.getElementsByClassName("three-dimension-annotations")[0] as HTMLElement; + this.ALERT = root.getElementsByClassName("three-dimension-alert")[0] as HTMLElement; + this.SPINNER = root.getElementsByClassName("spinner-cubes-container")[0] as HTMLElement; + this.updateImplementation(); + } + + saveState(): unknown { + return this.implementation === null ? null : this.implementation.saveState(); + } + + restoreState(state: unknown): void { + if (this.implementation !== null) { + this.implementation.restoreState(state); + } + } + + /** Switches the selected camera. */ + set3DCamera(index: number) { + this.implementation?.set3DCamera(index); + } + + /** Updates the orbit FOV. */ + setFov(fov: number) { + this.implementation?.setFov(fov); + } + + private updateImplementation() { + // Get current mode + let mode: "cinematic" | "standard" | "low-power" | null = null; + if (window.preferences) { + if (window.isBattery && window.preferences.threeDimensionModeBattery !== "") { + mode = window.preferences.threeDimensionModeBattery; + } else { + mode = window.preferences.threeDimensionModeAc; + } + } + + // Recreate visualizer if necessary + if (mode !== this.lastMode && mode !== null) { + this.lastMode = mode; + let state: any = null; + if (this.implementation !== null) { + state = this.implementation.saveState(); + this.implementation.stop(); + } + { + let newCanvas = document.createElement("canvas"); + this.CANVAS.classList.forEach((className) => { + newCanvas.classList.add(className); + }); + newCanvas.id = this.CANVAS.id; + this.CANVAS.replaceWith(newCanvas); + this.CANVAS = newCanvas; + } + { + let newDiv = document.createElement("div"); + this.ANNOTATIONS_DIV.classList.forEach((className) => { + newDiv.classList.add(className); + }); + newDiv.id = this.ANNOTATIONS_DIV.id; + this.ANNOTATIONS_DIV.replaceWith(newDiv); + this.ANNOTATIONS_DIV = newDiv; + } + this.implementation = new ThreeDimensionRendererImpl( + mode, + this.CANVAS, + this.CANVAS_CONTAINER, + this.ANNOTATIONS_DIV, + this.ALERT, + this.SPINNER + ); + if (state !== null) { + this.implementation.restoreState(state); + } + } + } + + getAspectRatio(): number | null { + return this.implementation === null ? null : this.implementation.getAspectRatio(); + } + + render(command: ThreeDimensionRendererCommand): void { + this.updateImplementation(); + this.implementation?.render(command); + } +} + +export type ThreeDimensionRendererCommand = { + game: string; + origin: "blue" | "red"; + objects: ThreeDimensionRendererCommand_AnyObj[]; + cameraOverride: AnnotatedPose3d | null; + autoDriverStation: number; +}; + +export type ThreeDimensionRendererCommand_AnyObj = + | ThreeDimensionRendererCommand_RobotObj + | ThreeDimensionRendererCommand_GhostObj + | ThreeDimensionRendererCommand_GamePieceObj + | ThreeDimensionRendererCommand_TrajectoryObj + | ThreeDimensionRendererCommand_HeatmapObj + | ThreeDimensionRendererCommand_AprilTagObj + | ThreeDimensionRendererCommand_AxesObj + | ThreeDimensionRendererCommand_ConeObj + | ThreeDimensionRendererCommand_ZebraMarkerObj; + +export type ThreeDimensionRendererCommand_GenericRobotObj = { + model: string; + poses: AnnotatedPose3d[]; + components: AnnotatedPose3d[]; + mechanism: MechanismState | null; + visionTargets: AnnotatedPose3d[]; + swerveStates: { + values: SwerveState[]; + color: string; + }[]; +}; + +export type ThreeDimensionRendererCommand_RobotObj = ThreeDimensionRendererCommand_GenericRobotObj & { + type: "robot"; +}; + +export type ThreeDimensionRendererCommand_GhostObj = ThreeDimensionRendererCommand_GenericRobotObj & { + type: "ghost"; + color: string; +}; + +export type ThreeDimensionRendererCommand_GamePieceObj = { + type: "gamePiece"; + variant: string; + poses: AnnotatedPose3d[]; +}; + +export type ThreeDimensionRendererCommand_TrajectoryObj = { + type: "trajectory"; + poses: AnnotatedPose3d[]; +}; + +export type ThreeDimensionRendererCommand_HeatmapObj = { + type: "heatmap"; + poses: AnnotatedPose3d[]; +}; + +export type ThreeDimensionRendererCommand_AprilTagObj = { + type: "aprilTag"; + poses: AnnotatedPose3d[]; + family: "36h11" | "16h5"; +}; + +export type ThreeDimensionRendererCommand_AxesObj = { + type: "axes"; + poses: AnnotatedPose3d[]; +}; + +export type ThreeDimensionRendererCommand_ConeObj = { + type: "cone"; + color: string; + position: "center" | "back" | "front"; + poses: AnnotatedPose3d[]; +}; + +export type ThreeDimensionRendererCommand_ZebraMarkerObj = { + type: "zebra"; + poses: AnnotatedPose3d[]; +}; diff --git a/src/shared/renderers/ThreeDimensionRendererImpl.ts b/src/shared/renderers/ThreeDimensionRendererImpl.ts new file mode 100644 index 00000000..11e7bbd6 --- /dev/null +++ b/src/shared/renderers/ThreeDimensionRendererImpl.ts @@ -0,0 +1,822 @@ +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js"; +import WorkerManager from "../../hub/WorkerManager"; +import { + Config3dField, + Config3d_Rotation, + DEFAULT_DRIVER_STATIONS, + STANDARD_FIELD_LENGTH, + STANDARD_FIELD_WIDTH +} from "../AdvantageScopeAssets"; +import { Rotation3d } from "../geometry"; +import { convert } from "../units"; +import { checkArrayType, clampValue } from "../util"; +import TabRenderer from "./TabRenderer"; +import { + ThreeDimensionRendererCommand, + ThreeDimensionRendererCommand_AnyObj, + ThreeDimensionRendererCommand_RobotObj +} from "./ThreeDimensionRenderer"; +import makeAxesField from "./threeDimension/AxesField"; +import makeEvergreenField from "./threeDimension/EvergreenField"; +import ObjectManager from "./threeDimension/ObjectManager"; +import AprilTagManager from "./threeDimension/objectManagers/AprilTagManager"; +import AxesManager from "./threeDimension/objectManagers/AxesManager"; +import ConeManager from "./threeDimension/objectManagers/ConeManager"; +import GamePieceManager from "./threeDimension/objectManagers/GamePieceManager"; +import HeatmapManager from "./threeDimension/objectManagers/HeatmapManager"; +import RobotManager from "./threeDimension/objectManagers/RobotManager"; +import TrajectoryManager from "./threeDimension/objectManagers/TrajectoryManager"; +import ZebraManager from "./threeDimension/objectManagers/ZebraManager"; + +export default class ThreeDimensionRendererImpl implements TabRenderer { + private LOWER_POWER_MAX_FPS = 30; + private MAX_ORBIT_FOV = 160; + private MIN_ORBIT_FOV = 10; + private ORBIT_FIELD_DEFAULT_TARGET = new THREE.Vector3(0, 0.5, 0); + private ORBIT_AXES_DEFAULT_TARGET = new THREE.Vector3(STANDARD_FIELD_LENGTH / 2, 0, -STANDARD_FIELD_WIDTH / 2); + private ORBIT_ROBOT_DEFAULT_TARGET = new THREE.Vector3(0, 0.5, 0); + private ORBIT_FIELD_DEFAULT_POSITION = new THREE.Vector3(0, 6, -12); + private ORBIT_AXES_DEFAULT_POSITION = new THREE.Vector3( + 2 + STANDARD_FIELD_LENGTH / 2, + 2, + -4 - STANDARD_FIELD_WIDTH / 2 + ); + private ORBIT_ROBOT_DEFAULT_POSITION = new THREE.Vector3(2, 1, 1); + private DS_CAMERA_HEIGHT = convert(62, "inches", "meters"); // https://www.ergocenter.ncsu.edu/wp-content/uploads/sites/18/2017/09/Anthropometric-Summary-Data-Tables.pdf + private DS_CAMERA_OFFSET = 1.5; // Distance away from the glass + private MATERIAL_SPECULAR: THREE.Color = new THREE.Color(0x666666); // Overridden if not cinematic + private MATERIAL_SHININESS: number = 100; // Overridden if not cinematic + private WPILIB_ROTATION = getQuaternionFromRotSeq([ + { + axis: "x", + degrees: -90 + }, + { + axis: "y", + degrees: 180 + } + ]); + private CAMERA_ROTATION = getQuaternionFromRotSeq([ + { + axis: "z", + degrees: -90 + }, + { + axis: "y", + degrees: -90 + } + ]); + + private shouldResetCamera = true; + private mode: "cinematic" | "standard" | "low-power"; + private canvas: HTMLCanvasElement; + private canvasContainer: HTMLElement; + private annotationsDiv: HTMLElement; + private alert: HTMLElement; + private spinner: HTMLElement; + + private renderer: THREE.WebGLRenderer; + private cssRenderer: CSS2DRenderer; + private scene: THREE.Scene; + private camera: THREE.PerspectiveCamera; + private controls: OrbitControls; + private wpilibCoordinateGroup: THREE.Group; // Rotated to match WPILib coordinates + private wpilibFieldCoordinateGroup: THREE.Group; // Field coordinates (origin at driver stations and flipped based on alliance) + private field: THREE.Object3D | null = null; + private fieldStagedPieces: THREE.Object3D | null = null; + private fieldPieces: { [key: string]: THREE.Mesh } = {}; + private primaryRobotGroup: THREE.Group; + private fixedCameraObj: THREE.Object3D; + private fixedCameraOverrideObj: THREE.Object3D; + private dsCameraGroup: THREE.Group; + private dsCameraObj: THREE.Object3D; + + private objectManagers: { + type: ThreeDimensionRendererCommand_AnyObj["type"]; + manager: ObjectManager; + active: boolean; + }[] = []; + + private shouldRender = false; + private cameraIndex: CameraIndex = CameraIndexEnum.OrbitField; + private orbitFov = 50; + private primaryRobotModel = ""; + private resolutionVector = new THREE.Vector2(); + private fieldConfigCache: Config3dField | null = null; + private robotLoadingCount = 0; + private shouldLoadNewField = false; + private isFieldLoading = false; + private aspectRatio: number | null = null; + private lastCameraIndex = -1; + private lastAutoDriverStation = -1; + private lastFrameTime = 0; + private lastWidth: number | null = 0; + private lastHeight: number | null = 0; + private lastDevicePixelRatio: number | null = null; + private lastIsDark: boolean | null = null; + private lastCommandString: string = ""; + private lastAssetsString: string = ""; + private lastFieldTitle: string = ""; + + constructor( + mode: "cinematic" | "standard" | "low-power", + canvas: HTMLCanvasElement, + canvasContainer: HTMLElement, + annotationsDiv: HTMLElement, + alert: HTMLElement, + spinner: HTMLElement + ) { + this.mode = mode; + this.canvas = canvas; + this.canvasContainer = canvasContainer; + this.annotationsDiv = annotationsDiv; + this.alert = alert; + this.spinner = spinner; + this.renderer = new THREE.WebGLRenderer({ + canvas: canvas, + powerPreference: mode === "cinematic" ? "high-performance" : mode === "low-power" ? "low-power" : "default" + }); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.shadowMap.enabled = mode === "cinematic"; + this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + this.cssRenderer = new CSS2DRenderer({ element: annotationsDiv }); + this.scene = new THREE.Scene(); + if (mode !== "cinematic") { + this.MATERIAL_SPECULAR = new THREE.Color(0x000000); + this.MATERIAL_SHININESS = 0; + } + + // Change camera menu + let startPx: [number, number] | null = null; + canvas.addEventListener("contextmenu", (event) => { + startPx = [event.x, event.y]; + }); + canvas.addEventListener("mouseup", (event) => { + if (startPx && event.x === startPx[0] && event.y === startPx[1]) { + let robotConfig = window.assets?.robots.find((robotData) => robotData.name === this.primaryRobotModel); + let cameraList = robotConfig === undefined ? [] : robotConfig.cameras.map((camera) => camera.name); + window.sendMainMessage("ask-3d-camera", { + options: cameraList, + selectedIndex: this.cameraIndex >= cameraList.length ? CameraIndexEnum.OrbitField : this.cameraIndex, + fov: this.orbitFov + }); + } + startPx = null; + }); + + // Create coordinate groups + this.wpilibCoordinateGroup = new THREE.Group(); + this.scene.add(this.wpilibCoordinateGroup); + this.wpilibCoordinateGroup.rotation.setFromQuaternion(this.WPILIB_ROTATION); + this.wpilibFieldCoordinateGroup = new THREE.Group(); + this.wpilibCoordinateGroup.add(this.wpilibFieldCoordinateGroup); + + // Create camera + { + const aspect = 2; + const near = 0.15; + const far = 1000; + this.camera = new THREE.PerspectiveCamera(this.orbitFov, aspect, near, far); + } + + // Create controls + { + this.controls = new OrbitControls(this.camera, canvas); + this.controls.maxDistance = 250; + this.controls.enabled = true; + this.controls.update(); + } + + // Add lights + { + const light = new THREE.HemisphereLight(0xffffff, 0x444444, mode === "cinematic" ? 0.5 : 2); + this.scene.add(light); + } + if (mode !== "cinematic") { + const light = new THREE.PointLight(0xffffff, 0.5); + light.position.set(0, 0, 10); + this.wpilibCoordinateGroup.add(light); + } else { + [ + [0, 1, 0, -2], + [6, -3, 6, 2], + [-6, -3, -6, 2] + ].forEach(([x, y, targetX, targetY]) => { + const light = new THREE.SpotLight(0xffffff, 150, 0, 50 * (Math.PI / 180), 0.2, 2); + light.position.set(x, y, 8); + light.target.position.set(targetX, targetY, 0); + light.castShadow = true; + light.shadow.mapSize.width = 2048; + light.shadow.mapSize.height = 2048; + light.shadow.bias = -0.0001; + this.wpilibCoordinateGroup.add(light, light.target); + }); + { + const light = new THREE.PointLight(0xff0000, 60); + light.position.set(4.5, 0, 5); + this.wpilibCoordinateGroup.add(light); + } + { + const light = new THREE.PointLight(0x0000ff, 60); + light.position.set(-4.5, 0, 5); + this.wpilibCoordinateGroup.add(light); + } + } + + // Create fixed camera objects + { + this.fixedCameraObj = new THREE.Object3D(); + this.primaryRobotGroup = new THREE.Group().add(this.fixedCameraObj); + this.primaryRobotGroup.visible = false; + this.fixedCameraOverrideObj = new THREE.Object3D(); + this.fixedCameraOverrideObj.visible = false; + this.wpilibFieldCoordinateGroup.add(this.primaryRobotGroup, this.fixedCameraOverrideObj); + } + + // Create DS camera object + { + this.dsCameraObj = new THREE.Object3D(); + this.dsCameraObj.position.set(-this.DS_CAMERA_OFFSET, 0.0, this.DS_CAMERA_HEIGHT); + this.dsCameraGroup = new THREE.Group().add(this.dsCameraObj); + this.wpilibCoordinateGroup.add(this.dsCameraGroup); + } + + // Render when camera is moved + this.controls.addEventListener("change", () => (this.shouldRender = true)); + } + + saveState(): unknown { + return { + cameraIndex: this.cameraIndex, + orbitFov: this.orbitFov, + cameraPosition: [this.camera.position.x, this.camera.position.y, this.camera.position.z], + cameraTarget: [this.controls.target.x, this.controls.target.y, this.controls.target.z] + }; + } + + restoreState(state: unknown) { + if (typeof state !== "object" || state === null) return; + if ("cameraIndex" in state && typeof state.cameraIndex === "number") { + this.cameraIndex = state.cameraIndex; + } + if ("orbitFov" in state && typeof state.orbitFov === "number") { + this.orbitFov = state.orbitFov; + } + if ( + "cameraPosition" in state && + checkArrayType(state.cameraPosition, "number") && + (state.cameraPosition as number[]).length === 3 + ) { + this.camera.position.set(...(state.cameraPosition as [number, number, number])); + } + if ( + "cameraTarget" in state && + checkArrayType(state.cameraTarget, "number") && + (state.cameraTarget as number[]).length === 3 + ) { + this.controls.target.set(...(state.cameraTarget as [number, number, number])); + } + this.controls.update(); + this.lastCameraIndex = this.cameraIndex; // Don't reset camera position + this.shouldResetCamera = false; + this.shouldRender = true; + } + + /** Switches the selected camera. */ + set3DCamera(index: number) { + this.cameraIndex = index; + this.shouldRender = true; + } + + /** Updates the orbit FOV. */ + setFov(fov: number) { + this.orbitFov = clampValue(fov, this.MIN_ORBIT_FOV, this.MAX_ORBIT_FOV); + this.shouldRender = true; + } + + stop() {} + + /** Resets the camera position and controls target. */ + private resetCamera(command: ThreeDimensionRendererCommand) { + if (this.cameraIndex === -1) { + // Orbit field + if (command && command.game === "Axes") { + this.camera.position.copy(this.ORBIT_AXES_DEFAULT_POSITION); + this.controls.target.copy(this.ORBIT_AXES_DEFAULT_TARGET); + } else { + this.camera.position.copy(this.ORBIT_FIELD_DEFAULT_POSITION); + this.controls.target.copy(this.ORBIT_FIELD_DEFAULT_TARGET); + } + } else if (this.cameraIndex === -2) { + // Orbit robot + this.camera.position.copy(this.ORBIT_ROBOT_DEFAULT_POSITION); + this.controls.target.copy(this.ORBIT_ROBOT_DEFAULT_TARGET); + } else { + // Driver Station + let fieldConfig = this.getFieldConfig(command); + if (fieldConfig !== null) { + let driverStation = -1; + if (this.cameraIndex < -3) { + driverStation = -4 - this.cameraIndex; + } else { + driverStation = command.autoDriverStation; + } + if (driverStation >= 0) { + let position = fieldConfig.driverStations[driverStation]; + this.dsCameraGroup.position.set(position[0], position[1], 0); + this.dsCameraGroup.rotation.set(0, 0, Math.atan2(-position[1], -position[0])); + this.camera.position.copy(this.dsCameraObj.getWorldPosition(new THREE.Vector3())); + this.camera.rotation.setFromQuaternion(this.dsCameraObj.getWorldQuaternion(new THREE.Quaternion())); + this.controls.target.copy(this.ORBIT_FIELD_DEFAULT_TARGET); // Look at the center of the field + } + } + } + this.controls.update(); + } + + private getFieldConfig(command: ThreeDimensionRendererCommand): Config3dField | null { + let fieldTitle = command.game; + if (fieldTitle === "Evergreen") { + return { + name: "Evergreen", + path: "", + rotations: [], + widthInches: convert(STANDARD_FIELD_LENGTH, "meters", "inches"), + heightInches: convert(STANDARD_FIELD_WIDTH, "meters", "inches"), + defaultOrigin: "auto", + driverStations: DEFAULT_DRIVER_STATIONS, + gamePieces: [] + }; + } else if (fieldTitle === "Axes") { + return { + name: "Axes", + path: "", + rotations: [], + widthInches: convert(STANDARD_FIELD_LENGTH, "meters", "inches"), + heightInches: convert(STANDARD_FIELD_WIDTH, "meters", "inches"), + defaultOrigin: "blue", + driverStations: DEFAULT_DRIVER_STATIONS, + gamePieces: [] + }; + } else { + let fieldConfig = window.assets?.field3ds.find((fieldData) => fieldData.name === fieldTitle); + if (fieldConfig === undefined) return null; + return fieldConfig; + } + } + + /** Make a new object manager for the provided type. */ + private makeObjectManager( + type: ThreeDimensionRendererCommand_AnyObj["type"] + ): ObjectManager { + let args = [ + this.wpilibFieldCoordinateGroup, + this.MATERIAL_SPECULAR, + this.MATERIAL_SHININESS, + this.mode, + () => (this.shouldRender = true) + ] as const; + let manager: ObjectManager; + switch (type) { + case "robot": + case "ghost": + manager = new RobotManager( + ...args, + () => this.robotLoadingCount++, + () => this.robotLoadingCount-- + ); + break; + case "gamePiece": + manager = new GamePieceManager(...args, this.fieldPieces); + break; + case "trajectory": + manager = new TrajectoryManager(...args); + break; + case "heatmap": + manager = new HeatmapManager(...args, () => this.fieldConfigCache); + break; + case "aprilTag": + manager = new AprilTagManager(...args); + break; + case "axes": + manager = new AxesManager(...args); + break; + case "cone": + manager = new ConeManager(...args); + break; + case "zebra": + manager = new ZebraManager(...args); + break; + } + manager.setResolution(this.resolutionVector); + return manager; + } + + getAspectRatio(): number | null { + return this.aspectRatio; + } + + render(command: ThreeDimensionRendererCommand): void { + // Check for new parameters + let commandString = JSON.stringify(command); + let assetsString = JSON.stringify(window.assets); + let isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + let newAssets = assetsString !== this.lastAssetsString; + if ( + this.renderer.domElement.clientWidth !== this.lastWidth || + this.renderer.domElement.clientHeight !== this.lastHeight || + window.devicePixelRatio !== this.lastDevicePixelRatio || + isDark !== this.lastIsDark || + command.game !== this.lastFieldTitle || + commandString !== this.lastCommandString || + newAssets + ) { + this.lastWidth = this.renderer.domElement.clientWidth; + this.lastHeight = this.renderer.domElement.clientHeight; + this.lastDevicePixelRatio = window.devicePixelRatio; + this.lastIsDark = isDark; + this.lastCommandString = commandString; + this.lastAssetsString = assetsString; + this.shouldRender = true; + } + + // Exit if not visible + if (this.canvas.getBoundingClientRect().width === 0) { + return; // Continue trying to render + } + + // Limit FPS in low power mode + let now = new Date().getTime(); + if (this.mode === "low-power" && now - this.lastFrameTime < 1000 / this.LOWER_POWER_MAX_FPS) { + return; // Continue trying to render + } + + // Check if rendering should continue + if (!this.shouldRender) { + return; + } + this.lastFrameTime = now; + this.shouldRender = false; + + // Get field config + let fieldTitle = command.game; + let fieldConfigTmp = this.getFieldConfig(command); + this.fieldConfigCache = fieldConfigTmp; + if (fieldConfigTmp === null) return; + let fieldConfig = fieldConfigTmp; + + // Reset camera on first render + if (this.shouldResetCamera) { + this.resetCamera(command); + this.shouldResetCamera = false; + } + + // Update field coordinates + if (fieldConfig) { + let isBlue = command.origin === "blue"; + this.wpilibFieldCoordinateGroup.setRotationFromAxisAngle(new THREE.Vector3(0, 0, 1), isBlue ? 0 : Math.PI); + this.wpilibFieldCoordinateGroup.position.set( + convert(fieldConfig.widthInches / 2, "inches", "meters") * (isBlue ? -1 : 1), + convert(fieldConfig.heightInches / 2, "inches", "meters") * (isBlue ? -1 : 1), + 0 + ); + } + + // Update field + if (fieldTitle !== this.lastFieldTitle || newAssets) { + this.shouldLoadNewField = true; + + // Reset camera if switching between axis and non-axis or if using DS camera + if ( + ((fieldTitle === "Axes") !== (this.lastFieldTitle === "Axes") && this.lastFieldTitle !== "") || + this.cameraIndex < -2 + ) { + this.resetCamera(command); + } + this.lastFieldTitle = fieldTitle; + } + if (this.shouldLoadNewField && !this.isFieldLoading) { + this.shouldLoadNewField = false; + + // Remove old field + if (this.field) { + this.wpilibCoordinateGroup.remove(this.field); + disposeObject(this.field); + } + if (this.fieldStagedPieces) { + this.wpilibCoordinateGroup.remove(this.fieldStagedPieces); + disposeObject(this.fieldStagedPieces); + } + + // Insert new field + let newFieldPieces: typeof this.fieldPieces = {}; + let newFieldReady = () => { + // Add new field + if (this.field) { + this.wpilibCoordinateGroup.add(this.field); + if (this.fieldStagedPieces !== null) this.wpilibCoordinateGroup.add(this.fieldStagedPieces); + } + + // Reset game piece objects + this.objectManagers.filter((entry) => entry.type === "gamePiece").forEach((entry) => entry.manager.dispose()); + this.objectManagers = this.objectManagers.filter((entry) => entry.type !== "gamePiece"); + Object.values(this.fieldPieces).forEach((mesh) => { + disposeObject(mesh); + }); + this.fieldPieces = newFieldPieces; + + // Render new frame + this.shouldRender = true; + }; + + // Load new field + if (fieldTitle === "Evergreen") { + this.isFieldLoading = false; + this.field = makeEvergreenField(this.MATERIAL_SPECULAR, this.MATERIAL_SHININESS); + this.fieldStagedPieces = new THREE.Object3D(); + newFieldReady(); + } else if (fieldTitle === "Axes") { + this.isFieldLoading = false; + this.field = makeAxesField(this.MATERIAL_SPECULAR, this.MATERIAL_SHININESS); + this.fieldStagedPieces = new THREE.Object3D(); + newFieldReady(); + } else { + this.isFieldLoading = true; + WorkerManager.request("../bundles/shared$loadField.js", { + fieldConfig: fieldConfig, + mode: this.mode, + materialSpecular: this.MATERIAL_SPECULAR.toArray(), + materialShininess: this.MATERIAL_SHININESS + }).then((result) => { + const loader = new THREE.ObjectLoader(); + this.field = loader.parse(result.field); + this.fieldStagedPieces = loader.parse(result.fieldStagedPieces); + Object.entries(result.fieldPieces).forEach(([name, meshData]) => { + newFieldPieces[name] = loader.parse(meshData) as THREE.Mesh; + }); + newFieldReady(); + this.isFieldLoading = false; + }); + } + } + + // Update primary robot + let robotObjects = command.objects.filter( + (object) => object.type === "robot" + ) as ThreeDimensionRendererCommand_RobotObj[]; + this.primaryRobotGroup.visible = false; + if (robotObjects.length > 0) { + this.primaryRobotModel = robotObjects[0].model; + if (robotObjects[0].poses.length > 0) { + let pose = robotObjects[0].poses[0].pose; + this.primaryRobotGroup.position.set(...pose.translation); + this.primaryRobotGroup.rotation.setFromQuaternion(rotation3dToQuaternion(pose.rotation)); + this.primaryRobotGroup.visible = true; + } + } + + // Update camera override + this.fixedCameraOverrideObj.visible = command.cameraOverride !== null; + if (command.cameraOverride !== null) { + let pose = command.cameraOverride.pose; + this.fixedCameraOverrideObj.position.set(...pose.translation); + this.fixedCameraOverrideObj.rotation.setFromQuaternion( + rotation3dToQuaternion(pose.rotation).multiply(this.CAMERA_ROTATION) + ); + } + + // Update staged game pieces + if (this.fieldStagedPieces !== null) { + this.fieldStagedPieces.visible = command.objects.every((object) => object.type !== "gamePiece"); + } + + // Update object managers + this.objectManagers.forEach((entry) => (entry.active = false)); + command.objects.forEach((object) => { + let entry = this.objectManagers.find((entry) => !entry.active && entry.type === object.type); + if (entry === undefined) { + entry = { + type: object.type, + manager: this.makeObjectManager(object.type), + active: true + }; + this.objectManagers.push(entry); + } else { + entry.active = true; + } + if (newAssets && (entry.type === "robot" || entry.type === "ghost")) { + (entry.manager as RobotManager).newAssets(); + } + entry.manager.setObjectData(object); + }); + this.objectManagers + .filter((entry) => !entry.active) + .forEach((entry) => { + entry.manager.dispose(); + }); + this.objectManagers = this.objectManagers.filter((entry) => entry.active); + + // Set camera for fixed views + { + // Reset camera index if invalid + let robotConfig = window.assets?.robots.find((robotData) => robotData.name === this.primaryRobotModel); + if (robotConfig !== undefined && this.cameraIndex >= robotConfig.cameras.length) + this.cameraIndex = CameraIndexEnum.OrbitField; + + // Update camera controls + let orbitalCamera = + this.cameraIndex === CameraIndexEnum.OrbitField || this.cameraIndex === CameraIndexEnum.OrbitRobot; + let dsCamera = this.cameraIndex < CameraIndexEnum.OrbitRobot; + if (orbitalCamera !== this.controls.enabled) { + this.controls.enabled = orbitalCamera; + this.controls.update(); + } + + // Update container and camera based on mode + let fov = this.orbitFov; + this.aspectRatio = null; + if (orbitalCamera || dsCamera) { + this.canvas.classList.remove("fixed"); + this.canvasContainer.classList.remove("fixed"); + this.annotationsDiv.classList.remove("fixed"); + this.canvas.style.width = ""; + this.canvas.style.height = ""; + this.annotationsDiv.style.width = ""; + this.annotationsDiv.style.height = ""; + if (this.cameraIndex === CameraIndexEnum.OrbitField || dsCamera) { + // Reset to default origin + this.wpilibCoordinateGroup.position.set(0, 0, 0); + this.wpilibCoordinateGroup.rotation.setFromQuaternion(this.WPILIB_ROTATION); + } else if (this.primaryRobotGroup.visible) { + // Shift based on robot location + this.wpilibCoordinateGroup.position.set(0, 0, 0); + this.wpilibCoordinateGroup.rotation.setFromQuaternion(new THREE.Quaternion()); + let position = this.primaryRobotGroup.getWorldPosition(new THREE.Vector3()); + let rotation = this.primaryRobotGroup + .getWorldQuaternion(new THREE.Quaternion()) + .multiply(this.WPILIB_ROTATION); + position.negate(); + rotation.invert(); + this.wpilibCoordinateGroup.position.copy(position.clone().applyQuaternion(rotation)); + this.wpilibCoordinateGroup.rotation.setFromQuaternion(rotation); + } + if ( + this.cameraIndex !== this.lastCameraIndex || + (this.cameraIndex === CameraIndexEnum.DSAuto && this.lastAutoDriverStation !== command.autoDriverStation) + ) { + this.resetCamera(command); + } + } else { + this.canvas.classList.add("fixed"); + this.canvasContainer.classList.add("fixed"); + this.annotationsDiv.classList.add("fixed"); + + // Get fixed aspect ratio and FOV + let cameraConfig = robotConfig === undefined ? undefined : robotConfig.cameras[this.cameraIndex]; + let aspectRatio = cameraConfig === undefined ? 4 / 3 : cameraConfig.resolution[0] / cameraConfig.resolution[1]; + if (cameraConfig !== undefined) fov = cameraConfig.fov / aspectRatio; + this.aspectRatio = aspectRatio; + let parentAspectRatio = this.canvas.parentElement + ? this.canvas.parentElement.clientWidth / this.canvas.parentElement.clientHeight + : aspectRatio; + if (aspectRatio > parentAspectRatio) { + this.canvas.style.width = "100%"; + this.canvas.style.height = ((parentAspectRatio / aspectRatio) * 100).toString() + "%"; + this.annotationsDiv.style.width = "100%"; + this.annotationsDiv.style.height = ((parentAspectRatio / aspectRatio) * 100).toString() + "%"; + } else { + this.canvas.style.width = ((aspectRatio / parentAspectRatio) * 100).toString() + "%"; + this.canvas.style.height = "100%"; + this.annotationsDiv.style.width = ((aspectRatio / parentAspectRatio) * 100).toString() + "%"; + this.annotationsDiv.style.height = "100%"; + } + + // Update camera position + let referenceObj: THREE.Object3D | null = null; + if (this.fixedCameraOverrideObj.visible) { + referenceObj = this.fixedCameraOverrideObj; + } else if (this.primaryRobotGroup.visible && cameraConfig !== undefined) { + this.fixedCameraObj.position.set(...cameraConfig.position); + this.fixedCameraObj.rotation.setFromQuaternion( + getQuaternionFromRotSeq(cameraConfig.rotations).multiply(this.CAMERA_ROTATION) + ); + referenceObj = this.fixedCameraObj; + } + if (referenceObj) { + this.camera.position.copy(referenceObj.getWorldPosition(new THREE.Vector3())); + this.camera.rotation.setFromQuaternion(referenceObj.getWorldQuaternion(new THREE.Quaternion())); + } + } + + // Update camera alert + if (this.cameraIndex === CameraIndexEnum.OrbitRobot) { + this.alert.hidden = this.primaryRobotGroup.visible; + this.alert.innerHTML = 'Robot pose not available
for camera "Orbit Robot".'; + } else if (this.cameraIndex === CameraIndexEnum.DSAuto) { + this.alert.hidden = command.autoDriverStation >= 0; + this.alert.innerHTML = "Driver Station position
not available."; + } else if (this.cameraIndex === CameraIndexEnum.OrbitField || dsCamera) { + this.alert.hidden = true; + } else { + this.alert.hidden = this.primaryRobotGroup.visible || this.fixedCameraOverrideObj.visible; + this.alert.innerHTML = + 'Robot pose not available
for camera "' + + (robotConfig ? robotConfig.cameras[this.cameraIndex].name : "???") + + '".'; + } + + // Update camera FOV + if (fov !== this.camera.fov) { + this.camera.fov = fov; + this.camera.updateProjectionMatrix(); + } + + this.lastCameraIndex = this.cameraIndex; + this.lastAutoDriverStation = command.autoDriverStation; + } + + // Update spinner + if (this.robotLoadingCount > 0 || this.isFieldLoading) { + this.spinner.classList.add("visible"); + this.spinner.classList.add("animating"); + } else if (this.spinner.classList.contains("visible")) { + this.spinner.classList.remove("visible"); + window.setTimeout(() => this.spinner.classList.remove("animating"), 250); + } + + // Render new frame + const devicePixelRatio = window.devicePixelRatio * (this.mode === "low-power" ? 0.75 : 1); + const clientWidth = this.canvas.clientWidth; + const clientHeight = this.canvas.clientHeight; + if ( + this.canvas.width / devicePixelRatio !== clientWidth || + this.canvas.height / devicePixelRatio !== clientHeight + ) { + this.renderer.setSize(clientWidth, clientHeight, false); + this.cssRenderer.setSize(clientWidth, clientHeight); + this.camera.aspect = clientWidth / clientHeight; + this.camera.updateProjectionMatrix(); + this.resolutionVector.set(clientWidth, clientHeight); + this.objectManagers.forEach((entry) => { + entry.manager.setResolution(this.resolutionVector); + }); + } + this.scene.background = isDark ? new THREE.Color("#222222") : new THREE.Color("#ffffff"); + this.renderer.setPixelRatio(devicePixelRatio); + this.renderer.render(this.scene, this.camera); + this.cssRenderer.render(this.scene, this.camera); + } +} + +type CameraIndex = number | CameraIndexEnum; +enum CameraIndexEnum { + OrbitField = -1, + OrbitRobot = -2, + DSAuto = -3, + DSB1 = -4, + DSB2 = -5, + DSB3 = -6, + DSR1 = -7, + DSR2 = -8, + DSR3 = -9 +} + +/** Converts a rotation sequence to a quaternion. */ +export function getQuaternionFromRotSeq(rotations: Config3d_Rotation[]): THREE.Quaternion { + let quaternion = new THREE.Quaternion(); + rotations.forEach((rotation) => { + let axis = new THREE.Vector3(0, 0, 0); + if (rotation.axis === "x") axis.setX(1); + if (rotation.axis === "y") axis.setY(1); + if (rotation.axis === "z") axis.setZ(1); + quaternion.premultiply( + new THREE.Quaternion().setFromAxisAngle(axis, convert(rotation.degrees, "degrees", "radians")) + ); + }); + return quaternion; +} + +/** Disposes of all materials and geometries in object. */ +export function disposeObject(object: THREE.Object3D) { + object.traverse((node) => { + let mesh = node as THREE.Mesh; + if (mesh.isMesh) { + mesh.geometry.dispose(); + if (Array.isArray(mesh.material)) { + mesh.material.forEach((material) => material.dispose()); + } else { + mesh.material.dispose(); + } + } + }); +} + +export function rotation3dToQuaternion(input: Rotation3d): THREE.Quaternion { + return new THREE.Quaternion(input[1], input[2], input[3], input[0]); +} + +export function quaternionToRotation3d(input: THREE.Quaternion): Rotation3d { + return [input.w, input.x, input.y, input.z]; +} diff --git a/src/shared/renderers/VideoRenderer.ts b/src/shared/renderers/VideoRenderer.ts new file mode 100644 index 00000000..bd9a62b5 --- /dev/null +++ b/src/shared/renderers/VideoRenderer.ts @@ -0,0 +1,34 @@ +import TabRenderer from "./TabRenderer"; + +export default class VideoRenderer implements TabRenderer { + private IMAGE: HTMLImageElement; + + private aspectRatio: number | null = null; + + constructor(root: HTMLElement) { + this.IMAGE = root.getElementsByTagName("img")[0] as HTMLImageElement; + } + + getAspectRatio(): number | null { + return this.aspectRatio; + } + + render(command: unknown): void { + if (typeof command !== "string") return; + this.IMAGE.hidden = command === ""; + this.IMAGE.src = command; + let width = this.IMAGE.naturalWidth; + let height = this.IMAGE.naturalHeight; + if (width > 0 && height > 0) { + this.aspectRatio = width / height; + } else { + this.aspectRatio = null; + } + } + + saveState(): unknown { + return null; + } + + restoreState(state: unknown): void {} +} diff --git a/src/shared/renderers/threeDimension/AxesField.ts b/src/shared/renderers/threeDimension/AxesField.ts new file mode 100644 index 00000000..6a195a27 --- /dev/null +++ b/src/shared/renderers/threeDimension/AxesField.ts @@ -0,0 +1,24 @@ +import * as THREE from "three"; +import { STANDARD_FIELD_LENGTH, STANDARD_FIELD_WIDTH } from "../../AdvantageScopeAssets"; +import makeAxesTemplate from "./AxesTemplate"; + +export default function makeAxesField(materialSpecular: THREE.Color, materialShininess: number): THREE.Object3D { + let field = new THREE.Group(); + + let axes = makeAxesTemplate(materialSpecular, materialShininess); + axes.position.set(-STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0); + field.add(axes); + let outline = new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(-STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0), + new THREE.Vector3(STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0), + new THREE.Vector3(STANDARD_FIELD_LENGTH / 2, STANDARD_FIELD_WIDTH / 2, 0), + new THREE.Vector3(-STANDARD_FIELD_LENGTH / 2, STANDARD_FIELD_WIDTH / 2, 0), + new THREE.Vector3(-STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0) + ]), + new THREE.LineBasicMaterial({ color: 0x444444 }) + ); + field.add(outline); + + return field; +} diff --git a/src/shared/renderers/threeDimension/AxesTemplate.ts b/src/shared/renderers/threeDimension/AxesTemplate.ts new file mode 100644 index 00000000..18b1b83c --- /dev/null +++ b/src/shared/renderers/threeDimension/AxesTemplate.ts @@ -0,0 +1,61 @@ +import * as THREE from "three"; + +export default function makeAxesTemplate(materialSpecular: THREE.Color, materialShininess: number): THREE.Object3D { + let axes = new THREE.Object3D(); + const radius = 0.02; + + const center = new THREE.Mesh( + new THREE.SphereGeometry(radius, 8, 4), + new THREE.MeshPhongMaterial({ + color: 0xffffff, + specular: materialSpecular, + shininess: materialShininess + }) + ); + center.castShadow = true; + center.receiveShadow = true; + axes.add(center); + + const xAxis = new THREE.Mesh( + new THREE.CylinderGeometry(radius, radius, 1, 8), + new THREE.MeshPhongMaterial({ + color: 0xff0000, + specular: materialSpecular, + shininess: materialShininess + }) + ); + xAxis.castShadow = true; + xAxis.receiveShadow = true; + xAxis.position.set(0.5, 0.0, 0.0); + xAxis.rotateZ(Math.PI / 2); + axes.add(xAxis); + + const yAxis = new THREE.Mesh( + new THREE.CylinderGeometry(radius, radius, 1, 8), + new THREE.MeshPhongMaterial({ + color: 0x00ff00, + specular: materialSpecular, + shininess: materialShininess + }) + ); + yAxis.castShadow = true; + yAxis.receiveShadow = true; + yAxis.position.set(0.0, 0.5, 0.0); + axes.add(yAxis); + + const zAxis = new THREE.Mesh( + new THREE.CylinderGeometry(radius, radius, 1, 8), + new THREE.MeshPhongMaterial({ + color: 0x2020ff, + specular: materialSpecular, + shininess: materialShininess + }) + ); + zAxis.castShadow = true; + zAxis.receiveShadow = true; + zAxis.position.set(0.0, 0.0, 0.5); + zAxis.rotateX(Math.PI / 2); + axes.add(zAxis); + + return axes; +} diff --git a/src/shared/renderers/threeDimension/EvergreenField.ts b/src/shared/renderers/threeDimension/EvergreenField.ts new file mode 100644 index 00000000..851009b4 --- /dev/null +++ b/src/shared/renderers/threeDimension/EvergreenField.ts @@ -0,0 +1,157 @@ +import * as THREE from "three"; +import { ALLIANCE_STATION_WIDTH, STANDARD_FIELD_LENGTH, STANDARD_FIELD_WIDTH } from "../../AdvantageScopeAssets"; +import { convert } from "../../units"; + +export default function makeEvergreenField(materialSpecular: THREE.Color, materialShininess: number): THREE.Object3D { + let field = new THREE.Group(); + + // Floor + let carpet = new THREE.Mesh( + new THREE.PlaneGeometry(STANDARD_FIELD_LENGTH + 4, STANDARD_FIELD_WIDTH + 1), + new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide }) + ); + carpet.name = "carpet"; + field.add(carpet); + + // Guardrails + const guardrailHeight = convert(20, "inches", "meters"); + [-STANDARD_FIELD_WIDTH / 2, STANDARD_FIELD_WIDTH / 2].forEach((y) => { + [0, guardrailHeight].forEach((z) => { + let guardrail = new THREE.Mesh( + new THREE.CylinderGeometry(0.02, 0.02, STANDARD_FIELD_LENGTH, 12), + new THREE.MeshPhongMaterial({ color: 0xdddddd }) + ); + field.add(guardrail); + guardrail.rotateZ(Math.PI / 2); + guardrail.position.set(0, y, z); + }); + { + let panel = new THREE.Mesh( + new THREE.PlaneGeometry(STANDARD_FIELD_LENGTH, guardrailHeight), + new THREE.MeshPhongMaterial({ + color: 0xffffff, + side: THREE.DoubleSide, + opacity: 0.25, + transparent: true + }) + ); + field.add(panel); + panel.rotateX(Math.PI / 2); + panel.position.set(0, y, guardrailHeight / 2); + } + for (let x = -STANDARD_FIELD_LENGTH / 2; x < STANDARD_FIELD_LENGTH / 2; x += STANDARD_FIELD_LENGTH / 16) { + if (x === -STANDARD_FIELD_LENGTH / 2) continue; + let guardrail = new THREE.Mesh( + new THREE.CylinderGeometry(0.02, 0.02, guardrailHeight, 12), + new THREE.MeshPhongMaterial({ color: 0xdddddd }) + ); + field.add(guardrail); + guardrail.rotateX(Math.PI / 2); + guardrail.position.set(x, y, guardrailHeight / 2); + } + }); + + // Alliance stations + const allianceStationWidth = ALLIANCE_STATION_WIDTH; + const allianceStationHeight = convert(78, "inches", "meters"); + const allianceStationSolidHeight = convert(36.75, "inches", "meters"); + const allianceStationShelfDepth = convert(12.25, "inches", "meters"); + const fillerWidth = (STANDARD_FIELD_WIDTH - allianceStationWidth * 3) / 2; + const blueColor = 0x6379a6; + const redColor = 0xa66363; + [-STANDARD_FIELD_LENGTH / 2, STANDARD_FIELD_LENGTH / 2].forEach((x) => { + [0, allianceStationSolidHeight, allianceStationHeight].forEach((z) => { + let guardrail = new THREE.Mesh( + new THREE.CylinderGeometry( + 0.02, + 0.02, + z === allianceStationSolidHeight ? allianceStationWidth * 3 : STANDARD_FIELD_WIDTH, + 12 + ), + new THREE.MeshPhongMaterial({ color: 0xdddddd }) + ); + field.add(guardrail); + guardrail.position.set(x, 0, z); + }); + [ + -STANDARD_FIELD_WIDTH / 2, + allianceStationWidth * -1.5, + allianceStationWidth * -0.5, + allianceStationWidth * 0.5, + allianceStationWidth * 1.5, + STANDARD_FIELD_WIDTH / 2 + ].forEach((y) => { + let guardrail = new THREE.Mesh( + new THREE.CylinderGeometry(0.02, 0.02, allianceStationHeight, 12), + new THREE.MeshPhongMaterial({ color: 0xdddddd }) + ); + field.add(guardrail); + guardrail.rotateX(Math.PI / 2); + guardrail.position.set(x, y, allianceStationHeight / 2); + }); + [-STANDARD_FIELD_WIDTH / 2 + fillerWidth / 2, STANDARD_FIELD_WIDTH / 2 - fillerWidth / 2].forEach((y) => { + let filler = new THREE.Mesh( + new THREE.PlaneGeometry(allianceStationHeight, fillerWidth), + new THREE.MeshPhongMaterial({ color: x < 0 ? blueColor : redColor, side: THREE.DoubleSide }) + ); + field.add(filler); + filler.rotateY(Math.PI / 2); + filler.position.set(x, y, allianceStationHeight / 2); + }); + { + let allianceWall = new THREE.Mesh( + new THREE.PlaneGeometry(allianceStationSolidHeight, allianceStationWidth * 3), + new THREE.MeshPhongMaterial({ color: x < 0 ? blueColor : redColor, side: THREE.DoubleSide }) + ); + field.add(allianceWall); + allianceWall.rotateY(Math.PI / 2); + allianceWall.position.set(x, 0, allianceStationSolidHeight / 2); + } + { + let allianceGlass = new THREE.Mesh( + new THREE.PlaneGeometry(allianceStationHeight - allianceStationSolidHeight, allianceStationWidth * 3), + new THREE.MeshPhongMaterial({ + color: x < 0 ? blueColor : redColor, + side: THREE.DoubleSide, + opacity: 0.25, + transparent: true + }) + ); + field.add(allianceGlass); + allianceGlass.rotateY(Math.PI / 2); + allianceGlass.position.set( + x, + 0, + allianceStationSolidHeight + (allianceStationHeight - allianceStationSolidHeight) / 2 + ); + } + { + let allianceShelves = new THREE.Mesh( + new THREE.PlaneGeometry(allianceStationShelfDepth, allianceStationWidth * 3), + new THREE.MeshPhongMaterial({ color: x < 0 ? blueColor : redColor, side: THREE.DoubleSide }) + ); + field.add(allianceShelves); + allianceShelves.position.set( + x + (allianceStationShelfDepth / 2) * (x > 0 ? 1 : -1), + 0, + allianceStationSolidHeight + ); + } + }); + + // Add lighting effects + field.traverse((node: any) => { + let mesh = node as THREE.Mesh; // Traverse function returns Object3d or Mesh + let isCarpet = mesh.name === "carpet"; + if (mesh.isMesh && mesh.material instanceof THREE.MeshPhongMaterial) { + if (!isCarpet && materialSpecular !== undefined && materialShininess !== undefined) { + mesh.material.specular = materialSpecular; + mesh.material.shininess = materialShininess; + } + mesh.castShadow = !isCarpet; + mesh.receiveShadow = true; + } + }); + + return field; +} diff --git a/src/shared/renderers/threeDimension/ObjectManager.ts b/src/shared/renderers/threeDimension/ObjectManager.ts new file mode 100644 index 00000000..fb6cacb7 --- /dev/null +++ b/src/shared/renderers/threeDimension/ObjectManager.ts @@ -0,0 +1,36 @@ +import * as THREE from "three"; +import { ThreeDimensionRendererCommand_AnyObj } from "../ThreeDimensionRenderer"; + +export default abstract class ObjectManager { + protected root: THREE.Object3D; + protected materialSpecular: THREE.Color; + protected materialShininess: number; + protected mode: "low-power" | "standard" | "cinematic"; + protected requestRender: () => void; + protected resolution = new THREE.Vector2(); + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void + ) { + this.root = root; + this.materialSpecular = materialSpecular; + this.materialShininess = materialShininess; + this.mode = mode; + this.requestRender = requestRender; + } + + /** Removes the objects from the scene and disposes of all resources. */ + abstract dispose(): void; + + /** Updates the canvas resolution. */ + setResolution(resolution: THREE.Vector2): void { + this.resolution = resolution; + } + + /** Updates the state of the objects based on a command. */ + abstract setObjectData(object: ObjectType): void; +} diff --git a/src/shared/renderers/threeDimension/OptimizeGeometries.ts b/src/shared/renderers/threeDimension/OptimizeGeometries.ts new file mode 100644 index 00000000..797cbcc6 --- /dev/null +++ b/src/shared/renderers/threeDimension/OptimizeGeometries.ts @@ -0,0 +1,178 @@ +import * as THREE from "three"; +import * as BufferGeometryUtils from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { disposeObject } from "../ThreeDimensionRendererImpl"; + +export default async function optimizeGeometries( + object: THREE.Object3D, + mode: "low-power" | "standard" | "cinematic", + materialSpecular: THREE.Color, + materialShininess: number, + enableSimplification = true +): Promise<{ + normal: THREE.Mesh | null; + transparent: THREE.Mesh | null; + carpet: THREE.Mesh | null; +}> { + return new Promise(async (resolve) => { + let geometries = getGeometries(object, mode, enableSimplification); + + let normalMesh: THREE.Mesh | null = null; + let transparentMesh: THREE.Mesh | null = null; + let carpetMesh: THREE.Mesh | null = null; + if (geometries.normal.length > 0) { + let geometry = BufferGeometryUtils.mergeGeometries(geometries.normal, false); + if (geometry !== null) { + normalMesh = new THREE.Mesh( + geometry, + new THREE.MeshPhongMaterial({ + vertexColors: true, + side: THREE.DoubleSide, + specular: materialSpecular, + shininess: materialShininess + }) + ); + if (mode === "cinematic") { + normalMesh.castShadow = true; + normalMesh.receiveShadow = false; + } + normalMesh.name = "normal"; + } + } + if (geometries.transparent.length > 0) { + let geometry = BufferGeometryUtils.mergeGeometries(geometries.transparent, false); + if (geometry !== null) { + transparentMesh = new THREE.Mesh( + geometry, + new THREE.MeshPhongMaterial({ + vertexColors: true, + side: THREE.DoubleSide, + specular: materialSpecular, + shininess: materialShininess, + transparent: true, + opacity: 0.2 + }) + ); + if (mode === "cinematic") { + transparentMesh.castShadow = true; + transparentMesh.receiveShadow = false; + } + transparentMesh.name = "transparent"; + } + } + if (geometries.carpet.length > 0) { + let geometry = BufferGeometryUtils.mergeGeometries(geometries.carpet, false); + if (geometry !== null) { + carpetMesh = new THREE.Mesh( + geometry, + new THREE.MeshPhongMaterial({ + vertexColors: true, + side: THREE.DoubleSide, + specular: materialSpecular, + shininess: 0 + }) + ); + if (mode === "cinematic") { + carpetMesh.castShadow = false; + carpetMesh.receiveShadow = true; + } + carpetMesh.name = "carpet"; + } + } + + disposeObject(object); + resolve({ + normal: normalMesh, + transparent: transparentMesh, + carpet: carpetMesh + }); + }); +} + +function getGeometries( + object: THREE.Object3D, + mode: "low-power" | "standard" | "cinematic", + enableSimplification = true +): { normal: THREE.BufferGeometry[]; transparent: THREE.BufferGeometry[]; carpet: THREE.BufferGeometry[] } { + let normal: THREE.BufferGeometry[] = []; + let transparent: THREE.BufferGeometry[] = []; + let carpet: THREE.BufferGeometry[] = []; + + let totalCount = 0; + object.traverse((object) => { + totalCount++; + + if (object.type === "Mesh") { + let mesh = object as THREE.Mesh; + let geometry = mesh.geometry.clone(); + mesh.updateWorldMatrix(true, false); + geometry.applyMatrix4(mesh.matrixWorld); + + let isTransparent = false; + if (!Array.isArray(mesh.material)) { + isTransparent = mesh.material.transparent && mesh.material.opacity < 0.75; + if ("color" in mesh.material) { + let rgb = (mesh.material.color as THREE.Color).toArray().map((v) => v * 255); + + const numVerts = geometry.getAttribute("position").count; + const itemSize = 3; // r, g, b + const colors = new Uint8Array(itemSize * numVerts); + + colors.forEach((_, ndx) => { + colors[ndx] = rgb[ndx % 3]; + }); + + const normalized = true; + const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized); + geometry.setAttribute("color", colorAttrib); + } + } + + let include = true; + if (enableSimplification && !mesh.name.includes("NOSIMPLIFY")) { + let vertices: THREE.Vector3[] = []; + let center = new THREE.Vector3(); + for (let i = 0; i < geometry.attributes.position.count; i++) { + let vertex = new THREE.Vector3( + geometry.attributes.position.getX(i), + geometry.attributes.position.getY(i), + geometry.attributes.position.getZ(i) + ); + vertices.push(vertex); + center.add(vertex); + } + center.divideScalar(vertices.length); + let maxDistance = vertices.reduce((prev, vertex) => { + let dist = vertex.distanceTo(center); + return dist > prev ? dist : prev; + }, 0); + switch (mode) { + case "low-power": + if (maxDistance < 0.08) include = false; + break; + case "standard": + if (maxDistance < 0.04) include = false; + break; + case "cinematic": + if (maxDistance < 0.02) include = false; + break; + } + } + + if (include) { + if (mesh.name.toLowerCase().includes("carpet")) { + carpet.push(geometry); + } else if (isTransparent) { + transparent.push(geometry); + } else { + normal.push(geometry); + } + } + } + }); + + return { + normal: normal, + transparent: transparent, + carpet: carpet + }; +} diff --git a/src/shared/renderers/threeDimension/ResizableInstancedMesh.ts b/src/shared/renderers/threeDimension/ResizableInstancedMesh.ts new file mode 100644 index 00000000..3430ce49 --- /dev/null +++ b/src/shared/renderers/threeDimension/ResizableInstancedMesh.ts @@ -0,0 +1,97 @@ +import * as THREE from "three"; +import { Pose3d } from "../../geometry"; +import { rotation3dToQuaternion } from "../ThreeDimensionRendererImpl"; + +export default class ResizableInstancedMesh { + private parent: THREE.Object3D; + private sources: { geometry: THREE.BufferGeometry; material: THREE.Material | THREE.Material[] }[] = []; + private castShadow: boolean[]; + + private count = 0; + private dummy = new THREE.Object3D(); + private meshes: (THREE.InstancedMesh | null)[] = []; + + constructor( + parent: THREE.Object3D, + sources: (THREE.Mesh | { geometry: THREE.BufferGeometry; material: THREE.Material | THREE.Material[] })[], + castShadow?: boolean[] + ) { + this.parent = parent; + this.castShadow = []; + sources.forEach((source, index) => { + this.sources.push({ geometry: source.geometry, material: source.material }); + this.meshes.push(null); + if (castShadow !== undefined && index < castShadow.length) { + this.castShadow.push(castShadow[index]); + } else { + this.castShadow.push(true); + } + }); + } + + dispose(disposeGeometries = true, disposeMaterials = true): void { + this.meshes.forEach((mesh) => { + if (mesh !== null) { + this.parent.remove(mesh); + mesh.dispose(); + } + }); + + this.sources.forEach((source) => { + if (disposeGeometries) { + source.geometry.dispose(); + } + if (disposeMaterials) { + if (Array.isArray(source.material)) { + source.material.forEach((material) => material.dispose()); + } else { + source.material.dispose(); + } + } + }); + } + + setPoses(poses: Pose3d[]): void { + // Resize instanced mesh + if (poses.length > this.count) { + if (this.count === 0) this.count = 1; + while (this.count < poses.length) { + this.count *= 2; + } + + this.meshes.forEach((mesh, i) => { + if (mesh !== null) { + this.parent.remove(mesh); + mesh.dispose(); + } + + this.meshes[i] = new THREE.InstancedMesh(this.sources[i].geometry, this.sources[i].material, this.count); + this.meshes[i]!.castShadow = this.castShadow[i]; + this.meshes[i]!.frustumCulled = false; + this.parent.add(this.meshes[i]!); + }); + } + + // Update all poses + for (let i = 0; i < this.count; i++) { + if (i < poses.length) { + this.dummy.position.set(...poses[i].translation); + this.dummy.rotation.setFromQuaternion(rotation3dToQuaternion(poses[i].rotation)); + } else { + this.dummy.position.set(1e6, 1e6, 1e6); + } + this.dummy.updateMatrix(); + this.meshes.forEach((mesh) => { + mesh?.setMatrixAt(i, this.dummy.matrix); + }); + } + + // Trigger instanced mesh update + this.meshes.forEach((mesh) => { + if (mesh !== null) { + mesh.instanceMatrix.needsUpdate = true; + mesh.computeBoundingBox(); + } + }); + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/AprilTagManager.ts b/src/shared/renderers/threeDimension/objectManagers/AprilTagManager.ts new file mode 100644 index 00000000..0d8d6c99 --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/AprilTagManager.ts @@ -0,0 +1,93 @@ +import * as THREE from "three"; +import { APRIL_TAG_16H5_SIZE, APRIL_TAG_36H11_SIZE } from "../../../geometry"; +import { zfill } from "../../../util"; +import { ThreeDimensionRendererCommand_AprilTagObj } from "../../ThreeDimensionRenderer"; +import { rotation3dToQuaternion } from "../../ThreeDimensionRendererImpl"; +import ObjectManager from "../ObjectManager"; + +export default class AprilTagManager extends ObjectManager { + private tags: { idStr: string; active: boolean; object: THREE.Mesh }[] = []; + + private textureLoader = new THREE.TextureLoader(); + private geometry36h11 = new THREE.BoxGeometry(0.02, APRIL_TAG_36H11_SIZE, APRIL_TAG_36H11_SIZE).rotateX(Math.PI / 2); + private geometry16h5 = new THREE.BoxGeometry(0.02, APRIL_TAG_16H5_SIZE, APRIL_TAG_16H5_SIZE).rotateX(Math.PI / 2); + private whiteMaterial = new THREE.MeshPhongMaterial({ + color: 0xffffff, + specular: this.materialSpecular, + shininess: this.materialShininess + }); + private idTextures: THREE.Texture[] = []; + + dispose(): void { + this.tags.forEach((tag) => { + this.root.remove(tag.object); + }); + this.geometry36h11.dispose(); + this.geometry16h5.dispose(); + this.whiteMaterial.dispose(); + this.idTextures.forEach((texture) => texture.dispose()); + } + + setObjectData(object: ThreeDimensionRendererCommand_AprilTagObj): void { + this.tags.forEach((entry) => (entry.active = false)); + + object.poses.forEach((annotatedPose) => { + let idStr = + object.family + + (annotatedPose.annotation.aprilTagId === undefined ? "" : annotatedPose.annotation.aprilTagId.toString()); + + // Find tag object + let entry = this.tags.find((x) => !x.active && x.idStr === idStr); + if (entry === undefined) { + // Make new object + entry = { + idStr: idStr, + active: true, + object: new THREE.Mesh(object.family === "36h11" ? this.geometry36h11 : this.geometry16h5, [ + this.whiteMaterial, // Front face, temporary until texture is loaded + this.whiteMaterial, + this.whiteMaterial, + this.whiteMaterial, + this.whiteMaterial, + this.whiteMaterial + ]) + }; + entry.object.castShadow = true; + this.root.add(entry.object); + this.textureLoader.load( + "../www/textures/apriltag-" + + object.family + + "/" + + (annotatedPose.annotation.aprilTagId === undefined + ? "smile" + : zfill(annotatedPose.annotation.aprilTagId.toString(), 3)) + + ".png", + (texture) => { + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + this.idTextures.push(texture); + this.requestRender(); + if (entry !== undefined && Array.isArray(entry.object.material)) { + entry.object.material[0] = new THREE.MeshPhongMaterial({ + map: texture, + specular: this.materialSpecular, + shininess: this.materialShininess + }); + } + } + ); + this.tags.push(entry); + } else { + entry.active = true; + } + + // Update object pose + entry.object.rotation.setFromQuaternion(rotation3dToQuaternion(annotatedPose.pose.rotation)); + entry.object.position.set(...annotatedPose.pose.translation); + }); + + this.tags.forEach((entry) => { + entry.object.visible = entry.active; + }); + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/AxesManager.ts b/src/shared/renderers/threeDimension/objectManagers/AxesManager.ts new file mode 100644 index 00000000..4fd00d05 --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/AxesManager.ts @@ -0,0 +1,37 @@ +import * as THREE from "three"; +import { ThreeDimensionRendererCommand_AxesObj } from "../../ThreeDimensionRenderer"; +import makeAxesTemplate from "../AxesTemplate"; +import ObjectManager from "../ObjectManager"; +import optimizeGeometries from "../OptimizeGeometries"; +import ResizableInstancedMesh from "../ResizableInstancedMesh"; + +export default class AxesManager extends ObjectManager { + private instances: ResizableInstancedMesh | null = null; + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void + ) { + super(root, materialSpecular, materialShininess, mode, requestRender); + + let axes = makeAxesTemplate(this.materialSpecular, this.materialShininess); + axes.scale.set(0.25, 0.25, 0.25); + optimizeGeometries(axes, this.mode, this.materialSpecular, this.materialShininess, false).then((result) => { + let axesMerged = result.normal; + if (axesMerged !== null) { + this.instances = new ResizableInstancedMesh(root, [axesMerged]); + } + }); + } + + dispose(): void { + this.instances?.dispose(); + } + + setObjectData(object: ThreeDimensionRendererCommand_AxesObj): void { + this.instances?.setPoses(object.poses.map((x) => x.pose)); + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/ConeManager.ts b/src/shared/renderers/threeDimension/objectManagers/ConeManager.ts new file mode 100644 index 00000000..505be898 --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/ConeManager.ts @@ -0,0 +1,99 @@ +import * as THREE from "three"; +import { ThreeDimensionRendererCommand_ConeObj } from "../../ThreeDimensionRenderer"; +import ObjectManager from "../ObjectManager"; +import ResizableInstancedMesh from "../ResizableInstancedMesh"; + +export default class ConeManager extends ObjectManager { + private instances: ResizableInstancedMesh; + + private geometry: THREE.ConeGeometry; + private mainMaterial: THREE.MeshPhongMaterial; + private baseMaterial: THREE.MeshPhongMaterial; + private mainContext: CanvasRenderingContext2D = document.createElement("canvas").getContext("2d")!; + private baseContext: CanvasRenderingContext2D = document.createElement("canvas").getContext("2d")!; + private mainTexture = new THREE.CanvasTexture(this.mainContext.canvas); + private baseTexture = new THREE.CanvasTexture(this.baseContext.canvas); + + private lastPosition: "center" | "back" | "front" = "center"; + private lastColor = ""; + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void + ) { + super(root, materialSpecular, materialShininess, mode, requestRender); + + this.geometry = new THREE.ConeGeometry(0.06, 0.25, 16, 32); + this.geometry.rotateZ(-Math.PI / 2); + this.geometry.rotateX(-Math.PI / 2); + + this.mainContext.canvas.width = 100; + this.mainContext.canvas.height = 100; + this.baseContext.canvas.width = 100; + this.baseContext.canvas.height = 100; + + this.mainMaterial = new THREE.MeshPhongMaterial({ + map: this.mainTexture, + specular: materialSpecular, + shininess: materialShininess + }); + let secondMaterial = new THREE.MeshPhongMaterial({ + specular: materialSpecular, + shininess: materialShininess + }); + this.baseMaterial = new THREE.MeshPhongMaterial({ + map: this.baseTexture, + specular: materialSpecular, + shininess: materialShininess + }); + + this.instances = new ResizableInstancedMesh(root, [ + { + geometry: this.geometry, + material: [this.mainMaterial, secondMaterial, this.baseMaterial] + } + ]); + } + + dispose(): void { + this.mainTexture.dispose(); + this.baseTexture.dispose(); + this.instances.dispose(); + } + + setObjectData(object: ThreeDimensionRendererCommand_ConeObj): void { + if (object.position !== this.lastPosition) { + let delta = this.getOffset(object.position) - this.getOffset(this.lastPosition); + this.geometry.translate(delta, 0, 0); + this.lastPosition = object.position; + } + + if (object.color !== this.lastColor) { + this.mainContext.fillStyle = object.color; + this.baseContext.fillStyle = object.color; + this.mainContext.fillRect(0, 0, 100, 100); + this.baseContext.fillRect(0, 0, 100, 100); + this.mainContext.fillStyle = "black"; + this.mainContext.fillRect(20, 0, 10, 100); + this.mainTexture.needsUpdate = true; + this.baseTexture.needsUpdate = true; + this.lastColor = object.color; + } + + this.instances.setPoses(object.poses.map((x) => x.pose)); + } + + private getOffset(position: "center" | "front" | "back"): number { + switch (position) { + case "center": + return 0.0; + case "front": + return -0.125; + case "back": + return 0.125; + } + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/GamePieceManager.ts b/src/shared/renderers/threeDimension/objectManagers/GamePieceManager.ts new file mode 100644 index 00000000..aaf7edc3 --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/GamePieceManager.ts @@ -0,0 +1,42 @@ +import * as THREE from "three"; +import { ThreeDimensionRendererCommand_GamePieceObj } from "../../ThreeDimensionRenderer"; +import ObjectManager from "../ObjectManager"; +import ResizableInstancedMesh from "../ResizableInstancedMesh"; + +export default class GamePieceManager extends ObjectManager { + private gamePieces: { [key: string]: THREE.Mesh }; + private instances: ResizableInstancedMesh | null = null; + private lastVariant = ""; + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void, + gamePieces: { [key: string]: THREE.Mesh } + ) { + super(root, materialSpecular, materialShininess, mode, requestRender); + this.gamePieces = gamePieces; + } + + dispose(): void { + // Don't dispose game piece geometries/materials + // (owned by main renderer and disposed there) + this.instances?.dispose(false, false); + } + + setObjectData(object: ThreeDimensionRendererCommand_GamePieceObj): void { + // Create new instances + if (object.variant !== this.lastVariant || this.instances === null) { + this.lastVariant = object.variant; + this.instances?.dispose(false, false); + if (object.variant in this.gamePieces) { + this.instances = new ResizableInstancedMesh(this.root, [this.gamePieces[object.variant]]); + } + } + + // Update poses + this.instances?.setPoses(object.poses.map((x) => x.pose)); + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/HeatmapManager.ts b/src/shared/renderers/threeDimension/objectManagers/HeatmapManager.ts new file mode 100644 index 00000000..039f9b90 --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/HeatmapManager.ts @@ -0,0 +1,80 @@ +import * as THREE from "three"; +import { Config3dField } from "../../../AdvantageScopeAssets"; +import { translation3dTo2d } from "../../../geometry"; +import { convert } from "../../../units"; +import Heatmap from "../../Heatmap"; +import { ThreeDimensionRendererCommand_HeatmapObj } from "../../ThreeDimensionRenderer"; +import { disposeObject } from "../../ThreeDimensionRendererImpl"; +import ObjectManager from "../ObjectManager"; + +export default class HeatmapManager extends ObjectManager { + private HEIGHT_PIXELS = 800; + + private getFieldConfig: () => Config3dField | null; + private container = document.createElement("div"); + private heatmap = new Heatmap(this.container); + private canvas: HTMLCanvasElement | null = null; + private mesh: THREE.Mesh | null = null; + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void, + getFieldConfig: () => Config3dField | null + ) { + super(root, materialSpecular, materialShininess, mode, requestRender); + this.getFieldConfig = getFieldConfig; + this.container.hidden = true; + document.body.appendChild(this.container); + } + + dispose(): void { + if (this.mesh !== null) { + this.root.remove(this.mesh); + disposeObject(this.mesh); + } + document.body.removeChild(this.container); + } + + setObjectData(object: ThreeDimensionRendererCommand_HeatmapObj): void { + let fieldConfig = this.getFieldConfig(); + if (fieldConfig === null) return; + + // Update heatmap + let fieldDimensions: [number, number] = [ + convert(fieldConfig.widthInches, "inches", "meters"), + convert(fieldConfig.heightInches, "inches", "meters") + ]; + let pixelDimensions: [number, number] = [ + Math.round(this.HEIGHT_PIXELS * (fieldDimensions[0] / fieldDimensions[1])), + this.HEIGHT_PIXELS + ]; + let translations = object.poses.map((x) => translation3dTo2d(x.pose.translation)); + this.heatmap.update(translations, pixelDimensions, fieldDimensions); + + // Update texture + let newCanvas = this.heatmap.getCanvas(); + if (newCanvas !== this.canvas && newCanvas !== null) { + this.canvas = newCanvas; + + if (this.mesh !== null) { + this.root.remove(this.mesh); + disposeObject(this.mesh); + } + this.mesh = new THREE.Mesh( + new THREE.PlaneGeometry(fieldDimensions[0], fieldDimensions[1]), + new THREE.MeshPhongMaterial({ + map: new THREE.CanvasTexture(this.canvas), + transparent: true + }) + ); + this.mesh.position.set(fieldDimensions[0] / 2, fieldDimensions[1] / 2, 0.02); + this.root.add(this.mesh); + } + if (this.mesh !== null) { + (this.mesh.material as THREE.MeshPhongMaterial).map!.needsUpdate = true; + } + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/RobotManager.ts b/src/shared/renderers/threeDimension/objectManagers/RobotManager.ts new file mode 100644 index 00000000..b69f029a --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/RobotManager.ts @@ -0,0 +1,430 @@ +import * as THREE from "three"; +import { Line2 } from "three/examples/jsm/lines/Line2.js"; +import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js"; +import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; +import WorkerManager from "../../../../hub/WorkerManager"; +import { SwerveState } from "../../../geometry"; +import { convert } from "../../../units"; +import { transformPx } from "../../../util"; +import { + ThreeDimensionRendererCommand_GhostObj, + ThreeDimensionRendererCommand_RobotObj +} from "../../ThreeDimensionRenderer"; +import { + getQuaternionFromRotSeq, + quaternionToRotation3d, + rotation3dToQuaternion +} from "../../ThreeDimensionRendererImpl"; +import ObjectManager from "../ObjectManager"; +import ResizableInstancedMesh from "../ResizableInstancedMesh"; + +export default class RobotManager extends ObjectManager< + ThreeDimensionRendererCommand_RobotObj | ThreeDimensionRendererCommand_GhostObj +> { + private SWERVE_CANVAS_PX = 2000; + private SWERVE_CANVAS_METERS = 4; + private SWERVE_BUMPER_OFFSET = 0.15; + + private loadingStart: () => void; + private loadingEnd: () => void; + + private meshes: ResizableInstancedMesh[] = []; + private dimensions: [number, number, number, number] = [0, 0, 0, 0]; // Distance to each side + private ghostMaterial = new THREE.MeshPhongMaterial({ + transparent: true, + opacity: 0.35, + specular: this.materialSpecular, + shininess: this.materialShininess + }); + private visionLines: Line2[] = []; + private mechanismLines: { + mesh: ResizableInstancedMesh; + geometry: THREE.BoxGeometry; + scale: THREE.Vector3; + translation: THREE.Vector3; + material: THREE.MeshPhongMaterial; + }[] = []; + + private swerveContainer = document.createElement("div"); + private swerveCanvas = document.createElement("canvas"); + private swerveTexture = new THREE.CanvasTexture(this.swerveCanvas); + + private loadingCounter = 0; + private shouldLoadNewModel = false; + private isLoading = false; + private dummyConfigPose = new THREE.Object3D(); + private dummyUserPose = new THREE.Group().add(this.dummyConfigPose); + private dummyRobotPose = new THREE.Group().add(this.dummyUserPose); + private hasNewAssets = false; + private lastModel = ""; + private lastColor = ""; + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void, + loadingStart: () => void, + loadingEnd: () => void + ) { + super(root, materialSpecular, materialShininess, mode, requestRender); + this.loadingStart = loadingStart; + this.loadingEnd = loadingEnd; + + this.swerveContainer.hidden = true; + this.swerveContainer.appendChild(this.swerveCanvas); + this.swerveContainer.style.width = this.SWERVE_CANVAS_PX.toString() + "px"; + this.swerveContainer.style.height = this.SWERVE_CANVAS_PX.toString() + "px"; + this.swerveCanvas.width = this.SWERVE_CANVAS_PX; + this.swerveCanvas.height = this.SWERVE_CANVAS_PX; + document.body.appendChild(this.swerveContainer); + } + + dispose(): void { + this.meshes.forEach((mesh) => { + mesh.dispose(); + }); + this.mechanismLines.forEach((entry) => { + entry.mesh.dispose(); + }); + while (this.visionLines.length > 0) { + this.visionLines[0].geometry.dispose(); + this.visionLines[0].material.dispose(); + this.root.remove(this.visionLines[0]); + this.visionLines.shift(); + } + this.swerveTexture.dispose(); + } + + setResolution(resolution: THREE.Vector2) { + super.setResolution(resolution); + this.visionLines.forEach((line) => (line.material.resolution = resolution)); + } + + newAssets() { + this.hasNewAssets = true; + } + + setObjectData(object: ThreeDimensionRendererCommand_RobotObj | ThreeDimensionRendererCommand_GhostObj): void { + let robotConfig = window.assets?.robots.find((robotData) => robotData.name === object.model); + + // Load new robot model + if (object.model !== this.lastModel || this.hasNewAssets) { + this.shouldLoadNewModel = true; + this.lastModel = object.model; + this.hasNewAssets = false; + } + if (this.shouldLoadNewModel && !this.isLoading) { + this.shouldLoadNewModel = false; + this.meshes.forEach((mesh) => { + mesh.dispose(); + }); + this.meshes = []; + + if (robotConfig !== undefined) { + this.loadingCounter++; + let loadingCounter = this.loadingCounter; + this.loadingStart(); + if (this.isLoading) this.loadingEnd(); + this.isLoading = true; + WorkerManager.request("../bundles/shared$loadRobot.js", { + robotConfig: robotConfig!, + mode: this.mode, + materialSpecular: this.materialSpecular.toArray(), + materialShininess: this.materialShininess + }).then((result: THREE.MeshJSON[][]) => { + if (loadingCounter !== this.loadingCounter) { + // Model was switched, throw away the data :( + return; + } + + const loader = new THREE.ObjectLoader(); + this.meshes = []; + this.dimensions = [0, 0, 0, 0]; + + result.forEach((sceneMeshJSONs, index) => { + // Load meshes + let sceneMeshes: THREE.Mesh[] = sceneMeshJSONs.map((json) => loader.parse(json) as THREE.Mesh); + sceneMeshes.forEach((mesh) => { + if (index === 0) { + mesh.geometry.computeBoundingBox(); + let box = mesh.geometry.boundingBox; + if (box !== null) { + this.dimensions[0] = Math.max(this.dimensions[0], box.max.x); + this.dimensions[1] = Math.max(this.dimensions[1], box.max.y); + this.dimensions[2] = Math.max(this.dimensions[2], -box.min.x); + this.dimensions[3] = Math.max(this.dimensions[3], -box.min.y); + } + } + + if (object.type === "ghost") { + if (!Array.isArray(mesh.material)) { + mesh.material.dispose(); + } + mesh.material = this.ghostMaterial; + } + }); + + // Add swerve mesh + if (index === 0) { + let swerveMesh = new THREE.Mesh( + new THREE.PlaneGeometry(this.SWERVE_CANVAS_METERS, this.SWERVE_CANVAS_METERS).translate(0, 0, 0.1), + new THREE.MeshPhongMaterial({ + map: this.swerveTexture, + transparent: true, + side: THREE.DoubleSide + }) + ); + swerveMesh.renderOrder = 999; + swerveMesh.material.depthTest = false; + swerveMesh.material.transparent = true; + sceneMeshes.push(swerveMesh); + } + + let castShadow = new Array(sceneMeshes.length).fill(true); + castShadow[castShadow.length - 1] = false; + this.meshes.push(new ResizableInstancedMesh(this.root, sceneMeshes, castShadow)); + }); + + this.requestRender(); + this.loadingEnd(); + this.isLoading = false; + }); + } + } + + // Update color + if (object.type === "ghost" && object.color !== this.lastColor) { + this.lastColor = object.color; + this.ghostMaterial.color = new THREE.Color(object.color); + } + + // Update primary model + if (this.meshes.length > 0) { + this.meshes[0].setPoses(object.poses.map((x) => x.pose)); + } + + // Update components + for (let i = 1; i < this.meshes.length; i++) { + this.meshes[i].setPoses( + object.poses + .map((x) => x.pose) + .map((robotPose) => { + this.dummyRobotPose.rotation.setFromQuaternion(rotation3dToQuaternion(robotPose.rotation)); + this.dummyRobotPose.position.set(...robotPose.translation); + + let userPose = + i - 1 < object.components.length && robotConfig !== undefined ? object.components[i - 1].pose : null; + if (userPose === null) { + this.dummyUserPose.rotation.set(0, 0, 0); + this.dummyUserPose.position.set(0, 0, 0); + } else { + this.dummyUserPose.rotation.setFromQuaternion(rotation3dToQuaternion(userPose.rotation)); + this.dummyUserPose.position.set(...userPose.translation); + } + + let configRotations = + userPose === null ? robotConfig!.rotations : robotConfig!.components[i - 1].zeroedRotations; + let configPosition = + userPose === null ? robotConfig!.position : robotConfig!.components[i - 1].zeroedPosition; + this.dummyConfigPose.rotation.setFromQuaternion(getQuaternionFromRotSeq(configRotations)); + this.dummyConfigPose.position.set(...configPosition); + + return { + translation: this.dummyConfigPose.getWorldPosition(new THREE.Vector3()).toArray(), + rotation: quaternionToRotation3d(this.dummyConfigPose.getWorldQuaternion(new THREE.Quaternion())) + }; + }) + ); + } + + // Update vision lines + if (object.poses.length === 0) { + // Remove all lines + while (this.visionLines.length > 0) { + this.root.remove(this.visionLines[0]); + this.visionLines.shift(); + } + } else { + while (this.visionLines.length > object.visionTargets.length) { + // Remove extra lines + this.visionLines[0].geometry.dispose(); + this.visionLines[0].material.dispose(); + this.root.remove(this.visionLines[0]); + this.visionLines.shift(); + } + while (this.visionLines.length < object.visionTargets.length) { + // Add new lines + let line = new Line2( + new LineGeometry(), + new LineMaterial({ + linewidth: 1, + resolution: this.resolution + }) + ); + this.visionLines.push(line); + this.root.add(line); + } + for (let i = 0; i < this.visionLines.length; i++) { + // Update poses + let translation = object.visionTargets[i].pose.translation; + if (object.visionTargets[i].annotation.is2DSource) { + // 2D targets shouldn't be rendered in the floor + translation[2] = 0.5; + } + this.visionLines[i].geometry.setPositions([ + object.poses[0].pose.translation[0], + object.poses[0].pose.translation[1], + object.poses[0].pose.translation[2] + 0.5, + translation[0], + translation[1], + translation[2] + ]); + this.visionLines[i].geometry.attributes.position.needsUpdate = true; + let color = object.visionTargets[i].annotation.visionColor; + if (color !== undefined) { + this.visionLines[i].material.color = new THREE.Color(color); + } + } + } + + // Update mechanism + if (object.mechanism === null) { + // No mechanism data, remove all meshes + while (this.mechanismLines.length > 0) { + this.mechanismLines[0].mesh.dispose(true, object.type === "robot"); // Ghost material is shared, don't dispose + this.mechanismLines.shift(); + } + } else { + // Remove extra lines + while (this.mechanismLines.length > object.mechanism.lines.length) { + this.mechanismLines[0].mesh.dispose(true, object.type === "robot"); // Ghost material is shared, don't dispose + this.mechanismLines.shift(); + } + + // Add new lines + while (this.mechanismLines.length < object.mechanism.lines.length) { + const geometry = new THREE.BoxGeometry(1, 1, 1); + const material = + object.type === "ghost" + ? this.ghostMaterial + : new THREE.MeshPhongMaterial({ specular: this.materialSpecular, shininess: this.materialShininess }); + this.mechanismLines.push({ + mesh: new ResizableInstancedMesh(this.root, [{ geometry: geometry, material: material }]), + geometry: geometry, + scale: new THREE.Vector3(1, 1, 1), + translation: new THREE.Vector3(), + material: material + }); + } + + // Update children + for (let i = 0; i < object.mechanism.lines.length; i++) { + const line = object.mechanism.lines[i]; + const meshEntry = this.mechanismLines[i]; + + const length = Math.hypot(line.end[1] - line.start[1], line.end[0] - line.start[0]); + const angle = Math.atan2(line.end[1] - line.start[1], line.end[0] - line.start[0]); + + // Update length + const newScale = new THREE.Vector3(length, line.weight * 0.01, line.weight * 0.01); + const newTranslation = new THREE.Vector3(length / 2, 0, 0); + const scaleFactor = newScale.clone().divide(meshEntry.scale); + const translationDelta = newTranslation.clone().sub(meshEntry.translation); + meshEntry.scale = newScale; + meshEntry.translation = newTranslation; + if (!scaleFactor.equals(new THREE.Vector3(1, 1, 1))) { + meshEntry.geometry.scale(scaleFactor.x, scaleFactor.y, scaleFactor.z); + meshEntry.geometry.translate(translationDelta.x, translationDelta.y, translationDelta.z); + } + + // Update color + if (object.type !== "ghost") { + meshEntry.material.color = new THREE.Color(line.color); + } + + // Update pose + meshEntry.mesh.setPoses( + object.poses + .map((x) => x.pose) + .map((robotPose) => { + this.dummyRobotPose.rotation.setFromQuaternion(rotation3dToQuaternion(robotPose.rotation)); + this.dummyRobotPose.position.set(...robotPose.translation); + + this.dummyUserPose.position.set(line.start[0] - object.mechanism!.dimensions[0] / 2, 0, line.start[1]); + this.dummyUserPose.rotation.set(0, -angle, 0); + + return { + translation: this.dummyUserPose.getWorldPosition(new THREE.Vector3()).toArray(), + rotation: quaternionToRotation3d(this.dummyUserPose.getWorldQuaternion(new THREE.Quaternion())) + }; + }) + ); + } + } + + // Update swerve canvas + let context = this.swerveCanvas.getContext("2d")!; + context.clearRect(0, 0, this.SWERVE_CANVAS_PX, this.SWERVE_CANVAS_PX); + const pxPerMeter = this.SWERVE_CANVAS_PX / this.SWERVE_CANVAS_METERS; + const moduleX = (Math.min(this.dimensions[0], this.dimensions[2]) - this.SWERVE_BUMPER_OFFSET) * pxPerMeter; + const moduleY = (Math.min(this.dimensions[1], this.dimensions[3]) - this.SWERVE_BUMPER_OFFSET) * pxPerMeter; + const centerPx = [this.SWERVE_CANVAS_PX / 2, this.SWERVE_CANVAS_PX / 2]; + ( + [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1] + ] as const + ).forEach((cornerMultipliers, index) => { + let moduleCenterPx = [ + centerPx[0] + moduleX * cornerMultipliers[0], + centerPx[1] - moduleY * cornerMultipliers[1] + ] as [number, number]; + + // Draw module data + let drawModuleData = (state: SwerveState, color: string) => { + context.lineWidth = 0.03 * pxPerMeter; + context.strokeStyle = color; + context.lineCap = "round"; + context.lineJoin = "round"; + + // Draw speed + if (Math.abs(state.speed) <= 0.001) return; + let vectorSpeed = state.speed / 5; + let vectorRotation = state.angle; + if (state.speed < 0) { + vectorSpeed *= -1; + vectorRotation += Math.PI; + } + if (vectorSpeed < 0.05) return; + let vectorLength = pxPerMeter * convert(36, "inches", "meters") * vectorSpeed; + let arrowBack = transformPx(moduleCenterPx, vectorRotation, [0, 0]); + let arrowFront = transformPx(moduleCenterPx, vectorRotation, [vectorLength, 0]); + let arrowLeft = transformPx(moduleCenterPx, vectorRotation, [ + vectorLength - pxPerMeter * 0.1, + pxPerMeter * 0.1 + ]); + let arrowRight = transformPx(moduleCenterPx, vectorRotation, [ + vectorLength - pxPerMeter * 0.1, + pxPerMeter * -0.1 + ]); + context.beginPath(); + context.moveTo(...arrowBack); + context.lineTo(...arrowFront); + context.moveTo(...arrowLeft); + context.lineTo(...arrowFront); + context.lineTo(...arrowRight); + context.stroke(); + }; + object.swerveStates.forEach((set) => { + if (index < set.values.length) { + drawModuleData(set.values[index], set.color); + } + }); + }); + this.swerveTexture.needsUpdate = true; + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/TrajectoryManager.ts b/src/shared/renderers/threeDimension/objectManagers/TrajectoryManager.ts new file mode 100644 index 00000000..d23b27b9 --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/TrajectoryManager.ts @@ -0,0 +1,62 @@ +import * as THREE from "three"; +import { Line2 } from "three/examples/jsm/lines/Line2.js"; +import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js"; +import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; +import { ThreeDimensionRendererCommand_TrajectoryObj } from "../../ThreeDimensionRenderer"; +import ObjectManager from "../ObjectManager"; + +export default class TrajectoryManager extends ObjectManager { + private line: Line2; + private length = 0; + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void + ) { + super(root, materialSpecular, materialShininess, mode, requestRender); + + this.line = new Line2( + new LineGeometry(), + new LineMaterial({ color: 0xff8c00, linewidth: 2, resolution: this.resolution }) + ); + this.root.add(this.line); + } + + dispose(): void { + this.root.remove(this.line); + this.line.geometry.dispose(); + this.line.material.dispose(); + } + + setResolution(resolution: THREE.Vector2) { + super.setResolution(resolution); + this.line.material.resolution = resolution; + } + + setObjectData(object: ThreeDimensionRendererCommand_TrajectoryObj): void { + if (object.poses.length <= 1) { + this.line.visible = false; + } else { + this.line.visible = true; + if (object.poses.length !== this.length) { + this.line.geometry.dispose(); + this.line.geometry = new LineGeometry(); + this.length = object.poses.length; + } + let positionData: number[] = []; + object.poses.forEach((annotatedPose) => { + let translation = annotatedPose.pose.translation; + if (annotatedPose.annotation.is2DSource) { + // 2D trajectories should be moved just above the carpet for cleaner rendering + translation[2] = 0.02; + } + positionData = positionData.concat(translation); + }); + this.line.geometry.setPositions(positionData); + this.line.geometry.attributes.position.needsUpdate = true; + } + } +} diff --git a/src/shared/renderers/threeDimension/objectManagers/ZebraManager.ts b/src/shared/renderers/threeDimension/objectManagers/ZebraManager.ts new file mode 100644 index 00000000..9586a77a --- /dev/null +++ b/src/shared/renderers/threeDimension/objectManagers/ZebraManager.ts @@ -0,0 +1,48 @@ +import * as THREE from "three"; +import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js"; +import { ThreeDimensionRendererCommand_ZebraMarkerObj } from "../../ThreeDimensionRenderer"; +import ObjectManager from "../ObjectManager"; + +export default class ZebraManager extends ObjectManager { + private mesh = new THREE.Mesh( + new THREE.CylinderGeometry(0.1, 0.1, 1).rotateX(Math.PI / 2).translate(0, 0, 0.5), + new THREE.MeshPhongMaterial({ + specular: this.materialSpecular, + shininess: this.materialShininess + }) + ); + private labelDiv = document.createElement("div"); + private label = new CSS2DObject(this.labelDiv); + + constructor( + root: THREE.Object3D, + materialSpecular: THREE.Color, + materialShininess: number, + mode: "low-power" | "standard" | "cinematic", + requestRender: () => void + ) { + super(root, materialSpecular, materialShininess, mode, requestRender); + this.mesh.castShadow = true; + this.root.add(this.mesh, this.label); + } + + dispose(): void { + this.root.remove(this.mesh, this.label); + this.mesh.geometry.dispose(); + this.mesh.material.dispose(); + } + + setObjectData(object: ThreeDimensionRendererCommand_ZebraMarkerObj): void { + let visible = object.poses.length > 0; + this.mesh.visible = visible; + this.labelDiv.hidden = !visible; + if (visible) { + let annotatedPose = object.poses[0]; + this.mesh.material.color = new THREE.Color(annotatedPose.annotation.zebraAlliance === "red" ? "red" : "blue"); + this.mesh.position.set(...annotatedPose.pose.translation); + this.labelDiv.innerText = + annotatedPose.annotation.zebraTeam === undefined ? "???" : annotatedPose.annotation.zebraTeam.toString(); + this.label.position.set(annotatedPose.pose.translation[0], annotatedPose.pose.translation[1], 1.25); + } + } +} diff --git a/src/shared/renderers/threeDimension/workers/loadField.ts b/src/shared/renderers/threeDimension/workers/loadField.ts new file mode 100644 index 00000000..edcd6f22 --- /dev/null +++ b/src/shared/renderers/threeDimension/workers/loadField.ts @@ -0,0 +1,107 @@ +import * as THREE from "three"; +import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { Config3dField } from "../../../AdvantageScopeAssets"; +import { getQuaternionFromRotSeq } from "../../ThreeDimensionRendererImpl"; +import optimizeGeometries from "../OptimizeGeometries"; +import { prepareTransfer } from "./prepareTransfer"; + +self.onmessage = (event) => { + // WORKER SETUP + self.onmessage = null; + let { id, payload } = event.data; + function resolve(result: any, transfer: Transferable[]) { + // @ts-expect-error + self.postMessage({ id: id, payload: result }, transfer); + } + + // MAIN LOGIC + + const fieldConfig: Config3dField = payload.fieldConfig; + const mode: "cinematic" | "standard" | "low-power" = payload.mode; + const materialSpecular = new THREE.Color().fromArray(payload.materialSpecular); + const materialShininess: number = payload.materialShininess; + + let field = new THREE.Object3D(); + let fieldStagedPieces = new THREE.Object3D(); + let fieldPieces: { [key: string]: THREE.Mesh } = {}; + + const gltfLoader = new GLTFLoader(); + Promise.all([ + new Promise((resolve) => { + gltfLoader.load(fieldConfig.path, resolve); + }), + ...fieldConfig.gamePieces.map( + (_, index) => + new Promise((resolve) => { + gltfLoader.load(fieldConfig.path.slice(0, -4) + "_" + index.toString() + ".glb", resolve); + }) + ) + ]).then(async (gltfs) => { + let gltfScenes = (gltfs as GLTF[]).map((gltf) => gltf.scene); + if (fieldConfig === undefined) return; + let loadCount = 0; + gltfScenes.forEach(async (scene, index) => { + // Add to scene + if (index === 0) { + let stagedPieces = new THREE.Group(); + fieldConfig.gamePieces.forEach((gamePieceConfig) => { + gamePieceConfig.stagedObjects.forEach((stagedName) => { + let stagedObject = scene.getObjectByName(stagedName); + if (stagedObject !== undefined) { + let rotation = stagedObject.getWorldQuaternion(new THREE.Quaternion()); + let position = stagedObject.getWorldPosition(new THREE.Vector3()); + stagedObject.removeFromParent(); + stagedObject.rotation.setFromQuaternion(rotation); + stagedObject.position.copy(position); + stagedPieces.add(stagedObject); + } + }); + }); + + stagedPieces.rotation.setFromQuaternion(getQuaternionFromRotSeq(fieldConfig.rotations)); + let fieldStagedPiecesMeshes = await optimizeGeometries( + stagedPieces, + mode, + materialSpecular, + materialShininess, + false + ); + fieldStagedPieces = new THREE.Group(); + if (fieldStagedPiecesMeshes.normal !== null) fieldStagedPieces.add(fieldStagedPiecesMeshes.normal); + if (fieldStagedPiecesMeshes.transparent !== null) fieldStagedPieces.add(fieldStagedPiecesMeshes.transparent); + if (fieldStagedPiecesMeshes.carpet !== null) fieldStagedPieces.add(fieldStagedPiecesMeshes.carpet); + + scene.rotation.setFromQuaternion(getQuaternionFromRotSeq(fieldConfig.rotations)); + let fieldMeshes = await optimizeGeometries(scene, mode, materialSpecular, materialShininess); + field = new THREE.Group(); + if (fieldMeshes.normal !== null) field.add(fieldMeshes.normal); + if (fieldMeshes.transparent !== null) field.add(fieldMeshes.transparent); + if (fieldMeshes.carpet !== null) field.add(fieldMeshes.carpet); + } else { + let gamePieceConfig = fieldConfig.gamePieces[index - 1]; + scene.rotation.setFromQuaternion(getQuaternionFromRotSeq(gamePieceConfig.rotations)); + scene.position.set(...gamePieceConfig.position); + let mesh = (await optimizeGeometries(scene, mode, materialSpecular, materialShininess, false)).normal; + if (mesh !== null) { + fieldPieces[gamePieceConfig.name] = mesh; + } + } + + if (++loadCount === gltfScenes.length) { + let fieldSerialized = field.toJSON(); + let fieldStagedPiecesSerialized = fieldStagedPieces.toJSON(); + let fieldPiecesSerialized: { [key: string]: unknown } = {}; + Object.entries(fieldPieces).forEach(([name, mesh]) => { + fieldPiecesSerialized[name] = mesh.toJSON(); + }); + let result = { + field: fieldSerialized, + fieldStagedPieces: fieldStagedPiecesSerialized, + fieldPieces: fieldPiecesSerialized + }; + let transfer = prepareTransfer(result); + resolve(result, transfer); + } + }); + }); +}; diff --git a/src/shared/renderers/threeDimension/workers/loadRobot.ts b/src/shared/renderers/threeDimension/workers/loadRobot.ts new file mode 100644 index 00000000..a8620a0e --- /dev/null +++ b/src/shared/renderers/threeDimension/workers/loadRobot.ts @@ -0,0 +1,56 @@ +import * as THREE from "three"; +import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { Config3dRobot } from "../../../AdvantageScopeAssets"; +import { getQuaternionFromRotSeq } from "../../ThreeDimensionRendererImpl"; +import optimizeGeometries from "../OptimizeGeometries"; +import { prepareTransfer } from "./prepareTransfer"; + +self.onmessage = (event) => { + // WORKER SETUP + self.onmessage = null; + let { id, payload } = event.data; + function resolve(result: any, transfer: Transferable[]) { + // @ts-expect-error + self.postMessage({ id: id, payload: result }, transfer); + } + + // MAIN LOGIC + + const robotConfig: Config3dRobot = payload.robotConfig; + const mode: "cinematic" | "standard" | "low-power" = payload.mode; + const materialSpecular = new THREE.Color().fromArray(payload.materialSpecular); + const materialShininess: number = payload.materialShininess; + + let meshes: THREE.MeshJSON[][] = []; + + const gltfLoader = new GLTFLoader(); + Promise.all([ + new Promise((resolve) => { + gltfLoader.load(robotConfig.path, resolve); + }), + ...robotConfig.components.map( + (_, index) => + new Promise((resolve) => { + gltfLoader.load(robotConfig.path.slice(0, -4) + "_" + index.toString() + ".glb", resolve); + }) + ) + ]).then(async (gltfs) => { + let gltfScenes = (gltfs as GLTF[]).map((gltf) => gltf.scene); + for (let index = 0; index < gltfScenes.length; index++) { + let scene = gltfScenes[index]; + if (index === 0) { + scene.rotation.setFromQuaternion(getQuaternionFromRotSeq(robotConfig!.rotations)); + scene.position.set(...robotConfig!.position); + } + + let optimized = await optimizeGeometries(scene, mode, materialSpecular, materialShininess); + let sceneMeshes: THREE.Mesh[] = []; + if (optimized.normal !== null) sceneMeshes.push(optimized.normal); + if (optimized.transparent !== null) sceneMeshes.push(optimized.transparent); + meshes.push(sceneMeshes.map((mesh) => mesh.toJSON())); + } + + let transfer = prepareTransfer(meshes); + resolve(meshes, transfer); + }); +}; diff --git a/src/shared/renderers/threeDimension/workers/prepareTransfer.ts b/src/shared/renderers/threeDimension/workers/prepareTransfer.ts new file mode 100644 index 00000000..131009ca --- /dev/null +++ b/src/shared/renderers/threeDimension/workers/prepareTransfer.ts @@ -0,0 +1,31 @@ +const TYPED_ARRAYS: { [key: string]: new (value: any) => any } = { + Int8Array: Int8Array, + Uint8Array: Uint8Array, + Uint8ClampedArray: Uint8ClampedArray, + Int16Array: Int16Array, + Uint16Array: Uint16Array, + Int32Array: Int32Array, + Uint32Array: Uint32Array, + Float32Array: Float32Array, + Float64Array: Float64Array +}; + +/** Prepare object descriptor for transfer. + * + * Converts all internal arrays to buffered types, and returns + * a list of all such arrays to be marked for transfer. */ +export function prepareTransfer(source: any): ArrayBuffer[] { + if (Array.isArray(source)) { + if (source.length < 10) { + return ([] as ArrayBuffer[]).concat(...source.map(prepareTransfer)); + } + } else if (typeof source === "object") { + if ("type" in source && typeof source.type === "string" && source.type.endsWith("Array")) { + source.array = new TYPED_ARRAYS[source.type](source.array); + return [source.array.buffer]; + } else { + return ([] as ArrayBuffer[]).concat(...Object.values(source).map(prepareTransfer)); + } + } + return []; +} diff --git a/src/shared/units.ts b/src/shared/units.ts index ee7cdec3..d5efe635 100644 --- a/src/shared/units.ts +++ b/src/shared/units.ts @@ -85,3 +85,10 @@ export interface UnitConversionPreset { to?: string; factor: number; } + +export const NoopUnitConversion: UnitConversionPreset = { + type: null, + factor: 1 +}; + +export const MAX_RECENT_UNITS = 5; diff --git a/src/shared/util.ts b/src/shared/util.ts index d3ed51ac..c3282242 100644 --- a/src/shared/util.ts +++ b/src/shared/util.ts @@ -25,7 +25,7 @@ export function checkArrayType(value: unknown, type: string): boolean { } /** Creates a deep copy of an object by converting to and from JSON. */ -export function jsonCopy(value: any): any { +export function jsonCopy(value: T): T { return JSON.parse(JSON.stringify(value)); } @@ -36,6 +36,11 @@ export function htmlEncode(text: string): string { }); } +/** Returns an array of ascending integers with the specified length. */ +export function indexArray(length: number): number[] { + return Array.from({ length: length }, (_, i) => i); +} + /** Adjust the brightness of a HEX color.*/ export function shiftColor(color: string, shift: number): string { let colorHexArray = color.slice(1).match(/.{1,2}/g); @@ -78,7 +83,7 @@ export function scaleValue(value: number, oldRange: [number, number], newRange: return ((value - oldRange[0]) / (oldRange[1] - oldRange[0])) * (newRange[1] - newRange[0]) + newRange[0]; } -/** Converts a value between two ranges, with caching for better performance.. */ +/** Converts a value between two ranges, with caching for better performance. */ export class ValueScaler { private a: number; private b: number; @@ -151,3 +156,11 @@ export function concatBuffers(arrays: Uint8Array[]): Uint8Array { }); return result; } + +export function calcAxisStepSize(dataRange: [number, number], pixelRange: number, stepSizeTarget: number): number { + let stepCount = pixelRange / stepSizeTarget; + let stepValueApprox = (dataRange[1] - dataRange[0]) / stepCount; + let roundBase = 10 ** Math.floor(Math.log10(stepValueApprox)); + let multiplierLookup = [0, 1, 2, 2, 5, 5, 5, 5, 5, 10, 10]; // Use friendly numbers if possible + return roundBase * multiplierLookup[Math.round(stepValueApprox / roundBase)]; +} diff --git a/src/shared/visualizers/OdometryVisualizer.ts b/src/shared/visualizers/OdometryVisualizer.ts deleted file mode 100644 index eac4b914..00000000 --- a/src/shared/visualizers/OdometryVisualizer.ts +++ /dev/null @@ -1,448 +0,0 @@ -import h337 from "heatmap.js"; -import { Pose2d, Translation2d } from "../geometry"; -import { convert } from "../units"; -import { transformPx } from "../util"; -import Visualizer from "./Visualizer"; -import { typed } from "mathjs"; - -export default class OdometryVisualizer implements Visualizer { - private HEATMAP_GRID_SIZE = 0.1; - private HEATMAP_RADIUS = 0.1; // Fraction of field height - - private CONTAINER: HTMLElement; - private HEATMAP_CONTAINER: HTMLElement; - private CANVAS: HTMLCanvasElement; - private IMAGE: HTMLImageElement; - - private heatmap: h337.Heatmap<"value", "x", "y"> | null = null; - private lastWidth = 0; - private lastHeight = 0; - private lastObjectsFlipped: boolean | null = null; - private lastHeatmapData = ""; - private lastImageSource = ""; - - constructor(container: HTMLElement, heatmapContainer: HTMLElement) { - this.CONTAINER = container; - this.CANVAS = container.firstElementChild as HTMLCanvasElement; - this.HEATMAP_CONTAINER = heatmapContainer; - this.IMAGE = document.createElement("img"); - this.CANVAS.appendChild(this.IMAGE); - } - - saveState() { - return null; - } - - restoreState(): void {} - - render(command: any): number | null { - // Set up canvas - let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; - let isVertical = - command.options.orientation === "blue bottom, red top" || command.options.orientation === "red bottom, blue top"; - let width = isVertical ? this.CONTAINER.clientHeight : this.CONTAINER.clientWidth; - let height = isVertical ? this.CONTAINER.clientWidth : this.CONTAINER.clientHeight; - this.CANVAS.style.width = width.toString() + "px"; - this.CANVAS.style.height = height.toString() + "px"; - this.CANVAS.width = width * window.devicePixelRatio; - this.CANVAS.height = height * window.devicePixelRatio; - context.scale(window.devicePixelRatio, window.devicePixelRatio); - context.clearRect(0, 0, width, height); - - // Set canvas transform - switch (command.options.orientation) { - case "blue left, red right": - this.CANVAS.style.transform = "translate(-50%, -50%)"; - break; - case "red left, blue right": - this.CANVAS.style.transform = "translate(-50%, -50%) rotate(180deg)"; - break; - case "blue bottom, red top": - this.CANVAS.style.transform = "translate(-50%, -50%) rotate(-90deg)"; - break; - case "red bottom, blue top": - this.CANVAS.style.transform = "translate(-50%, -50%) rotate(90deg)"; - break; - } - - // Get game data and update image element - let gameData = window.assets?.field2ds.find((game) => game.name === command.options.game); - if (!gameData) return null; - if (gameData.path !== this.lastImageSource) { - this.lastImageSource = gameData.path; - this.IMAGE.src = gameData.path; - } - if (!(this.IMAGE.width > 0 && this.IMAGE.height > 0)) { - return null; - } - - // Determine if objects are flipped - let objectsFlipped = command.allianceRedOrigin; - - // Render background - let fieldWidth = gameData.bottomRight[0] - gameData.topLeft[0]; - let fieldHeight = gameData.bottomRight[1] - gameData.topLeft[1]; - - let topMargin = gameData.topLeft[1]; - let bottomMargin = this.IMAGE.height - gameData.bottomRight[1]; - let leftMargin = gameData.topLeft[0]; - let rightMargin = this.IMAGE.width - gameData.bottomRight[0]; - - let margin = Math.min(topMargin, bottomMargin, leftMargin, rightMargin); - let extendedFieldWidth = fieldWidth + margin * 2; - let extendedFieldHeight = fieldHeight + margin * 2; - let constrainHeight = width / height > extendedFieldWidth / extendedFieldHeight; - let imageScalar: number; - if (constrainHeight) { - imageScalar = height / extendedFieldHeight; - } else { - imageScalar = width / extendedFieldWidth; - } - let fieldCenterX = fieldWidth * 0.5 + gameData.topLeft[0]; - let fieldCenterY = fieldHeight * 0.5 + gameData.topLeft[1]; - let renderValues = [ - Math.floor(width * 0.5 - fieldCenterX * imageScalar), // X (normal) - Math.floor(height * 0.5 - fieldCenterY * imageScalar), // Y (normal) - Math.ceil(width * -0.5 - fieldCenterX * imageScalar), // X (flipped) - Math.ceil(height * -0.5 - fieldCenterY * imageScalar), // Y (flipped) - this.IMAGE.width * imageScalar, // Width - this.IMAGE.height * imageScalar // Height - ]; - context.drawImage(this.IMAGE, renderValues[0], renderValues[1], renderValues[4], renderValues[5]); - - // Calculate field edges - let canvasFieldLeft = renderValues[0] + gameData.topLeft[0] * imageScalar; - let canvasFieldTop = renderValues[1] + gameData.topLeft[1] * imageScalar; - let canvasFieldWidth = fieldWidth * imageScalar; - let canvasFieldHeight = fieldHeight * imageScalar; - let pixelsPerInch = (canvasFieldHeight / gameData.heightInches + canvasFieldWidth / gameData.widthInches) / 2; - - // Convert translation to pixel coordinates - let calcCoordinates = (translation: Translation2d, alwaysFlipped = false): [number, number] => { - if (!gameData) return [0, 0]; - let positionInches = [convert(translation[0], "meters", "inches"), convert(translation[1], "meters", "inches")]; - - positionInches[1] *= -1; // Positive y is flipped on the canvas - switch (command.options.origin) { - case "left": - break; - case "center": - positionInches[1] += gameData.heightInches / 2; - break; - case "right": - positionInches[1] += gameData.heightInches; - break; - } - - let positionPixels: [number, number] = [ - positionInches[0] * (canvasFieldWidth / gameData.widthInches), - positionInches[1] * (canvasFieldHeight / gameData.heightInches) - ]; - if (objectsFlipped || alwaysFlipped) { - positionPixels[0] = canvasFieldLeft + canvasFieldWidth - positionPixels[0]; - positionPixels[1] = canvasFieldTop + canvasFieldHeight - positionPixels[1]; - } else { - positionPixels[0] += canvasFieldLeft; - positionPixels[1] += canvasFieldTop; - } - return positionPixels; - }; - - // Recreate heatmap canvas - let newHeatmapInstance = false; - if ( - width !== this.lastWidth || - height !== this.lastHeight || - objectsFlipped !== this.lastObjectsFlipped || - !this.heatmap - ) { - newHeatmapInstance = true; - this.lastWidth = width; - this.lastHeight = height; - this.lastObjectsFlipped = objectsFlipped; - while (this.HEATMAP_CONTAINER.firstChild) { - this.HEATMAP_CONTAINER.removeChild(this.HEATMAP_CONTAINER.firstChild); - } - this.HEATMAP_CONTAINER.style.width = width.toString() + "px"; - this.HEATMAP_CONTAINER.style.height = height.toString() + "px"; - this.heatmap = h337.create({ - container: this.HEATMAP_CONTAINER, - radius: this.IMAGE.height * imageScalar * this.HEATMAP_RADIUS, - maxOpacity: 0.75 - }); - } - - // Update heatmap data - let heatmapDataString = JSON.stringify(command.poses.heatmap); - if (heatmapDataString !== this.lastHeatmapData || newHeatmapInstance) { - this.lastHeatmapData = heatmapDataString; - let grid: number[][] = []; - let fieldWidthMeters = convert(gameData.widthInches, "inches", "meters"); - let fieldHeightMeters = convert(gameData.heightInches, "inches", "meters"); - for (let x = 0; x < fieldWidthMeters + this.HEATMAP_GRID_SIZE; x += this.HEATMAP_GRID_SIZE) { - let column: number[] = []; - grid.push(column); - for (let y = 0; y < fieldHeightMeters + this.HEATMAP_GRID_SIZE; y += this.HEATMAP_GRID_SIZE) { - column.push(0); - } - } - - (command.poses.heatmap as Translation2d[]).forEach((translation) => { - let gridX = Math.floor(translation[0] / this.HEATMAP_GRID_SIZE); - let gridY = Math.floor(translation[1] / this.HEATMAP_GRID_SIZE); - if (gridX >= 0 && gridY >= 0 && gridX < grid.length && gridY < grid[0].length) { - grid[gridX][gridY] += 1; - } - }); - - let heatmapData: { x: number; y: number; value: number }[] = []; - let x = this.HEATMAP_GRID_SIZE / 2; - let y: number; - let maxValue = 0; - grid.forEach((column) => { - x += this.HEATMAP_GRID_SIZE; - y = this.HEATMAP_GRID_SIZE / 2; - column.forEach((gridValue) => { - y += this.HEATMAP_GRID_SIZE; - let coordinates = calcCoordinates([x, y]); - coordinates = [Math.round(coordinates[0]), Math.round(coordinates[1])]; - maxValue = Math.max(maxValue, gridValue); - if (gridValue > 0) { - heatmapData.push({ - x: coordinates[0], - y: coordinates[1], - value: gridValue - }); - } - }); - }); - this.heatmap.setData({ - min: 0, - max: maxValue, - data: heatmapData - }); - } - - // Copy heatmap to main canvas - context.drawImage(this.HEATMAP_CONTAINER.firstElementChild as HTMLCanvasElement, 0, 0); - - // Draw trajectories - command.poses.trajectory.forEach((trajectory: Pose2d[]) => { - context.strokeStyle = "orange"; - context.lineWidth = 2 * pixelsPerInch; - context.lineCap = "round"; - context.lineJoin = "round"; - context.beginPath(); - let firstPoint = true; - trajectory.forEach((pose) => { - if (firstPoint) { - context.moveTo(...calcCoordinates(pose.translation)); - firstPoint = false; - } else { - context.lineTo(...calcCoordinates(pose.translation)); - } - }); - context.stroke(); - }); - - // Draw vision targets - if (command.poses.robot.length > 0) { - let robotPos = calcCoordinates(command.poses.robot[0].translation); - command.poses.visionTarget.forEach((target: Pose2d) => { - context.strokeStyle = "lightgreen"; - context.lineWidth = 1 * pixelsPerInch; // 1 inch - context.beginPath(); - context.moveTo(robotPos[0], robotPos[1]); - context.lineTo(...calcCoordinates(target.translation)); - context.stroke(); - }); - } - - // Draw robots - let robotLengthPixels = pixelsPerInch * convert(command.options.size, command.options.unitDistance, "inches"); - command.poses.robot.forEach((robotPose: Pose2d, index: number) => { - let robotPos = calcCoordinates(robotPose.translation); - let rotation = robotPose.rotation; - if (objectsFlipped) rotation += Math.PI; - - // Render trail - let trailData = command.poses.trail[index] as Translation2d[]; - let trailCoordinates: [number, number][] = []; - let maxDistance = 0; - trailData.forEach((translation: Translation2d) => { - let coordinates = calcCoordinates(translation); - trailCoordinates.push(coordinates); - let distance = Math.hypot(coordinates[0] - robotPos[0], coordinates[1] - robotPos[0]); - if (distance > maxDistance) maxDistance = distance; - }); - - let gradient = context.createRadialGradient( - robotPos[0], - robotPos[1], - robotLengthPixels * 0.5, - robotPos[0], - robotPos[1], - maxDistance - ); - gradient.addColorStop(0, "rgba(170, 170, 170, 1)"); - gradient.addColorStop(0.25, "rgba(170, 170, 170, 1)"); - gradient.addColorStop(1, "rgba(170, 170, 170, 0)"); - - context.strokeStyle = gradient; - context.lineWidth = 1 * pixelsPerInch; // 1 inch - context.lineCap = "round"; - context.lineJoin = "round"; - context.beginPath(); - let firstPoint = true; - trailCoordinates.forEach((position) => { - if (firstPoint) { - context.moveTo(position[0], position[1]); - firstPoint = false; - } else { - context.lineTo(position[0], position[1]); - } - }); - context.stroke(); - - // Render robot - context.fillStyle = "#222"; - context.strokeStyle = command.allianceRedBumpers ? "red" : "blue"; - context.lineWidth = 3 * pixelsPerInch; - let backLeft = transformPx(robotPos, rotation, [robotLengthPixels * -0.5, robotLengthPixels * 0.5]); - let frontLeft = transformPx(robotPos, rotation, [robotLengthPixels * 0.5, robotLengthPixels * 0.5]); - let frontRight = transformPx(robotPos, rotation, [robotLengthPixels * 0.5, robotLengthPixels * -0.5]); - let backRight = transformPx(robotPos, rotation, [robotLengthPixels * -0.5, robotLengthPixels * -0.5]); - context.beginPath(); - context.moveTo(frontLeft[0], frontLeft[1]); - context.lineTo(frontRight[0], frontRight[1]); - context.lineTo(backRight[0], backRight[1]); - context.lineTo(backLeft[0], backLeft[1]); - context.closePath(); - context.fill(); - context.stroke(); - - context.strokeStyle = "white"; - context.lineWidth = 1.5 * pixelsPerInch; - let arrowBack = transformPx(robotPos, rotation, [robotLengthPixels * -0.3, 0]); - let arrowFront = transformPx(robotPos, rotation, [robotLengthPixels * 0.3, 0]); - let arrowLeft = transformPx(robotPos, rotation, [robotLengthPixels * 0.15, robotLengthPixels * 0.15]); - let arrowRight = transformPx(robotPos, rotation, [robotLengthPixels * 0.15, robotLengthPixels * -0.15]); - context.beginPath(); - context.moveTo(arrowBack[0], arrowBack[1]); - context.lineTo(arrowFront[0], arrowFront[1]); - context.lineTo(arrowLeft[0], arrowLeft[1]); - context.moveTo(arrowFront[0], arrowFront[1]); - context.lineTo(arrowRight[0], arrowRight[1]); - context.stroke(); - }); - - // Draw ghosts - [command.poses.ghost as Pose2d[], command.poses.zebraGhost as Pose2d[]].forEach((ghostSet, index) => { - ghostSet.forEach((robotPose: Pose2d) => { - const forceFlipped = index === 1; // Zebra data always uses red origin - let robotPos = calcCoordinates(robotPose.translation, forceFlipped); - let rotation = robotPose.rotation; - if (objectsFlipped || forceFlipped) rotation += Math.PI; - - context.globalAlpha = 0.5; - context.lineCap = "round"; - context.lineJoin = "round"; - context.fillStyle = "#222"; - context.strokeStyle = command.allianceRedBumpers ? "red" : "blue"; - context.lineWidth = 3 * pixelsPerInch; - let backLeft = transformPx(robotPos, rotation, [robotLengthPixels * -0.5, robotLengthPixels * 0.5]); - let frontLeft = transformPx(robotPos, rotation, [robotLengthPixels * 0.5, robotLengthPixels * 0.5]); - let frontRight = transformPx(robotPos, rotation, [robotLengthPixels * 0.5, robotLengthPixels * -0.5]); - let backRight = transformPx(robotPos, rotation, [robotLengthPixels * -0.5, robotLengthPixels * -0.5]); - context.beginPath(); - context.moveTo(frontLeft[0], frontLeft[1]); - context.lineTo(frontRight[0], frontRight[1]); - context.lineTo(backRight[0], backRight[1]); - context.lineTo(backLeft[0], backLeft[1]); - context.closePath(); - context.fill(); - context.stroke(); - - context.strokeStyle = "white"; - context.lineWidth = 1.5 * pixelsPerInch; - let arrowBack = transformPx(robotPos, rotation, [robotLengthPixels * -0.3, 0]); - let arrowFront = transformPx(robotPos, rotation, [robotLengthPixels * 0.3, 0]); - let arrowLeft = transformPx(robotPos, rotation, [robotLengthPixels * 0.15, robotLengthPixels * 0.15]); - let arrowRight = transformPx(robotPos, rotation, [robotLengthPixels * 0.15, robotLengthPixels * -0.15]); - context.beginPath(); - context.moveTo(arrowBack[0], arrowBack[1]); - context.lineTo(arrowFront[0], arrowFront[1]); - context.lineTo(arrowLeft[0], arrowLeft[1]); - context.moveTo(arrowFront[0], arrowFront[1]); - context.lineTo(arrowRight[0], arrowRight[1]); - context.stroke(); - context.globalAlpha = 1; - }); - }); - - // Draw arrows - [command.poses.arrowFront, command.poses.arrowCenter, command.poses.arrowBack].forEach( - (arrowPoses: Pose2d[], index: number) => { - arrowPoses.forEach((arrowPose: Pose2d) => { - let position = calcCoordinates(arrowPose.translation); - let rotation = arrowPose.rotation; - if (objectsFlipped) rotation += Math.PI; - - context.strokeStyle = "white"; - context.lineCap = "round"; - context.lineJoin = "round"; - context.lineWidth = 1.5 * pixelsPerInch; - let arrowBack = transformPx(position, rotation, [robotLengthPixels * (-0.6 + 0.3 * index), 0]); - let arrowFront = transformPx(position, rotation, [robotLengthPixels * (0.3 * index), 0]); - let arrowLeft = transformPx(position, rotation, [ - robotLengthPixels * (-0.15 + 0.3 * index), - robotLengthPixels * 0.15 - ]); - let arrowRight = transformPx(position, rotation, [ - robotLengthPixels * (-0.15 + 0.3 * index), - robotLengthPixels * -0.15 - ]); - let crossbarLeft = transformPx(position, rotation, [0, robotLengthPixels * (index === 0 ? 0.15 : 0.1)]); - let crossbarRight = transformPx(position, rotation, [0, robotLengthPixels * -(index === 0 ? 0.15 : 0.1)]); - context.beginPath(); - context.moveTo(arrowBack[0], arrowBack[1]); - context.lineTo(arrowFront[0], arrowFront[1]); - context.lineTo(arrowLeft[0], arrowLeft[1]); - context.moveTo(arrowFront[0], arrowFront[1]); - context.lineTo(arrowRight[0], arrowRight[1]); - context.stroke(); - context.beginPath(); - context.moveTo(crossbarLeft[0], crossbarLeft[1]); - context.lineTo(crossbarRight[0], crossbarRight[1]); - context.stroke(); - }); - } - ); - - // Draw Zebra markers - context.font = - Math.round(12 * pixelsPerInch).toString() + "px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont"; - Object.entries(command.poses.zebraMarker).forEach(([team, value]) => { - let typedValue = value as { - translation: Translation2d; - alliance: string; - }; - let coordinates = calcCoordinates(typedValue.translation, true); - - context.fillStyle = typedValue.alliance; - context.strokeStyle = "white"; - context.lineWidth = 2 * pixelsPerInch; - context.beginPath(); - context.arc(coordinates[0], coordinates[1], 6 * pixelsPerInch, 0, Math.PI * 2); - context.fill(); - context.stroke(); - - context.fillStyle = "white"; - context.textAlign = "center"; - context.fillText(team, coordinates[0], coordinates[1] - 15 * pixelsPerInch); - }); - - // Return target aspect ratio - return isVertical ? fieldHeight / fieldWidth : fieldWidth / fieldHeight; - } -} diff --git a/src/shared/visualizers/PointsVisualizer.ts b/src/shared/visualizers/PointsVisualizer.ts deleted file mode 100644 index 08edcbef..00000000 --- a/src/shared/visualizers/PointsVisualizer.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { AllColors } from "../Colors"; -import Visualizer from "./Visualizer"; - -export default class PointsVisualizer implements Visualizer { - private CONTAINER: HTMLElement; - private BACKGROUND: HTMLElement; - private TEMPLATES: HTMLElement; - - constructor(container: HTMLElement) { - this.CONTAINER = container; - this.BACKGROUND = container.children[0] as HTMLElement; - this.TEMPLATES = container.children[1] as HTMLElement; - } - - saveState() { - return null; - } - - restoreState(): void {} - - render(command: any): number | null { - // Update background size - let containerWidth = this.CONTAINER.getBoundingClientRect().width; - let containerHeight = this.CONTAINER.getBoundingClientRect().height; - let targetWidth = command.options.width; - let targetHeight = command.options.height; - if (targetWidth < 1) targetWidth = 1; - if (targetHeight < 1) targetHeight = 1; - - let finalWidth, finalHeight; - if (targetWidth / targetHeight < containerWidth / containerHeight) { - finalHeight = containerHeight; - finalWidth = containerHeight * (targetWidth / targetHeight); - } else { - finalWidth = containerWidth; - finalHeight = containerWidth * (targetHeight / targetWidth); - } - - this.BACKGROUND.style.width = Math.ceil(finalWidth + 1).toString() + "px"; - this.BACKGROUND.style.height = Math.ceil(finalHeight + 1).toString() + "px"; - - // Clear old points - while (this.BACKGROUND.firstChild) { - this.BACKGROUND.removeChild(this.BACKGROUND.firstChild); - } - - // Render new points - for (let i = 0; i < Math.min(command.data.x.length, command.data.y.length); i++) { - let position = [command.data.x[i], command.data.y[i]]; - let dimensions = [command.options.width, command.options.height]; - switch (command.options.coordinates) { - case "xr,yd": - // Default, no changes - break; - case "xr,yu": - position = [position[0], -position[1]]; - break; - case "xu,yl": - position = [-position[1], -position[0]]; - break; - } - switch (command.options.origin) { - case "ul": - // Default, no changes - break; - case "ur": - position = [position[0] + dimensions[0], position[1]]; - break; - case "ll": - position = [position[0], position[1] + dimensions[1]]; - break; - case "lr": - position = [position[0] + dimensions[0], position[1] + dimensions[1]]; - break; - case "c": - position = [position[0] + dimensions[0] / 2, position[1] + dimensions[1] / 2]; - break; - } - if (position[0] < 0 || position[0] > dimensions[0] || position[1] < 0 || position[1] > dimensions[1]) { - continue; - } - position[0] = (position[0] / dimensions[0]) * finalWidth; - position[1] = (position[1] / dimensions[1]) * finalHeight; - - // Create point - let point: HTMLElement | null = null; - switch (command.options.pointShape) { - case "plus": - point = this.TEMPLATES.children[0].cloneNode(true) as HTMLElement; - break; - case "cross": - point = this.TEMPLATES.children[1].cloneNode(true) as HTMLElement; - break; - case "circle": - point = this.TEMPLATES.children[2].cloneNode(true) as HTMLElement; - break; - } - if (!point) continue; - switch (command.options.pointSize) { - case "large": - point.style.transform = "translate(-50%,-50%) scale(1, 1)"; - break; - case "medium": - point.style.transform = "translate(-50%,-50%) scale(0.5, 0.5)"; - break; - case "small": - point.style.transform = "translate(-50%,-50%) scale(0.25, 0.25)"; - break; - } - - // Set color - let color = ""; - if (command.options.groupSize < 1) { - color = window.matchMedia("(prefers-color-scheme: dark)").matches ? "white" : "black"; - } else { - color = AllColors[Math.floor(i / command.options.groupSize) % AllColors.length]; - } - point.style.fill = color; - point.style.stroke = color; - - // Set coordinates and append - point.style.left = position[0].toString() + "px"; - point.style.top = position[1].toString() + "px"; - this.BACKGROUND.appendChild(point); - } - - // Return target aspect ratio - return targetWidth / targetHeight; - } -} diff --git a/src/shared/visualizers/SwerveVisualizer.ts b/src/shared/visualizers/SwerveVisualizer.ts deleted file mode 100644 index a246bd5b..00000000 --- a/src/shared/visualizers/SwerveVisualizer.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { transformPx, wrapRadians } from "../util"; -import Visualizer from "./Visualizer"; - -export default class SwerveVisualizer implements Visualizer { - private CONTAINER: HTMLElement; - private CANVAS: HTMLCanvasElement; - - private BLACK_COLOR = "#222222"; - private WHITE_COLOR = "#eeeeee"; - private BLACK_BACKGROUND_COLOR = "#000000"; - private WHITE_BACKGROUND_COLOR = "#ffffff"; - - constructor(container: HTMLElement) { - this.CONTAINER = container; - this.CANVAS = container.firstElementChild as HTMLCanvasElement; - } - - saveState() { - return null; - } - - restoreState(): void {} - - render(command: any): number | null { - // Update canvas size - let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; - let size = Math.min(this.CONTAINER.clientWidth, this.CONTAINER.clientHeight); - this.CANVAS.style.width = size.toString() + "px"; - this.CANVAS.style.height = size.toString() + "px"; - this.CANVAS.width = size * window.devicePixelRatio; - this.CANVAS.height = size * window.devicePixelRatio; - context.scale(window.devicePixelRatio, window.devicePixelRatio); - context.clearRect(0, 0, size, size); - context.lineCap = "round"; - context.lineJoin = "round"; - let centerPx: [number, number] = [size / 2, size / 2]; - let isLight = !window.matchMedia("(prefers-color-scheme: dark)").matches; - let strokeColor = isLight ? this.BLACK_COLOR : this.WHITE_COLOR; - let backgroundColor = isLight ? this.BLACK_BACKGROUND_COLOR : this.WHITE_BACKGROUND_COLOR; - - // Calculate component dimensions - let frameWidthPx = size * 0.3 * Math.min(command.frameAspectRatio, 1); - let frameHeightPx = (size * 0.3) / Math.max(command.frameAspectRatio, 1); - let moduleRadiusPx = size * 0.05; - let fullVectorPx = size * 0.25; - - // Draw frame - context.strokeStyle = strokeColor; - context.lineWidth = 4; - context.beginPath(); - context.moveTo( - ...transformPx(centerPx, command.robotRotation, [frameHeightPx / 2, frameWidthPx / 2 - moduleRadiusPx]) - ); - context.lineTo( - ...transformPx(centerPx, command.robotRotation, [frameHeightPx / 2, -frameWidthPx / 2 + moduleRadiusPx]) - ); - context.stroke(); - context.beginPath(); - context.moveTo( - ...transformPx(centerPx, command.robotRotation, [frameHeightPx / 2 - moduleRadiusPx, -frameWidthPx / 2]) - ); - context.lineTo( - ...transformPx(centerPx, command.robotRotation, [-frameHeightPx / 2 + moduleRadiusPx, -frameWidthPx / 2]) - ); - context.stroke(); - context.beginPath(); - context.moveTo( - ...transformPx(centerPx, command.robotRotation, [frameHeightPx / 2 - moduleRadiusPx, frameWidthPx / 2]) - ); - context.lineTo( - ...transformPx(centerPx, command.robotRotation, [-frameHeightPx / 2 + moduleRadiusPx, frameWidthPx / 2]) - ); - context.stroke(); - context.beginPath(); - context.moveTo( - ...transformPx(centerPx, command.robotRotation, [-frameHeightPx / 2, frameWidthPx / 2 - moduleRadiusPx]) - ); - context.lineTo( - ...transformPx(centerPx, command.robotRotation, [-frameHeightPx / 2, -frameWidthPx / 2 + moduleRadiusPx]) - ); - context.stroke(); - - // Draw arrow on robot - context.strokeStyle = strokeColor; - context.lineWidth = 4; - let arrowBack = transformPx(centerPx, command.robotRotation, [frameHeightPx * -0.3, 0]); - let arrowFront = transformPx(centerPx, command.robotRotation, [frameHeightPx * 0.3, 0]); - let arrowLeft = transformPx(centerPx, command.robotRotation, [frameHeightPx * 0.15, frameWidthPx * 0.15]); - let arrowRight = transformPx(centerPx, command.robotRotation, [frameHeightPx * 0.15, frameWidthPx * -0.15]); - context.beginPath(); - context.moveTo(...arrowBack); - context.lineTo(...arrowFront); - context.moveTo(...arrowLeft); - context.lineTo(...arrowFront); - context.lineTo(...arrowRight); - context.stroke(); - - // Draw each corner - [ - [1, 1], - [1, -1], - [-1, 1], - [-1, -1] - ].forEach((corner, index) => { - let moduleCenterPx = transformPx(centerPx, command.robotRotation, [ - (frameHeightPx / 2) * corner[0], - (frameWidthPx / 2) * corner[1] - ]); - - // Draw module data - let drawModuleData = (state: NormalizedModuleState, isRed: boolean) => { - let fullRotation = command.robotRotation + state.rotation; - context.fillStyle = isRed ? "#ff0000" : "#4444ff"; - context.strokeStyle = isRed ? "#ff0000" : "#4444ff"; - context.lineWidth = 4; - - // Draw rotation - context.beginPath(); - if (command.redStates && command.blueStates) { - context.moveTo(...moduleCenterPx); - } else { - context.moveTo(...transformPx(moduleCenterPx, fullRotation, [moduleRadiusPx, 0])); - } - context.arc( - ...moduleCenterPx, - moduleRadiusPx, - -wrapRadians(fullRotation - (5 * Math.PI) / 6), - -wrapRadians(fullRotation + (5 * Math.PI) / 6) - ); - context.closePath(); - context.fill(); - - // Draw speed - if (Math.abs(state.normalizedVelocity) <= 0.001) return; - let vectorSpeed = state.normalizedVelocity; - let vectorRotation = fullRotation; - if (state.normalizedVelocity < 0) { - vectorSpeed *= -1; - vectorRotation += Math.PI; - } - let vectorLength = fullVectorPx * vectorSpeed; - let arrowBack = transformPx(moduleCenterPx, vectorRotation, [moduleRadiusPx, 0]); - let arrowFront = transformPx(moduleCenterPx, vectorRotation, [moduleRadiusPx + vectorLength, 0]); - let arrowLeft = transformPx(moduleCenterPx, vectorRotation, [ - moduleRadiusPx + vectorLength - moduleRadiusPx * 0.4, - moduleRadiusPx * 0.4 - ]); - let arrowRight = transformPx(moduleCenterPx, vectorRotation, [ - moduleRadiusPx + vectorLength - moduleRadiusPx * 0.4, - moduleRadiusPx * -0.4 - ]); - context.beginPath(); - context.moveTo(...arrowBack); - context.lineTo(...arrowFront); - context.moveTo(...arrowLeft); - context.lineTo(...arrowFront); - context.lineTo(...arrowRight); - context.stroke(); - }; - if (command.blueStates) drawModuleData(command.blueStates[index], false); - if (command.redStates) drawModuleData(command.redStates[index], true); - - // Draw module outline - context.strokeStyle = strokeColor; - context.lineWidth = 4; - context.beginPath(); - context.arc(...moduleCenterPx, moduleRadiusPx, 0, Math.PI * 2); - context.stroke(); - }); - - return 1; - } -} - -export interface NormalizedModuleState { - rotation: number; - normalizedVelocity: number; -} diff --git a/src/shared/visualizers/ThreeDimensionVisualizer.ts b/src/shared/visualizers/ThreeDimensionVisualizer.ts deleted file mode 100644 index fa7cd935..00000000 --- a/src/shared/visualizers/ThreeDimensionVisualizer.ts +++ /dev/null @@ -1,1635 +0,0 @@ -import * as THREE from "three"; -import { MeshStandardMaterial } from "three"; -import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { Line2 } from "three/examples/jsm/lines/Line2.js"; -import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js"; -import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; -import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; -import { CSS2DObject, CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js"; -import { - ALLIANCE_STATION_WIDTH, - Config3dField, - Config3dRobot, - Config3d_Rotation, - DEFAULT_DRIVER_STATIONS, - STANDARD_FIELD_LENGTH, - STANDARD_FIELD_WIDTH -} from "../AdvantageScopeAssets"; -import { - APRIL_TAG_16H5_COUNT, - APRIL_TAG_36H11_COUNT, - AprilTag, - Pose3d, - Translation2d, - rotation3dToQuaternion -} from "../geometry"; -import { MechanismState } from "../log/LogUtil"; -import { convert } from "../units"; -import { clampValue, zfill } from "../util"; -import Visualizer from "./Visualizer"; - -export default class ThreeDimensionVisualizer implements Visualizer { - static GHOST_COLORS = ["Green", "Yellow", "Blue", "Red"]; - private LOWER_POWER_MAX_FPS = 30; - private MAX_ORBIT_FOV = 160; - private MIN_ORBIT_FOV = 10; - private ORBIT_FIELD_DEFAULT_TARGET = new THREE.Vector3(0, 0.5, 0); - private ORBIT_AXES_DEFAULT_TARGET = new THREE.Vector3(STANDARD_FIELD_LENGTH / 2, 0, -STANDARD_FIELD_WIDTH / 2); - private ORBIT_ROBOT_DEFAULT_TARGET = new THREE.Vector3(0, 0.5, 0); - private ORBIT_FIELD_DEFAULT_POSITION = new THREE.Vector3(0, 6, -12); - private ORBIT_AXES_DEFAULT_POSITION = new THREE.Vector3( - 2 + STANDARD_FIELD_LENGTH / 2, - 2, - -4 - STANDARD_FIELD_WIDTH / 2 - ); - private ORBIT_ROBOT_DEFAULT_POSITION = new THREE.Vector3(2, 1, 1); - private DS_CAMERA_HEIGHT = convert(62, "inches", "meters"); // https://www.ergocenter.ncsu.edu/wp-content/uploads/sites/18/2017/09/Anthropometric-Summary-Data-Tables.pdf - private DS_CAMERA_OFFSET = 1.5; // Distance away from the glass - private MATERIAL_SPECULAR: THREE.Color | undefined = new THREE.Color(0x666666); // Overridden if not cinematic - private MATERIAL_SHININESS: number | undefined = 100; // Overridden if not cinematic - private WPILIB_ROTATION = getQuaternionFromRotSeq([ - { - axis: "x", - degrees: -90 - }, - { - axis: "y", - degrees: 180 - } - ]); - private CAMERA_ROTATION = getQuaternionFromRotSeq([ - { - axis: "z", - degrees: -90 - }, - { - axis: "y", - degrees: -90 - } - ]); - - private stopped = false; - private mode: "cinematic" | "standard" | "low-power"; - private content: HTMLElement; - private canvas: HTMLCanvasElement; - private annotationsDiv: HTMLElement; - private alert: HTMLElement; - - private renderer: THREE.WebGLRenderer; - private cssRenderer: CSS2DRenderer; - private scene: THREE.Scene; - private camera: THREE.PerspectiveCamera; - private controls: OrbitControls; - private textureLoader: THREE.TextureLoader; - private wpilibCoordinateGroup: THREE.Group; // Rotated to match WPILib coordinates - private wpilibFieldCoordinateGroup: THREE.Group; // Field coordinates (origin at driver stations and flipped based on alliance) - private wpilibZebraCoordinateGroup: THREE.Group; // Field coordinates (origin at red driver stations) - private fixedCameraGroup: THREE.Group; - private fixedCameraObj: THREE.Object3D; - private fixedCameraOverrideObj: THREE.Object3D; - private dsCameraGroup: THREE.Group; - private dsCameraObj: THREE.Object3D; - - private axesTemplate: THREE.Object3D; - private field: THREE.Object3D | null = null; - private robotSet: ObjectSet; - private ghostSets: { [key: string]: ObjectSet } = {}; - private ghostMaterials: { [key: string]: THREE.Material } = {}; - private aprilTag36h11Sets: Map = new Map(); - private aprilTag16h5Sets: Map = new Map(); - private trajectories: Line2[] = []; - private trajectoryLengths: number[] = []; - private gamePieceSets: ObjectSet[] = []; - private visionTargets: Line2[] = []; - private axesSet: ObjectSet; - private coneBlueFrontSet: ObjectSet; - private coneBlueCenterSet: ObjectSet; - private coneBlueBackSet: ObjectSet; - private coneYellowFrontSet: ObjectSet; - private coneYellowCenterSet: ObjectSet; - private coneYellowBackSet: ObjectSet; - private zebraMarkerBlueSet: ObjectSet; - private zebraMarkerRedSet: ObjectSet; - private zebraTeamLabels: { [key: string]: CSS2DObject } = {}; - private zebraGhostSets: { [key: string]: ObjectSet } = {}; - - private command: any; - private shouldRender = false; - private cameraIndex = -1; // -1 = Orbit Field, -2 = Orbit Robot, -3 = Auto DS, -4 to -9 = B1, B2, B3, R1, R2, R3 - private orbitFov = 50; - private lastCameraIndex = -1; - private lastAutoDriverStation = -1; - private lastFrameTime = 0; - private lastWidth: number | null = 0; - private lastHeight: number | null = 0; - private lastDevicePixelRatio: number | null = null; - private lastIsDark: boolean | null = null; - private lastAspectRatio: number | null = null; - private lastAssetsString: string = ""; - private lastFieldTitle: string = ""; - private lastRobotTitle: string = ""; - - constructor( - mode: "cinematic" | "standard" | "low-power", - content: HTMLElement, - canvas: HTMLCanvasElement, - annotationsDiv: HTMLElement, - alert: HTMLElement - ) { - this.mode = mode; - this.content = content; - this.canvas = canvas; - this.annotationsDiv = annotationsDiv; - this.alert = alert; - this.renderer = new THREE.WebGLRenderer({ - canvas: canvas, - powerPreference: mode === "cinematic" ? "high-performance" : mode === "low-power" ? "low-power" : "default" - }); - this.renderer.outputColorSpace = THREE.SRGBColorSpace; - this.renderer.shadowMap.enabled = mode === "cinematic"; - this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; - this.cssRenderer = new CSS2DRenderer({ element: annotationsDiv }); - this.scene = new THREE.Scene(); - if (mode !== "cinematic") { - this.MATERIAL_SPECULAR = new THREE.Color(0x000000); - this.MATERIAL_SHININESS = 0; - } - - // Change camera menu - let startPx: [number, number] | null = null; - canvas.addEventListener("contextmenu", (event) => { - startPx = [event.x, event.y]; - }); - canvas.addEventListener("mouseup", (event) => { - if (startPx && event.x === startPx[0] && event.y === startPx[1]) { - if (!this.command) return; - let robotTitle = this.command.options.robot; - let robotConfig = window.assets?.robots.find((robotData) => robotData.name === robotTitle); - if (robotConfig === undefined) return; - window.sendMainMessage("ask-3d-camera", { - options: robotConfig.cameras.map((camera) => camera.name), - selectedIndex: this.cameraIndex >= robotConfig.cameras.length ? -1 : this.cameraIndex, - fov: this.orbitFov - }); - } - startPx = null; - }); - - // Create coordinate groups - this.wpilibCoordinateGroup = new THREE.Group(); - this.scene.add(this.wpilibCoordinateGroup); - this.wpilibCoordinateGroup.rotation.setFromQuaternion(this.WPILIB_ROTATION); - this.wpilibFieldCoordinateGroup = new THREE.Group(); - this.wpilibCoordinateGroup.add(this.wpilibFieldCoordinateGroup); - this.wpilibZebraCoordinateGroup = new THREE.Group(); - this.wpilibCoordinateGroup.add(this.wpilibZebraCoordinateGroup); - - // Create camera - { - const aspect = 2; - const near = 0.15; - const far = 1000; - this.camera = new THREE.PerspectiveCamera(this.orbitFov, aspect, near, far); - } - - // Create controls - { - this.controls = new OrbitControls(this.camera, canvas); - this.controls.maxDistance = 250; - this.controls.enabled = true; - this.controls.update(); - } - - // Reset camera and controls - this.resetCamera(); - - // Add lights - { - const light = new THREE.HemisphereLight(0xffffff, 0x444444, mode === "cinematic" ? 0.5 : 2); - this.scene.add(light); - } - if (mode !== "cinematic") { - const light = new THREE.PointLight(0xffffff, 0.5); - light.position.set(0, 0, 10); - this.wpilibCoordinateGroup.add(light); - } else { - [ - [0, 1, 0, -2], - [6, -3, 6, 2], - [-6, -3, -6, 2] - ].forEach(([x, y, targetX, targetY]) => { - const light = new THREE.SpotLight(0xffffff, 150, 0, 50 * (Math.PI / 180), 0.2, 2); - light.position.set(x, y, 8); - light.target.position.set(targetX, targetY, 0); - light.castShadow = true; - light.shadow.mapSize.width = 2048; - light.shadow.mapSize.height = 2048; - light.shadow.bias = -0.0001; - this.wpilibCoordinateGroup.add(light, light.target); - }); - { - const light = new THREE.PointLight(0xff0000, 60); - light.position.set(4.5, 0, 5); - this.wpilibCoordinateGroup.add(light); - } - { - const light = new THREE.PointLight(0x0000ff, 60); - light.position.set(-4.5, 0, 5); - this.wpilibCoordinateGroup.add(light); - } - } - - // Create fixed camera objects - { - this.fixedCameraObj = new THREE.Object3D(); - this.fixedCameraGroup = new THREE.Group().add(this.fixedCameraObj); - this.fixedCameraOverrideObj = new THREE.Object3D(); - this.wpilibFieldCoordinateGroup.add(this.fixedCameraGroup, this.fixedCameraOverrideObj); - } - - // Create DS camera object - { - this.dsCameraObj = new THREE.Object3D(); - this.dsCameraObj.position.set(-this.DS_CAMERA_OFFSET, 0.0, this.DS_CAMERA_HEIGHT); - this.dsCameraGroup = new THREE.Group().add(this.dsCameraObj); - this.wpilibCoordinateGroup.add(this.dsCameraGroup); - } - - // Set up object sets - { - this.robotSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - this.ghostSets[color] = new ObjectSet(this.wpilibFieldCoordinateGroup); - }); - this.axesSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - this.coneBlueFrontSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - this.coneBlueCenterSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - this.coneBlueBackSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - this.coneYellowFrontSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - this.coneYellowCenterSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - this.coneYellowBackSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - this.zebraMarkerBlueSet = new ObjectSet(this.wpilibZebraCoordinateGroup); - this.zebraMarkerRedSet = new ObjectSet(this.wpilibZebraCoordinateGroup); - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - this.zebraGhostSets[color] = new ObjectSet(this.wpilibZebraCoordinateGroup); - }); - for (let i = 0; i < 6; i++) { - this.gamePieceSets.push(new ObjectSet(this.wpilibFieldCoordinateGroup)); - } - } - - // Create axes template - { - this.axesTemplate = new THREE.Object3D(); - const radius = 0.02; - - const center = new THREE.Mesh( - new THREE.SphereGeometry(radius, 8, 4), - new THREE.MeshPhongMaterial({ - color: 0xffffff, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ); - center.castShadow = true; - center.receiveShadow = true; - this.axesTemplate.add(center); - - const xAxis = new THREE.Mesh( - new THREE.CylinderGeometry(radius, radius, 1, 8), - new THREE.MeshPhongMaterial({ - color: 0xff0000, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ); - xAxis.castShadow = true; - xAxis.receiveShadow = true; - xAxis.position.set(0.5, 0.0, 0.0); - xAxis.rotateZ(Math.PI / 2); - this.axesTemplate.add(xAxis); - - const yAxis = new THREE.Mesh( - new THREE.CylinderGeometry(radius, radius, 1, 8), - new THREE.MeshPhongMaterial({ - color: 0x00ff00, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ); - yAxis.castShadow = true; - yAxis.receiveShadow = true; - yAxis.position.set(0.0, 0.5, 0.0); - this.axesTemplate.add(yAxis); - - const zAxis = new THREE.Mesh( - new THREE.CylinderGeometry(radius, radius, 1, 8), - new THREE.MeshPhongMaterial({ - color: 0x2020ff, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ); - zAxis.castShadow = true; - zAxis.receiveShadow = true; - zAxis.position.set(0.0, 0.0, 0.5); - zAxis.rotateX(Math.PI / 2); - this.axesTemplate.add(zAxis); - - let poseAxes = this.axesTemplate.clone(true); - poseAxes.scale.set(0.25, 0.25, 0.25); - this.axesSet.setSource(poseAxes); - } - - // Create cone models - this.textureLoader = new THREE.TextureLoader(); - { - let coneTextureBlue = this.textureLoader.load("../www/textures/cone-blue.png"); - let coneTextureBlueBase = this.textureLoader.load("../www/textures/cone-blue-base.png"); - coneTextureBlue.offset.set(0.25, 0); - - let coneMesh = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.25, 16, 32), [ - new THREE.MeshPhongMaterial({ - map: coneTextureBlue, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }), - new THREE.MeshPhongMaterial({ - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }), - new THREE.MeshPhongMaterial({ - map: coneTextureBlueBase, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ]); - coneMesh.castShadow = true; - coneMesh.receiveShadow = true; - coneMesh.rotateZ(-Math.PI / 2); - coneMesh.rotateY(-Math.PI / 2); - - this.coneBlueCenterSet.setSource(new THREE.Group().add(coneMesh)); - let frontMesh = coneMesh.clone(true); - frontMesh.position.set(-0.125, 0, 0); - this.coneBlueFrontSet.setSource(new THREE.Group().add(frontMesh)); - let backMesh = coneMesh.clone(true); - backMesh.position.set(0.125, 0, 0); - this.coneBlueBackSet.setSource(new THREE.Group().add(backMesh)); - } - { - let coneTextureYellow = this.textureLoader.load("../www/textures/cone-yellow.png"); - let coneTextureYellowBase = this.textureLoader.load("../www/textures/cone-yellow-base.png"); - coneTextureYellow.offset.set(0.25, 0); - - let coneMesh = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.25, 16, 32), [ - new THREE.MeshPhongMaterial({ - map: coneTextureYellow, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }), - new THREE.MeshPhongMaterial({ - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }), - new THREE.MeshPhongMaterial({ - map: coneTextureYellowBase, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ]); - coneMesh.castShadow = true; - coneMesh.receiveShadow = true; - coneMesh.rotateZ(-Math.PI / 2); - coneMesh.rotateY(-Math.PI / 2); - - this.coneYellowCenterSet.setSource(new THREE.Group().add(coneMesh)); - let frontMesh = coneMesh.clone(true); - frontMesh.position.set(-0.125, 0, 0); - this.coneYellowFrontSet.setSource(new THREE.Group().add(frontMesh)); - let backMesh = coneMesh.clone(true); - backMesh.position.set(0.125, 0, 0); - this.coneYellowBackSet.setSource(new THREE.Group().add(backMesh)); - } - - // Create Zebra marker models - { - let blueMesh = new THREE.Mesh( - new THREE.CylinderGeometry(0.1, 0.1, 1), - new THREE.MeshPhongMaterial({ - color: 0x0000ff, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ); - blueMesh.castShadow = true; - blueMesh.receiveShadow = true; - blueMesh.rotateX(Math.PI / 2); - blueMesh.position.set(0, 0, 0.5); - this.zebraMarkerBlueSet.setSource(new THREE.Group().add(blueMesh)); - let redMesh = new THREE.Mesh( - new THREE.CylinderGeometry(0.1, 0.1, 1), - new THREE.MeshPhongMaterial({ - color: 0xff0000, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }) - ); - redMesh.castShadow = true; - redMesh.receiveShadow = true; - redMesh.rotateX(Math.PI / 2); - redMesh.position.set(0, 0, 0.5); - this.zebraMarkerRedSet.setSource(new THREE.Group().add(redMesh)); - } - - // Define ghost materials - [ - ["Green", 0x00ff00], - ["Yellow", 0xffff00], - ["Blue", 0x0000ff], - ["Red", 0xff0000] - ].forEach(([name, color]) => { - this.ghostMaterials[name] = new THREE.MeshPhongMaterial({ - color: color, - transparent: true, - opacity: 0.35, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }); - }); - - // Render when camera is moved - this.controls.addEventListener("change", () => (this.shouldRender = true)); - - // Render loop - let periodic = () => { - if (this.stopped) return; - this.renderFrame(); - window.requestAnimationFrame(periodic); - }; - window.requestAnimationFrame(periodic); - } - - saveState() { - return { - cameraIndex: this.cameraIndex, - orbitFov: this.orbitFov, - cameraPosition: [this.camera.position.x, this.camera.position.y, this.camera.position.z], - cameraTarget: [this.controls.target.x, this.controls.target.y, this.controls.target.z] - }; - } - - restoreState(state: any): void { - this.cameraIndex = state.cameraIndex; - this.orbitFov = state.orbitFov; - this.camera.position.set(state.cameraPosition[0], state.cameraPosition[1], state.cameraPosition[2]); - this.controls.target.set(state.cameraTarget[0], state.cameraTarget[1], state.cameraTarget[2]); - this.controls.update(); - this.lastCameraIndex = this.cameraIndex; // Don't reset camera position - this.shouldRender = true; - } - - /** Switches the selected camera. */ - set3DCamera(index: number) { - this.cameraIndex = index; - this.shouldRender = true; - } - - /** Updates the orbit FOV. */ - setFov(fov: number) { - this.orbitFov = clampValue(fov, this.MIN_ORBIT_FOV, this.MAX_ORBIT_FOV); - this.shouldRender = true; - } - - render(command: any): number | null { - if (JSON.stringify(command) !== JSON.stringify(this.command)) { - // Also triggered if new assets counter changes - this.shouldRender = true; - } - this.command = command; - return this.lastAspectRatio; - } - - stop() { - this.stopped = true; - this.controls.dispose(); - this.renderer.dispose(); - disposeObject(this.scene); - } - - /** Resets the camera position and controls target. */ - private resetCamera() { - if (this.cameraIndex === -1) { - // Orbit field - if (this.command && this.command.options.field === "Axes") { - this.camera.position.copy(this.ORBIT_AXES_DEFAULT_POSITION); - this.controls.target.copy(this.ORBIT_AXES_DEFAULT_TARGET); - } else { - this.camera.position.copy(this.ORBIT_FIELD_DEFAULT_POSITION); - this.controls.target.copy(this.ORBIT_FIELD_DEFAULT_TARGET); - } - } else if (this.cameraIndex === -2) { - // Orbit robot - this.camera.position.copy(this.ORBIT_ROBOT_DEFAULT_POSITION); - this.controls.target.copy(this.ORBIT_ROBOT_DEFAULT_TARGET); - } else { - // Driver Station - let fieldConfig = this.getFieldConfig(); - if (fieldConfig !== null) { - let driverStation = -1; - if (this.cameraIndex < -3) { - driverStation = -4 - this.cameraIndex; - } else { - driverStation = this.command.autoDriverStation; - } - if (driverStation >= 0) { - let position = fieldConfig.driverStations[driverStation]; - this.dsCameraGroup.position.set(position[0], position[1], 0); - this.dsCameraGroup.rotation.set(0, 0, Math.atan2(-position[1], -position[0])); - this.camera.position.copy(this.dsCameraObj.getWorldPosition(new THREE.Vector3())); - this.camera.rotation.setFromQuaternion(this.dsCameraObj.getWorldQuaternion(new THREE.Quaternion())); - this.controls.target.copy(this.ORBIT_FIELD_DEFAULT_TARGET); // Look at the center of the field - } - } - } - this.controls.update(); - } - - private getFieldConfig(): Config3dField | null { - let fieldTitle = this.command.options.field; - if (fieldTitle === "Evergreen") { - return { - name: "Evergreen", - path: "", - rotations: [], - widthInches: convert(STANDARD_FIELD_LENGTH, "meters", "inches"), - heightInches: convert(STANDARD_FIELD_WIDTH, "meters", "inches"), - defaultOrigin: "auto", - driverStations: DEFAULT_DRIVER_STATIONS, - gamePieces: [] - }; - } else if (fieldTitle === "Axes") { - return { - name: "Axes", - path: "", - rotations: [], - widthInches: convert(STANDARD_FIELD_LENGTH, "meters", "inches"), - heightInches: convert(STANDARD_FIELD_WIDTH, "meters", "inches"), - defaultOrigin: "blue", - driverStations: DEFAULT_DRIVER_STATIONS, - gamePieces: [] - }; - } else { - let fieldConfig = window.assets?.field3ds.find((fieldData) => fieldData.name === fieldTitle); - if (fieldConfig === undefined) return null; - return fieldConfig; - } - } - - private getRobotConfig(): Config3dRobot | null { - let robotTitle = this.command.options.robot; - let robotConfig = window.assets?.robots.find((robotData) => robotData.name === robotTitle); - if (robotConfig === undefined) return null; - return robotConfig; - } - - private renderFrame() { - // Check for new size, device pixel ratio, or theme - let isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - if ( - this.renderer.domElement.clientWidth !== this.lastWidth || - this.renderer.domElement.clientHeight !== this.lastHeight || - window.devicePixelRatio !== this.lastDevicePixelRatio || - isDark !== this.lastIsDark - ) { - this.lastWidth = this.renderer.domElement.clientWidth; - this.lastHeight = this.renderer.domElement.clientHeight; - this.lastDevicePixelRatio = window.devicePixelRatio; - this.lastIsDark = isDark; - this.shouldRender = true; - } - - // Exit if no command is set - if (!this.command) { - return; // Continue trying to render - } - - // Exit if not visible - if (this.content.hidden) { - return; // Continue trying to render - } - - // Limit FPS in low power mode - let now = new Date().getTime(); - if (this.mode === "low-power" && now - this.lastFrameTime < 1000 / this.LOWER_POWER_MAX_FPS) { - return; // Continue trying to render - } - - // Check if rendering should continue - if (!this.shouldRender) { - return; - } - this.lastFrameTime = now; - this.shouldRender = false; - - // Get config - let fieldTitle = this.command.options.field; - let robotTitle = this.command.options.robot; - let fieldConfigTmp = this.getFieldConfig(); - let robotConfigTmp = this.getRobotConfig(); - if (fieldConfigTmp === null || robotConfigTmp === null) return; - let fieldConfig = fieldConfigTmp; - let robotConfig = robotConfigTmp; - - // Check for new assets - let assetsString = JSON.stringify(window.assets); - let newAssets = assetsString !== this.lastAssetsString; - if (newAssets) this.lastAssetsString = assetsString; - - // Update field - if (fieldTitle !== this.lastFieldTitle || newAssets) { - // Delete old field - if (this.field) { - this.wpilibCoordinateGroup.remove(this.field); - disposeObject(this.field); - } - - // Delete old game pieces - this.gamePieceSets.forEach((set) => { - set.setPoses([]); - set.setSource(new THREE.Object3D()); - }); - - // Load new field - if (fieldTitle === "Evergreen") { - this.field = new THREE.Group(); - this.wpilibCoordinateGroup.add(this.field); - - // Floor - let carpet = new THREE.Mesh( - new THREE.PlaneGeometry(STANDARD_FIELD_LENGTH + 4, STANDARD_FIELD_WIDTH + 1), - new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide }) - ); - carpet.name = "carpet"; - this.field.add(carpet); - - // Guardrails - const guardrailHeight = convert(20, "inches", "meters"); - [-STANDARD_FIELD_WIDTH / 2, STANDARD_FIELD_WIDTH / 2].forEach((y) => { - [0, guardrailHeight].forEach((z) => { - let guardrail = new THREE.Mesh( - new THREE.CylinderGeometry(0.02, 0.02, STANDARD_FIELD_LENGTH, 12), - new THREE.MeshPhongMaterial({ color: 0xdddddd }) - ); - this.field!.add(guardrail); - guardrail.rotateZ(Math.PI / 2); - guardrail.position.set(0, y, z); - }); - { - let panel = new THREE.Mesh( - new THREE.PlaneGeometry(STANDARD_FIELD_LENGTH, guardrailHeight), - new THREE.MeshPhongMaterial({ - color: 0xffffff, - side: THREE.DoubleSide, - opacity: 0.25, - transparent: true - }) - ); - this.field!.add(panel); - panel.rotateX(Math.PI / 2); - panel.position.set(0, y, guardrailHeight / 2); - } - for (let x = -STANDARD_FIELD_LENGTH / 2; x < STANDARD_FIELD_LENGTH / 2; x += STANDARD_FIELD_LENGTH / 16) { - if (x === -STANDARD_FIELD_LENGTH / 2) continue; - let guardrail = new THREE.Mesh( - new THREE.CylinderGeometry(0.02, 0.02, guardrailHeight, 12), - new THREE.MeshPhongMaterial({ color: 0xdddddd }) - ); - this.field!.add(guardrail); - guardrail.rotateX(Math.PI / 2); - guardrail.position.set(x, y, guardrailHeight / 2); - } - }); - - // Alliance stations - const allianceStationWidth = ALLIANCE_STATION_WIDTH; - const allianceStationHeight = convert(78, "inches", "meters"); - const allianceStationSolidHeight = convert(36.75, "inches", "meters"); - const allianceStationShelfDepth = convert(12.25, "inches", "meters"); - const fillerWidth = (STANDARD_FIELD_WIDTH - allianceStationWidth * 3) / 2; - const blueColor = 0x6379a6; - const redColor = 0xa66363; - [-STANDARD_FIELD_LENGTH / 2, STANDARD_FIELD_LENGTH / 2].forEach((x) => { - [0, allianceStationSolidHeight, allianceStationHeight].forEach((z) => { - let guardrail = new THREE.Mesh( - new THREE.CylinderGeometry( - 0.02, - 0.02, - z === allianceStationSolidHeight ? allianceStationWidth * 3 : STANDARD_FIELD_WIDTH, - 12 - ), - new THREE.MeshPhongMaterial({ color: 0xdddddd }) - ); - this.field!.add(guardrail); - guardrail.position.set(x, 0, z); - }); - [ - -STANDARD_FIELD_WIDTH / 2, - allianceStationWidth * -1.5, - allianceStationWidth * -0.5, - allianceStationWidth * 0.5, - allianceStationWidth * 1.5, - STANDARD_FIELD_WIDTH / 2 - ].forEach((y) => { - let guardrail = new THREE.Mesh( - new THREE.CylinderGeometry(0.02, 0.02, allianceStationHeight, 12), - new THREE.MeshPhongMaterial({ color: 0xdddddd }) - ); - this.field!.add(guardrail); - guardrail.rotateX(Math.PI / 2); - guardrail.position.set(x, y, allianceStationHeight / 2); - }); - [-STANDARD_FIELD_WIDTH / 2 + fillerWidth / 2, STANDARD_FIELD_WIDTH / 2 - fillerWidth / 2].forEach((y) => { - let filler = new THREE.Mesh( - new THREE.PlaneGeometry(allianceStationHeight, fillerWidth), - new THREE.MeshPhongMaterial({ color: x < 0 ? blueColor : redColor, side: THREE.DoubleSide }) - ); - this.field!.add(filler); - filler.rotateY(Math.PI / 2); - filler.position.set(x, y, allianceStationHeight / 2); - }); - { - let allianceWall = new THREE.Mesh( - new THREE.PlaneGeometry(allianceStationSolidHeight, allianceStationWidth * 3), - new THREE.MeshPhongMaterial({ color: x < 0 ? blueColor : redColor, side: THREE.DoubleSide }) - ); - this.field!.add(allianceWall); - allianceWall.rotateY(Math.PI / 2); - allianceWall.position.set(x, 0, allianceStationSolidHeight / 2); - } - { - let allianceGlass = new THREE.Mesh( - new THREE.PlaneGeometry(allianceStationHeight - allianceStationSolidHeight, allianceStationWidth * 3), - new THREE.MeshPhongMaterial({ - color: x < 0 ? blueColor : redColor, - side: THREE.DoubleSide, - opacity: 0.25, - transparent: true - }) - ); - this.field!.add(allianceGlass); - allianceGlass.rotateY(Math.PI / 2); - allianceGlass.position.set( - x, - 0, - allianceStationSolidHeight + (allianceStationHeight - allianceStationSolidHeight) / 2 - ); - } - { - let allianceShelves = new THREE.Mesh( - new THREE.PlaneGeometry(allianceStationShelfDepth, allianceStationWidth * 3), - new THREE.MeshPhongMaterial({ color: x < 0 ? blueColor : redColor, side: THREE.DoubleSide }) - ); - this.field!.add(allianceShelves); - allianceShelves.position.set( - x + (allianceStationShelfDepth / 2) * (x > 0 ? 1 : -1), - 0, - allianceStationSolidHeight - ); - } - }); - - // Add lighting effects - this.field.traverse((node: any) => { - let mesh = node as THREE.Mesh; // Traverse function returns Object3d or Mesh - let isCarpet = mesh.name === "carpet"; - if (mesh.isMesh && mesh.material instanceof THREE.MeshPhongMaterial) { - if (!isCarpet && this.MATERIAL_SPECULAR !== undefined && this.MATERIAL_SHININESS !== undefined) { - mesh.material.specular = this.MATERIAL_SPECULAR; - mesh.material.shininess = this.MATERIAL_SHININESS; - } - mesh.castShadow = !isCarpet; - mesh.receiveShadow = true; - } - }); - - // Render new frame - this.shouldRender = true; - } else if (fieldTitle === "Axes") { - // Add axes to scene - this.field = new THREE.Group(); - this.wpilibCoordinateGroup.add(this.field); - let axes = this.axesTemplate.clone(true); - axes.position.set(-STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0); - this.field.add(axes); - let outline = new THREE.Line( - new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(-STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0), - new THREE.Vector3(STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0), - new THREE.Vector3(STANDARD_FIELD_LENGTH / 2, STANDARD_FIELD_WIDTH / 2, 0), - new THREE.Vector3(-STANDARD_FIELD_LENGTH / 2, STANDARD_FIELD_WIDTH / 2, 0), - new THREE.Vector3(-STANDARD_FIELD_LENGTH / 2, -STANDARD_FIELD_WIDTH / 2, 0) - ]), - new THREE.LineBasicMaterial({ color: 0x444444 }) - ); - this.field.add(outline); - - // Render new frame - this.shouldRender = true; - } else { - const loader = new GLTFLoader(); - Promise.all([ - new Promise((resolve) => { - loader.load(fieldConfig.path, resolve); - }), - ...fieldConfig.gamePieces.map( - (_, index) => - new Promise((resolve) => { - loader.load(fieldConfig.path.slice(0, -4) + "_" + index.toString() + ".glb", resolve); - }) - ) - ]).then((gltfs) => { - let gltfScenes = (gltfs as GLTF[]).map((gltf) => gltf.scene); - if (fieldConfig === undefined) return; - gltfScenes.forEach((scene, index) => { - // Apply adjustments - scene.traverse((node: any) => { - let mesh = node as THREE.Mesh; // Traverse function returns Object3d or Mesh - if (mesh.isMesh && mesh.material instanceof MeshStandardMaterial) { - if (this.mode === "cinematic") { - // Cinematic, replace with MeshPhongMaterial - let newMaterial = new THREE.MeshPhongMaterial({ - color: mesh.material.color, - transparent: mesh.material.transparent, - opacity: mesh.material.opacity, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }); - if (mesh.name.toLowerCase().includes("carpet")) { - newMaterial.shininess = 0; - mesh.castShadow = false; - mesh.receiveShadow = true; - } else { - mesh.castShadow = !mesh.material.transparent; - mesh.receiveShadow = !mesh.material.transparent; - } - mesh.material.dispose(); - mesh.material = newMaterial; - } else { - // Not cinematic, disable metalness and roughness - mesh.material.metalness = 0; - mesh.material.roughness = 1; - } - } - }); - - // Add to scene - if (index === 0) { - this.field = scene; - this.field.rotation.setFromQuaternion(getQuaternionFromRotSeq(fieldConfig.rotations)); - this.wpilibCoordinateGroup.add(this.field); - } else { - let gamePieceConfig = fieldConfig.gamePieces[index - 1]; - let gamePieceGroup = new THREE.Group(); - gamePieceGroup.add(scene); - scene.rotation.setFromQuaternion(getQuaternionFromRotSeq(gamePieceConfig.rotations)); - scene.position.set(...gamePieceConfig.position); - this.gamePieceSets[index - 1].setSource(gamePieceGroup); - } - }); - - // Render new frame - this.shouldRender = true; - }); - } - - // Reset camera if switching between axis and non-axis or if using DS camera - if ((fieldTitle === "Axes") !== (this.lastFieldTitle === "Axes") || this.cameraIndex < -2) { - this.resetCamera(); - } - this.lastFieldTitle = fieldTitle; - } - - // Update robot - if (robotTitle !== this.lastRobotTitle || newAssets) { - this.lastRobotTitle = robotTitle; - const loader = new GLTFLoader(); - Promise.all([ - new Promise((resolve) => { - loader.load(robotConfig.path, resolve); - }), - ...robotConfig.components.map( - (_, index) => - new Promise((resolve) => { - loader.load(robotConfig.path.slice(0, -4) + "_" + index.toString() + ".glb", resolve); - }) - ) - ]).then((gltfs) => { - let gltfScenes = (gltfs as GLTF[]).map((gltf) => gltf.scene); - if (robotConfig === undefined) return; - - // Update model materials and set up groups - let robotGroup = new THREE.Group(); - let ghostGroups: { [key: string]: THREE.Group } = {}; - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - ghostGroups[color] = new THREE.Group(); - }); - gltfScenes.forEach((originalScene, index) => { - originalScene.traverse((node: any) => { - // Adjust materials - let mesh = node as THREE.Mesh; // Traverse function returns Object3d or Mesh - if (mesh.isMesh && mesh.material instanceof MeshStandardMaterial) { - if (this.mode === "cinematic") { - // Cinematic, replace with MeshPhongMaterial - let newMaterial = new THREE.MeshPhongMaterial({ - color: mesh.material.color, - transparent: mesh.material.transparent, - opacity: mesh.material.opacity, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }); - mesh.material.dispose(); - mesh.material = newMaterial; - mesh.castShadow = !mesh.material.transparent; - mesh.receiveShadow = !mesh.material.transparent; - } else { - // Not cinematic, disable metalness and roughness - mesh.material.metalness = 0; - mesh.material.roughness = 1; - } - } - }); - - // Add to robot group - if (index === 0) { - // Root model, set position and add directly - robotGroup.add(originalScene); - originalScene.rotation.setFromQuaternion(getQuaternionFromRotSeq(robotConfig.rotations)); - originalScene.position.set(...robotConfig.position); - } else { - // Component model, add name and store in group - let componentGroup = new THREE.Group(); - componentGroup.name = "AdvantageScope_Component" + (index - 1).toString(); - componentGroup.add(originalScene); - robotGroup.add(componentGroup); - } - - // Create ghosts and add to groups - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - let ghostScene = originalScene.clone(true); - ghostScene.traverse((node: any) => { - let mesh = node as THREE.Mesh; // Traverse function returns Object3d or Mesh - if (mesh.isMesh) { - mesh.material = this.ghostMaterials[color].clone(); - } - }); - - if (index === 0) { - // Root model, set position and add directly - ghostGroups[color].add(ghostScene); - ghostScene.rotation.setFromQuaternion(getQuaternionFromRotSeq(robotConfig.rotations)); - ghostScene.position.set(...robotConfig.position); - } else { - // Component model, add name and store in group - let componentGroup = new THREE.Group(); - componentGroup.name = "AdvantageScope_Component" + (index - 1).toString(); - componentGroup.add(ghostScene); - ghostGroups[color].add(componentGroup); - } - }); - }); - - // Add mechanism roots - let robotMechanismRoot = new THREE.Group(); - robotMechanismRoot.name = "AdvantageScope_MechanismRoot"; - robotGroup.add(robotMechanismRoot); - let ghostMechanismRoots: { [key: string]: THREE.Group } = {}; - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - ghostMechanismRoots[color] = new THREE.Group(); - ghostMechanismRoots[color].name = "AdvantageScope_MechanismRoot"; - ghostGroups[color].add(ghostMechanismRoots[color]); - }); - - // Update robot sets - this.robotSet.setSource(robotGroup); - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - this.ghostSets[color].setSource(ghostGroups[color]); - this.zebraGhostSets[color].setSource(ghostGroups[color]); - }); - - // Render new frame - this.shouldRender = true; - }); - } - - // Update field coordinates - if (fieldConfig) { - let isBlue = !this.command.allianceRedOrigin; - this.wpilibFieldCoordinateGroup.setRotationFromAxisAngle(new THREE.Vector3(0, 0, 1), isBlue ? 0 : Math.PI); - this.wpilibFieldCoordinateGroup.position.set( - convert(fieldConfig.widthInches / 2, "inches", "meters") * (isBlue ? -1 : 1), - convert(fieldConfig.heightInches / 2, "inches", "meters") * (isBlue ? -1 : 1), - 0 - ); - this.wpilibZebraCoordinateGroup.setRotationFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI); - if (fieldConfig.name === "Axes") { - this.wpilibZebraCoordinateGroup.position.set(STANDARD_FIELD_LENGTH, STANDARD_FIELD_WIDTH, 0); - } else { - this.wpilibZebraCoordinateGroup.position.set( - convert(fieldConfig.widthInches / 2, "inches", "meters"), - convert(fieldConfig.heightInches / 2, "inches", "meters"), - 0 - ); - } - } - - // Update robot poses (max of 6 poses each) - this.robotSet.setPoses(this.command.poses.robot.slice(0, 7)); - ThreeDimensionVisualizer.GHOST_COLORS.forEach((color) => { - this.ghostSets[color].setPoses(this.command.poses.ghost[color].slice(0, 7)); - this.zebraGhostSets[color].setPoses(this.command.poses.zebraGhost[color].slice(0, 7)); - }); - - // Update robot components - if (robotConfig && robotConfig.components.length > 0) { - ( - [ - [this.robotSet, this.command.poses.componentRobot], - ...ThreeDimensionVisualizer.GHOST_COLORS.map((color) => [ - this.ghostSets[color], - this.command.poses.componentGhost - ]), - ...ThreeDimensionVisualizer.GHOST_COLORS.map((color) => [ - this.zebraGhostSets[color], - this.command.poses.componentGhost - ]) - ] as [ObjectSet, Pose3d[]][] - ).forEach(([objectSet, poseData]) => { - objectSet.getChildren().forEach((childRobot) => { - for (let i = 0; i < robotConfig.components.length; i++) { - let componentGroup = childRobot.getObjectByName("AdvantageScope_Component" + i.toString()); - if (componentGroup === undefined) continue; - let componentModel = componentGroup?.children[0]; - - // Use component data or reset to default position - if (i < poseData.length) { - let componentPose = poseData[i]; - - // The group has the user's pose - componentGroup?.rotation.setFromQuaternion(rotation3dToQuaternion(componentPose.rotation)); - componentGroup?.position.set(...componentPose.translation); - - // The model should use the component's zeroed pose offset - componentModel?.rotation.setFromQuaternion( - getQuaternionFromRotSeq(robotConfig.components[i].zeroedRotations) - ); - componentModel?.position.set(...robotConfig.components[i].zeroedPosition); - } else { - // The group has the user's pose, reset to origin - componentGroup?.rotation.set(0, 0, 0); - componentGroup?.position.set(0, 0, 0); - - // The model should use the robot's default pose offset - componentModel?.rotation.setFromQuaternion(getQuaternionFromRotSeq(robotConfig.rotations)); - componentModel?.position.set(...robotConfig.position); - } - } - }); - }); - } - - // Update mechanisms - ( - [ - [ - this.robotSet, - this.command.poses.mechanismRobot, - () => - new THREE.MeshPhongMaterial({ - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }), - true - ], - ...ThreeDimensionVisualizer.GHOST_COLORS.map((color) => [ - this.ghostSets[color], - this.command.poses.mechanismGhost, - () => this.ghostMaterials[color].clone(), - false - ]), - ...ThreeDimensionVisualizer.GHOST_COLORS.map((color) => [ - this.zebraGhostSets[color], - this.command.poses.mechanismGhost, - () => this.ghostMaterials[color].clone(), - false - ]) - ] as [ObjectSet, MechanismState | null, () => THREE.MeshPhongMaterial, boolean][] - ).forEach(([objectSet, state, getMaterial, updateColors]) => { - objectSet.getChildren().forEach((childRobot) => { - let mechanismRoot = childRobot.getObjectByName("AdvantageScope_MechanismRoot"); - if (mechanismRoot === undefined) return; - - if (state === null) { - // No mechanism data, remove all children - while (mechanismRoot.children.length > 0) { - // Dispose of old material - ( - mechanismRoot.children[0].children[0] as THREE.Mesh - ).material.dispose(); - mechanismRoot.remove(mechanismRoot.children[0]); - } - } else { - // Remove extra children - while (mechanismRoot.children.length > state.lines.length) { - // Dispose of old material - ( - mechanismRoot.children[0].children[0] as THREE.Mesh - ).material.dispose(); - mechanismRoot.remove(mechanismRoot.children[0]); - } - - // Add new children - while (mechanismRoot.children.length < state.lines.length) { - const lineObject = new THREE.Mesh(new THREE.BoxGeometry(0.0, 0.0, 0.0), getMaterial()); - lineObject.castShadow = true; - lineObject.receiveShadow = true; - const lineGroup = new THREE.Group().add(lineObject); - mechanismRoot.add(lineGroup); - } - - // Update children - for (let i = 0; i < mechanismRoot.children.length; i++) { - const line = state.lines[i]; - const lineGroup = mechanismRoot.children[i]; - const lineObject = lineGroup.children[0] as THREE.Mesh; - - const length = Math.hypot(line.end[1] - line.start[1], line.end[0] - line.start[0]); - const angle = Math.atan2(line.end[1] - line.start[1], line.end[0] - line.start[0]); - - lineGroup.position.set(line.start[0] - state!.dimensions[0] / 2, 0.0, line.start[1]); - lineGroup.rotation.set(0.0, -angle, 0.0); - lineObject.position.set(length / 2, 0.0, 0.0); - lineObject.geometry.dispose(); - lineObject.geometry = new THREE.BoxGeometry(length, line.weight * 0.01, line.weight * 0.01); - if (updateColors) { - lineObject.material.color = new THREE.Color(line.color); - } - } - } - }); - }); - - // Update AprilTag poses - let aprilTags36h11: AprilTag[] = this.command.poses.aprilTag36h11; - let aprilTags16h5: AprilTag[] = this.command.poses.aprilTag16h5; - [aprilTags36h11, aprilTags16h5].forEach((tags, index) => { - let is36h11 = index === 0; - let sets = is36h11 ? this.aprilTag36h11Sets : this.aprilTag16h5Sets; - tags.forEach((tag) => { - let id = tag.id; - if (!sets.has(id)) { - this.textureLoader.load( - "../www/textures/apriltag-" + - (is36h11 ? "36h11" : "16h5") + - "/" + - (id === null ? "smile" : zfill(id.toString(), 3)) + - ".png", - (texture) => { - texture.minFilter = THREE.NearestFilter; - texture.magFilter = THREE.NearestFilter; - let whiteMaterial = new THREE.MeshPhongMaterial({ - color: 0xffffff, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }); - let size = convert(is36h11 ? 8.125 : 8, "inches", "meters"); - let mesh = new THREE.Mesh(new THREE.BoxGeometry(0.02, size, size), [ - new THREE.MeshPhongMaterial({ - map: texture, - specular: this.MATERIAL_SPECULAR, - shininess: this.MATERIAL_SHININESS - }), - whiteMaterial, - whiteMaterial, - whiteMaterial, - whiteMaterial, - whiteMaterial - ]); - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.rotateX(Math.PI / 2); - let objectSet = new ObjectSet(this.wpilibFieldCoordinateGroup); - objectSet.setSource(new THREE.Group().add(mesh)); - if (is36h11) { - sets.set(id, objectSet); - } else { - sets.set(id, objectSet); - } - } - ); - } - }); - }); - [null, ...Array(APRIL_TAG_36H11_COUNT).keys()].forEach((id) => { - this.aprilTag36h11Sets.get(id)?.setPoses(aprilTags36h11.filter((tag) => tag.id === id).map((tag) => tag.pose)); - }); - [null, ...Array(APRIL_TAG_16H5_COUNT).keys()].forEach((id) => { - this.aprilTag16h5Sets.get(id)?.setPoses(aprilTags16h5.filter((tag) => tag.id === id).map((tag) => tag.pose)); - }); - - // Update vision target lines - if (this.command.poses.robot.length === 0) { - // Remove all lines - while (this.visionTargets.length > 0) { - this.wpilibFieldCoordinateGroup.remove(this.visionTargets[0]); - this.visionTargets.shift(); - } - } else { - while (this.visionTargets.length > this.command.poses.visionTarget.length) { - // Remove extra lines - this.visionTargets[0].geometry.dispose(); - this.visionTargets[0].material.dispose(); - this.wpilibFieldCoordinateGroup.remove(this.visionTargets[0]); - this.visionTargets.shift(); - } - while (this.visionTargets.length < this.command.poses.visionTarget.length) { - // Add new lines - let line = new Line2( - new LineGeometry(), - new LineMaterial({ - color: 0x00ff00, - linewidth: 1, - resolution: new THREE.Vector2(this.canvas.clientWidth, this.canvas.clientHeight) - }) - ); - this.visionTargets.push(line); - this.wpilibFieldCoordinateGroup.add(line); - } - for (let i = 0; i < this.visionTargets.length; i++) { - // Update poses - this.visionTargets[i].geometry.setPositions([ - this.command.poses.robot[0].translation[0], - this.command.poses.robot[0].translation[1], - this.command.poses.robot[0].translation[2] + 0.75, - this.command.poses.visionTarget[i].translation[0], - this.command.poses.visionTarget[i].translation[1], - this.command.poses.visionTarget[i].translation[2] - ]); - this.visionTargets[i].geometry.attributes.position.needsUpdate = true; - } - } - - // Update trajectories - { - while (this.trajectories.length > this.command.poses.trajectory.length) { - // Remove extra lines - this.trajectories[0].geometry.dispose(); - this.trajectories[0].material.dispose(); - this.wpilibFieldCoordinateGroup.remove(this.trajectories[0]); - this.trajectories.shift(); - this.trajectoryLengths.shift(); - } - while (this.trajectories.length < this.command.poses.trajectory.length) { - // Add new lines - let line = new Line2( - new LineGeometry(), - new LineMaterial({ - color: 0xffa500, - linewidth: 2, - resolution: new THREE.Vector2(this.canvas.clientWidth, this.canvas.clientHeight) - }) - ); - this.trajectories.push(line); - this.trajectoryLengths.push(0); - this.wpilibFieldCoordinateGroup.add(line); - } - for (let i = 0; i < this.trajectories.length; i++) { - // Update poses - if (this.command.poses.trajectory[i].length > 0) { - let positions: number[] = []; - this.command.poses.trajectory[i].forEach((pose: Pose3d) => { - positions = positions.concat(pose.translation); - }); - if (positions.length !== this.trajectoryLengths[i]) { - this.trajectories[i].geometry.dispose(); - this.trajectories[i].geometry = new LineGeometry(); - this.trajectoryLengths[i] = positions.length; - } - this.trajectories[i].geometry.setPositions(positions); - this.trajectories[i].geometry.attributes.position.needsUpdate = true; - } - } - } - - // Update axes and cones - this.axesSet.setPoses(this.command.poses.axes); - this.coneBlueFrontSet.setPoses(this.command.poses.coneBlueFront); - this.coneBlueCenterSet.setPoses(this.command.poses.coneBlueCenter); - this.coneBlueBackSet.setPoses(this.command.poses.coneBlueBack); - this.coneYellowFrontSet.setPoses(this.command.poses.coneYellowFront); - this.coneYellowCenterSet.setPoses(this.command.poses.coneYellowCenter); - this.coneYellowBackSet.setPoses(this.command.poses.coneYellowBack); - - // Update game pieces - (this.command.poses.gamePiece as Pose3d[][]).forEach((gamePiecePoses, index) => { - this.gamePieceSets[index].setPoses(gamePiecePoses); - }); - fieldConfig.gamePieces.forEach((gamePieceConfig) => { - gamePieceConfig.stagedObjects.forEach((objectName) => { - if (this.field !== null) { - let object = this.field.getObjectByName(objectName); - if (object) { - object.visible = !this.command.hasUserGamePieces; - } - } - }); - }); - - // Update Zebra markers - let bluePoses = Object.values(this.command.poses.zebraMarker) - .filter((x: any) => x.alliance === "blue") - .map((x: any) => { - return { - translation: [x.translation[0], x.translation[1], 0], - rotation: [0, 0, 0, 0] - } as Pose3d; - }); - let redPoses = Object.values(this.command.poses.zebraMarker) - .filter((x: any) => x.alliance === "red") - .map((x: any) => { - return { - translation: [x.translation[0], x.translation[1], 0], - rotation: [0, 0, 0, 0] - } as Pose3d; - }); - this.zebraMarkerBlueSet.setPoses(bluePoses); - this.zebraMarkerRedSet.setPoses(redPoses); - - // Update Zebra team labels - (Object.keys(this.command.poses.zebraMarker) as string[]).forEach((team) => { - if (!(team in this.zebraTeamLabels)) { - let labelDiv = document.createElement("div"); - labelDiv.innerText = team; - this.zebraTeamLabels[team] = new CSS2DObject(labelDiv); - } - }); - Object.entries(this.zebraTeamLabels).forEach(([team, object]) => { - if (team in this.command.poses.zebraMarker) { - this.wpilibZebraCoordinateGroup.add(object); - let translation = this.command.poses.zebraMarker[team].translation as Translation2d; - object.position.set(translation[0], translation[1], 1.25); - } else { - this.wpilibZebraCoordinateGroup.remove(object); - } - }); - - // Set camera for fixed views - { - // Reset camera index if invalid - if (this.cameraIndex >= robotConfig.cameras.length) this.cameraIndex = -1; - - // Update camera controls - let orbitalCamera = this.cameraIndex === -1 || this.cameraIndex === -2; - let dsCamera = this.cameraIndex < -2; - if (orbitalCamera !== this.controls.enabled) { - this.controls.enabled = orbitalCamera; - this.controls.update(); - } - - // Update container and camera based on mode - let fov = this.orbitFov; - this.lastAspectRatio = null; - if (orbitalCamera || dsCamera) { - this.canvas.classList.remove("fixed"); - this.annotationsDiv.classList.remove("fixed"); - this.canvas.style.width = ""; - this.canvas.style.height = ""; - this.annotationsDiv.style.width = ""; - this.annotationsDiv.style.height = ""; - if (this.cameraIndex === -1 || dsCamera) { - // Reset to default origin - this.wpilibCoordinateGroup.position.set(0, 0, 0); - this.wpilibCoordinateGroup.rotation.setFromQuaternion(this.WPILIB_ROTATION); - } else if (this.command.poses.robot.length > 0) { - // Shift based on robot location - this.wpilibCoordinateGroup.position.set(0, 0, 0); - this.wpilibCoordinateGroup.rotation.setFromQuaternion(new THREE.Quaternion()); - let robotObj = this.robotSet.getChildren()[0]; - let position = robotObj.getWorldPosition(new THREE.Vector3()); - let rotation = robotObj.getWorldQuaternion(new THREE.Quaternion()).multiply(this.WPILIB_ROTATION); - position.negate(); - rotation.invert(); - this.wpilibCoordinateGroup.position.copy(position.clone().applyQuaternion(rotation)); - this.wpilibCoordinateGroup.rotation.setFromQuaternion(rotation); - } - if ( - this.cameraIndex !== this.lastCameraIndex || - (this.cameraIndex === -3 && this.lastAutoDriverStation !== this.command.autoDriverStation) - ) { - this.resetCamera(); - } - } else { - this.canvas.classList.add("fixed"); - this.annotationsDiv.classList.add("fixed"); - let aspectRatio = 16 / 9; - if (robotConfig) { - // Get fixed aspect ratio and FOV - let cameraConfig = robotConfig.cameras[this.cameraIndex]; - aspectRatio = cameraConfig.resolution[0] / cameraConfig.resolution[1]; - this.lastAspectRatio = aspectRatio; - fov = cameraConfig.fov / aspectRatio; - let parentAspectRatio = this.canvas.parentElement - ? this.canvas.parentElement.clientWidth / this.canvas.parentElement.clientHeight - : aspectRatio; - if (aspectRatio > parentAspectRatio) { - this.canvas.style.width = "100%"; - this.canvas.style.height = ((parentAspectRatio / aspectRatio) * 100).toString() + "%"; - this.annotationsDiv.style.width = "100%"; - this.annotationsDiv.style.height = ((parentAspectRatio / aspectRatio) * 100).toString() + "%"; - } else { - this.canvas.style.width = ((aspectRatio / parentAspectRatio) * 100).toString() + "%"; - this.canvas.style.height = "100%"; - this.annotationsDiv.style.width = ((aspectRatio / parentAspectRatio) * 100).toString() + "%"; - this.annotationsDiv.style.height = "100%"; - } - - // Update camera position - let referenceObj: THREE.Object3D | null = null; - if (this.command.poses.cameraOverride.length > 0) { - let cameraPose: Pose3d = this.command.poses.cameraOverride[0]; - this.fixedCameraOverrideObj.position.set(...cameraPose.translation); - this.fixedCameraOverrideObj.rotation.setFromQuaternion( - rotation3dToQuaternion(cameraPose.rotation).multiply(this.CAMERA_ROTATION) - ); - referenceObj = this.fixedCameraOverrideObj; - } else if (this.command.poses.robot.length > 0) { - let robotPose: Pose3d = this.command.poses.robot[0]; - this.fixedCameraGroup.position.set(...robotPose.translation); - this.fixedCameraGroup.rotation.setFromQuaternion(rotation3dToQuaternion(robotPose.rotation)); - this.fixedCameraObj.position.set(...cameraConfig.position); - this.fixedCameraObj.rotation.setFromQuaternion( - getQuaternionFromRotSeq(cameraConfig.rotations).multiply(this.CAMERA_ROTATION) - ); - referenceObj = this.fixedCameraObj; - } - if (referenceObj) { - this.camera.position.copy(referenceObj.getWorldPosition(new THREE.Vector3())); - this.camera.rotation.setFromQuaternion(referenceObj.getWorldQuaternion(new THREE.Quaternion())); - } - } - } - - // Update camera alert - if (this.cameraIndex === -2) { - this.alert.hidden = this.command.poses.robot.length > 0; - this.alert.innerHTML = 'Robot pose not available
for camera "Orbit Robot".'; - } else if (this.cameraIndex === -3) { - this.alert.hidden = this.command.autoDriverStation >= 0; - this.alert.innerHTML = "Driver Station position
not available."; - } else if (this.cameraIndex === -1 || dsCamera) { - this.alert.hidden = true; - } else { - this.alert.hidden = this.command.poses.robot.length > 0 || this.command.poses.cameraOverride.length > 0; - this.alert.innerHTML = - 'Robot pose not available
for camera "' + - (robotConfig ? robotConfig.cameras[this.cameraIndex].name : "???") + - '".'; - } - - // Update camera FOV - if (fov !== this.camera.fov) { - this.camera.fov = fov; - this.camera.updateProjectionMatrix(); - } - - this.lastCameraIndex = this.cameraIndex; - this.lastAutoDriverStation = this.command.autoDriverStation; - } - - // Render new frame - const devicePixelRatio = window.devicePixelRatio * (this.mode === "low-power" ? 0.5 : 1); - const canvas = this.renderer.domElement; - const clientWidth = canvas.clientWidth; - const clientHeight = canvas.clientHeight; - if (canvas.width / devicePixelRatio !== clientWidth || canvas.height / devicePixelRatio !== clientHeight) { - this.renderer.setSize(clientWidth, clientHeight, false); - this.cssRenderer.setSize(clientWidth, clientHeight); - this.camera.aspect = clientWidth / clientHeight; - this.camera.updateProjectionMatrix(); - const resolution = new THREE.Vector2(clientWidth, clientHeight); - this.trajectories.forEach((line) => { - line.material.resolution = resolution; - }); - this.visionTargets.forEach((line) => { - line.material.resolution = resolution; - }); - } - this.scene.background = isDark ? new THREE.Color("#222222") : new THREE.Color("#ffffff"); - this.renderer.setPixelRatio(devicePixelRatio); - this.renderer.render(this.scene, this.camera); - this.cssRenderer.render(this.scene, this.camera); - } -} - -/** Represents a set of cloned objects updated from an array of poses. */ -class ObjectSet { - private parent: THREE.Object3D; - private source: THREE.Object3D = new THREE.Object3D(); - private children: THREE.Object3D[] = []; - private poses: Pose3d[] = []; - private displayedPoses = 0; - private hidden = false; - - constructor(parent: THREE.Object3D) { - this.parent = parent; - } - - /** Updates the source object, regenerating the clones based on the current poses. */ - setSource(newSource: THREE.Object3D) { - disposeObject(this.source); - this.source = newSource; - - // Remove all children - while (this.children.length > 0) { - disposeObject(this.children[0]); - this.parent.remove(this.children[0]); - this.children.shift(); - } - this.displayedPoses = 0; - - // Recreate children - this.setPoses(this.poses); - } - - /** Updates the list of displayed poses, adding or removing children as necessary. */ - setPoses(poses: Pose3d[]) { - this.poses = poses; - - // Clone new children - while (this.children.length < poses.length) { - let child = this.source.clone(true); - child.visible = !this.hidden; - this.children.push(child); - } - - // Remove extra children from parent - while (this.displayedPoses > poses.length) { - this.parent.remove(this.children[this.displayedPoses - 1]); - this.displayedPoses -= 1; - } - - // Add new children to parent - while (this.displayedPoses < poses.length) { - this.parent.add(this.children[this.displayedPoses]); - this.displayedPoses += 1; - } - - // Update poses - for (let i = 0; i < this.poses.length; i++) { - this.children[i].position.set(...poses[i].translation); - this.children[i].rotation.setFromQuaternion(rotation3dToQuaternion(poses[i].rotation)); - } - } - - /** Sets whether to hide all of the objects. */ - setHidden(hidden: boolean) { - this.children.forEach((child) => { - child.visible = !hidden; - }); - } - - /** Returns the set of cloned objects. */ - getChildren() { - return [...this.children]; - } -} - -/** Converts a rotation sequence to a quaternion. */ -function getQuaternionFromRotSeq(rotations: Config3d_Rotation[]): THREE.Quaternion { - let quaternion = new THREE.Quaternion(); - rotations.forEach((rotation) => { - let axis = new THREE.Vector3(0, 0, 0); - if (rotation.axis === "x") axis.setX(1); - if (rotation.axis === "y") axis.setY(1); - if (rotation.axis === "z") axis.setZ(1); - quaternion.premultiply( - new THREE.Quaternion().setFromAxisAngle(axis, convert(rotation.degrees, "degrees", "radians")) - ); - }); - return quaternion; -} - -/** Disposes of all materials and geometries in object. */ -function disposeObject(object: THREE.Object3D) { - object.traverse((node) => { - let mesh = node as THREE.Mesh; - if (mesh.isMesh) { - mesh.geometry.dispose(); - if (Array.isArray(mesh.material)) { - mesh.material.forEach((material) => material.dispose()); - } else { - mesh.material.dispose(); - } - } - }); -} diff --git a/src/shared/visualizers/ThreeDimensionVisualizerSwitching.ts b/src/shared/visualizers/ThreeDimensionVisualizerSwitching.ts deleted file mode 100644 index 6a0e4627..00000000 --- a/src/shared/visualizers/ThreeDimensionVisualizerSwitching.ts +++ /dev/null @@ -1,95 +0,0 @@ -import ThreeDimensionVisualizer from "./ThreeDimensionVisualizer"; -import Visualizer from "./Visualizer"; - -/** Wrapper around ThreeDimensionVisualizer to automatically switch rendering modes. */ -export default class ThreeDimensionVisualizerSwitching implements Visualizer { - private content: HTMLElement; - private canvas: HTMLCanvasElement; - private annotationsDiv: HTMLElement; - private alert: HTMLElement; - private visualizer: ThreeDimensionVisualizer | null = null; - - private lastMode: "cinematic" | "standard" | "low-power" | null = null; - - constructor(content: HTMLElement, canvas: HTMLCanvasElement, annotationsDiv: HTMLElement, alert: HTMLElement) { - this.content = content; - this.canvas = canvas; - this.annotationsDiv = annotationsDiv; - this.alert = alert; - this.render(null); - } - - saveState() { - if (this.visualizer !== null) { - return this.visualizer.saveState(); - } - return null; - } - - restoreState(state: any): void { - if (this.visualizer !== null && state !== null) { - this.visualizer.restoreState(state); - } - } - - /** Switches the selected camera. */ - set3DCamera(index: number) { - this.visualizer?.set3DCamera(index); - } - - /** Updates the orbit FOV. */ - setFov(fov: number) { - this.visualizer?.setFov(fov); - } - - render(command: any): number | null { - // Get current mode - let mode: "cinematic" | "standard" | "low-power" = "standard"; - if (window.preferences) { - if (window.isBattery && window.preferences.threeDimensionModeBattery !== "") { - mode = window.preferences.threeDimensionModeBattery; - } else { - mode = window.preferences.threeDimensionModeAc; - } - } - - // Recreate visualizer if necessary - if (mode !== this.lastMode) { - this.lastMode = mode; - let state: any = null; - if (this.visualizer !== null) { - state = this.visualizer.saveState(); - this.visualizer.stop(); - } - { - let newCanvas = document.createElement("canvas"); - this.canvas.classList.forEach((className) => { - newCanvas.classList.add(className); - }); - newCanvas.id = this.canvas.id; - this.canvas.replaceWith(newCanvas); - this.canvas = newCanvas; - } - { - let newDiv = document.createElement("div"); - this.annotationsDiv.classList.forEach((className) => { - newDiv.classList.add(className); - }); - newDiv.id = this.annotationsDiv.id; - this.annotationsDiv.replaceWith(newDiv); - this.annotationsDiv = newDiv; - } - this.visualizer = new ThreeDimensionVisualizer(mode, this.content, this.canvas, this.annotationsDiv, this.alert); - if (state !== null) { - this.visualizer.restoreState(state); - } - } - - // Send command - if (this.visualizer === null || command === null) { - return null; - } else { - return this.visualizer.render(command); - } - } -} diff --git a/src/shared/visualizers/VideoVisualizer.ts b/src/shared/visualizers/VideoVisualizer.ts deleted file mode 100644 index 38f05e6d..00000000 --- a/src/shared/visualizers/VideoVisualizer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import Visualizer from "./Visualizer"; - -export default class VideoVisualizer implements Visualizer { - private IMAGE: HTMLImageElement; - - constructor(image: HTMLImageElement) { - this.IMAGE = image; - } - - saveState() { - return null; - } - - restoreState(): void {} - - render(command: any): number | null { - this.IMAGE.hidden = command === ""; - this.IMAGE.src = command; - let width = this.IMAGE.naturalWidth; - let height = this.IMAGE.naturalHeight; - if (width > 0 && height > 0) { - return width / height; - } - return null; - } -} diff --git a/src/shared/visualizers/Visualizer.ts b/src/shared/visualizers/Visualizer.ts deleted file mode 100644 index c594b0e1..00000000 --- a/src/shared/visualizers/Visualizer.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** A visualizer contained in a satellite window or timeline visualizer. */ -export default interface Visualizer { - /** - * Renders a single frame. - * @returns The target aspect ratio (if it exists) - */ - render(command: any): number | null; - - /** Returns the current state. */ - saveState(): any; - - /** Restores to the provided state. */ - restoreState(state: any): void; -} diff --git a/src/sourceListHelp.ts b/src/sourceListHelp.ts new file mode 100644 index 00000000..423c7a47 --- /dev/null +++ b/src/sourceListHelp.ts @@ -0,0 +1,201 @@ +import { ensureThemeContrast } from "./shared/Colors"; +import NamedMessage from "./shared/NamedMessage"; +import { SourceListConfig, SourceListOptionValueConfig } from "./shared/SourceListConfig"; +import LoggableType from "./shared/log/LoggableType"; + +let themeCallbacks: (() => void)[] = []; + +function isDark() { + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +window.addEventListener("message", (event) => { + if (event.source === window && event.data === "port") { + let messagePort = event.ports[0]; + messagePort.onmessage = (event) => { + let message: NamedMessage = event.data; + if (message.name !== "set-config") return; + let config: SourceListConfig = message.data; + + // Update title + document.title = config.title + " Help \u2014 AdvantageScope"; + + // Add items + let usedColors: string[] = []; + config.types.forEach((typeConfig) => { + if (!typeConfig.showDocs) return; + let title = typeConfig.display; + let symbol = typeConfig.symbol; + + // Get colors + let lightColor = "#000000"; + if (typeConfig.color.startsWith("#")) { + lightColor = typeConfig.color; + } else { + let colorOptionConfig = typeConfig.options.find((optionConfig) => optionConfig.key === typeConfig.color); + if (colorOptionConfig !== undefined) { + let i = 0; + do { + lightColor = colorOptionConfig.values[i].key; + i++; + } while (usedColors.includes(lightColor)); + usedColors.push(lightColor); + } + } + let darkColor = typeConfig.darkColor !== undefined ? typeConfig.darkColor : lightColor; + lightColor = ensureThemeContrast(lightColor, false); + darkColor = ensureThemeContrast(darkColor, true); + + // Get source types + let sourceTypes = typeConfig.sourceTypes; + config.types.forEach((extraTypeConfig) => { + if (extraTypeConfig.key.startsWith(typeConfig.key)) { + extraTypeConfig.sourceTypes.forEach((type) => { + if (!sourceTypes.includes(type)) { + sourceTypes.push(type); + } + }); + } + }); + sourceTypes = sourceTypes.map((type) => { + if (Object.values(LoggableType).includes(type)) { + return type.replaceAll("Array", "[]").toLowerCase(); + } else { + return type; + } + }); + + // Get parent types + let parentTypes: string[] = []; + if (typeConfig.childOf !== undefined) { + config.types.forEach((extraTypeConfig) => { + if (extraTypeConfig.parentKey === typeConfig.childOf && !parentTypes.includes(extraTypeConfig.display)) { + parentTypes.push(extraTypeConfig.display); + } + }); + } + + // Get options + let options: { name: string; values: SourceListOptionValueConfig[] }[] = typeConfig.options.map( + (optionConfig) => { + return { + name: optionConfig.display, + values: optionConfig.values + }; + } + ); + + // Add item + addItem(title, symbol, lightColor, darkColor, sourceTypes, parentTypes, options); + }); + + // Update when theme changes + let lastIsDark: boolean | null = null; + let periodic = () => { + let newIsDark = isDark(); + if (newIsDark !== lastIsDark) { + lastIsDark = newIsDark; + themeCallbacks.forEach((callback) => { + callback(); + }); + } + window.requestAnimationFrame(periodic); + }; + window.requestAnimationFrame(periodic); + }; + } +}); + +function addItem( + title: string, + symbol: string, + lightColor: string, + darkColor: string, + sourceTypes: string[], + parentTypes: string[], + options: { name: string; values: SourceListOptionValueConfig[] }[] +) { + let typeHeader = document.createElement("div"); + typeHeader.classList.add("type-header"); + document.body.appendChild(typeHeader); + + let typeIconContainer = document.createElement("div"); + typeIconContainer.classList.add("type-icon-container"); + typeHeader.appendChild(typeIconContainer); + + let typeIcon = document.createElement("object"); + typeIcon.classList.add("type-icon"); + typeIcon.type = "image/svg+xml"; + typeIcon.data = "symbols/sourceList/" + symbol + ".svg"; + typeIconContainer.appendChild(typeIcon); + let updateColor = () => { + if (typeIcon.contentDocument !== null) { + typeIcon.contentDocument.getElementsByTagName("svg")[0].style.color = isDark() ? darkColor : lightColor; + } + }; + typeIcon.addEventListener("load", () => { + updateColor(); + themeCallbacks.push(updateColor); + }); + + let typeTitle = document.createElement("div"); + typeTitle.classList.add("type-title"); + typeHeader.appendChild(typeTitle); + typeTitle.innerText = title; + + if (parentTypes.length > 0) { + let parentWarning = document.createElement("div"); + parentWarning.classList.add("parent-warning"); + document.body.appendChild(parentWarning); + parentWarning.innerText = "Add to existing " + makeCommaList(parentTypes.map((str) => '"' + str + '"')) + " item."; + } + + if (options.length > 0) { + let optionsTable = document.createElement("table"); + optionsTable.classList.add("options"); + document.body.appendChild(optionsTable); + + let optionsTableBody = document.createElement("tbody"); + optionsTable.appendChild(optionsTableBody); + + options.forEach((option) => { + let row = document.createElement("tr"); + optionsTableBody.appendChild(row); + + let nameCell = document.createElement("td"); + row.appendChild(nameCell); + nameCell.innerHTML = option.name + ":"; + + let valuesCell = document.createElement("td"); + row.appendChild(valuesCell); + let valueStrings = option.values.map((optionConfig) => "" + optionConfig.display + ""); + valuesCell.innerHTML = makeCommaList(valueStrings); + Array.from(valuesCell.getElementsByTagName("span")).forEach((span, index) => { + let valueKey = option.values[index].key; + if (valueKey.startsWith("#")) { + themeCallbacks.push(() => { + span.style.color = ensureThemeContrast(valueKey); + }); + } + }); + }); + } + + let sourceTypesDiv = document.createElement("div"); + sourceTypesDiv.classList.add("source-types"); + document.body.appendChild(sourceTypesDiv); + sourceTypesDiv.innerText = "Source" + (sourceTypes.length === 1 ? "" : "s") + ": " + makeCommaList(sourceTypes); +} + +function makeCommaList(values: string[]): string { + switch (values.length) { + case 0: + return ""; + case 1: + return values[0]; + case 2: + return values[0] + " or " + values[1]; + default: + return [...values.slice(0, -1), "or " + values[values.length - 1]].join(", "); + } +} diff --git a/tsconfig.json b/tsconfig.json index 5f058c14..8b25f8c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,6 @@ "strict": true, "removeComments": true, "resolveJsonModule": true - } + }, + "include": ["src/**/*"] } diff --git a/www/download.css b/www/download.css index c063fb48..eedf5d5c 100644 --- a/www/download.css +++ b/www/download.css @@ -221,7 +221,7 @@ div.progress-details { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-family: monospace; + font-family: Courier, monospace; } button { diff --git a/www/global.css b/www/global.css index eafe1891..cab5f986 100644 --- a/www/global.css +++ b/www/global.css @@ -50,6 +50,17 @@ button > img { filter: invert(32%) sepia(0%) saturate(60%) hue-rotate(224deg) brightness(100%) contrast(95%); } +button > object { + position: absolute; + max-height: 100%; + max-width: 100%; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) scale(75%); + + pointer-events: none; +} + button.blurred > img, button:disabled > img { /* https://codepen.io/sosuke/pen/Pjoqqp */ diff --git a/www/hub.css b/www/hub.css index 73c673e1..fedec8d7 100644 --- a/www/hub.css +++ b/www/hub.css @@ -1,6 +1,9 @@ :root { --side-bar-width: 300px; --show-side-bar: 1; + --show-timeline: 1; + --tab-controls-height: 200px; + --show-tab-controls: 1; --show-tuning-button: 0; --show-lock-buttons: 0; --show-update-button: 0; @@ -23,7 +26,12 @@ div.title-bar { -webkit-app-region: drag; } -body.fancy-title-bar div.title-bar { +body { + overflow: hidden; +} + +body.fancy-title-bar-mac div.title-bar, +body.fancy-title-bar-win div.title-bar { display: initial; } @@ -31,8 +39,6 @@ div.title-bar-text { position: absolute; top: 0px; bottom: 0px; - left: calc(var(--side-bar-width) + 20px + ((1 - var(--show-side-bar)) * 60px)); - right: calc(182px + var(--show-lock-buttons) * 28px + var(--show-update-button) * 38px); text-align: left; line-height: 38px; @@ -42,17 +48,79 @@ div.title-bar-text { text-overflow: ellipsis; } -div.main-view { +body.fancy-title-bar-mac div.title-bar-text { + left: calc(var(--side-bar-width) + 20px + ((1 - var(--show-side-bar)) * 60px)); + right: calc(182px + var(--show-lock-buttons) * 28px + var(--show-update-button) * 38px); +} + +body.fancy-title-bar-win div.title-bar-text { + left: calc(var(--side-bar-width) + 20px); + right: 140px; +} + +div.title-bar-menu { position: absolute; - z-index: 4; - left: 0px; - right: 0px; - top: 0px; - bottom: 0px; + left: calc(var(--side-bar-width) * 0.5); + width: calc(var(--side-bar-width) - 20px); + max-width: 250px; + top: 19px; + height: 28px; + transform: translate(-50%, -50%); + + z-index: 6; + display: none; + -webkit-app-region: no-drag; + overflow-x: auto; } -body.fancy-title-bar div.main-view { - top: 38px; +div.title-bar-menu::-webkit-scrollbar { + display: none; +} + +body.fancy-title-bar-win div.title-bar-menu, +body.fancy-title-bar-linux div.title-bar-menu { + display: initial; +} + +div.title-bar-menu table { + height: 100%; + width: 100%; + border-collapse: collapse; +} + +div.title-bar-menu td { + padding: 0; + margin: 0; +} + +div.title-bar-menu button { + width: 100%; + height: 100%; + font-size: 12px; + padding-left: 3px; + padding-right: 3px; +} + +div.title-bar-menu button:hover { + background-color: #cccccc88; +} + +div.title-bar-menu button:active { + background-color: #cccccc; +} + +@media (prefers-color-scheme: dark) { + div.title-bar-menu button { + color: #eee; + } + + div.title-bar-menu button:hover { + background-color: #44444488; + } + + div.title-bar-menu button:active { + background-color: #444444; + } } /* Loading glow */ @@ -63,8 +131,8 @@ div.loading-glow { left: var(--side-bar-width); width: calc((100% - var(--side-bar-width)) * var(--loading-glow-progress)); top: 0px; - height: 3px; - transform: translateY(calc(-3px + 3px * var(--show-loading-glow))); + height: 4px; + transform: translateY(calc(-4px + 4px * var(--show-loading-glow))); transition: transform 0.3s; background-color: #303dc9; } @@ -80,6 +148,7 @@ div.loading-glow { opacity: 100%; } } + /* Side bar layout */ div.side-bar-background { @@ -93,10 +162,14 @@ div.side-bar-background { background-color: #e9e9e9; } -body.fancy-side-bar div.side-bar-background { +body.fancy-side-bar-mac div.side-bar-background { display: none; } +body.fancy-side-bar-win div.side-bar-background { + opacity: 0.5; +} + @media (prefers-color-scheme: dark) { div.side-bar-background { background-color: #292929; @@ -117,7 +190,9 @@ div.side-bar-shadow { opacity: 0%; } -body.fancy-title-bar div.side-bar-shadow { +body.fancy-title-bar-mac div.side-bar-shadow, +body.fancy-title-bar-win div.side-bar-shadow, +body.fancy-title-bar-linux div.side-bar-shadow { display: initial; } @@ -133,15 +208,27 @@ div.side-bar { width: var(--side-bar-width); top: 0px; bottom: 0px; + z-index: 4; overflow: auto; } +body.fancy-title-bar-mac div.side-bar, +body.fancy-title-bar-win div.side-bar, +body.fancy-title-bar-linux div.side-bar { + top: 38px; +} + +body.fancy-title-bar-win div.side-bar::-webkit-scrollbar, +body.fancy-title-bar-linux div.side-bar::-webkit-scrollbar { + display: none; +} + div.side-bar-handle { position: absolute; top: 0px; bottom: 0px; - left: calc(var(--side-bar-width) - 2px); - width: calc(20px - (var(--show-side-bar) * 16px)); + left: calc(var(--side-bar-width) - 5px); + width: calc(20px - (var(--show-side-bar) * 10px)); z-index: 8; cursor: col-resize; @@ -156,7 +243,7 @@ div.fps { z-index: 13; pointer-events: none; - font-family: "Courier New", Courier, monospace; + font-family: Courier, monospace; font-size: 16px; font-weight: bold; color: black; @@ -173,7 +260,7 @@ div.viewer-background { top: 0px; bottom: 0px; - box-shadow: 0px 0px 3px 2px #ddd; + box-shadow: 0px 0px 3px 0px #00000022; background-color: #fff; } @@ -190,6 +277,13 @@ div.viewer { right: 0px; top: 0px; bottom: 0px; + z-index: 4; + overflow: hidden; +} + +body.fancy-title-bar-mac div.viewer, +body.fancy-title-bar-win div.viewer { + top: 38px; } /* Field selector */ @@ -328,7 +422,7 @@ div.field-value { line-height: 30px; font-size: 15px; - font-family: monospace; + font-family: Courier, monospace; text-align: right; white-space: nowrap; } @@ -482,10 +576,18 @@ div.search-results-item.hovered { #dragItem { position: absolute; z-index: 999; + cursor: grabbing; +} + +#dragItem span.field-text { white-space: nowrap; font-size: 15px; font-style: italic; - cursor: grabbing; +} + +#dragItem div.tab { + margin-top: 0; + opacity: 0.5; } /* Tab bar */ @@ -493,7 +595,7 @@ div.search-results-item.hovered { div.tab-bar { position: absolute; left: 10px; - right: calc(182px + var(--show-lock-buttons) * 28px + var(--show-update-button) * 38px); + right: calc(154px + var(--show-lock-buttons) * 28px + var(--show-update-button) * 38px); top: 0px; height: 50px; @@ -501,7 +603,7 @@ div.tab-bar { white-space: nowrap; } -body.fancy-title-bar div.tab-bar { +body.fancy-title-bar-mac div.tab-bar { right: 10px; } @@ -525,11 +627,11 @@ div.tab-bar-shadow-left { } div.tab-bar-shadow-right { - right: calc(182px + var(--show-lock-buttons) * 28px + var(--show-update-button) * 38px); + right: calc(154px + var(--show-lock-buttons) * 28px + var(--show-update-button) * 38px); background-image: linear-gradient(to left, white, rgba(255, 255, 255, 0.75), transparent); } -body.fancy-title-bar div.tab-bar-shadow-right { +body.fancy-title-bar-mac div.tab-bar-shadow-right { right: 10px; } @@ -543,17 +645,29 @@ body.fancy-title-bar div.tab-bar-shadow-right { } } +div.tab-bar-drag-highlight { + position: absolute; + top: 10px; + height: 32px; + width: 15px; + transform: translateX(-50%); + + background-color: lightgreen; + opacity: 75%; + user-select: none; + pointer-events: none; +} + div.tab-bar-scroll { position: absolute; left: 10px; right: 210px; top: 0px; height: 50px; - overflow: scroll; } -body.fancy-title-bar div.tab-bar-scroll { +body.fancy-title-bar-mac div.tab-bar-scroll { right: 10px; } @@ -631,12 +745,16 @@ button.tab-control { -webkit-app-region: no-drag; } -body.fancy-title-bar button.tab-control { +body.fancy-title-bar-mac button.tab-control { top: 8px; } +body.fancy-title-bar-win button.tab-control { + top: 49px; +} + button.update { - right: calc(182px + var(--show-lock-buttons) * 28px); + right: calc(154px + var(--show-lock-buttons) * 28px); } button.update > img { @@ -651,12 +769,12 @@ button.update.blurred > img { button.play, button.pause { - right: calc(144px + var(--show-lock-buttons) * 28px); + right: calc(116px + var(--show-lock-buttons) * 28px); } button.lock, button.unlock { - right: 144px; + right: 116px; } button.unlock > img { @@ -681,221 +799,253 @@ button.unlock.blurred > img { } } -button.move-left { - right: 106px; -} - button.close { right: 78px; } -button.move-right { - right: 50px; +button.popup { + right: 45px; +} + +button.close img { + transform: translate(-50%, -50%) scale(90%); } button.add-tab { right: 12px; } -/* Tab content */ +/* Timeline */ -div.tab-content { +div.timeline { position: absolute; top: 50px; - bottom: 0px; - left: 0px; - right: 0px; + height: calc(32px * var(--show-timeline)); + left: 10px; + right: 10px; + z-index: 5; } -div.tab-centered { +canvas.timeline-canvas { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); + left: 0%; + top: 0%; + width: 100%; + height: 100%; + border-radius: 10px; + box-sizing: border-box; - text-align: center; - font-style: italic; + background-color: #eee; + border: 1px solid #222; } -/* Documentation */ +@media (prefers-color-scheme: dark) { + canvas.timeline-canvas { + background-color: #444; + border: 1px solid #eee; + } +} -div.documentation-container { +div.timeline-scroll { position: absolute; - left: 0px; - top: 0px; + left: 0%; + top: 0%; width: 100%; height: 100%; - overflow: auto; -} - -div.documentation-text { - padding: 15px; - user-select: text; - overflow-wrap: normal; + overflow: scroll; + z-index: 9; } -div.documentation-text h1 { - margin: 0px 0px 0px 0px; +div.timeline-scroll::-webkit-scrollbar { + display: none; } -div.documentation-text h2 { - margin: 20px 0px 0px 0px; +div.timeline-scroll-content { + width: 1000000px; + height: 1000000px; } -div.documentation-text h3 { - margin: 20px 0px 0px 0px; -} +/* Tab layout */ -div.documentation-text p { - margin: 12px 0px 12px 0px; +div.renderer-content { + position: absolute; + left: 0%; + width: 100%; + top: calc(50px + var(--show-timeline) * 42px); + bottom: var(--tab-controls-height); } -div.documentation-text li { - margin: 5px 0px 5px 0px; +div.controls-content { + position: absolute; + left: 0%; + width: 100%; + bottom: 0%; + height: var(--tab-controls-height); + box-shadow: 0px 0px 3px 2px #00000022; + background-color: #fff; } -div.documentation-text a { - text-decoration: none; +@media (prefers-color-scheme: dark) { + div.controls-content { + box-shadow: 0px 0px 2px 1px #000; + background-color: #222; + } } -div.documentation-text a:hover { - text-decoration: underline; -} +div.controls-handle { + position: absolute; + left: 0%; + width: 100%; + bottom: calc(var(--tab-controls-height) - 5px); + height: calc(20px - (var(--show-tab-controls) * 10px)); -div.documentation-text code { - font-size: 17px; + z-index: 9; + cursor: row-resize; } -div.documentation-text pre > code { - display: block; - font-size: 14px; - background-color: #eee; - padding: 8px; - overflow-x: auto; -} +/* Source list */ -div.documentation-text img, -div.documentation-text video { - max-width: calc(100% - 50px); - max-height: 50vh; - margin: 10px 15px 10px 15px; +div.source-list { + position: absolute; + top: 10px; + left: 10px; + width: 50%; + height: 50%; } -div.documentation-text blockquote { - background-color: #eee; - margin: 12px 0px 12px 0px; - padding: 10px; -} +div.source-list div.title { + position: absolute; + top: 0px; + height: 30px; + left: 0px; + right: 0px; -div.documentation-text blockquote p { - margin: 0px; + text-align: center; + font-size: 16px; + line-height: 30px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -@media (prefers-color-scheme: dark) { - div.documentation-text pre > code { - background-color: #111; - } +div.source-list button.edit, +div.source-list button.clear, +div.source-list button.help { + position: absolute; + top: 15px; + right: 15px; + transform: translate(50%, -50%); - div.documentation-text blockquote { - background-color: #111; - } + width: 25px; + height: 25px; } -/* Line graph */ +div.source-list button.help { + right: 40px; +} -div.legend-handle { +div.source-list div.list { position: absolute; - left: 0%; + top: 30px; + bottom: 0px; + left: 0px; + right: 0px; + overflow-x: hidden; + overflow-y: auto; +} + +div.source-list div.item { + position: relative; width: 100%; - bottom: var(--legend-height); - height: 6px; - transform: translateY(50%); - cursor: row-resize; + height: 30px; + border-top: 1px solid #eee; + box-sizing: border-box; +} - z-index: 8; - opacity: 0; - background-color: #ddd; +div.source-list div.item:first-child { + border-top: none; } -div.legend-handle:hover { - display: initial; - opacity: 0.5; +div.source-list div.item:last-child { + border-bottom: 1px solid #eee; } -div.legend-left { - position: absolute; - left: 0%; - width: 33%; - bottom: 0px; - height: var(--legend-height); - overflow: auto; +#dragItem div.source-list div.item { + border: none; } -div.legend-discrete { - position: absolute; - left: 33%; - width: 33%; - bottom: 0px; - height: var(--legend-height); +div.source-list div.item.child { + border-top: none; +} - overflow: auto; - border-left: 1px solid #555; - border-right: 1px solid #555; +div.source-list div.item.parent-highlight { + box-shadow: -0.5px 0px 3px 2px #00ff00bb inset; } @media (prefers-color-scheme: dark) { - div.legend-discrete { - border-left: 1px solid #999; - border-right: 1px solid #999; + div.source-list div.item { + border-top: 1px solid #333; + } + + div.source-list div.item:last-child { + border-bottom: 1px solid #333; } } -div.legend-right { +div.source-list button { position: absolute; - left: 66%; - width: 34%; - bottom: 0px; - height: var(--legend-height); - overflow: auto; + width: 25px; + height: 25px; } -div.legend-drag-target { - background-color: lightgreen; - opacity: 25%; +div.source-list button.type { + top: 15px; + left: 15px; + transform: translate(-50%, -50%); } -div.legend-item { - position: relative; - width: 100%; - height: 30px; +div.source-list div.item.child button.type { + left: 40px; +} + +div.source-list button.type object.hidden { + opacity: 0; } -div.legend-item-with-value { - height: 45px; +div.source-list div.hidden button.type, +div.source-list div.hidden div.type-name, +div.source-list div.hidden div.key-container, +div.source-list div.hidden img.value-symbol, +div.source-list div.hidden div.value { + opacity: 0.4; } -div.legend-title { +div.source-list div.type-name { position: absolute; top: 0px; height: 30px; - left: 5px; - right: 35px; + left: 30px; + max-width: calc(100% - 30px - 55px); - text-align: center; - font-size: 16px; - line-height: 30px; font-weight: bold; + font-size: 14px; + line-height: 30px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -div.legend-key-container { +div.source-list div.item.child div.type-name { + left: 55px; +} + +div.source-list div.key-container { position: absolute; top: 0px; height: 30px; - left: 30px; - max-width: calc(100% - 30px - 30px); + left: calc(30px + var(--type-width)); + max-width: calc(100% - 31px - var(--type-width) - 55px - (var(--has-warning) * 25px)); text-align: right; direction: rtl; @@ -907,31 +1057,51 @@ div.legend-key-container { text-overflow: ellipsis; } -span.legend-key { +div.source-list div.item.child div.key-container { + left: calc(55px + var(--type-width)); + max-width: calc(100% - 31px - 25px - var(--type-width) - 55px - (var(--has-warning) * 25px)); +} + +div.source-list div.key-container span { unicode-bidi: plaintext; } -svg.legend-splotch { - position: absolute; +div.source-list button.remove { top: 15px; - left: 15px; - transform: translate(-50%, -50%); + right: 15px; + transform: translate(50%, -50%); +} - stroke: #555; +div.source-list button.hide { + top: 15px; + right: 40px; + transform: translate(50%, -50%); } -button.legend-edit { - position: absolute; +div.source-list button.warning { top: 15px; - right: 15px; + right: 65px; transform: translate(50%, -50%); +} - width: 25px; - height: 25px; +div.source-list button.warning > img { + /* https://codepen.io/sosuke/pen/Pjoqqp */ + filter: invert(60%) sepia(41%) saturate(3324%) hue-rotate(356deg) brightness(96%) contrast(112%); } -img.legend-value-symbol { - display: none; +div.source-list button.warning.blurred > img { + /* https://codepen.io/sosuke/pen/Pjoqqp */ + filter: invert(85%) sepia(44%) saturate(1384%) hue-rotate(314deg) brightness(114%) contrast(101%); +} + +@media (prefers-color-scheme: dark) { + div.source-list button.warning.blurred > img { + /* https://codepen.io/sosuke/pen/Pjoqqp */ + filter: invert(30%) sepia(37%) saturate(3821%) hue-rotate(26deg) brightness(97%) contrast(101%); + } +} + +div.source-list img.value-symbol { position: absolute; top: 32px; left: 32px; @@ -942,8 +1112,11 @@ img.legend-value-symbol { filter: invert(43%) sepia(0%) saturate(2564%) hue-rotate(1deg) brightness(110%) contrast(107%); } -div.legend-value { - display: none; +div.source-list div.item.child img.value-symbol { + left: 57px; +} + +div.source-list div.value { position: absolute; top: 30px; height: 15px; @@ -961,851 +1134,406 @@ div.legend-value { text-overflow: ellipsis; } -div.legend-item-with-value img.legend-value-symbol, -div.legend-item-with-value div.legend-value { - display: initial; -} - -@media (prefers-color-scheme: dark) { - div.legend-handle { - background-color: #444; - } - - svg.legend-splotch { - stroke: #999; - } - - img.legend-value-symbol { - /* https://codepen.io/sosuke/pen/Pjoqqp */ - filter: invert(75%) sepia(12%) saturate(0%) hue-rotate(185deg) brightness(89%) contrast(90%); - } - - div.legend-value { - color: #aaa; - } -} - -div.line-graph-canvas-container { - position: absolute; - top: 0px; - bottom: var(--legend-height); - left: 0px; - right: 0px; -} - -canvas.line-graph-canvas { - position: absolute; - height: 100%; - width: 100%; -} - -div.line-graph-scroll { - position: absolute; - z-index: 9; - left: 0px; - right: 0px; - top: 8px; - bottom: 50px; - overflow: scroll; -} - -div.line-graph-scroll::-webkit-scrollbar { - display: none; -} - -div.line-graph-scroll-content { - width: 1000000px; - height: 1000000px; -} - -/* Table */ - -div.data-table-container { - position: absolute; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; - overflow: auto; -} - -table.data-table { - text-align: left; - white-space: nowrap; - border-collapse: separate; - border-spacing: 0; - font-size: 14px; - - border-style: hidden; -} - -table.data-table th { - position: sticky; - top: 0px; - height: 30px; - padding: 0px; - border-right: 1px solid #eee; - border-bottom: 1px solid #222; - - background-color: #fff; - z-index: 8; -} - -table.data-table th:first-child { - left: 0px; - min-width: 97px; - z-index: 10; -} - -table.data-table th:first-child input { - position: absolute; - left: 4px; - top: 4.5px; - height: 15px; - width: 56px; -} - -table.data-table th:first-child button { - position: absolute; +div.source-list div.item.child div.value { left: 70px; - top: 2.5px; - height: 25px; - width: 25px; -} - -table.data-table th:not(:first-child) { - min-width: 175px; -} - -div.data-table-key-container { - position: absolute; - left: 6px; - right: 30px; - top: 0px; - height: 30px; - line-height: 30px; - - direction: rtl; - overflow: hidden; - text-overflow: ellipsis; } -div.data-table-key-container span { - unicode-bidi: plaintext; -} - -button.data-table-key-delete { +div.source-list div.drag-highlight { position: absolute; - height: 25px; - width: 25px; - top: 2.5px; - right: 2px; -} - -table.data-table th:last-child { - border-right: none; -} - -table.data-table td { - height: 16px; - padding: 4px; - font-family: monospace; - user-select: text; - border-right: 1px solid #eee; - border-bottom: 1px solid #eee; -} - -table.data-table td:first-child { - position: sticky; - left: 0px; - text-align: right; - font-weight: bold; - - background-color: #fff; -} - -table.data-table td:last-child { - border-right: none; -} - -table.data-table tr:last-child td { - border-bottom: none; -} - -table.data-table tr.hovered td { - background-color: #ddd; -} - -table.data-table tr.selected td { - background-color: #aaa; -} - -@media (prefers-color-scheme: dark) { - table.data-table th { - border-right: 1px solid #333; - border-bottom: 1px solid #eee; - background-color: #222; - } - - table.data-table td { - border-right: 1px solid #333; - border-bottom: 1px solid #333; - } - - table.data-table td:first-child { - background-color: #222; - } - - table.data-table tr.hovered td { - background-color: #444; - } - - table.data-table tr.selected td { - background-color: #777; - } -} - -div.data-table-drag-highlight { - position: absolute; - z-index: 11; - width: 25px; - top: 0px; - bottom: 0px; - background-color: lightgreen; - opacity: 25%; -} - -/* Console */ - -div.console-table-drag-highlight { - position: absolute; - left: 0px; - top: 0px; - width: 100%; - height: 100%; z-index: 11; background-color: lightgreen; opacity: 25%; + user-select: none; + pointer-events: none; } -div.console-table-container { +div.source-list img.hand-icon { position: absolute; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; - overflow-x: hidden; - overflow-y: auto; -} - -table.console-table { - width: 100%; - table-layout: fixed; - - border-collapse: separate; - border-spacing: 0; - border-style: hidden; - margin-bottom: 6px; -} - -table.console-table th { - position: sticky; - top: 0px; - height: 30px; - padding: 0px; - border-right: 1px solid #eee; - border-bottom: 1px solid #222; - font-size: 14px; - - background-color: #fff; - z-index: 8; -} - -table.console-table th:first-child { - width: 97px; - z-index: 10; + left: 50%; + top: calc(50% + 15px); + transform: translate(-50%, -50%); + width: 60px; + pointer-events: none; } -table.console-table th:first-child input { - position: absolute; - left: 4px; - top: 4.5px; - height: 15px; - width: 56px; +@media (prefers-color-scheme: dark) { + div.source-list img.hand-icon { + filter: invert(100%); + } } -table.console-table th:first-child button { - position: absolute; - left: 70px; - top: 2.5px; - height: 25px; - width: 25px; -} +/* Setting blocks */ -table.console-table th:not(:first-child) { - border-right: none; +div.setting-blocks div { + display: block; } -table.console-table th:not(:first-child) div { - position: absolute; - left: 0px; - top: 0px; - height: 30px; - right: 200px; - padding-left: 5px; - overflow: hidden; - font-size: 14px; - line-height: 30px; +div.setting-blocks div.title { + height: 20px; + width: 100%; text-align: left; - white-space: nowrap; -} - -table.console-table th:not(:first-child) input { - position: absolute; - top: 50%; - right: 5px; - height: 15px; - width: 180px; - padding-top: 9px; - padding-bottom: 9px; - transform: translateY(-50%); -} - -table.console-table th:not(:first-child) input::-webkit-search-cancel-button { - -webkit-appearance: none; - height: 1em; - width: 1em; - background: url("symbols/xmark.circle.fill.svg") no-repeat 50% 50%; - background-size: contain; -} - -table.console-table td { - padding: 0px 6px 0px 6px; - user-select: text; - vertical-align: top; - font-family: monospace; - font-size: 12px; - line-height: 16px; - overflow-wrap: break-word; - word-break: break-all; - white-space: break-spaces; - tab-size: 4; -} - -table.console-table td:first-child { - text-align: right; + font-size: 0px; + line-height: 20px; font-weight: bold; - user-select: none; -} -table.console-table td:last-child { - border-right: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -table.console-table tr.hovered td { - background-color: #eee; +div.setting-blocks div.input { + width: 100%; + min-height: 40px; + text-align: center; + font-size: 0px; } -table.console-table tr.selected td { - background-color: #e3e3e3; +div.setting-blocks.fix-first div.input:nth-child(2) { + height: 40px; } -@media (prefers-color-scheme: dark) { - table.console-table th { - border-right: 1px solid #333; - border-bottom: 1px solid #eee; - background-color: #222; - } - - table.console-table td:first-child { - background-color: #222; - } - - table.console-table tr.hovered td { - background-color: #333; - } - - table.console-table tr.selected td { - background-color: #3e3e3e; - } +div.setting-blocks.fix-first div.input:nth-child(4), +div.setting-blocks.fix-first div.input:nth-child(6) { + height: calc((100% - 105px) / 2); + max-height: 120px; } -/* Statistics */ - -table.stats-config, -table.stats-values { - border-collapse: separate; - border-spacing: 0; - border: 1px solid #555; +div.setting-blocks.fix-first div.input.tall { + height: calc(100% - 85px); + max-height: 100px; } -table.stats-config td, -table.stats-values td { - border: 1px solid #eee; - overflow: hidden; +div.setting-block.fix-second div.input:nth-child(2), +div.setting-blocks.fix-second div.input:nth-child(4) { + height: 40px; } -@media (prefers-color-scheme: dark) { - table.stats-config, - table.stats-values { - border: 1px solid #999; - } - - table.stats-config td, - table.stats-values td { - border: 1px solid #333; - } +div.setting-blocks.fix-second div.input:nth-child(6) { + height: calc(100% - 145px); + max-height: 120px; } -table.stats-config { - position: absolute; - table-layout: fixed; - left: 10px; - width: calc(100% - 20px); - top: 10px; +div.setting-block.fix-third div.input:nth-child(2), +div.setting-blocks.fix-third div.input:nth-child(4), +div.setting-blocks.fix-third div.input:nth-child(6) { + height: 40px; } -table.stats-config td { - position: relative; - word-wrap: break-word; +div.setting-blocks div.full, +div.setting-blocks div.half, +div.setting-blocks div.percent-70, +div.setting-blocks div.percent-30 { + display: inline-block; + height: 100%; + font-size: 16px; text-align: center; - padding: 5px; -} - -table.stats-config tr:last-child td { - line-height: 25px; -} - -table.stats-config span.label { - font-weight: 600; -} - -table.stats-config span.field-name { - font-family: monospace; - font-size: 14px; -} - -table.stats-config input[type="number"] { - width: 50px; -} -div.stats-values-container { - position: absolute; - left: 10px; - bottom: 0px; - overflow-y: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -table.stats-values { +div.setting-blocks div.full { width: 100%; - margin-top: 10px; - margin-bottom: 10px; -} - -table.stats-values td { - word-wrap: break-word; - padding: 2px 6px 2px 6px; -} - -table.stats-values tr.title td { - font-weight: 600; - font-size: 14px; } -table.stats-values tr.section td { - text-align: center; - font-weight: 600; - font-size: 14px; +div.setting-blocks div.half { + width: 50%; } -table.stats-values tr.section td { - border-top: 1px solid #555; +div.setting-blocks div.percent-70 { + width: 70%; } -@media (prefers-color-scheme: dark) { - table.stats-values tr.section td { - border-top: 1px solid #999; - } +div.setting-blocks div.percent-30 { + width: 30%; } -table.stats-values tr.values td:first-child { - text-align: right; - font-size: 12px; - width: 100px; +div.setting-blocks div.split { + position: relative; + left: 50%; + top: 8px; + height: 65%; + width: 80%; + transform: translateX(-50%); + font-size: 0px; } -table.stats-values tr.values td:not(:first-child) { - user-select: text; - font-family: monospace; - font-size: 14px; -} +div.setting-blocks div.split div { + position: relative; + display: inline-block; + height: 100%; + box-sizing: border-box; + vertical-align: top; -div.stats-histogram-container { - position: absolute; - right: 10px; - bottom: 10px; - overflow: hidden; + transition: + background-color 0.2s ease-in-out, + border 0.2s ease-in-out; + background-color: #eee; + border: 1px solid #aaa; } -div.stats-drag-highlight { - position: absolute; - z-index: 11; - background-color: lightgreen; - opacity: 25%; +div.setting-blocks div.split-3 div { + width: calc(100% / 3); } -/* Timline visualizers */ - -div.timeline-viz-timeline-container { - position: absolute; - top: 0px; - left: 38px; - right: 71px; - height: 30px; +div.setting-blocks div.split-2 div { + width: calc(100% / 2); } -input.timeline-viz-timeline-slider { - position: absolute; - margin: 0px; - left: 0px; +div.setting-blocks div.split-1 div { width: 100%; - top: 0px; - height: 23px; - - appearance: none; - background-color: #aaa; } -input.timeline-viz-timeline-slider::-webkit-slider-thumb { - appearance: none; - height: 23px; - width: 4px; - background: black; - cursor: pointer; -} - -input.timeline-viz-timeline-slider:disabled::-webkit-slider-thumb { - cursor: initial; +div.setting-blocks div.split div img { + position: relative; + top: 50%; + width: 60%; + height: 60%; + object-fit: contain; + transform: translateY(-50%); } -div.timeline-viz-timeline-marker-container { - position: absolute; - bottom: 0px; - height: 7px; - left: 0px; - right: 0px; - background: red; +div.setting-blocks div.split div:first-child { + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; } -div.timeline-viz-timeline-marker-container div { - position: absolute; - height: 100%; - background-color: lightgreen; +div.setting-blocks div.split div:last-child { + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; } -div.timeline-viz-timeline-label { - position: absolute; - top: 30px; - left: 0px; - transform: translateX(-50%); - z-index: 10; - font-size: 12px; - padding: 8px; - user-select: none; - pointer-events: none; - - background-color: #ffffff; - border-radius: 6px; - box-shadow: 0px 0px 3px 2px #00000020; - opacity: 0; - transition: opacity 0.1s; +div.setting-blocks div.split div:hover { + background-color: #ddd; } -div.timeline-viz-timeline-label.show { - opacity: 1; +div.setting-blocks div.split div.selected { + background-color: #aaa; + border: 1px solid #222; } @media (prefers-color-scheme: dark) { - div.timeline-viz-timeline-label { - box-shadow: none; - border: 0.5px solid rgba(255, 255, 255, 0.3); - background-color: #222222; + div.setting-blocks div.split div { + background-color: #444; + border: 1px solid #666; } -} - -button.timeline-viz-reset-button { - position: absolute; - width: 28px; - height: 28px; - top: 1px; - left: 5px; -} - -button.timeline-viz-hide-button, -button.timeline-viz-show-button { - position: absolute; - width: 28px; - height: 28px; - top: 1px; - right: 38px; -} - -button.timeline-viz-popup-button { - position: absolute; - width: 28px; - height: 28px; - top: 1px; - right: 5px; -} -div.timeline-viz-drag-highlight { - position: absolute; - z-index: 11; - background-color: lightgreen; - opacity: 25%; -} - -table.timeline-viz-config { - position: absolute; - left: 0px; - bottom: 0px; - width: 100%; - table-layout: fixed; - border-collapse: separate; - border-spacing: 0; - border-top: 1px solid #555; -} + div.setting-blocks div.split div:hover { + background-color: #555; + } -table.timeline-viz-config th { - position: relative; - text-align: center; - font-size: 16px; - font-weight: bold; - padding: 5px; - border-right: 1px solid #555; - border-bottom: 1px solid #555; -} + div.setting-blocks div.split div.selected { + background-color: #888; + border: 1px solid #eee; + } -table.timeline-viz-config td { - position: relative; - word-wrap: break-word; - padding: 5px; - border-right: 1px solid #555; - border-bottom: 1px solid #eee; - overflow: hidden; + div.setting-blocks div.split div img.invertable { + filter: invert(100%); + } } -table.timeline-viz-config td:last-child, -table.timeline-viz-config th:last-child { - border-right: none; +div.setting-blocks > div:first-child { + margin-top: 5px; } -table.timeline-viz-config tr:last-child td { - border-bottom: none; - padding-bottom: 6px; +div.setting-blocks div.game > div { + position: relative; + top: 50%; + transform: translateY(-50%); } -table.timeline-viz-config td.list { - padding: 0px; - vertical-align: top; - border-bottom: none; +div.setting-blocks select.game-select { + height: 24px; } -table.timeline-viz-config td.list div.list-content { - left: 0%; - top: 0%; - width: 100%; - max-height: 25vh; - overflow-x: hidden; - overflow-y: auto; - padding: 0px; - vertical-align: top; +div.setting-blocks a.game-source { + padding: 5px; + font-size: 14px; } -table.timeline-viz-config td.list div.list-shadow-top, -table.timeline-viz-config td.list div.list-shadow-bottom { - position: absolute; - left: 0%; - width: 100%; - height: 30px; - pointer-events: none; - opacity: 0; - transition: opacity 0.2s ease-in-out; +div.setting-blocks input { + margin: 5px; + width: 70%; + text-align: center; } -table.timeline-viz-config td.list div.list-shadow-top { - top: 0%; - background-image: linear-gradient(to bottom, white, transparent); +div.setting-blocks select:not(.game-select) { + margin: 5px; + width: 70%; + height: 25px; } -table.timeline-viz-config td.list div.list-shadow-bottom { - bottom: 0%; - background-image: linear-gradient(to top, white, transparent); +div.setting-blocks input[type="number"]::-webkit-outer-spin-button, +div.setting-blocks input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; } @media (prefers-color-scheme: dark) { - table.timeline-viz-config td.list div.list-shadow-top { - background-image: linear-gradient(to bottom, #222, transparent); - } - - table.timeline-viz-config td.list div.list-shadow-bottom { - background-image: linear-gradient(to top, #222, transparent); + div.setting-blocks div.split div img.lighten { + /* Lighten blue tone */ + filter: hue-rotate(-45deg) brightness(450%); } } -table.timeline-viz-config div.list-filler { +div.tab-centered { position: absolute; - left: 0%; - width: 100%; + left: 50%; top: 50%; - transform: translateY(-50%); - text-align: center; - font-family: monospace; - font-size: 14px; -} + transform: translate(-50%, -50%); -table.timeline-viz-config div.list-item { - padding: 5px; - border-bottom: 1px solid #eee; + text-align: center; + font-style: italic; } -table.timeline-viz-config span.label { - font-weight: 600; -} +/* Line graph */ -table.timeline-viz-config span.field-name { - font-family: monospace; - font-size: 14px; +div.line-graph-axis { + position: absolute; + top: 0%; + height: 100%; + width: calc(100% / 3); } -table.timeline-viz-config a.credit-link { - font-size: 14px; +div.line-graph-left { + left: 0%; } -table.timeline-viz-config input[type="number"] { - width: 75px; +div.line-graph-right { + left: calc(100% / 3 * 2); } -table.timeline-viz-config button { - height: 30px; - width: 30px; +div.line-graph-discrete { + left: calc(100% / 3); + box-sizing: border-box; + border-left: 1px solid #ddd; + border-right: 1px solid #ddd; } @media (prefers-color-scheme: dark) { - table.timeline-viz-config { - border-top: 1px solid #999; - } - - table.timeline-viz-config th { - border-right: 1px solid #999; - border-bottom: 1px solid #999; - } - - table.timeline-viz-config td { - border-right: 1px solid #999; - border-bottom: 1px solid #333; - } - - table.timeline-viz-config div.list-item { - border-bottom: 1px solid #333; + div.line-graph-discrete { + border-left: 1px solid black; + border-right: 1px solid black; } } -div.odometry-canvas-container, -div.three-dimension-canvas-container, -canvas.joysticks-canvas { - position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; - width: 100%; -} +/* Odometry */ -div.odometry-canvas-container canvas { +div.odometry-sources { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); + top: 0%; + height: 100%; + left: 0%; + width: 60%; + min-width: calc(100% - 300px); } -div.odometry-canvas-container div { +div.odometry-settings { position: absolute; - left: 0%; - width: 100%; top: 0%; height: 100%; -} + right: 0%; + width: 40%; + max-width: 300px; + border-left: 1px solid #ddd; -div.three-dimension-annotations { - pointer-events: none; + overflow-x: hidden; + overflow-y: auto; } -div.three-dimension-annotations > div { - text-align: center; - font-weight: bold; - color: #ffffff; - text-shadow: 0px 0px 10px black; +@media (prefers-color-scheme: dark) { + div.odometry-settings { + border-left: 1px solid black; + } } -canvas.three-dimension-canvas:not(.fixed), -div.three-dimension-annotations:not(.fixed) { +/* 3D Field */ + +div.three-dimension-sources { position: absolute; - left: 0%; top: 0%; - width: 100%; height: 100%; + left: 0%; + width: 60%; + min-width: calc(100% - 200px); } -canvas.three-dimension-canvas.fixed, -div.three-dimension-annotations.fixed { +div.three-dimension-settings { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); + top: 0%; + height: 100%; + right: 0%; + width: 40%; + max-width: 200px; + border-left: 1px solid #ddd; + + overflow-x: hidden; + overflow-y: auto; +} + +@media (prefers-color-scheme: dark) { + div.three-dimension-settings { + border-left: 1px solid black; + } } -canvas.three-dimension-canvas.fixed { - border: 1px solid #555; +/* Statistics */ + +div.stats-sources { + position: absolute; + top: 0%; + height: 100%; + left: 0%; + width: 60%; + min-width: calc(100% - 200px); } -div.three-dimension-alert { +div.stats-settings { position: absolute; - left: 50%; - top: 20px; - transform: translateX(-50%); - text-align: center; - padding: 10px; - background-color: #fff; - border: 1px solid #555; + top: 0%; + height: 100%; + right: 0%; + width: 40%; + max-width: 200px; + border-left: 1px solid #ddd; + + overflow-x: hidden; + overflow-y: auto; } @media (prefers-color-scheme: dark) { - canvas.three-dimension-canvas.fixed { - border: 1px solid #999; - } - - div.three-dimension-alert { - background-color: #222; - border: 1px solid #999; + div.stats-settings { + border-left: 1px solid black; } } -div.video-container { +/* Video */ + +div.video-source { position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; - width: 100%; - overflow: hidden; + left: 0%; + top: 0%; + width: 30%; + height: 100%; + border-right: 1px solid #ddd; } -div.video-container img { - width: 100%; +div.video-synchronization { + position: absolute; + left: 30%; + top: 0%; + width: 70%; height: 100%; - object-fit: contain; } -td.video-source > button { +@media (prefers-color-scheme: dark) { + div.video-source { + border-right: 1px solid black; + } +} + +div.video-source > button { position: absolute; height: 70%; width: calc(80% / 3); @@ -1813,224 +1541,237 @@ td.video-source > button { overflow: hidden; } -td.video-source > button > img { +div.video-source > button > img { max-height: 50%; max-width: 50%; transform: translate(-50%, -50%) scale(120%); filter: brightness(0%) saturate(0%) invert(100%); } -td.video-source > button > svg > path { +div.video-source > button > svg > path { fill: none; stroke-width: 0px; transition: stroke-width 0.3s; } -td.video-source > button.animating > svg > path { +div.video-source > button.animating > svg > path { stroke-width: 8px; } -td.video-source > button:nth-child(2) > svg > path { +div.video-source > button:nth-child(2) > svg > path { stroke: #ffb8b8; } -td.video-source > button:nth-child(3) > svg > path { +div.video-source > button:nth-child(3) > svg > path { stroke: #9aacff; } -td.video-source > button:nth-child(1) { +div.video-source > button:nth-child(1) { left: 5%; background-color: #666666; } -td.video-source > button:nth-child(2) { +div.video-source > button:nth-child(2) { left: calc(5% + 80% / 3 + 5%); background-color: #ff0000; } -td.video-source > button:nth-child(3) { +div.video-source > button:nth-child(3) { left: calc(5% + 80% / 3 + 5% + 80% / 3 + 5%); background-color: #4556a5; } -td.video-source > button:nth-child(1):hover { +div.video-source > button:nth-child(1):hover { background-color: #565656; } -td.video-source > button:nth-child(2):hover { +div.video-source > button:nth-child(2):hover { background-color: #e00000; } -td.video-source > button:nth-child(3):hover { +div.video-source > button:nth-child(3):hover { background-color: #354695; } -td.video-source > button:nth-child(1):active { +div.video-source > button:nth-child(1):active { background-color: #464646; } -td.video-source > button:nth-child(2):active { +div.video-source > button:nth-child(2):active { background-color: #c00000; } -td.video-source > button:nth-child(3):active { +div.video-source > button:nth-child(3):active { background-color: #253685; } +div.video-timeline-section { + position: absolute; + left: 0%; + top: 0%; + width: 100%; + height: 50%; +} + +div.video-timeline-section button { + height: 30px; + width: 30px; + left: 5px; + top: 50%; + transform: translateY(-50%); +} + div.video-timeline-container { position: absolute; right: 5px; left: 40px; top: 5px; - height: 30px; -} + height: calc(100% - 10px); -div.video-timeline-container div.timeline-viz-timeline-marker-container { - background-color: #888; + border-radius: 5px; + overflow: hidden; } -div.video-timeline-container div.timeline-viz-timeline-marker-container div { - background-color: #00f; -} +input.video-timeline-slider { + position: absolute; + margin: 0px; + left: 0px; + width: 100%; + top: 0px; + height: 25px; -td.video-controls { - text-align: center; + appearance: none; + background-color: #aaa; } -table.joysticks-config td { - text-align: center; +input.video-timeline-slider::-webkit-slider-thumb { + appearance: none; + height: 25px; + width: 4px; + background: black; + cursor: pointer; } -table.joysticks-config input[type="number"] { - width: 40px; +input.video-timeline-slider:disabled::-webkit-slider-thumb { + cursor: initial; } -div.swerve-canvas-container { +div.video-timeline-marker-container { position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); + bottom: 0px; + height: calc(100% - 25px); left: 0px; - width: 100%; - overflow: hidden; + right: 0px; + background-color: #888; } -canvas.swerve-canvas { +div.video-timeline-marker-container div { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - - background-color: #f4f4f4; - border: 1px solid #555; -} - -@media (prefers-color-scheme: dark) { - canvas.swerve-canvas { - background-color: #292929; - border: 1px solid #999; - } + height: 100%; + background-color: #00f; } -div.mechanism-svg-container { +div.video-timeline-controls { position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; + left: 0%; + top: 50%; width: 100%; + height: 50%; + text-align: center; } -svg.mechanism-svg { - position: absolute; - left: 50%; +div.video-timeline-controls button { top: 50%; - transform: translate(-50%, -50%); + height: 30px; + width: 30px; + transform: translateY(-50%); } -div.points-background-container { +/* Joysticks */ + +table.joystick-selector { position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; + left: 0%; + top: 0%; width: 100%; - overflow: hidden; + height: 100%; } -div.points-background { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - - background-color: #f4f4f4; - border: 1px solid #555; +table.joystick-selector td { + text-align: center; + font-weight: bold; } -@media (prefers-color-scheme: dark) { - div.points-background { - background-color: #292929; - border: 1px solid #999; - } +table.joystick-selector select { + width: calc(100% - 30px); } -div.points-background svg { +/* Swerve */ + +div.swerve-sources { position: absolute; - stroke-width: 5px; + top: 0%; + height: 100%; + left: 0%; + width: 60%; + min-width: calc(100% - 200px); } -/* Metadata */ - -div.metadata-table-container { +div.swerve-settings { + position: absolute; + top: 0%; height: 100%; - width: 100%; - overflow: auto; -} + right: 0%; + width: 40%; + max-width: 200px; + border-left: 1px solid #ddd; -table.metadata-table { - width: 100%; - table-layout: fixed; - text-align: left; - word-wrap: break-word; - border-collapse: separate; - border-spacing: 0; + overflow-x: hidden; + overflow-y: auto; } -table.metadata-table th:first-child { - width: 150px; +@media (prefers-color-scheme: dark) { + div.swerve-settings { + border-left: 1px solid black; + } } -table.metadata-table th { - padding: 6px; - position: sticky; - top: 0px; - border-bottom: 1px solid #222; - background-color: #fff; -} +/* Mechanism */ -table.metadata-table td:first-child { - padding: 6px; - text-align: right; - font-weight: bold; - border-right: 1px solid #222; +div.mechanism-sources { + position: absolute; + left: 0%; + top: 0%; + width: 100%; + height: 100%; } -table.metadata-table td:not(:first-child) { - padding: 6px; - font-family: monospace; - font-size: 14px; - user-select: text; +/* Points */ + +div.points-sources { + position: absolute; + top: 0%; + height: 100%; + left: 0%; + width: 60%; + min-width: calc(100% - 200px); } -table.metadata-table td.no-data { - font-style: italic; +div.points-settings { + position: absolute; + top: 0%; + height: 100%; + right: 0%; + width: 40%; + max-width: 200px; + border-left: 1px solid #ddd; + + overflow-x: hidden; + overflow-y: auto; } @media (prefers-color-scheme: dark) { - table.metadata-table th { - border-bottom: 1px solid #eee; - background-color: #222; - } - - table.metadata-table td:first-child { - border-right: 1px solid #eee; + div.points-settings { + border-left: 1px solid black; } } diff --git a/www/hub.html b/www/hub.html index 270852a7..1d6614c6 100644 --- a/www/hub.html +++ b/www/hub.html @@ -13,6 +13,7 @@ + AdvantageScope @@ -22,6 +23,16 @@ +
+ + + + + + + +
+
- - - + - + + + + - +
+
+
+
+ +
+
-
- -
-
-
-
-
-
+
+ +
+
-