From 4e8d10e42b15ca35d291d3ed7727c98431d73955 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sun, 31 May 2026 22:17:55 +0200 Subject: [PATCH 01/58] chore: add TypeScript tooling foundation (#924 Phase 0) Lay the incremental TS migration foundation so .ts and .js coexist: - tsconfig.json: allowJs + checkJs:false (existing JS untouched), strict for new TS, noEmit (Vite transpiles), bundler resolution, @/* alias - src/env.d.ts: *.vue shims, __APP_VERSION__ define, script-loaded globals - eslint.config.mjs: typescript-eslint scoped to src/**/*.ts only; JS ruleset unchanged; shared stylistic rules factored out - package.json: add type-check (vue-tsc --noEmit) + typescript, vue-tsc, typescript-eslint devDeps - build.yml: run type-check in CI before build A file opts into type-checking by being renamed .js -> .ts; no big-bang. type-check, lint, and build all pass. Refs #924 --- .github/workflows/build.yml | 2 + eslint.config.mjs | 70 +++++-- package-lock.json | 402 +++++++++++++++++++++++++++++++++++- package.json | 6 +- src/env.d.ts | 17 ++ tsconfig.json | 37 ++++ 6 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 src/env.d.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6979e50..6a681c10 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,8 @@ jobs: cache-dependency-path: 'package-lock.json' - run: npm ci + - name: Type check + run: npm run type-check - name: Build run: npm run build env: diff --git a/eslint.config.mjs b/eslint.config.mjs index 8474fca0..b983e40c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,12 +1,40 @@ import js from "@eslint/js"; +import tseslint from "typescript-eslint"; import eslintConfigPrettier from "eslint-config-prettier"; import globals from "globals"; +// Stylistic rules shared by JS and TS sources. +const sharedRules = { + "no-var": "error", + "no-prototype-builtins": "error", + "no-empty": "error", + "no-inner-declarations": "error", + "no-fallthrough": "error", + "no-useless-escape": "error", + "no-constant-condition": "error", + "no-unreachable": "error", + "no-duplicate-case": "error", + "no-dupe-keys": "error", + "no-irregular-whitespace": "error", + "no-case-declarations": "error", + eqeqeq: ["error", "smart"], + "prefer-const": "error", + "prefer-template": "error", + radix: "error", + "comma-dangle": ["error", "always-multiline"], + semi: ["error", "always"], +}; + export default [ { ignores: ["src/vendor/", "dist/", "public/", "test/"], }, js.configs.recommended, + // typescript-eslint scoped to TS sources only — JS keeps the core ruleset below. + ...tseslint.config({ + files: ["src/**/*.ts"], + extends: [tseslint.configs.recommended], + }), eslintConfigPrettier, { files: ["src/**/*.js"], @@ -22,30 +50,32 @@ export default [ }, }, rules: { - "no-var": "error", + ...sharedRules, + "no-undef": "error", + "no-redeclare": "error", "no-unused-vars": [ "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], - "no-undef": "error", - "no-redeclare": "error", - "no-prototype-builtins": "error", - "no-empty": "error", - "no-inner-declarations": "error", - "no-fallthrough": "error", - "no-useless-escape": "error", - "no-constant-condition": "error", - "no-unreachable": "error", - "no-duplicate-case": "error", - "no-dupe-keys": "error", - "no-irregular-whitespace": "error", - "no-case-declarations": "error", - eqeqeq: ["error", "smart"], - "prefer-const": "error", - "prefer-template": "error", - radix: "error", - "comma-dangle": ["error", "always-multiline"], - semi: ["error", "always"], + }, + }, + { + files: ["src/**/*.ts"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + }, + }, + rules: { + ...sharedRules, + // TypeScript's own checker handles undefined references and redeclarations. + "no-undef": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], }, }, ]; diff --git a/package-lock.json b/package-lock.json index b70add9e..791bee6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,8 +31,11 @@ "globals": "^17.6.0", "prettier": "^3.8.3", "tailwindcss": "^4.2.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", "vite": "^7.3.2", - "vite-plugin-pwa": "^1.0.3" + "vite-plugin-pwa": "^1.0.3", + "vue-tsc": "^3.3.3" }, "engines": { "node": "24.x" @@ -96,6 +99,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2252,6 +2256,7 @@ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -3778,6 +3783,7 @@ "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3881,6 +3887,7 @@ "integrity": "sha512-3rax34HKSo8L5ihv5iGxISNBUIdVP0Fq9O6T/mq4kQOLhVhNbqwr9I/lWOvaz24nPE7u5Vkz0Ii3zWGlzxyPPA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3912,6 +3919,7 @@ "integrity": "sha512-ClpgtNJZTUctQDB64KAZjhowZUK3QwFCiYJBXMg/fk9YzYHZ1L9qjzjqUcHiCsbQN9ILNZY1q9CQkce1LzKlrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/dom": "^1.6.13" }, @@ -4083,6 +4091,7 @@ "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4142,6 +4151,7 @@ "integrity": "sha512-1C00Dur+8KNjcKcI6nuYY4RFOrp/1YUFR04Wj0VZVc8L8l4jCXS+JY+aYtTwKjxcXIH3UzplfwoRZj9thn98pg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4241,6 +4251,7 @@ "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4274,6 +4285,7 @@ "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -4336,6 +4348,7 @@ "integrity": "sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4351,6 +4364,7 @@ "integrity": "sha512-xwSXPwDjauIVktMXBMaNaSgFyq3O1sXcX1vWyHyyCFlq4+8ekq4uXbjkD6y6IhZyr/AQoRYnjgosus+apGyGuA==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4430,6 +4444,237 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@unhead/vue": { "version": "2.1.13", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.13.tgz", @@ -4471,6 +4716,35 @@ "vue": "^3.2.25" } }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.33", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", @@ -4563,6 +4837,22 @@ "rfdc": "^1.4.1" } }, + "node_modules/@vue/language-core": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.3.tgz", + "integrity": "sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.33", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", @@ -4619,6 +4909,7 @@ "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", @@ -4727,6 +5018,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4761,6 +5053,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -5016,6 +5315,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5523,7 +5823,8 @@ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-auto-height": { "version": "8.6.0", @@ -5855,6 +6156,7 @@ "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6447,6 +6749,7 @@ "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=10" }, @@ -7294,7 +7597,6 @@ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -7452,7 +7754,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/leaflet-marker-rotation": { "version": "0.4.0", @@ -7498,7 +7801,6 @@ "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -8075,6 +8377,13 @@ "dev": true, "license": "MIT" }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -8339,6 +8648,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8612,6 +8928,7 @@ "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -8634,6 +8951,7 @@ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -8670,6 +8988,7 @@ "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -9237,7 +9556,8 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/source-map": { "version": "0.8.0-beta.0", @@ -9497,6 +9817,7 @@ "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -9527,7 +9848,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.3", @@ -9578,6 +9900,7 @@ "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9663,6 +9986,19 @@ "punycode": "^2.1.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -9796,6 +10132,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -10492,6 +10852,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10592,11 +10953,19 @@ } } }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.33", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", @@ -10641,6 +11010,23 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/vue-tsc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.3.tgz", + "integrity": "sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.3" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10961,6 +11347,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11018,6 +11405,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/package.json b/package.json index 13d4f1ff..fe44af93 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "vite", "build": "vite build", "preview": "vite preview", + "type-check": "vue-tsc --noEmit", "lint": "eslint src", "lint:fix": "eslint src --fix", "format": "prettier --write src" @@ -44,8 +45,11 @@ "globals": "^17.6.0", "prettier": "^3.8.3", "tailwindcss": "^4.2.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", "vite": "^7.3.2", - "vite-plugin-pwa": "^1.0.3" + "vite-plugin-pwa": "^1.0.3", + "vue-tsc": "^3.3.3" }, "overrides": { "@rollup/plugin-terser": "^1.0.0" diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 00000000..248cf8d2 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,17 @@ +/// +/// + +// Single-file components — typed as generic Vue components until each gains `lang="ts"`. +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent, Record, unknown>; + export default component; +} + +// Injected by Vite `define` (see vite.config.js). +declare const __APP_VERSION__: string; + +// Globals loaded via diff --git a/src/components/SeekBarToolbar.vue b/src/components/SeekBarToolbar.vue index 1e67200f..a69ead90 100644 --- a/src/components/SeekBarToolbar.vue +++ b/src/components/SeekBarToolbar.vue @@ -14,8 +14,10 @@ diff --git a/src/composables/use_blackbox_viewer.js b/src/composables/use_blackbox_viewer.js index 9f3ce132..c03c692f 100644 --- a/src/composables/use_blackbox_viewer.js +++ b/src/composables/use_blackbox_viewer.js @@ -50,7 +50,7 @@ export function useBlackboxViewer() { } // Operations exposed to components (instead of via store callback refs). - let spectrumExport, spectrumImport, spectrumClear; + const ops = {}; function supportsRequiredAPIs() { return ( @@ -423,7 +423,7 @@ export function useBlackboxViewer() { invalidateGraph(); } userSettings = settingsStore.userSettings; - workspaceStore.gotoBookmark = (index) => { + ops.gotoBookmark = (index) => { if (workspaceStore.bookmarkTimes?.[index] != null) { setCurrentBlackboxTime(workspaceStore.bookmarkTimes[index]); invalidateGraph(); @@ -431,9 +431,9 @@ export function useBlackboxViewer() { }; // Spectrum operations (exposed via the composable return; no longer on graphStore) - spectrumExport = () => exportSpectrumToCsv(graph.getAnalyser(), appStore.logFilename); - spectrumImport = (files) => graph.getAnalyser()?.importSpectrumFromCSV(files); - spectrumClear = () => graph.getAnalyser()?.removeImportedSpectrums(); + ops.spectrumExport = () => exportSpectrumToCsv(graph.getAnalyser(), appStore.logFilename); + ops.spectrumImport = (files) => graph.getAnalyser()?.importSpectrumFromCSV(files); + ops.spectrumClear = () => graph.getAnalyser()?.removeImportedSpectrums(); window.addEventListener("resize", function () { updateCanvasSize(); @@ -580,19 +580,19 @@ export function useBlackboxViewer() { } }); - graphStore.zoomGraphConfig = (gi) => zoomGraphConfig(gi); - graphStore.expandGraphConfig = (gi) => expandGraphConfig(gi); - graphStore.reorderGraphs = (newOrder) => { + ops.zoomGraphConfig = (gi) => zoomGraphConfig(gi); + ops.expandGraphConfig = (gi) => expandGraphConfig(gi); + ops.reorderGraphs = (newOrder) => { const oldGraphs = graphStore.graphConfig; const newGraphs = newOrder.map((i) => oldGraphs[i]); newGraphConfig(newGraphs); }; - graphStore.resetPen = (gi, fi) => { + ops.resetPen = (gi, fi) => { const graphs = graphStore.activeGraphConfig.getGraphs(); const msg = restorePenDefaults(graphs, String(gi), fi == null ? null : String(fi)); applyPenChange(msg); }; - graphStore.fieldWheel = (gi, fi, delta, shiftKey, altKey, ctrlKey) => { + ops.fieldWheel = (gi, fi, delta, shiftKey, altKey, ctrlKey) => { const graphs = graphStore.activeGraphConfig.getGraphs(); const g = String(gi); const f = String(fi); @@ -607,17 +607,17 @@ export function useBlackboxViewer() { } applyPenChange(msg); }; - playbackStore.logSyncHere = logSyncHere; - playbackStore.logSyncBack = logSyncBack; - playbackStore.logSyncForward = logSyncForward; - playbackStore.logSmartSync = logSmartSync; - playbackStore.setVideoOffsetValue = (val) => { + ops.logSyncHere = logSyncHere; + ops.logSyncBack = logSyncBack; + ops.logSyncForward = logSyncForward; + ops.logSmartSync = logSmartSync; + ops.setVideoOffsetValue = (val) => { const offset = Number.parseFloat(val); if (!Number.isNaN(offset)) { setVideoOffset(offset, true); } }; - playbackStore.setGraphTime = (timeStr) => { + ops.setGraphTime = (timeStr) => { let newTime = stringTimetoMsec(timeStr); if (!isNaN(newTime)) { if (logStore.hasVideo) { @@ -629,20 +629,20 @@ export function useBlackboxViewer() { invalidateGraph(); } }; - graphStore.setSeekBarMode = setSeekBarMode; - graphStore.selectLogIndex = (index) => { + ops.setSeekBarMode = setSeekBarMode; + ops.selectLogIndex = (index) => { selectLog(Number.parseInt(index, 10)); if (graph) { graph.setAnalyser(graphStore.hasAnalyserFullscreen); } }; - workspaceStore.switchWorkspace = (id) => { + ops.switchWorkspace = (id) => { if (workspaceStore.workspaceGraphConfigs[id] != null) { onSwitchWorkspace(workspaceStore.workspaceGraphConfigs, id); } }; - workspaceStore.saveWorkspace = (id, title) => onSaveWorkspace(id, title); - workspaceStore.applyDefaultWorkspace = (index) => { + ops.saveWorkspace = (id, title) => onSaveWorkspace(id, title); + ops.applyDefaultWorkspace = (index) => { const presets = [null, structuredClone(ctzsnoozeWorkspace), structuredClone(supaflyWorkspace)]; if (presets[index]) { onSwitchWorkspace(presets[index], 1); @@ -650,28 +650,25 @@ export function useBlackboxViewer() { }; } - graphStore.applyGraphZoom = setGraphZoom; - playbackStore.applyPlaybackRate = setPlaybackRate; - playbackStore.logPlayPause = logPlayPause; - playbackStore.logJumpBack = () => logJumpBack(null); - playbackStore.logJumpForward = () => logJumpForward(null); - playbackStore.logJumpStart = logJumpStart; - playbackStore.logJumpEnd = logJumpEnd; - playbackStore.videoJumpStart = () => { + ops.applyGraphZoom = setGraphZoom; + ops.applyPlaybackRate = setPlaybackRate; + ops.logPlayPause = logPlayPause; + ops.logJumpBack = () => logJumpBack(null); + ops.logJumpForward = () => logJumpForward(null); + ops.logJumpStart = logJumpStart; + ops.logJumpEnd = logJumpEnd; + ops.videoJumpStart = () => { setVideoTime(0); setGraphState(GRAPH_STATE_PAUSED); }; - playbackStore.videoJumpEnd = () => { + ops.videoJumpEnd = () => { if (video.duration) { setVideoTime(video.duration); setGraphState(GRAPH_STATE_PAUSED); } }; - viewerInstance = { - spectrumExport: (...args) => spectrumExport?.(...args), - spectrumImport: (...args) => spectrumImport?.(...args), - spectrumClear: (...args) => spectrumClear?.(...args), + Object.assign(ops, { refreshGraph, loadFiles, // Note: internal newGraphConfig takes `noRedraw` (inverted). Callers pass `redrawChart` (true = redraw). @@ -716,6 +713,7 @@ export function useBlackboxViewer() { updateCanvasSize(); } }, - }; - return viewerInstance; + }); + viewerInstance = ops; + return ops; } diff --git a/src/stores/graph.js b/src/stores/graph.js index 4c31846b..4ffe520f 100644 --- a/src/stores/graph.js +++ b/src/stores/graph.js @@ -59,14 +59,6 @@ export const useGraphStore = defineStore("graph", () => { // Callbacks registered by main.js const invalidateGraph = shallowRef(null); const updateCanvasSize = shallowRef(null); - const zoomGraphConfig = shallowRef(null); - const expandGraphConfig = shallowRef(null); - const reorderGraphs = shallowRef(null); - const resetPen = shallowRef(null); - const fieldWheel = shallowRef(null); - const applyGraphZoom = shallowRef(null); - const selectLogIndex = shallowRef(null); - const setSeekBarMode = shallowRef(null); // --- Legend actions --- @@ -210,14 +202,6 @@ export const useGraphStore = defineStore("graph", () => { seekBarMode, invalidateGraph, updateCanvasSize, - zoomGraphConfig, - expandGraphConfig, - reorderGraphs, - resetPen, - fieldWheel, - applyGraphZoom, - selectLogIndex, - setSeekBarMode, buildLegendGraphs, highlightLegendField, selectLegendField, diff --git a/src/stores/playback.js b/src/stores/playback.js index a14c8459..1a80ab18 100644 --- a/src/stores/playback.js +++ b/src/stores/playback.js @@ -27,20 +27,6 @@ export const usePlaybackStore = defineStore("playback", () => { const isPaused = computed(() => graphState.value === GRAPH_STATE_PAUSED); // Callbacks registered by main.js (need video element + renderer closures) - const logPlayPause = shallowRef(null); - const logJumpBack = shallowRef(null); - const logJumpForward = shallowRef(null); - const logJumpStart = shallowRef(null); - const logJumpEnd = shallowRef(null); - const videoJumpStart = shallowRef(null); - const videoJumpEnd = shallowRef(null); - const logSyncHere = shallowRef(null); - const logSyncBack = shallowRef(null); - const logSyncForward = shallowRef(null); - const logSmartSync = shallowRef(null); - const setVideoOffsetValue = shallowRef(null); - const setGraphTime = shallowRef(null); - const applyPlaybackRate = shallowRef(null); function setPlaybackRate(rate) { playbackRate.value = Math.max( @@ -65,20 +51,6 @@ export const usePlaybackStore = defineStore("playback", () => { currentOffsetCache, isPlaying, isPaused, - logPlayPause, - logJumpBack, - logJumpForward, - logJumpStart, - logJumpEnd, - videoJumpStart, - videoJumpEnd, - logSyncHere, - logSyncBack, - logSyncForward, - logSmartSync, - setVideoOffsetValue, - setGraphTime, - applyPlaybackRate, setPlaybackRate, setVideoOffset, }; diff --git a/src/stores/workspace.js b/src/stores/workspace.js index a7f68beb..5e1dbba3 100644 --- a/src/stores/workspace.js +++ b/src/stores/workspace.js @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import { ref, shallowRef } from "vue"; +import { ref } from "vue"; export const useWorkspaceStore = defineStore("workspace", () => { const workspaceGraphConfigs = ref([]); @@ -17,10 +17,6 @@ export const useWorkspaceStore = defineStore("workspace", () => { const showDefaultMenu = ref(false); // Callbacks registered by main.js - const switchWorkspace = shallowRef(null); - const saveWorkspace = shallowRef(null); - const applyDefaultWorkspace = shallowRef(null); - const gotoBookmark = shallowRef(null); /** Get title for a workspace slot (1-9, 0) */ function getTitle(id) { @@ -40,10 +36,6 @@ export const useWorkspaceStore = defineStore("workspace", () => { setActiveWorkspace, setWorkspaceGraphConfigs, showDefaultMenu, - switchWorkspace, - saveWorkspace, - applyDefaultWorkspace, - gotoBookmark, getTitle, hasWorkspace, }; From 9a27230e10511601a2e26a08f0c102cf3b6d0dfc Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Mon, 1 Jun 2026 01:02:25 +0200 Subject: [PATCH 16/58] fix: defer viewer init until after Vue mount (#924 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime regression from the composable extraction: useBlackboxViewer() self-initialized on first call, which happened during SpectrumAnalyser.vue setup — before that component renders its #analyserCanvas. So the captured analyserCanvas was null, and FlightLogGrapher's analyser failed to create (getContext on null), cascading to "analyser.resize is not a function" on log load. Split the composable: - useBlackboxViewer() now just returns the shared `ops` object (no init), safe to call from any component setup(). - initBlackboxViewer() does the heavy init (renderer instances + wiring) exactly once. main.js calls it; since main.js loads after vue_init.js mounts the app, all Vue-rendered canvases (incl. #analyserCanvas) exist — restoring the pre-refactor init timing. Build, type-check, lint pass. Needs an app re-test (load a log) to confirm the reported errors are gone. Refs #924 --- src/composables/use_blackbox_viewer.js | 30 +++++++++++++++----------- src/main.js | 10 +++++---- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/composables/use_blackbox_viewer.js b/src/composables/use_blackbox_viewer.js index c03c692f..b4f3c88b 100644 --- a/src/composables/use_blackbox_viewer.js +++ b/src/composables/use_blackbox_viewer.js @@ -38,19 +38,26 @@ function createNewBlackboxWindow(_fileToOpen) { globalThis.open(globalThis.location.href, "_blank").focus(); } -// Singleton that owns the renderer instances and imperative operations. -// Stores hold reactive state; this composable provides the actions that drive -// the renderers. Initialized once at app bootstrap (main.js); components call -// it to reach the operations (collapsing the legacy callback bridge). -let viewerInstance = null; - +// Imperative operations exposed to components (the legacy callback bridge, +// collapsed). Shared by reference and populated by initBlackboxViewer(). +const ops = {}; +let initialized = false; + +// Components call this in setup() to reach the operations. It does NOT trigger +// initialization — it just hands back the shared `ops` object (populated once +// the viewer is initialized at bootstrap). export function useBlackboxViewer() { - if (viewerInstance) { - return viewerInstance; - } + return ops; +} - // Operations exposed to components (instead of via store callback refs). - const ops = {}; +// Owns the renderer instances and wires the operations. Must run AFTER the Vue +// app has mounted, so Vue-rendered canvases (e.g. #analyserCanvas, rendered by +// SpectrumAnalyser.vue) exist. Called once from main.js. +export function initBlackboxViewer() { + if (initialized) { + return ops; + } + initialized = true; function supportsRequiredAPIs() { return ( @@ -714,6 +721,5 @@ export function useBlackboxViewer() { } }, }); - viewerInstance = ops; return ops; } diff --git a/src/main.js b/src/main.js index 1c16410f..d2cd04a4 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,7 @@ -// Application entry point. Bootstraps the Blackbox viewer: the composable owns -// the renderer instances and imperative operations; Pinia stores hold state. -import { useBlackboxViewer } from "./composables/use_blackbox_viewer.js"; +// Application entry point. Bootstraps the Blackbox viewer after the Vue app has +// mounted (vue_init.js runs first), so Vue-rendered canvases exist. The +// composable owns the renderer instances and imperative operations; Pinia +// stores hold state. +import { initBlackboxViewer } from "./composables/use_blackbox_viewer.js"; -useBlackboxViewer(); +initBlackboxViewer(); From 9f8d3fc8f41f51fd87bdcb43dc5943eae999d8c8 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Mon, 1 Jun 2026 01:08:40 +0200 Subject: [PATCH 17/58] refactor: convert Pinia stores to TypeScript (#924 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert all 6 stores + pinia_instance to .ts, no logic change (renames): - log store: flightLog typed as `FlightLog | null` (now that flightlog is TS) — getMinTime/getMaxTime/hasGpsData/getSysConfig flow typed; flightLogDataArray: Uint8Array|null; FIRMWARE_CLASS_MAP: Record. - playback store: typed video/offset refs + OffsetEntry; action params. - app/workspace/settings stores: typed action params + ref type args; settings deepMerge/saveSetting use Record + a localized any (dynamic key write) behind eslint-disable. - graph store: state refs typed; renderer/config instances (grapher, graph_config — still JS) typed via a `Loose` alias until those convert; callback refs typed `(() => void) | null`. Consumers use extensionless/.js store specifiers → no changes. type-check, lint, build, and the parser golden test all pass; diffs are type-only. Refs #924 --- src/{pinia_instance.js => pinia_instance.ts} | 0 src/stores/{app.js => app.ts} | 4 +- src/stores/{graph.js => graph.ts} | 49 +++++++++++--------- src/stores/{log.js => log.ts} | 25 +++++----- src/stores/{playback.js => playback.ts} | 21 ++++++--- src/stores/{settings.js => settings.ts} | 13 +++--- src/stores/{workspace.js => workspace.ts} | 18 ++++--- 7 files changed, 75 insertions(+), 55 deletions(-) rename src/{pinia_instance.js => pinia_instance.ts} (100%) rename src/stores/{app.js => app.ts} (94%) rename src/stores/{graph.js => graph.ts} (79%) rename src/stores/{log.js => log.ts} (71%) rename src/stores/{playback.js => playback.ts} (71%) rename src/stores/{settings.js => settings.ts} (71%) rename src/stores/{workspace.js => workspace.ts} (65%) diff --git a/src/pinia_instance.js b/src/pinia_instance.ts similarity index 100% rename from src/pinia_instance.js rename to src/pinia_instance.ts diff --git a/src/stores/app.js b/src/stores/app.ts similarity index 94% rename from src/stores/app.js rename to src/stores/app.ts index bec3f395..70330a21 100644 --- a/src/stores/app.js +++ b/src/stores/app.ts @@ -29,11 +29,11 @@ export const useAppStore = defineStore("app", () => { // (Imperative operations moved to the useBlackboxViewer composable.) - function setLegendHidden(hidden) { + function setLegendHidden(hidden: boolean) { legendHidden.value = hidden; } - function setViewVideo(visible) { + function setViewVideo(visible: boolean) { viewVideo.value = visible; } diff --git a/src/stores/graph.js b/src/stores/graph.ts similarity index 79% rename from src/stores/graph.js rename to src/stores/graph.ts index 4ffe520f..12936f5f 100644 --- a/src/stores/graph.js +++ b/src/stores/graph.ts @@ -4,6 +4,11 @@ import { useSettingsStore } from "./settings.js"; import { useLogStore } from "./log.js"; import { PrefStorage } from "../pref_storage.js"; +// Renderer/config instances live in still-JS modules (grapher, graph_config); +// typed loosely until those are converted. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Loose = any; + export const GRAPH_MIN_ZOOM = 1; export const GRAPH_MAX_ZOOM = 1000; export const GRAPH_DEFAULT_ZOOM = 100; @@ -12,16 +17,16 @@ export const useGraphStore = defineStore("graph", () => { const prefs = new PrefStorage(); // Renderer instances — registered by main.js after creation - const graph = shallowRef(null); - const mapGrapher = shallowRef(null); - const seekBar = shallowRef(null); + const graph = shallowRef(null); + const mapGrapher = shallowRef(null); + const seekBar = shallowRef(null); // Canvas DOM refs — registered by main.js - const canvasRefs = shallowRef(null); + const canvasRefs = shallowRef(null); - const graphConfig = ref(null); - const activeGraphConfig = shallowRef(null); - const lastGraphConfig = ref(null); + const graphConfig = ref(null); + const activeGraphConfig = shallowRef(null); + const lastGraphConfig = ref(null); const graphZoom = ref(GRAPH_DEFAULT_ZOOM); const lastGraphZoom = ref(GRAPH_DEFAULT_ZOOM); @@ -37,14 +42,14 @@ export const useGraphStore = defineStore("graph", () => { const hasConfig = ref(false); const hasConfigOverlay = ref(false); const configFileName = ref(""); - const configLines = shallowRef([]); + const configLines = shallowRef([]); // Legend const legendVisible = ref(true); const legendTitle = ref("Legend"); - const legendGraphs = shallowRef([]); + const legendGraphs = shallowRef([]); // Each: { label, fields: [{ name, friendlyName, color, hidden }] } - const legendValues = shallowRef({}); + const legendValues = shallowRef>({}); // Map of fieldName → { value, settings } // Analyser @@ -57,16 +62,16 @@ export const useGraphStore = defineStore("graph", () => { const seekBarMode = ref("avgThrottle"); // Callbacks registered by main.js - const invalidateGraph = shallowRef(null); - const updateCanvasSize = shallowRef(null); + const invalidateGraph = shallowRef<(() => void) | null>(null); + const updateCanvasSize = shallowRef<(() => void) | null>(null); // --- Legend actions --- function buildLegendGraphs() { - const graphs = activeGraphConfig.value?.getGraphs() ?? []; - legendGraphs.value = graphs.map((g, gi) => ({ + const graphs: Loose[] = activeGraphConfig.value?.getGraphs() ?? []; + legendGraphs.value = graphs.map((g: Loose, gi: number) => ({ label: g.label, - fields: g.fields.map((f, fi) => ({ + fields: g.fields.map((f: Loose, fi: number) => ({ name: f.name, friendlyName: f.friendlyName, color: f.color, @@ -75,7 +80,7 @@ export const useGraphStore = defineStore("graph", () => { })); } - function highlightLegendField(gi, fi) { + function highlightLegendField(gi: number, fi: number) { if (!activeGraphConfig.value) { return; } @@ -84,7 +89,7 @@ export const useGraphStore = defineStore("graph", () => { invalidateGraph.value?.(); } - function selectLegendField(gi, fi, fieldName, ctrlKey) { + function selectLegendField(gi: number, fi: number, fieldName: string, ctrlKey: boolean) { if (!activeGraphConfig.value) { return; } @@ -103,7 +108,7 @@ export const useGraphStore = defineStore("graph", () => { invalidateGraph.value?.(); } - function toggleLegendField(gi, fi) { + function toggleLegendField(gi: number, fi: number) { if (!activeGraphConfig.value) { return; } @@ -112,14 +117,14 @@ export const useGraphStore = defineStore("graph", () => { invalidateGraph.value?.(); } - function legendVisibilityChange(hidden) { + function legendVisibilityChange(hidden: boolean) { prefs.set("log-legend-hidden", hidden); updateCanvasSize.value?.(); } function toggleAnalyser() { if (activeGraphConfig.value?.selectedFieldName == null) { - const graphs = activeGraphConfig.value?.getGraphs() ?? []; + const graphs: Loose[] = activeGraphConfig.value?.getGraphs() ?? []; if (graphs.length === 0 || graphs[0].fields.length === 0) { hasAnalyser.value = false; } else { @@ -155,11 +160,11 @@ export const useGraphStore = defineStore("graph", () => { } } - function setGraphZoom(zoom) { + function setGraphZoom(zoom: number) { graphZoom.value = Math.max(GRAPH_MIN_ZOOM, Math.min(GRAPH_MAX_ZOOM, zoom)); } - function quickZoomToggle(newZoom) { + function quickZoomToggle(newZoom: number) { if (graphZoom.value === newZoom) { setGraphZoom(lastGraphZoom.value); } else { diff --git a/src/stores/log.js b/src/stores/log.ts similarity index 71% rename from src/stores/log.js rename to src/stores/log.ts index d94b593a..9964befd 100644 --- a/src/stores/log.js +++ b/src/stores/log.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; import { ref, shallowRef, computed } from "vue"; +import type { FlightLog } from "../flightlog"; import { FIRMWARE_TYPE_BASEFLIGHT, FIRMWARE_TYPE_CLEANFLIGHT, @@ -7,7 +8,7 @@ import { FIRMWARE_TYPE_INAV, } from "../flightlog_fielddefs.js"; -const FIRMWARE_CLASS_MAP = { +const FIRMWARE_CLASS_MAP: Record = { [FIRMWARE_TYPE_BASEFLIGHT]: "isBaseF", [FIRMWARE_TYPE_CLEANFLIGHT]: "isCF", [FIRMWARE_TYPE_BETAFLIGHT]: "isBF", @@ -17,8 +18,8 @@ const FIRMWARE_CLASS_MAP = { export const FIRMWARE_CLASSES = Object.values(FIRMWARE_CLASS_MAP); export const useLogStore = defineStore("log", () => { - const flightLog = ref(null); - const flightLogDataArray = ref(null); + const flightLog = ref(null); + const flightLogDataArray = ref(null); const currentBlackboxTime = ref(0); const hasLog = ref(false); const hasVideo = ref(false); @@ -26,14 +27,14 @@ export const useLogStore = defineStore("log", () => { // activeLogIndex dependency ensures re-evaluation when log index changes return activeLogIndex.value >= 0 && !!flightLog.value?.hasGpsData?.(); }); - const videoURL = ref(null); + const videoURL = ref(null); // Field values table data (updated by updateValuesChart in main.js) - const fieldValues = shallowRef([]); - const fieldStats = shallowRef([]); + const fieldValues = shallowRef([]); + const fieldStats = shallowRef([]); // Log index picker (multiple logs in one file) - const logIndexEntries = shallowRef([]); + const logIndexEntries = shallowRef>([]); // Each: { label, value, disabled } const activeLogIndex = ref(0); @@ -42,23 +43,23 @@ export const useLogStore = defineStore("log", () => { const firmwareClass = computed(() => { const type = flightLog.value?.getSysConfig?.()?.firmwareType; - return FIRMWARE_CLASS_MAP[type] ?? null; + return FIRMWARE_CLASS_MAP[type as number] ?? null; }); - function setFlightLog(log) { + function setFlightLog(log: FlightLog | null) { flightLog.value = log; hasLog.value = !!log; } - function setFlightLogDataArray(dataArray) { + function setFlightLogDataArray(dataArray: Uint8Array | null) { flightLogDataArray.value = dataArray; } - function setCurrentBlackboxTime(time) { + function setCurrentBlackboxTime(time: number) { currentBlackboxTime.value = time; } - function setVideo(url) { + function setVideo(url: string | null) { videoURL.value = url; hasVideo.value = !!url; } diff --git a/src/stores/playback.js b/src/stores/playback.ts similarity index 71% rename from src/stores/playback.js rename to src/stores/playback.ts index 1a80ab18..4510e6ed 100644 --- a/src/stores/playback.js +++ b/src/stores/playback.ts @@ -8,34 +8,41 @@ export const PLAYBACK_MAX_RATE = 300; export const PLAYBACK_DEFAULT_RATE = 100; export const PLAYBACK_RATE_STEP = 5; +interface OffsetEntry { + log: string | null; + index: number | null; + video: string | null; + offset: number | null; +} + export const usePlaybackStore = defineStore("playback", () => { const graphState = ref(GRAPH_STATE_PAUSED); const playbackRate = ref(PLAYBACK_DEFAULT_RATE); const videoOffset = ref(0); - const videoExportInTime = ref(null); - const videoExportOutTime = ref(null); + const videoExportInTime = ref(null); + const videoExportOutTime = ref(null); const videoConfig = ref({ width: 1280, height: 720, frameRate: 30, videoDim: 0.4 }); // Video DOM element — registered by main.js - const videoElement = shallowRef(null); + const videoElement = shallowRef(null); // Offset cache for auto-syncing video to log - const offsetCache = shallowRef([]); - const currentOffsetCache = shallowRef({ log: null, index: null, video: null, offset: null }); + const offsetCache = shallowRef([]); + const currentOffsetCache = shallowRef({ log: null, index: null, video: null, offset: null }); const isPlaying = computed(() => graphState.value === GRAPH_STATE_PLAY); const isPaused = computed(() => graphState.value === GRAPH_STATE_PAUSED); // Callbacks registered by main.js (need video element + renderer closures) - function setPlaybackRate(rate) { + function setPlaybackRate(rate: number) { playbackRate.value = Math.max( PLAYBACK_MIN_RATE, Math.min(PLAYBACK_MAX_RATE, rate), ); } - function setVideoOffset(offset) { + function setVideoOffset(offset: number) { videoOffset.value = offset; } diff --git a/src/stores/settings.js b/src/stores/settings.ts similarity index 71% rename from src/stores/settings.js rename to src/stores/settings.ts index 7499bcc7..95fd44fa 100644 --- a/src/stores/settings.js +++ b/src/stores/settings.ts @@ -5,7 +5,8 @@ import { PrefStorage } from "../pref_storage.js"; const prefs = new PrefStorage(); -function deepMerge(target, source) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function deepMerge(target: Record, source: Record) { for (const key of Object.keys(source)) { const sv = source[key]; if (sv && typeof sv === "object" && !Array.isArray(sv) && target[key] && typeof target[key] === "object") { @@ -20,22 +21,22 @@ export const useSettingsStore = defineStore("settings", () => { const userSettings = reactive(structuredClone(defaultUserSettings)); function load() { - prefs.get("userSettings", (item) => { + prefs.get("userSettings", (item: unknown) => { if (item) { const merged = structuredClone(defaultUserSettings); - deepMerge(merged, item); + deepMerge(merged, item as Record); Object.assign(userSettings, merged); } }); } - function saveAll(newSettings) { + function saveAll(newSettings: Record) { Object.assign(userSettings, newSettings); prefs.set("userSettings", toRaw(userSettings)); } - function saveSetting(key, value) { - userSettings[key] = value; + function saveSetting(key: string, value: unknown) { + (userSettings as Record)[key] = value; prefs.set("userSettings", { ...toRaw(userSettings) }); } diff --git a/src/stores/workspace.js b/src/stores/workspace.ts similarity index 65% rename from src/stores/workspace.js rename to src/stores/workspace.ts index 5e1dbba3..df34d3ea 100644 --- a/src/stores/workspace.js +++ b/src/stores/workspace.ts @@ -1,16 +1,22 @@ import { defineStore } from "pinia"; import { ref } from "vue"; +interface WorkspaceEntry { + title: string; + graphConfig: unknown; +} +type WorkspaceConfigs = Array; + export const useWorkspaceStore = defineStore("workspace", () => { - const workspaceGraphConfigs = ref([]); + const workspaceGraphConfigs = ref([]); const activeWorkspace = ref(1); - const bookmarkTimes = ref([]); + const bookmarkTimes = ref([]); - function setActiveWorkspace(id) { + function setActiveWorkspace(id: number) { activeWorkspace.value = id; } - function setWorkspaceGraphConfigs(configs) { + function setWorkspaceGraphConfigs(configs: WorkspaceConfigs) { workspaceGraphConfigs.value = configs; } @@ -19,13 +25,13 @@ export const useWorkspaceStore = defineStore("workspace", () => { // Callbacks registered by main.js /** Get title for a workspace slot (1-9, 0) */ - function getTitle(id) { + function getTitle(id: number) { const entry = workspaceGraphConfigs.value[id]; return entry ? entry.title : null; } /** Check if a workspace slot has data */ - function hasWorkspace(id) { + function hasWorkspace(id: number) { return workspaceGraphConfigs.value[id] != null; } From 9a1c2faf2ced721616c2f640cce0c6e88b4ab5d7 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Mon, 1 Jun 2026 01:17:20 +0200 Subject: [PATCH 18/58] refactor: convert 24 small/medium components to diff --git a/src/components/FieldValuesPanel.vue b/src/components/FieldValuesPanel.vue index 0598a1fb..aa4a5f33 100644 --- a/src/components/FieldValuesPanel.vue +++ b/src/components/FieldValuesPanel.vue @@ -78,7 +78,7 @@ - diff --git a/src/components/MapView.vue b/src/components/MapView.vue index 625b9ee0..65f6ef33 100644 --- a/src/components/MapView.vue +++ b/src/components/MapView.vue @@ -6,7 +6,7 @@ /> - diff --git a/src/components/PidTable.vue b/src/components/PidTable.vue index 7514c94f..e3ebfa0f 100644 --- a/src/components/PidTable.vue +++ b/src/components/PidTable.vue @@ -48,16 +48,23 @@ - diff --git a/src/components/SeekBarToolbar.vue b/src/components/SeekBarToolbar.vue index a69ead90..1a86ee79 100644 --- a/src/components/SeekBarToolbar.vue +++ b/src/components/SeekBarToolbar.vue @@ -11,7 +11,7 @@ - diff --git a/src/components/UiBox.vue b/src/components/UiBox.vue index ab6631ba..21428e12 100644 --- a/src/components/UiBox.vue +++ b/src/components/UiBox.vue @@ -31,7 +31,7 @@ - diff --git a/src/components/SpectrumAnalyser.vue b/src/components/SpectrumAnalyser.vue index 0a06749a..2c7b1669 100644 --- a/src/components/SpectrumAnalyser.vue +++ b/src/components/SpectrumAnalyser.vue @@ -88,7 +88,7 @@ - - - - - - - - \ No newline at end of file diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 1279e05d..00000000 --- a/test/index.js +++ /dev/null @@ -1,62 +0,0 @@ -"use strict"; - -function assert(condition) { - if (!condition) { - throw "Assert failed"; - } -} - -function testExpoCurve() { - var - curve = new ExpoCurve(0, 0.700, 750, 1.0, 10); - - assert(curve.lookup(0) == 0.0); - assert(curve.lookup(-750) == -1.0); - assert(curve.lookup(750) == 1.0); -} - -function testExpoStraightLine() { - var - curve = new ExpoCurve(0, 1.0, 500, 1.0, 1); - - assert(curve.lookup(0) == 0.0); - assert(curve.lookup(-500) == -1.0); - assert(curve.lookup(500) == 1.0); - assert(curve.lookup(-250) == -0.5); - assert(curve.lookup(250) == 0.5); -} - -function benchExpoCurve() { - var - trial, i, - curve = new ExpoCurve(0, 0.700, 750, 1.0, 10), - acc = 0, - endTime, results = ""; - - for (trial = 0; trial < 10; trial++) { - var - start = Date.now(), - end; - - for (i = 0; i < 10000000; i++) { - acc += curve.lookup(Math.random() * 750); - } - - end = Date.now(); - - results += (end - start) + "\n"; - } - - alert("Expo curve bench\n" + results); -} - -try { - testExpoCurve(); - testExpoStraightLine(); - - //benchExpoCurve(); - - alert("All tests pass"); -} catch (e) { - alert(e); -} \ No newline at end of file From 3ed386d77aab6f730655fd49a4e6ae63a16f86a6 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 5 Jun 2026 23:31:54 +0200 Subject: [PATCH 54/58] refactor(types): retire the intentional Loose = any data surfaces (#924 Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three deferred dynamic data bags with real types: - HeaderDialog `sysConfig` → `SysConfig` (already has the index signature); the lookup-list / group-map helpers typed too, removing the Loose alias. - Video export → `VideoOptions` / `VideoLogParameters` / `VideoExportEvents` (and `flightLog: FlightLog`, `isSupported(): boolean`). The false time sentinels are guarded with `|| 0` and the optional flight video captured to a narrowed local; only the webm-writer, vendor-prefixed `document.hidden` and the grapher construct-cast stay Loose. - pen_adjustment → `PenGraph`/`PenField`/`PenCurve`/`PenDefault`; the legacy `default` array-as-object is typed as an array carrying optional smoothing/power (so it serializes unchanged), and callers assert the stored→runtime-adapted shape at the two getGraphs() sites. Behaviour-preserving (type-only, plus the `|| 0` sentinel guards that match the prior coercion and a string→number array-index normalization in changePenZoom). Gates green: type-check 0, lint, npm test, build. --- src/components/HeaderDialog.vue | 18 +++--- src/composables/use_blackbox_viewer.ts | 6 +- src/flightlog_video_renderer.ts | 61 +++++++++++++++----- src/pen_adjustment.ts | 78 ++++++++++++++++---------- 4 files changed, 104 insertions(+), 59 deletions(-) diff --git a/src/components/HeaderDialog.vue b/src/components/HeaderDialog.vue index b8560a47..5d056e3b 100644 --- a/src/components/HeaderDialog.vue +++ b/src/components/HeaderDialog.vue @@ -218,11 +218,7 @@ import { FIRMWARE_TYPE_BETAFLIGHT, FIRMWARE_TYPE_INAV, } from "../flightlog_fielddefs"; - -// sysConfig is a dynamic key→value header map (hundreds of optional fields); -// access is intentionally untyped, consistent with the rest of the migration. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Loose = any; +import type { SysConfig } from "../flightlog_types"; interface HeaderParam { name: string; @@ -234,13 +230,13 @@ const open = defineModel("open", { default: false }); const cols = ref(null); const props = withDefaults( - defineProps<{ sysConfig?: Loose }>(), + defineProps<{ sysConfig?: SysConfig | null }>(), { sysConfig: null }, ); // --- Helpers --- -const sc = computed(() => props.sysConfig || {}); +const sc = computed(() => props.sysConfig ?? ({} as SysConfig)); const filteredSc = computed(() => { if (hiddenFields.value.size === 0) { return sc.value; @@ -280,7 +276,7 @@ function fmtFloat(data: number | null | undefined, decimalPlaces: number): strin return data.toFixed(decimalPlaces); } -function selectVal(data: number | null | undefined, list: Loose): string | null { +function selectVal(data: number | null | undefined, list: readonly string[]): string | null { if (data == null || !list) { return null; } @@ -709,7 +705,7 @@ const rpmFilterParams = computed(() => { // --- RC Smoothing --- -function buildRcSmoothing43(s: Loose): HeaderParam[] { +function buildRcSmoothing43(s: SysConfig): HeaderParam[] { const result = [ param("Mode", selectVal(s.rc_smoothing_mode, RC_SMOOTHING_MODE)), param("Setpoint Hz", fmtVal(s.rc_smoothing_setpoint_hz, 0)), @@ -749,7 +745,7 @@ function buildRcSmoothing43(s: Loose): HeaderParam[] { return result; } -function buildRcSmoothing34(s: Loose): HeaderParam[] { +function buildRcSmoothing34(s: SysConfig): HeaderParam[] { const result = [ param("Mode", selectVal(s.rc_smoothing_mode, RC_SMOOTHING_TYPE)), ]; @@ -1276,7 +1272,7 @@ const HEADER_SKIP_KEYS = new Set([ "flightControllerVersion", ]); -function buildGroupMap(s: Loose) { +function buildGroupMap(s: SysConfig) { const groups: Record> = {}; for (const key of Object.keys(s)) { if (HEADER_SKIP_KEYS.has(key)) { diff --git a/src/composables/use_blackbox_viewer.ts b/src/composables/use_blackbox_viewer.ts index ad2a4788..9959b359 100644 --- a/src/composables/use_blackbox_viewer.ts +++ b/src/composables/use_blackbox_viewer.ts @@ -13,7 +13,7 @@ import { validate, mouseNotification, } from "../tools.js"; -import { restorePenDefaults, changePenSmoothing, changePenZoom, changePenExpo } from "../pen_adjustment.js"; +import { restorePenDefaults, changePenSmoothing, changePenZoom, changePenExpo, type PenGraph } from "../pen_adjustment.js"; import { createKeydownHandler } from "../keyboard_handler.js"; import { upgradeWorkspaceFormat, saveWorkspaces, loadWorkspaces } from "../workspace_io.js"; import { exportCsv, exportGpx, exportSpectrumToCsv } from "../export_utils.js"; @@ -598,12 +598,12 @@ export function initBlackboxViewer(): BlackboxViewerOps { newGraphConfig(newGraphs); }; ops.resetPen = (gi, fi) => { - const graphs = graphStore.activeGraphConfig!.getGraphs(); + const graphs = graphStore.activeGraphConfig!.getGraphs() as unknown as PenGraph[]; const msg = restorePenDefaults(graphs, String(gi), fi == null ? null : String(fi)); applyPenChange(msg); }; ops.fieldWheel = (gi, fi, delta, shiftKey, altKey, ctrlKey) => { - const graphs = graphStore.activeGraphConfig!.getGraphs(); + const graphs = graphStore.activeGraphConfig!.getGraphs() as unknown as PenGraph[]; const g = String(gi); const f = String(fi); const increase = delta >= 0; diff --git a/src/flightlog_video_renderer.ts b/src/flightlog_video_renderer.ts index 649967a3..c3063ba7 100644 --- a/src/flightlog_video_renderer.ts +++ b/src/flightlog_video_renderer.ts @@ -2,6 +2,8 @@ import WebMWriter from "webm-writer"; import { FlightLogGrapher } from "./grapher"; import { triggerDownload } from "./tools.js"; import { useSettingsStore } from "./stores/settings.js"; +import type { FlightLog } from "./flightlog"; +import type { GraphConfig } from "./graph_config"; /** * Render a video of the given log using the given videoOptions (user video settings) and logParameters. @@ -25,12 +27,40 @@ import { useSettingsStore } from "./stores/settings.js"; * onComplete - On render completion, called with (success, frameCount) * onProgress - Called periodically with (frameIndex, frameCount) to report progress */ -// flightLog, the log/video parameters, video options and event callbacks are -// free-form structures from the still-JS layer; access stays loose, consistent -// with the rest of the migration. +// The WebM writer, the vendored grapher construction and the vendor-prefixed +// document.hidden probes are genuinely dynamic; access there stays loose. // eslint-disable-next-line @typescript-eslint/no-explicit-any type Loose = any; +/** User-chosen output video settings. */ +export interface VideoOptions { + width: number; + height: number; + frameRate: number; + // Amount of dimming applied to the background video, 0.0–1.0. + videoDim: number; +} + +/** What and how to render (the selected range, overlays and optional video). */ +export interface VideoLogParameters { + // Time codes, or `false` to mean start-of-log / end-of-log (the renderer + // resolves `false` against the flight log). + inTime: number | false; + outTime: number | false; + graphConfig: GraphConfig; + flightVideo?: HTMLVideoElement | null; + flightVideoOffset?: number; + hasCraft?: boolean; + hasSticks?: boolean; + hasAnalyser?: boolean; +} + +/** Progress / completion callbacks. */ +export interface VideoExportEvents { + onComplete?: (success: boolean, frameCount?: number) => void; + onProgress?: (frameIndex: number, frameCount: number) => void; +} + // Instance shape (the constructor's `this`). The value `FlightLogVideoRenderer` // below is the constructor function (it also carries the static isSupported). export interface FlightLogVideoRenderer { @@ -42,10 +72,10 @@ export interface FlightLogVideoRenderer { export function FlightLogVideoRenderer( this: FlightLogVideoRenderer, - flightLog: Loose, - logParameters: Loose, - videoOptions: Loose, - events: Loose, + flightLog: FlightLog, + logParameters: VideoLogParameters, + videoOptions: VideoOptions, + events: VideoExportEvents, ) { const { userSettings } = useSettingsStore(); @@ -176,15 +206,16 @@ export function FlightLogVideoRenderer( }; if (logParameters.flightVideo) { + const flightVideo = logParameters.flightVideo; const renderFrames = function (frameCount: number) { if (frameCount === 0) { completeChunk(); return; } - logParameters.flightVideo.onseeked = function () { + flightVideo.onseeked = function () { canvasContext.drawImage( - logParameters.flightVideo, + flightVideo, 0, 0, videoOptions.width, @@ -203,8 +234,8 @@ export function FlightLogVideoRenderer( renderFrames(frameCount - 1); }; - logParameters.flightVideo.currentTime = - (frameTime - flightLog.getMinTime()) / 1000000 + + flightVideo.currentTime = + (frameTime - (flightLog.getMinTime() || 0)) / 1000000 + (logParameters.flightVideoOffset || 0); }; @@ -232,7 +263,7 @@ export function FlightLogVideoRenderer( this.start = function () { cancel = false; - frameTime = logParameters.inTime; + frameTime = logParameters.inTime || 0; frameIndex = 0; installVisibilityHandler(); @@ -299,7 +330,7 @@ export function FlightLogVideoRenderer( // If the in -> out time is not an exact number of frames, we'll round the end time of the video to make it so: const frameCount = Math.round( - (logParameters.outTime - logParameters.inTime) / frameDuration, + ((logParameters.outTime || 0) - (logParameters.inTime || 0)) / frameDuration, ); if (logParameters.flightVideo) { @@ -310,7 +341,7 @@ export function FlightLogVideoRenderer( /** * Is video rendering supported on this web browser? We require the ability to encode canvases to WebP. */ -FlightLogVideoRenderer.isSupported = function (): Loose { +FlightLogVideoRenderer.isSupported = function (): boolean { const canvas = document.createElement("canvas"); canvas.width = 16; @@ -320,5 +351,5 @@ FlightLogVideoRenderer.isSupported = function (): Loose { // (kept verbatim); cast keeps it type-clean. const encoded = canvas.toDataURL("image/webp", { quality: 0.9 } as Loose); - return encoded && (/^data:image\/webp;/.exec(encoded)); + return Boolean(encoded && /^data:image\/webp;/.exec(encoded)); }; diff --git a/src/pen_adjustment.ts b/src/pen_adjustment.ts index 92f39c26..1840fe0c 100644 --- a/src/pen_adjustment.ts +++ b/src/pen_adjustment.ts @@ -1,15 +1,32 @@ import { constrain } from "./tools.js"; -// graphs/group/field are the free-form graph-config structures and string/null -// pen selectors from the still-JS layer; access stays loose, consistent with -// the rest of the migration. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Loose = any; +// The runtime-adapted graph/field shape the pen adjustments mutate: by the time +// these run the graphs have been expanded against a log, so `curve.power`, +// `curve.MinMax` and `smoothing` are present (the stored model types them +// optional). `default` is a legacy array that also carries `.smoothing`/`.power` +// — kept as an array so it serializes exactly as before. +interface PenDefault extends Array { + smoothing?: number; + power?: number; +} +interface PenCurve { + power: number; + MinMax: { min: number; max: number }; +} +interface PenField { + smoothing: number; + curve: PenCurve; + friendlyName: string; + default?: PenDefault; +} +export interface PenGraph { + fields: PenField[]; +} export function savePenDefaults( - graphs: Loose, - group: Loose, - field: Loose, + graphs: PenGraph[], + group: string | null, + field: string | null, ): string | null { if (group == null && field == null) { return null; @@ -40,9 +57,9 @@ export function savePenDefaults( } export function restorePenDefaults( - graphs: Loose, - group: Loose, - field: Loose, + graphs: PenGraph[], + group: string | null, + field: string | null, ): string | null { if (group == null && field == null) { return null; @@ -54,8 +71,8 @@ export function restorePenDefaults( if (configField.default == null) { return null; } - configField.smoothing = configField.default.smoothing; - configField.curve.power = configField.default.power; + configField.smoothing = configField.default.smoothing!; + configField.curve.power = configField.default.power!; } return "

Restored defaults for all pens

"; } @@ -65,18 +82,18 @@ export function restorePenDefaults( if (graphs[gi].fields[fi].default == null) { return null; } - graphs[gi].fields[fi].smoothing = graphs[gi].fields[fi].default.smoothing; - graphs[gi].fields[fi].curve.power = graphs[gi].fields[fi].default.power; + graphs[gi].fields[fi].smoothing = graphs[gi].fields[fi].default!.smoothing!; + graphs[gi].fields[fi].curve.power = graphs[gi].fields[fi].default!.power!; return "

Restored defaults for single pen

"; } return null; } export function changePenSmoothing( - graphs: Loose, - group: Loose, - field: Loose, - delta: Loose, + graphs: PenGraph[], + group: string | null, + field: string | null, + delta: boolean, ): string | null { const range = { min: 0, max: 10000 }; const scroll = 1000; @@ -108,10 +125,10 @@ export function changePenSmoothing( } export function changePenZoom( - graphs: Loose, - group: Loose, - field: Loose, - delta: Loose, + graphs: PenGraph[], + group: string | null, + field: string | null, + delta: boolean, ): string | null { if (group == null && field == null) { return null; @@ -134,18 +151,19 @@ export function changePenZoom( } if (group != null && field != null) { const gi = Number.parseInt(group, 10); - graphs[gi].fields[field].curve.MinMax.min *= scale; - graphs[gi].fields[field].curve.MinMax.max *= scale; - return `

${direction}${graphs[gi].fields[field].friendlyName}\n`; + const fi = Number.parseInt(field, 10); + graphs[gi].fields[fi].curve.MinMax.min *= scale; + graphs[gi].fields[fi].curve.MinMax.max *= scale; + return `

${direction}${graphs[gi].fields[fi].friendlyName}\n`; } return null; } export function changePenExpo( - graphs: Loose, - group: Loose, - field: Loose, - delta: Loose, + graphs: PenGraph[], + group: string | null, + field: string | null, + delta: boolean, ): string | null { const range = { min: 0.05, max: 1 }; const scroll = 0.05; From 644099bd4c1efd339ee6e426acf42354e2c2666c Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 5 Jun 2026 23:44:41 +0200 Subject: [PATCH 55/58] style(migration): clear new-code SonarCloud smells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour-preserving cleanup of the smells introduced by the latest commits: - worker entries: `globalThis` instead of `self` (S7764) - pen_adjustment: drop the redundant `default!` assertions — TS already narrows via the preceding `== null` guard (S4325) - test/expo: drop zero fractions in the ExpoCurve assertions (S7748) - test/export: `Number.NaN` over `NaN` in the fixture/expected arrays (S7773) Gates green: type-check 0, lint, npm test, build. --- src/pen_adjustment.ts | 4 ++-- src/workers/csv-export-worker.ts | 2 +- src/workers/gpx-export-worker.ts | 2 +- src/workers/spectrum-export-worker.ts | 2 +- test/expo.test.ts | 16 ++++++++-------- test/export.golden.test.ts | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/pen_adjustment.ts b/src/pen_adjustment.ts index 1840fe0c..3d79bbc8 100644 --- a/src/pen_adjustment.ts +++ b/src/pen_adjustment.ts @@ -82,8 +82,8 @@ export function restorePenDefaults( if (graphs[gi].fields[fi].default == null) { return null; } - graphs[gi].fields[fi].smoothing = graphs[gi].fields[fi].default!.smoothing!; - graphs[gi].fields[fi].curve.power = graphs[gi].fields[fi].default!.power!; + graphs[gi].fields[fi].smoothing = graphs[gi].fields[fi].default.smoothing!; + graphs[gi].fields[fi].curve.power = graphs[gi].fields[fi].default.power!; return "

Restored defaults for single pen

"; } return null; diff --git a/src/workers/csv-export-worker.ts b/src/workers/csv-export-worker.ts index 59f97673..a1616e57 100644 --- a/src/workers/csv-export-worker.ts +++ b/src/workers/csv-export-worker.ts @@ -1,7 +1,7 @@ // Module worker entry: thin shell around the pure buildCsv serializer. import { buildCsv } from "./csv_export"; -const ctx = self as unknown as Worker; +const ctx = globalThis as unknown as Worker; ctx.onmessage = (event: MessageEvent) => { ctx.postMessage(buildCsv(event.data)); }; diff --git a/src/workers/gpx-export-worker.ts b/src/workers/gpx-export-worker.ts index 56fb0bc9..7af0cb8a 100644 --- a/src/workers/gpx-export-worker.ts +++ b/src/workers/gpx-export-worker.ts @@ -1,7 +1,7 @@ // Module worker entry: thin shell around the pure buildGpx serializer. import { buildGpx } from "./gpx_export"; -const ctx = self as unknown as Worker; +const ctx = globalThis as unknown as Worker; ctx.onmessage = (event: MessageEvent) => { ctx.postMessage(buildGpx(event.data)); }; diff --git a/src/workers/spectrum-export-worker.ts b/src/workers/spectrum-export-worker.ts index 3ec31a9c..985c5bdc 100644 --- a/src/workers/spectrum-export-worker.ts +++ b/src/workers/spectrum-export-worker.ts @@ -1,7 +1,7 @@ // Module worker entry: thin shell around the pure buildSpectrumCsv serializer. import { buildSpectrumCsv } from "./spectrum_export"; -const ctx = self as unknown as Worker; +const ctx = globalThis as unknown as Worker; ctx.onmessage = (event: MessageEvent) => { ctx.postMessage(buildSpectrumCsv(event.data)); }; diff --git a/test/expo.test.ts b/test/expo.test.ts index 27c22857..9fa99d77 100644 --- a/test/expo.test.ts +++ b/test/expo.test.ts @@ -13,17 +13,17 @@ function assert(condition: boolean, message: string): void { } function testExpoCurve(): void { - const curve = new ExpoCurve(0, 0.7, 750, 1.0, 10); - assert(curve.lookup(0) === 0.0, "ExpoCurve lookup(0) === 0"); - assert(curve.lookup(-750) === -1.0, "ExpoCurve lookup(-750) === -1"); - assert(curve.lookup(750) === 1.0, "ExpoCurve lookup(750) === 1"); + const curve = new ExpoCurve(0, 0.7, 750, 1, 10); + assert(curve.lookup(0) === 0, "ExpoCurve lookup(0) === 0"); + assert(curve.lookup(-750) === -1, "ExpoCurve lookup(-750) === -1"); + assert(curve.lookup(750) === 1, "ExpoCurve lookup(750) === 1"); } function testExpoStraightLine(): void { - const curve = new ExpoCurve(0, 1.0, 500, 1.0, 1); - assert(curve.lookup(0) === 0.0, "straight lookup(0) === 0"); - assert(curve.lookup(-500) === -1.0, "straight lookup(-500) === -1"); - assert(curve.lookup(500) === 1.0, "straight lookup(500) === 1"); + const curve = new ExpoCurve(0, 1, 500, 1, 1); + assert(curve.lookup(0) === 0, "straight lookup(0) === 0"); + assert(curve.lookup(-500) === -1, "straight lookup(-500) === -1"); + assert(curve.lookup(500) === 1, "straight lookup(500) === 1"); assert(curve.lookup(-250) === -0.5, "straight lookup(-250) === -0.5"); assert(curve.lookup(250) === 0.5, "straight lookup(250) === 0.5"); } diff --git a/test/export.golden.test.ts b/test/export.golden.test.ts index c51a91c7..867b4bff 100644 --- a/test/export.golden.test.ts +++ b/test/export.golden.test.ts @@ -37,7 +37,7 @@ const chunkFrames = [ ], [ [2000, 0, 85342111, 1234, 4, 1501], - [3000, 473712999, 85342999, 1250, NaN, 1502], + [3000, 473712999, 85342999, 1250, Number.NaN, 1502], ], ]; const opts = { columnDelimiter: ",", stringDelimiter: '"', quoteStrings: true }; @@ -83,8 +83,8 @@ if (!flat) { failures++; } else { const expectFlat = [ - 0, 473712345, 85342111, 1234, 12, 1500, 1000, NaN, 85342111, 1234, -7, NaN, - 2000, 0, 85342111, 1234, 4, 1501, 3000, 473712999, 85342999, 1250, NaN, 1502, + 0, 473712345, 85342111, 1234, 12, 1500, 1000, Number.NaN, 85342111, 1234, -7, Number.NaN, + 2000, 0, 85342111, 1234, 4, 1501, 3000, 473712999, 85342999, 1250, Number.NaN, 1502, ]; const ok = flat.rowCount === 4 && From 3677a092b59ce6c97bba50b8638b9a2bcf9018fc Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 5 Jun 2026 23:55:38 +0200 Subject: [PATCH 56/58] style(migration): clear safe negated-condition smells (S7735/S3358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provably-equivalent inversions of negated conditions where the change is clearly behaviour-preserving and/or test-verifiable: - flightlog_parser `translateFieldName` → single ternary (golden-covered). - flightlog_index / craft_2d: flip small if/else. - flightlog (rcCommand/gyro computed-field ternaries), flightlog stats, graph_spectrum_plot catmull-rom p3: invert `x !== undefined ? a : b` ternaries to `x === undefined ? b : a`. - export.golden.test: collapse the nested exit-code ternary (S3358). Left intentionally: the negated-condition flips with large branches, big-template ternaries, nested ternaries, and the NaN-sensitive Math.min/max rewrites — style-only nits in code paths without test coverage, where a flip carries transcription risk for no functional gain. Gates green: type-check 0, lint, npm test (incl. parser golden), build. --- src/craft_2d.ts | 6 +++--- src/flightlog.ts | 22 +++++++++++----------- src/flightlog_index.ts | 6 +++--- src/flightlog_parser.ts | 6 +----- src/graph_spectrum_plot.ts | 2 +- test/export.golden.test.ts | 2 +- 6 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/craft_2d.ts b/src/craft_2d.ts index fe59ed23..47b897a3 100644 --- a/src/craft_2d.ts +++ b/src/craft_2d.ts @@ -33,10 +33,10 @@ export function Craft2D( const customMix: Loose = userSettings.customMix ?? null; let numMotors: number; - if (!customMix) { - numMotors = propColors.length; - } else { + if (customMix) { numMotors = customMix.motorOrder.length; + } else { + numMotors = propColors.length; } const shadeColors: Loose[] = []; diff --git a/src/flightlog.ts b/src/flightlog.ts index 29ad358e..10936280 100644 --- a/src/flightlog.ts +++ b/src/flightlog.ts @@ -884,18 +884,18 @@ export function FlightLog(this: FlightLog, logData: Uint8Array) { } else { for (let axis = 0; axis <= AXIS.YAW; axis++) { destFrame[fieldIndex++] = - rcCommand[axis] !== undefined - ? this.rcCommandRawToDegreesPerSecond( + rcCommand[axis] === undefined + ? 0 + : this.rcCommandRawToDegreesPerSecond( srcFrame[rcCommand[axis]], axis, currentFlightMode, - ) - : 0; + ); } destFrame[fieldIndex++] = - rcCommand[AXIS.YAW + 1] !== undefined - ? this.rcCommandRawToThrottle(srcFrame[rcCommand[AXIS.YAW + 1]]) - : 0; + rcCommand[AXIS.YAW + 1] === undefined + ? 0 + : this.rcCommandRawToThrottle(srcFrame[rcCommand[AXIS.YAW + 1]]); } return fieldIndex; }; @@ -914,9 +914,9 @@ export function FlightLog(this: FlightLog, logData: Uint8Array) { ) => { for (let axis = 0; axis < 3; axis++) { const gyroADCdegrees = - gyroADC[axis] !== undefined - ? this.gyroRawToDegreesPerSecond(srcFrame[gyroADC[axis]]) - : 0; + gyroADC[axis] === undefined + ? 0 + : this.gyroRawToDegreesPerSecond(srcFrame[gyroADC[axis]]); destFrame[fieldIndex++] = destFrame[fieldIndexRcCommands + axis] - gyroADCdegrees; } @@ -1493,7 +1493,7 @@ export function FlightLog(this: FlightLog, logData: Uint8Array) { max = -Number.MAX_VALUE; const fieldIndex = this.getMainFieldIndexByName(field_name), - fieldStat = fieldIndex !== undefined ? stats.field![fieldIndex] : false; + fieldStat = fieldIndex === undefined ? false : stats.field![fieldIndex]; if (fieldStat) { min = Math.min(min, fieldStat.min); diff --git a/src/flightlog_index.ts b/src/flightlog_index.ts index f3264563..316d39ee 100644 --- a/src/flightlog_index.ts +++ b/src/flightlog_index.ts @@ -132,10 +132,10 @@ export function FlightLogIndex( } for (let j = 0; j < 3; j++) { - if (mainFrameDef.nameToIndex[`rcCommand[${j}]`] !== undefined) { - maxRCFields.push(mainFrameDef.nameToIndex[`rcCommand[${j}]`]); - } else { + if (mainFrameDef.nameToIndex[`rcCommand[${j}]`] === undefined) { console.log("RCField not found"); + } else { + maxRCFields.push(mainFrameDef.nameToIndex[`rcCommand[${j}]`]); } } diff --git a/src/flightlog_parser.ts b/src/flightlog_parser.ts index 858015dd..7426e5a1 100644 --- a/src/flightlog_parser.ts +++ b/src/flightlog_parser.ts @@ -564,11 +564,7 @@ export function FlightLogParser(this: FlightLogParser, logData: Uint8Array) { */ function translateFieldName(fieldName: string) { const translation = translationValues[fieldName]; - if (translation !== undefined) { - return translation; - } else { - return fieldName; - } + return translation === undefined ? fieldName : translation; } // Sets of field names for dispatch-based header parsing (avoids large switch statement) diff --git a/src/graph_spectrum_plot.ts b/src/graph_spectrum_plot.ts index 2d120cc5..dc64f633 100644 --- a/src/graph_spectrum_plot.ts +++ b/src/graph_spectrum_plot.ts @@ -2012,7 +2012,7 @@ GraphSpectrumPlot._drawCurve = function (canvasCtx, points, tension) { const p0 = i > 0 ? points[i - 1] : points[0]; const p1 = points[i]; const p2 = points[i + 1]; - const p3 = i !== points.length - 2 ? points[i + 2] : p2; + const p3 = i === points.length - 2 ? p2 : points[i + 2]; const cp1x = p1.x + ((p2.x - p0.x) / 6) * t; const cp1y = p1.y + ((p2.y - p0.y) / 6) * t; diff --git a/test/export.golden.test.ts b/test/export.golden.test.ts index 867b4bff..6d1d91dc 100644 --- a/test/export.golden.test.ts +++ b/test/export.golden.test.ts @@ -148,4 +148,4 @@ const spectrumOut = buildSpectrumCsv({ }); compareGolden("spectrum", "test/fixtures/export.golden.spectrum.csv", spectrumOut); -process.exit(update ? 0 : failures === 0 ? 0 : 1); +process.exit(update || failures === 0 ? 0 : 1); From 87ee5401ec7ed376c9f490858b94c917d86f5418 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sat, 6 Jun 2026 00:03:13 +0200 Subject: [PATCH 57/58] test(flightlog): golden-cover the computed-field pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test/flightlog.golden.test.ts: builds a FlightLog from the synthetic logs, opens it, and snapshots the chunk frames (rounded to 4 decimals) plus fieldNames/min-max/numMotors/hasGpsData. This locks FlightLog's computed fields — attitude (imu.ts), PID sum/error, RC-command scaling, and GPS coord/distance/azimuth (gps_transform.ts via the complex GPS fixture) — which the parser golden (FlightLogParser only) did not cover. Wired into `npm test` and CI. Covers the previously-untested core paths touched by this PR (the injectComputedFields rcCommand/gyro ternaries, the Phase-6 frame typing). Renderers (grapher/sticks/craft) remain uncovered — they couple to Pinia stores + ThemeColors (getComputedStyle/DOM) + a canvas context, so a headless golden there needs heavy mocking; tracked as a follow-up. Gates green: type-check 0, lint, npm test (9 checks), build. --- package.json | 3 +- test/fixtures/flightlog.golden.complex.json | 106 ++++++++++++++++++++ test/fixtures/flightlog.golden.json | 90 +++++++++++++++++ test/flightlog.golden.test.ts | 91 +++++++++++++++++ 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/flightlog.golden.complex.json create mode 100644 test/fixtures/flightlog.golden.json create mode 100644 test/flightlog.golden.test.ts diff --git a/package.json b/package.json index 87cfe81d..abef4504 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "build": "vite build", "preview": "vite preview", "type-check": "vue-tsc --noEmit", - "test": "npm run test:parser && npm run test:export && npm run test:expo", + "test": "npm run test:parser && npm run test:flightlog && npm run test:export && npm run test:expo", "test:parser": "esbuild test/parser.golden.test.ts --bundle --platform=node --format=esm | node --input-type=module -", + "test:flightlog": "esbuild test/flightlog.golden.test.ts --bundle --platform=node --format=esm | node --input-type=module -", "test:export": "esbuild test/export.golden.test.ts --bundle --platform=node --format=esm | node --input-type=module -", "test:expo": "esbuild test/expo.test.ts --bundle --platform=node --format=esm | node --input-type=module -", "lint": "eslint src", diff --git a/test/fixtures/flightlog.golden.complex.json b/test/fixtures/flightlog.golden.complex.json new file mode 100644 index 00000000..af90fd93 --- /dev/null +++ b/test/fixtures/flightlog.golden.complex.json @@ -0,0 +1,106 @@ +{ + "minTime": 1000, + "maxTime": 2000, + "numMotors": 3, + "numCellsEstimate": false, + "hasGpsData": true, + "fieldNames": [ + "loopIteration", + "time", + "gyroADC[0]", + "gyroADC[1]", + "gyroADC[2]", + "motor[0]", + "motor[1]", + "motor[2]", + "debug[0]", + "flightModeFlags", + "stateFlags", + "failsafePhase", + "GPS_coord[0]", + "GPS_coord[1]", + "GPS_numSat", + "axisSum[0]", + "axisSum[1]", + "axisSum[2]", + "rcCommands[0]", + "rcCommands[1]", + "rcCommands[2]", + "rcCommands[3]", + "axisError[0]", + "axisError[1]", + "axisError[2]", + "gpsCartesianCoords[0]", + "gpsCartesianCoords[1]", + "gpsCartesianCoords[2]", + "gpsDistance", + "gpsHomeAzimuth" + ], + "frameCount": 2, + "frames": [ + [ + 0, + 1000, + 1, + 0, + -1, + 10, + 20, + 30, + -5, + null, + null, + null, + null, + null, + null, + 0, + 0, + 0, + null, + null, + null, + null, + null, + null, + null, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 2000, + -2, + 1, + 0, + 1, + 2, + 3, + -3, + null, + null, + null, + null, + null, + null, + 0, + 0, + 0, + null, + null, + null, + null, + null, + null, + null, + 0, + 0, + 0, + 0, + 0 + ] + ] +} diff --git a/test/fixtures/flightlog.golden.json b/test/fixtures/flightlog.golden.json new file mode 100644 index 00000000..d9cdeda3 --- /dev/null +++ b/test/fixtures/flightlog.golden.json @@ -0,0 +1,90 @@ +{ + "minTime": 1000, + "maxTime": 4000, + "numMotors": 1, + "numCellsEstimate": false, + "hasGpsData": false, + "fieldNames": [ + "loopIteration", + "time", + "axisP[0]", + "motor[0]", + "axisSum[0]", + "axisSum[1]", + "axisSum[2]", + "rcCommands[0]", + "rcCommands[1]", + "rcCommands[2]", + "rcCommands[3]", + "axisError[0]", + "axisError[1]", + "axisError[2]" + ], + "frameCount": 4, + "frames": [ + [ + 0, + 1000, + -5, + 1100, + -5, + 0, + 0, + null, + null, + null, + null, + null, + null, + null + ], + [ + 1, + 2000, + -2, + 1110, + -2, + 0, + 0, + null, + null, + null, + null, + null, + null, + null + ], + [ + 2, + 3001, + -10, + 1106, + -10, + 0, + 0, + null, + null, + null, + null, + null, + null, + null + ], + [ + 3, + 4000, + -10, + 1131, + -10, + 0, + 0, + null, + null, + null, + null, + null, + null, + null + ] + ] +} diff --git a/test/flightlog.golden.test.ts b/test/flightlog.golden.test.ts new file mode 100644 index 00000000..6211d39d --- /dev/null +++ b/test/flightlog.golden.test.ts @@ -0,0 +1,91 @@ +// Golden-file characterization test for FlightLog — the layer above the parser +// that builds chunks and injects the computed fields (attitude via IMU, PID +// sum/error, RC-command scaling, GPS coord/distance/azimuth via GpsTransform). +// +// The parser golden covers FlightLogParser; this covers FlightLog's +// computed-field pipeline (and transitively imu.ts + gps_transform.ts), which +// is otherwise untested. Values are rounded to 4 decimals so the snapshot is +// stable and readable while still catching any logic change. +// +// npm run test:flightlog +// npm run test:flightlog -- --update +import { readFileSync, writeFileSync } from "node:fs"; +import { FlightLog } from "../src/flightlog.js"; +import { buildSyntheticLog, buildComplexLog } from "./fixtures/synthetic_log.js"; + +const CASES = [ + { name: "simple", build: buildSyntheticLog, golden: "test/fixtures/flightlog.golden.json" }, + { name: "complex", build: buildComplexLog, golden: "test/fixtures/flightlog.golden.complex.json" }, +]; + +function round(v) { + return v == null || Number.isNaN(v) ? null : Math.round(v * 10000) / 10000; +} + +function snapshot(logData) { + const fl = new FlightLog(logData); + if (!fl.openLog(0)) { + throw new Error("openLog(0) failed for the synthetic log"); + } + const minTime = fl.getMinTime(); + const maxTime = fl.getMaxTime(); + const chunks = + minTime === false || maxTime === false + ? [] + : fl.getChunksInTimeRange(minTime, maxTime); + const frames = chunks + .flatMap((c) => c.frames) + .map((f) => Array.from(f, round)); + + return { + minTime, + maxTime, + numMotors: fl.getNumMotors(), + numCellsEstimate: fl.getNumCellsEstimate(), + hasGpsData: fl.hasGpsData(), + fieldNames: fl.getMainFieldNames(), + frameCount: frames.length, + frames, + }; +} + +const update = process.argv.includes("--update"); +let failures = 0; + +for (const c of CASES) { + const actual = `${JSON.stringify(snapshot(c.build()), null, 2)}\n`; + + if (update) { + writeFileSync(c.golden, actual); + console.log(`updated ${c.name}: ${c.golden}`); + continue; + } + + let expected; + try { + expected = readFileSync(c.golden, "utf8"); + } catch { + console.error(`Missing golden ${c.golden}. Run: npm run test:flightlog -- --update`); + failures++; + continue; + } + + if (actual === expected) { + console.log(`ok ${c.name} flightlog golden matches`); + continue; + } + + failures++; + const a = actual.split("\n"); + const e = expected.split("\n"); + for (let i = 0; i < Math.max(a.length, e.length); i++) { + if (a[i] !== e[i]) { + console.error(`FAIL ${c.name} differs at line ${i + 1}:`); + console.error(` expected: ${e[i] ?? ""}`); + console.error(` actual: ${a[i] ?? ""}`); + break; + } + } +} + +process.exit(update ? 0 : failures === 0 ? 0 : 1); From b422c32a0726f0050870b6e08ec33aac55d700c3 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Sat, 6 Jun 2026 14:25:49 +0200 Subject: [PATCH 58/58] fix(graph-store): remember spectrum fullscreen across hide/show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding the analyser collapsed `hasAnalyserFullscreen` (so the chart, params and fullscreen layout don't linger — R4), but that meant re-showing always came back in normal mode; fullscreen was never restored. Remember the fullscreen preference when hiding and restore it (incl. the grapher's setAnalyser state) when the analyser is shown again, via a shared applyAnalyserFullscreenOnToggle() used by both toggleAnalyser (toolbar) and selectLegendField (legend click). Selecting a different field while shown leaves fullscreen untouched. Addresses R5 (manual runtime finding). Gates green; UI behaviour — manual re-test recommended. --- src/stores/graph.ts | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/stores/graph.ts b/src/stores/graph.ts index 48b85492..d6b9b7ef 100644 --- a/src/stores/graph.ts +++ b/src/stores/graph.ts @@ -50,6 +50,10 @@ export const useGraphStore = defineStore("graph", () => { const hasTableOverlay = ref(false); const hasAnalyser = ref(false); const hasAnalyserFullscreen = ref(false); + // Remembered fullscreen preference, so it survives hiding and re-showing the + // analyser (the live `hasAnalyserFullscreen` is collapsed while hidden so the + // chart/params/layout don't linger). + let rememberedAnalyserFullscreen = false; const hasAnalyserSticks = ref(false); const settingsStore = useSettingsStore(); const hasCraft = computed(() => !!settingsStore.userSettings.drawCraft); @@ -108,10 +112,25 @@ export const useGraphStore = defineStore("graph", () => { invalidateGraph.value?.(); } + // Keep fullscreen consistent across an analyser show/hide toggle: while + // hidden, collapse fullscreen (so the chart/params/layout don't linger) but + // remember it; when shown again, restore the remembered state. + function applyAnalyserFullscreenOnToggle(wasShown: boolean) { + if (!hasAnalyser.value) { + rememberedAnalyserFullscreen = hasAnalyserFullscreen.value; + hasAnalyserFullscreen.value = false; + graph.value?.setAnalyser(false); + } else if (!wasShown) { + hasAnalyserFullscreen.value = rememberedAnalyserFullscreen; + graph.value?.setAnalyser(hasAnalyserFullscreen.value); + } + } + function selectLegendField(gi: number, fi: number, fieldName: string, ctrlKey: boolean) { if (!activeGraphConfig.value) { return; } + const wasAnalyserShown = hasAnalyser.value; const toggleAnalizer = activeGraphConfig.value.selectedFieldName === fieldName; const lockAnalyserHide = ctrlKey || graph.value?.hasMultiSpectrumAnalyser(); if (toggleAnalizer) { @@ -122,13 +141,7 @@ export const useGraphStore = defineStore("graph", () => { activeGraphConfig.value.selectedFieldIndex = fi; hasAnalyser.value = true; } - // Hiding the analyser via the legend must also drop fullscreen (same as the - // toolbar toggle), otherwise the fullscreen spectrum parameter controls - // stay visible after the chart is gone. - if (!hasAnalyser.value) { - hasAnalyserFullscreen.value = false; - graph.value?.setAnalyser(false); - } + applyAnalyserFullscreenOnToggle(wasAnalyserShown); graph.value?.setDrawAnalyser(hasAnalyser.value, ctrlKey); prefs.set("hasAnalyser", hasAnalyser.value); invalidateGraph.value?.(); @@ -149,6 +162,7 @@ export const useGraphStore = defineStore("graph", () => { } function toggleAnalyser() { + const wasAnalyserShown = hasAnalyser.value; if (activeGraphConfig.value?.selectedFieldName == null) { const graphs: Loose[] = activeGraphConfig.value?.getGraphs() ?? []; if (graphs.length === 0 || graphs[0].fields.length === 0) { @@ -163,10 +177,7 @@ export const useGraphStore = defineStore("graph", () => { } else { hasAnalyser.value = !hasAnalyser.value; } - if (!hasAnalyser.value) { - hasAnalyserFullscreen.value = false; - graph.value?.setAnalyser(false); - } + applyAnalyserFullscreenOnToggle(wasAnalyserShown); graph.value?.setDrawAnalyser(hasAnalyser.value); prefs.set("hasAnalyser", hasAnalyser.value); invalidateGraph.value?.();