From 636fba170fc5a15a6d4e2457ca0ac05bb8ca59bf Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 16 Jun 2026 22:06:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20radar=20app=20=E2=80=94=20l?= =?UTF-8?q?ist,=20editor,=20chart,=20scoring,=20PNG=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vue-router with list (/) and editor (/radar/:id) views - localStorage persistence via useRadars() composable - types.ts: Radar / Criterion / Profile / Score with cardinality constants - RadarChart.vue: SVG renderer (rings, axes, profile polygons, labels, legend) - CriteriaEditor / ProfilesEditor / ScoreGrid components - PNG export: SVG → canvas → blob, download + clipboard copy - Remove 'coming soon' placeholder from App.vue --- package.json | 3 +- pnpm-lock.yaml | 332 +++++++++++++++++++++++++++++- src/App.vue | 24 +-- src/components/CriteriaEditor.vue | 78 +++++++ src/components/ProfilesEditor.vue | 108 ++++++++++ src/components/RadarChart.vue | 249 ++++++++++++++++++++++ src/components/ScoreGrid.vue | 72 +++++++ src/main.ts | 3 +- src/router.ts | 11 + src/storage.ts | 117 +++++++++++ src/types.ts | 39 ++++ src/utils/png.ts | 72 +++++++ src/views/RadarEditor.vue | 120 +++++++++++ src/views/RadarList.vue | 88 ++++++++ 14 files changed, 1294 insertions(+), 22 deletions(-) create mode 100644 src/components/CriteriaEditor.vue create mode 100644 src/components/ProfilesEditor.vue create mode 100644 src/components/RadarChart.vue create mode 100644 src/components/ScoreGrid.vue create mode 100644 src/router.ts create mode 100644 src/storage.ts create mode 100644 src/types.ts create mode 100644 src/utils/png.ts create mode 100644 src/views/RadarEditor.vue create mode 100644 src/views/RadarList.vue diff --git a/package.json b/package.json index ea06f4f..0dcba29 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "daisyui": "^5.5.23", - "vue": "^3.5.34" + "vue": "^3.5.34", + "vue-router": "^5.1.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15c92f5..fb6fe64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,16 +14,19 @@ importers: vue: specifier: ^3.5.34 version: 3.5.38(typescript@6.0.3) + vue-router: + specifier: ^5.1.0 + version: 5.1.0(@vue/compiler-sfc@3.5.38)(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) devDependencies: '@tailwindcss/vite': specifier: ^4.3.1 - version: 4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)) + version: 4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.12.3 version: 24.13.2 '@vitejs/plugin-vue': specifier: ^6.0.6 - version: 6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3)) + version: 6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) '@vue/tsconfig': specifier: ^0.9.1 version: 0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) @@ -35,30 +38,51 @@ importers: version: 6.0.3 vite: specifier: ^8.0.12 - version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0) + version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) vue-tsc: specifier: ^3.2.8 version: 3.3.5(typescript@6.0.3) packages: + '@babel/generator@8.0.0-rc.6': + resolution: {integrity: sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-rc.6': + resolution: {integrity: sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.6': + resolution: {integrity: sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.0-rc.6': + resolution: {integrity: sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.6': + resolution: {integrity: sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -288,6 +312,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} @@ -307,6 +334,15 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue-macros/common@3.1.2': + resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + '@vue/compiler-core@3.5.38': resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==} @@ -319,6 +355,15 @@ packages: '@vue/compiler-ssr@3.5.38': resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==} + '@vue/devtools-api@8.1.3': + resolution: {integrity: sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==} + + '@vue/devtools-kit@8.1.3': + resolution: {integrity: sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA==} + + '@vue/devtools-shared@8.1.3': + resolution: {integrity: sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==} + '@vue/language-core@3.3.5': resolution: {integrity: sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==} @@ -350,9 +395,35 @@ packages: vue: optional: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.9.0: + resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} + engines: {node: '>=20.19.0'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -374,6 +445,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -391,10 +465,23 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -469,9 +556,20 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -483,6 +581,12 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -490,15 +594,31 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -522,9 +642,20 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -571,6 +702,24 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-router@5.1.0: + resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.34 + pinia: ^3.0.4 + vite: ^7.0.0 || ^8.0.0 + vue: ^3.5.34 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + vite: + optional: true + vue-tsc@3.3.5: resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==} hasBin: true @@ -585,21 +734,51 @@ packages: typescript: optional: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + snapshots: + '@babel/generator@8.0.0-rc.6': + dependencies: + '@babel/parser': 8.0.0-rc.6 + '@babel/types': 8.0.0-rc.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0-rc.6': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.0-rc.6': {} + '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 + '@babel/parser@8.0.0-rc.6': + dependencies: + '@babel/types': 8.0.0-rc.6 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0-rc.6': + dependencies: + '@babel/helper-string-parser': 8.0.0-rc.6 + '@babel/helper-validator-identifier': 8.0.0-rc.6 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -756,26 +935,28 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 - '@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.1 '@tailwindcss/oxide': 4.3.1 tailwindcss: 4.3.1 - vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0) + vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/jsesc@2.5.1': {} + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 - '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0) + vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) vue: 3.5.38(typescript@6.0.3) '@volar/language-core@2.4.28': @@ -790,6 +971,16 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 + '@vue-macros/common@3.1.2(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@vue/compiler-sfc': 3.5.38 + ast-kit: 2.2.0 + local-pkg: 1.2.1 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.1 + optionalDependencies: + vue: 3.5.38(typescript@6.0.3) + '@vue/compiler-core@3.5.38': dependencies: '@babel/parser': 7.29.7 @@ -820,6 +1011,19 @@ snapshots: '@vue/compiler-dom': 3.5.38 '@vue/shared': 3.5.38 + '@vue/devtools-api@8.1.3': + dependencies: + '@vue/devtools-kit': 8.1.3 + + '@vue/devtools-kit@8.1.3': + dependencies: + '@vue/devtools-shared': 8.1.3 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.1.3': {} + '@vue/language-core@3.3.5': dependencies: '@volar/language-core': 2.4.28 @@ -859,8 +1063,31 @@ snapshots: typescript: 6.0.3 vue: 3.5.38(typescript@6.0.3) + acorn@8.17.0: {} + alien-signals@3.2.1: {} + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.7 + pathe: 2.0.3 + + ast-walker-scope@0.9.0: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + ast-kit: 2.2.0 + + birpc@2.9.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + csstype@3.2.3: {} daisyui@5.5.23: {} @@ -876,6 +1103,8 @@ snapshots: estree-walker@2.0.2: {} + exsolve@1.0.8: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -885,8 +1114,14 @@ snapshots: graceful-fs@4.2.11: {} + hookable@5.5.3: {} + jiti@2.7.0: {} + jsesc@3.1.0: {} + + json5@2.2.3: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -936,26 +1171,63 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + muggle-string@0.4.1: {} nanoid@3.3.12: {} path-browserify@1.0.1: {} + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 + quansync@0.2.11: {} + + readdirp@5.0.0: {} + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -977,6 +1249,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + scule@1.3.0: {} + source-map-js@1.2.1: {} tailwindcss@4.3.1: {} @@ -993,9 +1267,22 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.4: {} + undici-types@7.18.2: {} - vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0): + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -1006,9 +1293,34 @@ snapshots: '@types/node': 24.13.2 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.0 vscode-uri@3.1.0: {} + vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)): + dependencies: + '@babel/generator': 8.0.0-rc.6 + '@vue-macros/common': 3.1.2(vue@3.5.38(typescript@6.0.3)) + '@vue/devtools-api': 8.1.3 + ast-walker-scope: 0.9.0 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.4 + scule: 1.3.0 + tinyglobby: 0.2.17 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + vue: 3.5.38(typescript@6.0.3) + yaml: 2.9.0 + optionalDependencies: + '@vue/compiler-sfc': 3.5.38 + vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) + vue-tsc@3.3.5(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 @@ -1024,3 +1336,7 @@ snapshots: '@vue/shared': 3.5.38 optionalDependencies: typescript: 6.0.3 + + webpack-virtual-modules@0.6.2: {} + + yaml@2.9.0: {} diff --git a/src/App.vue b/src/App.vue index 0cc1c88..b8a0cfa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,17 +2,17 @@ diff --git a/src/components/CriteriaEditor.vue b/src/components/CriteriaEditor.vue new file mode 100644 index 0000000..adb9bff --- /dev/null +++ b/src/components/CriteriaEditor.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/components/ProfilesEditor.vue b/src/components/ProfilesEditor.vue new file mode 100644 index 0000000..59fa4e5 --- /dev/null +++ b/src/components/ProfilesEditor.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/components/RadarChart.vue b/src/components/RadarChart.vue new file mode 100644 index 0000000..3ac6f0e --- /dev/null +++ b/src/components/RadarChart.vue @@ -0,0 +1,249 @@ + + + diff --git a/src/components/ScoreGrid.vue b/src/components/ScoreGrid.vue new file mode 100644 index 0000000..42baaa6 --- /dev/null +++ b/src/components/ScoreGrid.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/main.ts b/src/main.ts index 2425c0f..bb813d5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import './style.css' import App from './App.vue' +import { router } from './router' -createApp(App).mount('#app') +createApp(App).use(router).mount('#app') diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..b63c2de --- /dev/null +++ b/src/router.ts @@ -0,0 +1,11 @@ +import { createRouter, createWebHistory } from 'vue-router' +import RadarList from './views/RadarList.vue' +import RadarEditor from './views/RadarEditor.vue' + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'list', component: RadarList }, + { path: '/radar/:id', name: 'editor', component: RadarEditor, props: true }, + ], +}) diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..70e7938 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,117 @@ +import { ref, watch } from 'vue' +import type { Radar, Criterion, Profile } from './types' +import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from './types' + +const STORAGE_KEY = 'product-radar:radars' + +function loadAll(): Radar[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +const radars = ref(loadAll()) + +watch( + radars, + (next) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + }, + { deep: true }, +) + +function uid(): string { + return crypto.randomUUID() +} + +function blankCriterion(name = ''): Criterion { + return { id: uid(), name } +} + +function blankProfile(name = '', colorIndex = 0): Profile { + return { id: uid(), name, color: PROFILE_PALETTE[colorIndex % PROFILE_PALETTE.length] } +} + +function ensureScores(radar: Radar): void { + for (const profile of radar.profiles) { + if (!radar.scores[profile.id]) radar.scores[profile.id] = {} + for (const criterion of radar.criteria) { + if (radar.scores[profile.id][criterion.id] == null) { + radar.scores[profile.id][criterion.id] = 0 + } + } + } + // garbage-collect scores for removed entities + for (const profileId of Object.keys(radar.scores)) { + if (!radar.profiles.find((p) => p.id === profileId)) { + delete radar.scores[profileId] + continue + } + for (const criterionId of Object.keys(radar.scores[profileId])) { + if (!radar.criteria.find((c) => c.id === criterionId)) { + delete radar.scores[profileId][criterionId] + } + } + } +} + +export function useRadars() { + function list(): Radar[] { + return radars.value + } + + function get(id: string): Radar | undefined { + return radars.value.find((r) => r.id === id) + } + + function createBlank(name = 'Untitled radar'): Radar { + const criteria: Criterion[] = [] + for (let i = 0; i < MIN_CRITERIA; i++) criteria.push(blankCriterion(`Criterion ${i + 1}`)) + + const profiles: Profile[] = [] + for (let i = 0; i < MIN_PROFILES; i++) profiles.push(blankProfile(`Profile ${i + 1}`, i)) + + const now = Date.now() + const radar: Radar = { + id: uid(), + name, + criteria, + profiles, + scores: {}, + createdAt: now, + updatedAt: now, + } + ensureScores(radar) + radars.value.push(radar) + return radar + } + + function update(id: string, mutate: (r: Radar) => void): void { + const radar = get(id) + if (!radar) return + mutate(radar) + ensureScores(radar) + radar.updatedAt = Date.now() + } + + function remove(id: string): void { + const idx = radars.value.findIndex((r) => r.id === id) + if (idx >= 0) radars.value.splice(idx, 1) + } + + return { + radars, + list, + get, + createBlank, + update, + remove, + blankCriterion, + blankProfile, + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..90f3e88 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +export type Criterion = { + id: string + name: string +} + +export type Profile = { + id: string + name: string + color: string +} + +export type Radar = { + id: string + name: string + criteria: Criterion[] + profiles: Profile[] + /** scores[profileId][criterionId] = 0..5 */ + scores: Record> + createdAt: number + updatedAt: number +} + +export const MIN_CRITERIA = 3 +export const MAX_CRITERIA = 7 +export const MIN_PROFILES = 1 +export const MAX_PROFILES = 5 +export const MIN_SCORE = 0 +export const MAX_SCORE = 5 + +export const PROFILE_PALETTE = [ + '#e11d48', // rose-600 + '#2563eb', // blue-600 + '#059669', // emerald-600 + '#d97706', // amber-600 + '#7c3aed', // violet-600 + '#0891b2', // cyan-600 + '#db2777', // pink-600 + '#65a30d', // lime-600 +] as const diff --git a/src/utils/png.ts b/src/utils/png.ts new file mode 100644 index 0000000..86fa428 --- /dev/null +++ b/src/utils/png.ts @@ -0,0 +1,72 @@ +export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promise { + const serializer = new XMLSerializer() + let source = serializer.serializeToString(svgElement) + if (!source.match(/^]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)) { + source = source.replace(/^((resolve, reject) => { + img.onload = () => resolve() + img.onerror = (e) => reject(e instanceof Event ? new Error('SVG image failed to load') : e) + img.src = svgUrl + }) + + const vb = svgElement.viewBox.baseVal + const w = vb.width || svgElement.clientWidth || 720 + const h = vb.height || svgElement.clientHeight || 800 + + const canvas = document.createElement('canvas') + canvas.width = Math.round(w * scale) + canvas.height = Math.round(h * scale) + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Could not get 2D context') + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(img, 0, 0, canvas.width, canvas.height) + + return await new Promise((resolve, reject) => { + canvas.toBlob((b) => { + if (b) resolve(b) + else reject(new Error('canvas.toBlob returned null')) + }, 'image/png') + }) + } finally { + URL.revokeObjectURL(svgUrl) + } +} + +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +export async function copyBlobToClipboard(blob: Blob): Promise { + const ClipItem = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem + if (!navigator.clipboard || !ClipItem) { + throw new Error('Clipboard image copy not supported in this browser') + } + await navigator.clipboard.write([new ClipItem({ [blob.type]: blob })]) +} + +export function slugify(input: string): string { + return ( + input + .toLowerCase() + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'radar' + ) +} diff --git a/src/views/RadarEditor.vue b/src/views/RadarEditor.vue new file mode 100644 index 0000000..75799de --- /dev/null +++ b/src/views/RadarEditor.vue @@ -0,0 +1,120 @@ + + +
+ + +
+ + +
+
+ + +
+
+ {{ toast.text }} +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ +
+ + diff --git a/src/views/RadarList.vue b/src/views/RadarList.vue new file mode 100644 index 0000000..e51bc38 --- /dev/null +++ b/src/views/RadarList.vue @@ -0,0 +1,88 @@ + + +