feat: add product concept subtitle to radars
A Radar gains an optional Concept — a short subtitle naming the product and its typical performances, rendered under the title and wrapping to a balanced second line when long. Old radars default to an empty Concept on load.
This commit is contained in:
6
.oxfmtrc.json
Normal file
6
.oxfmtrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"ignorePatterns": []
|
||||||
|
}
|
||||||
11
.oxlintrc.json
Normal file
11
.oxlintrc.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["typescript", "oxc"],
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2024": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
CONTEXT.md
29
CONTEXT.md
@@ -5,36 +5,45 @@ Ubiquitous language for the product-radar app. Terms here should appear verbatim
|
|||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
### Radar
|
### Radar
|
||||||
|
|
||||||
The top-level entity. A user creates a Radar, names it, defines its **Criteria** (3–7), and adds its **Profiles** (up to 5). A Radar is the unit of save / open / delete / share.
|
The top-level entity. A user creates a Radar, names it, defines its **Criteria** (3–7), and adds its **Profiles** (up to 5). A Radar is the unit of save / open / delete / share.
|
||||||
|
|
||||||
- **Aliases to avoid:** *chart* (the chart is the visualization of the Radar, not the Radar itself), *comparison* (we use Radar even when only one Profile is plotted).
|
- **Aliases to avoid:** _chart_ (the chart is the visualization of the Radar, not the Radar itself), _comparison_ (we use Radar even when only one Profile is plotted).
|
||||||
|
|
||||||
### Criterion
|
### Criterion
|
||||||
A single named dimension on the radar chart (one "spoke"). Examples: *Price*, *Battery life*, *Ease of use*. A radar has **between 3 and 7 Criteria**. Flat — Criteria do not nest.
|
|
||||||
|
|
||||||
- **Aliases to avoid:** *branch* (the user's original term — implies a hierarchy that does not exist), *axis* (acceptable but reserved for the geometric/visual concept, not the domain concept).
|
A single named dimension on the radar chart (one "spoke"). Examples: _Price_, _Battery life_, _Ease of use_. A radar has **between 3 and 7 Criteria**. Flat — Criteria do not nest.
|
||||||
|
|
||||||
|
- **Aliases to avoid:** _branch_ (the user's original term — implies a hierarchy that does not exist), _axis_ (acceptable but reserved for the geometric/visual concept, not the domain concept).
|
||||||
|
|
||||||
### Profile
|
### Profile
|
||||||
One labeled shape on the radar — a set of Scores, one per Criterion. Examples: *Us-Now*, *Us-Target*, *Competitor X*. A radar has **up to 5 Profiles** total. Neutral term covering both self-snapshots and competitors.
|
|
||||||
|
|
||||||
- **Aliases to avoid:** *product* (too narrow — *Us-Target* isn't a product yet, it's an aspiration), *snapshot* (implies point-in-time, awkward for competitors).
|
One labeled shape on the radar — a set of Scores, one per Criterion. Examples: _Us-Now_, _Us-Target_, _Competitor X_. A radar has **up to 5 Profiles** total. Neutral term covering both self-snapshots and competitors.
|
||||||
|
|
||||||
|
- **Aliases to avoid:** _product_ (too narrow — _Us-Target_ isn't a product yet, it's an aspiration), _snapshot_ (implies point-in-time, awkward for competitors).
|
||||||
|
|
||||||
### Score
|
### Score
|
||||||
|
|
||||||
A single integer in **{0, 1, 2, 3, 4, 5}** assigned to one (Criterion, Profile) pair. 0 = worst on this Criterion; 5 = best. Every Profile must have a Score for every Criterion of its Radar — no nulls, no "N/A".
|
A single integer in **{0, 1, 2, 3, 4, 5}** assigned to one (Criterion, Profile) pair. 0 = worst on this Criterion; 5 = best. Every Profile must have a Score for every Criterion of its Radar — no nulls, no "N/A".
|
||||||
|
|
||||||
- **Aliases to avoid:** *note* (French calque — use *Score*), *rating* (acceptable in user-facing copy, but *Score* is canonical in code).
|
- **Aliases to avoid:** _note_ (French calque — use _Score_), _rating_ (acceptable in user-facing copy, but _Score_ is canonical in code).
|
||||||
|
|
||||||
|
### Concept
|
||||||
|
|
||||||
|
A short free-text subtitle on a Radar that names the product and helps the reader identify its typical performances at a glance. Optional — a Radar may have an empty Concept. Rendered under the Radar name (the title) on the chart, wrapping to a second line when long.
|
||||||
|
|
||||||
|
- **Aliases to avoid:** _subtitle_ (that's the visual role, not the domain term), _description_ (too generic — a Concept is a tight positioning phrase, not prose), _tagline_ (acceptable in user-facing copy, but _Concept_ is canonical in code).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
### Higher is better
|
### Higher is better
|
||||||
All Criteria must be phrased so that a higher Score is more desirable (e.g. *Affordability*, not *Price*; *Onboarding speed*, not *Time-to-onboard*). The app does not invert any axis. This is a naming discipline imposed on the user via UI hint text, not enforced by code.
|
|
||||||
|
All Criteria must be phrased so that a higher Score is more desirable (e.g. _Affordability_, not _Price_; _Onboarding speed_, not _Time-to-onboard_). The app does not invert any axis. This is a naming discipline imposed on the user via UI hint text, not enforced by code.
|
||||||
|
|
||||||
Consequence: a larger Profile shape on the radar always means a "stronger" Profile, making visual comparison meaningful.
|
Consequence: a larger Profile shape on the radar always means a "stronger" Profile, making visual comparison meaningful.
|
||||||
|
|
||||||
### Cardinality
|
### Cardinality
|
||||||
|
|
||||||
- A Radar has **3 to 7 Criteria** (inclusive), and **1 to 5 Profiles** (inclusive).
|
- A Radar has **3 to 7 Criteria** (inclusive), and **1 to 5 Profiles** (inclusive).
|
||||||
- Within one Radar, Criterion names are unique. Profile names are unique.
|
- Within one Radar, Criterion names are unique. Profile names are unique.
|
||||||
- A Profile's Scores cover every Criterion in its Radar — exactly one Score per (Criterion, Profile) pair.
|
- A Profile's Scores cover every Criterion in its Radar — exactly one Score per (Criterion, Profile) pair.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
|
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
|
||||||
<link rel="preconnect" href="https://cdn.fonts.coollabs.io" crossorigin />
|
<link rel="preconnect" href="https://cdn.fonts.coollabs.io" crossorigin />
|
||||||
<link rel="stylesheet" href="https://api.fonts.coollabs.io/css2?family=Fredoka:wght@300..700&display=swap" />
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://api.fonts.coollabs.io/css2?family=Fredoka:wght@300..700&display=swap"
|
||||||
|
/>
|
||||||
<title>product-radar</title>
|
<title>product-radar</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "product-radar",
|
"name": "product-radar",
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"lint:fix": "oxlint --fix",
|
||||||
|
"fmt": "oxfmt",
|
||||||
|
"fmt:check": "oxfmt --check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"daisyui": "^5.5.23",
|
"daisyui": "^5.5.23",
|
||||||
@@ -18,6 +22,8 @@
|
|||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"oxfmt": "^0.55.0",
|
||||||
|
"oxlint": "^1.70.0",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
|||||||
442
pnpm-lock.yaml
generated
442
pnpm-lock.yaml
generated
@@ -30,6 +30,12 @@ importers:
|
|||||||
'@vue/tsconfig':
|
'@vue/tsconfig':
|
||||||
specifier: ^0.9.1
|
specifier: ^0.9.1
|
||||||
version: 0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
version: 0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
||||||
|
oxfmt:
|
||||||
|
specifier: ^0.55.0
|
||||||
|
version: 0.55.0
|
||||||
|
oxlint:
|
||||||
|
specifier: ^1.70.0
|
||||||
|
version: 1.70.0
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.3.1
|
specifier: ^4.3.1
|
||||||
version: 4.3.1
|
version: 4.3.1
|
||||||
@@ -117,6 +123,250 @@ packages:
|
|||||||
'@oxc-project/types@0.133.0':
|
'@oxc-project/types@0.133.0':
|
||||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||||
|
|
||||||
|
'@oxfmt/binding-android-arm-eabi@0.55.0':
|
||||||
|
resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxfmt/binding-android-arm64@0.55.0':
|
||||||
|
resolution: {integrity: sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxfmt/binding-darwin-arm64@0.55.0':
|
||||||
|
resolution: {integrity: sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxfmt/binding-darwin-x64@0.55.0':
|
||||||
|
resolution: {integrity: sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxfmt/binding-freebsd-x64@0.55.0':
|
||||||
|
resolution: {integrity: sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm-gnueabihf@0.55.0':
|
||||||
|
resolution: {integrity: sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm-musleabihf@0.55.0':
|
||||||
|
resolution: {integrity: sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm64-gnu@0.55.0':
|
||||||
|
resolution: {integrity: sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm64-musl@0.55.0':
|
||||||
|
resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-ppc64-gnu@0.55.0':
|
||||||
|
resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-riscv64-gnu@0.55.0':
|
||||||
|
resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-riscv64-musl@0.55.0':
|
||||||
|
resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-s390x-gnu@0.55.0':
|
||||||
|
resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-x64-gnu@0.55.0':
|
||||||
|
resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-x64-musl@0.55.0':
|
||||||
|
resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxfmt/binding-openharmony-arm64@0.55.0':
|
||||||
|
resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@oxfmt/binding-win32-arm64-msvc@0.55.0':
|
||||||
|
resolution: {integrity: sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxfmt/binding-win32-ia32-msvc@0.55.0':
|
||||||
|
resolution: {integrity: sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxfmt/binding-win32-x64-msvc@0.55.0':
|
||||||
|
resolution: {integrity: sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm-eabi@1.70.0':
|
||||||
|
resolution: {integrity: sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm64@1.70.0':
|
||||||
|
resolution: {integrity: sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-arm64@1.70.0':
|
||||||
|
resolution: {integrity: sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-x64@1.70.0':
|
||||||
|
resolution: {integrity: sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxlint/binding-freebsd-x64@1.70.0':
|
||||||
|
resolution: {integrity: sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-gnueabihf@1.70.0':
|
||||||
|
resolution: {integrity: sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-musleabihf@1.70.0':
|
||||||
|
resolution: {integrity: sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-gnu@1.70.0':
|
||||||
|
resolution: {integrity: sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-musl@1.70.0':
|
||||||
|
resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-ppc64-gnu@1.70.0':
|
||||||
|
resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-gnu@1.70.0':
|
||||||
|
resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-musl@1.70.0':
|
||||||
|
resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-s390x-gnu@1.70.0':
|
||||||
|
resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-gnu@1.70.0':
|
||||||
|
resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-musl@1.70.0':
|
||||||
|
resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxlint/binding-openharmony-arm64@1.70.0':
|
||||||
|
resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-arm64-msvc@1.70.0':
|
||||||
|
resolution: {integrity: sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-ia32-msvc@1.70.0':
|
||||||
|
resolution: {integrity: sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-x64-msvc@1.70.0':
|
||||||
|
resolution: {integrity: sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.3':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -578,6 +828,32 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
oxfmt@0.55.0:
|
||||||
|
resolution: {integrity: sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.0.0
|
||||||
|
vite-plus: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
svelte:
|
||||||
|
optional: true
|
||||||
|
vite-plus:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
oxlint@1.70.0:
|
||||||
|
resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
oxlint-tsgolint: '>=0.22.1'
|
||||||
|
vite-plus: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
oxlint-tsgolint:
|
||||||
|
optional: true
|
||||||
|
vite-plus:
|
||||||
|
optional: true
|
||||||
|
|
||||||
path-browserify@1.0.1:
|
path-browserify@1.0.1:
|
||||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||||
|
|
||||||
@@ -634,6 +910,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinypool@2.1.0:
|
||||||
|
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
|
||||||
|
engines: {node: ^20.0.0 || >=22.0.0}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -823,6 +1103,120 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/types@0.133.0': {}
|
'@oxc-project/types@0.133.0': {}
|
||||||
|
|
||||||
|
'@oxfmt/binding-android-arm-eabi@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-android-arm64@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-darwin-arm64@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-darwin-x64@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-freebsd-x64@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm-gnueabihf@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm-musleabihf@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm64-gnu@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-arm64-musl@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-ppc64-gnu@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-riscv64-gnu@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-riscv64-musl@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-s390x-gnu@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-x64-gnu@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-linux-x64-musl@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-openharmony-arm64@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-win32-arm64-msvc@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-win32-ia32-msvc@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxfmt/binding-win32-x64-msvc@0.55.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm-eabi@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm64@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-arm64@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-x64@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-freebsd-x64@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-gnueabihf@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-musleabihf@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-gnu@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-musl@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-ppc64-gnu@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-gnu@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-musl@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-s390x-gnu@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-gnu@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-musl@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-openharmony-arm64@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-arm64-msvc@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-ia32-msvc@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-x64-msvc@1.70.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.3':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1196,6 +1590,52 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
oxfmt@0.55.0:
|
||||||
|
dependencies:
|
||||||
|
tinypool: 2.1.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@oxfmt/binding-android-arm-eabi': 0.55.0
|
||||||
|
'@oxfmt/binding-android-arm64': 0.55.0
|
||||||
|
'@oxfmt/binding-darwin-arm64': 0.55.0
|
||||||
|
'@oxfmt/binding-darwin-x64': 0.55.0
|
||||||
|
'@oxfmt/binding-freebsd-x64': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-arm-gnueabihf': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-arm-musleabihf': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-arm64-gnu': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-arm64-musl': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-ppc64-gnu': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-riscv64-gnu': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-riscv64-musl': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-s390x-gnu': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-x64-gnu': 0.55.0
|
||||||
|
'@oxfmt/binding-linux-x64-musl': 0.55.0
|
||||||
|
'@oxfmt/binding-openharmony-arm64': 0.55.0
|
||||||
|
'@oxfmt/binding-win32-arm64-msvc': 0.55.0
|
||||||
|
'@oxfmt/binding-win32-ia32-msvc': 0.55.0
|
||||||
|
'@oxfmt/binding-win32-x64-msvc': 0.55.0
|
||||||
|
|
||||||
|
oxlint@1.70.0:
|
||||||
|
optionalDependencies:
|
||||||
|
'@oxlint/binding-android-arm-eabi': 1.70.0
|
||||||
|
'@oxlint/binding-android-arm64': 1.70.0
|
||||||
|
'@oxlint/binding-darwin-arm64': 1.70.0
|
||||||
|
'@oxlint/binding-darwin-x64': 1.70.0
|
||||||
|
'@oxlint/binding-freebsd-x64': 1.70.0
|
||||||
|
'@oxlint/binding-linux-arm-gnueabihf': 1.70.0
|
||||||
|
'@oxlint/binding-linux-arm-musleabihf': 1.70.0
|
||||||
|
'@oxlint/binding-linux-arm64-gnu': 1.70.0
|
||||||
|
'@oxlint/binding-linux-arm64-musl': 1.70.0
|
||||||
|
'@oxlint/binding-linux-ppc64-gnu': 1.70.0
|
||||||
|
'@oxlint/binding-linux-riscv64-gnu': 1.70.0
|
||||||
|
'@oxlint/binding-linux-riscv64-musl': 1.70.0
|
||||||
|
'@oxlint/binding-linux-s390x-gnu': 1.70.0
|
||||||
|
'@oxlint/binding-linux-x64-gnu': 1.70.0
|
||||||
|
'@oxlint/binding-linux-x64-musl': 1.70.0
|
||||||
|
'@oxlint/binding-openharmony-arm64': 1.70.0
|
||||||
|
'@oxlint/binding-win32-arm64-msvc': 1.70.0
|
||||||
|
'@oxlint/binding-win32-ia32-msvc': 1.70.0
|
||||||
|
'@oxlint/binding-win32-x64-msvc': 1.70.0
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
@@ -1262,6 +1702,8 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinypool@2.1.0: {}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts"></script>
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-200 flex flex-col">
|
<div class="min-h-screen bg-base-200 flex flex-col">
|
||||||
@@ -8,7 +7,7 @@
|
|||||||
<router-link to="/" class="text-xl font-semibold tracking-tight">
|
<router-link to="/" class="text-xl font-semibold tracking-tight">
|
||||||
product-radar
|
product-radar
|
||||||
</router-link>
|
</router-link>
|
||||||
<span class="text-xs text-base-content/50">browser-local · 0–5 scale</span>
|
<span class="text-xs text-base-content/50">0–5 scale</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Radar } from '../types'
|
import type { Radar } from "../types"
|
||||||
import { MIN_CRITERIA, MAX_CRITERIA } from '../types'
|
import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
|
||||||
import { useRadars } from '../storage'
|
import { useRadars } from "../storage"
|
||||||
|
|
||||||
const props = defineProps<{ radar: Radar }>()
|
const props = defineProps<{ radar: Radar }>()
|
||||||
const { update, blankCriterion } = useRadars()
|
const { update, blankCriterion } = useRadars()
|
||||||
@@ -37,15 +37,10 @@ function renameCriterion(criterionId: string, name: string): void {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-base-content/60 mb-3">
|
<p class="text-xs text-base-content/60 mb-3">
|
||||||
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not
|
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>).
|
||||||
<em>Price</em>).
|
|
||||||
</p>
|
</p>
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
<li
|
<li v-for="c in radar.criteria" :key="c.id" class="flex items-center gap-2">
|
||||||
v-for="c in radar.criteria"
|
|
||||||
:key="c.id"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm flex-1"
|
class="input input-bordered input-sm flex-1"
|
||||||
@@ -58,9 +53,7 @@ function renameCriterion(criterionId: string, name: string): void {
|
|||||||
:disabled="radar.criteria.length <= MIN_CRITERIA"
|
:disabled="radar.criteria.length <= MIN_CRITERIA"
|
||||||
@click="removeCriterion(c.id)"
|
@click="removeCriterion(c.id)"
|
||||||
:title="
|
:title="
|
||||||
radar.criteria.length <= MIN_CRITERIA
|
radar.criteria.length <= MIN_CRITERIA ? `Minimum ${MIN_CRITERIA} criteria` : 'Remove'
|
||||||
? `Minimum ${MIN_CRITERIA} criteria`
|
|
||||||
: 'Remove'
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Radar } from '../types'
|
import type { Radar } from "../types"
|
||||||
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from '../types'
|
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
|
||||||
import { useRadars } from '../storage'
|
import { useRadars } from "../storage"
|
||||||
|
|
||||||
const props = defineProps<{ radar: Radar }>()
|
const props = defineProps<{ radar: Radar }>()
|
||||||
const { update, blankProfile } = useRadars()
|
const { update, blankProfile } = useRadars()
|
||||||
@@ -71,9 +71,7 @@ function setColor(profileId: string, color: string): void {
|
|||||||
:disabled="radar.profiles.length <= MIN_PROFILES"
|
:disabled="radar.profiles.length <= MIN_PROFILES"
|
||||||
@click="removeProfile(p.id)"
|
@click="removeProfile(p.id)"
|
||||||
:title="
|
:title="
|
||||||
radar.profiles.length <= MIN_PROFILES
|
radar.profiles.length <= MIN_PROFILES ? `Minimum ${MIN_PROFILES} profile` : 'Remove'
|
||||||
? `Minimum ${MIN_PROFILES} profile`
|
|
||||||
: 'Remove'
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from "vue"
|
||||||
import type { Radar } from '../types'
|
import type { Radar } from "../types"
|
||||||
import { MAX_SCORE } from '../types'
|
import { MAX_SCORE } from "../types"
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -14,7 +14,43 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const WIDTH = 720
|
const WIDTH = 720
|
||||||
const HEIGHT = computed(() => (props.withChrome === false ? 600 : 800))
|
const HEIGHT = computed(() => (props.withChrome === false ? 600 : 800))
|
||||||
const TITLE_H = computed(() => (props.withChrome === false ? 0 : 60))
|
|
||||||
|
const subtitleText = computed(() => props.radar.concept?.trim() ?? "")
|
||||||
|
|
||||||
|
const SUBTITLE_SINGLE_LINE_MAX = 64
|
||||||
|
|
||||||
|
function wrapToTwoLines(text: string): string[] {
|
||||||
|
const words = text.split(/\s+/).filter(Boolean)
|
||||||
|
if (words.length < 2) return [text]
|
||||||
|
let bestSplit = 1
|
||||||
|
let bestDiff = Infinity
|
||||||
|
for (let i = 1; i < words.length; i++) {
|
||||||
|
const diff = Math.abs(words.slice(0, i).join(" ").length - words.slice(i).join(" ").length)
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff
|
||||||
|
bestSplit = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [words.slice(0, bestSplit).join(" "), words.slice(bestSplit).join(" ")]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 lines = no concept, 1 = fits, 2 = wrapped.
|
||||||
|
const subtitleLines = computed<string[]>(() => {
|
||||||
|
const text = subtitleText.value
|
||||||
|
if (props.withChrome === false || !text) return []
|
||||||
|
return text.length <= SUBTITLE_SINGLE_LINE_MAX ? [text] : wrapToTwoLines(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
const TITLE_H = computed(() => {
|
||||||
|
if (props.withChrome === false) return 0
|
||||||
|
const n = subtitleLines.value.length
|
||||||
|
return n === 0 ? 60 : n === 1 ? 92 : 116
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleY = computed(() => (subtitleLines.value.length === 0 ? TITLE_H.value / 2 + 4 : 32))
|
||||||
|
const subtitleFontSize = computed(() => (subtitleLines.value.length > 1 ? 14 : 16))
|
||||||
|
const subtitleStartY = computed(() => (subtitleLines.value.length > 1 ? 56 : 64))
|
||||||
|
const subtitleLineHeight = computed(() => (subtitleLines.value.length > 1 ? 18 : 0))
|
||||||
const LEGEND_H = computed(() => (props.withChrome === false ? 0 : 120))
|
const LEGEND_H = computed(() => (props.withChrome === false ? 0 : 120))
|
||||||
const CHART_TOP = computed(() => TITLE_H.value)
|
const CHART_TOP = computed(() => TITLE_H.value)
|
||||||
const CHART_BOTTOM = computed(() => HEIGHT.value - LEGEND_H.value)
|
const CHART_BOTTOM = computed(() => HEIGHT.value - LEGEND_H.value)
|
||||||
@@ -47,7 +83,7 @@ const ringPaths = computed<string[]>(() => {
|
|||||||
if (total < 3) return []
|
if (total < 3) return []
|
||||||
return [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => {
|
return [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => {
|
||||||
const pts = Array.from({ length: total }, (_, i) => pointOnAxis(i, total, ratio))
|
const pts = Array.from({ length: total }, (_, i) => pointOnAxis(i, total, ratio))
|
||||||
return pts.map((p, idx) => `${idx === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
return pts.map((p, idx) => `${idx === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ") + " Z"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,13 +103,12 @@ const profileShapes = computed<ProfileShape[]>(() => {
|
|||||||
const score = props.radar.scores[profile.id]?.[criterion.id] ?? 0
|
const score = props.radar.scores[profile.id]?.[criterion.id] ?? 0
|
||||||
return pointOnAxis(i, total, score / MAX_SCORE)
|
return pointOnAxis(i, total, score / MAX_SCORE)
|
||||||
})
|
})
|
||||||
const path =
|
const path = vertices.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ") + " Z"
|
||||||
vertices.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
|
||||||
return { id: profile.id, name: profile.name, color: profile.color, path, vertices }
|
return { id: profile.id, name: profile.name, color: profile.color, path, vertices }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
type Label = { x: number; y: number; anchor: 'start' | 'middle' | 'end'; text: string; dy: string }
|
type Label = { x: number; y: number; anchor: "start" | "middle" | "end"; text: string; dy: string }
|
||||||
|
|
||||||
const criterionLabels = computed<Label[]>(() => {
|
const criterionLabels = computed<Label[]>(() => {
|
||||||
const total = props.radar.criteria.length
|
const total = props.radar.criteria.length
|
||||||
@@ -83,14 +118,14 @@ const criterionLabels = computed<Label[]>(() => {
|
|||||||
const x = CHART_CX + Math.cos(angle) * labelRadius
|
const x = CHART_CX + Math.cos(angle) * labelRadius
|
||||||
const y = CHART_CY.value + Math.sin(angle) * labelRadius
|
const y = CHART_CY.value + Math.sin(angle) * labelRadius
|
||||||
const cosA = Math.cos(angle)
|
const cosA = Math.cos(angle)
|
||||||
let anchor: 'start' | 'middle' | 'end' = 'middle'
|
let anchor: "start" | "middle" | "end" = "middle"
|
||||||
if (cosA > 0.2) anchor = 'start'
|
if (cosA > 0.2) anchor = "start"
|
||||||
else if (cosA < -0.2) anchor = 'end'
|
else if (cosA < -0.2) anchor = "end"
|
||||||
const sinA = Math.sin(angle)
|
const sinA = Math.sin(angle)
|
||||||
let dy = '0.35em'
|
let dy = "0.35em"
|
||||||
if (sinA < -0.5) dy = '0em'
|
if (sinA < -0.5) dy = "0em"
|
||||||
else if (sinA > 0.5) dy = '0.7em'
|
else if (sinA > 0.5) dy = "0.7em"
|
||||||
return { x, y, anchor, text: criterion.name || 'Untitled', dy }
|
return { x, y, anchor, text: criterion.name || "Untitled", dy }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,15 +135,19 @@ const scoreRingLabels = computed<Label[]>(() => {
|
|||||||
const ratio = score / MAX_SCORE
|
const ratio = score / MAX_SCORE
|
||||||
const x = CHART_CX + 4
|
const x = CHART_CX + 4
|
||||||
const y = CHART_CY.value - CHART_R.value * ratio
|
const y = CHART_CY.value - CHART_R.value * ratio
|
||||||
return { x, y, anchor: 'start' as const, text: String(score), dy: '0.35em' }
|
return { x, y, anchor: "start" as const, text: String(score), dy: "0.35em" }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const titleText = computed(() => props.radar.name || 'Untitled radar')
|
const titleText = computed(() => props.radar.name || "Untitled radar")
|
||||||
|
|
||||||
const legendItems = computed(() => {
|
const legendItems = computed(() => {
|
||||||
// up to 5 profiles — fit in one row if possible, else two rows
|
// up to 5 profiles — fit in one row if possible, else two rows
|
||||||
return props.radar.profiles.map((p) => ({ id: p.id, name: p.name || 'Untitled', color: p.color }))
|
return props.radar.profiles.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name || "Untitled",
|
||||||
|
color: p.color,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const legendLayout = computed(() => {
|
const legendLayout = computed(() => {
|
||||||
@@ -132,7 +171,7 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
|
|||||||
role="img"
|
role="img"
|
||||||
:aria-label="`Radar chart: ${titleText}`"
|
:aria-label="`Radar chart: ${titleText}`"
|
||||||
class="w-full h-auto"
|
class="w-full h-auto"
|
||||||
style="font-family: 'Fredoka', ui-sans-serif, system-ui, sans-serif"
|
style="font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif"
|
||||||
>
|
>
|
||||||
<rect :width="WIDTH" :height="HEIGHT" fill="#ffffff" />
|
<rect :width="WIDTH" :height="HEIGHT" fill="#ffffff" />
|
||||||
|
|
||||||
@@ -140,7 +179,7 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
|
|||||||
<text
|
<text
|
||||||
v-if="withChrome !== false"
|
v-if="withChrome !== false"
|
||||||
:x="WIDTH / 2"
|
:x="WIDTH / 2"
|
||||||
:y="TITLE_H / 2 + 4"
|
:y="titleY"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
font-size="28"
|
font-size="28"
|
||||||
@@ -150,6 +189,20 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
|
|||||||
{{ titleText }}
|
{{ titleText }}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
<!-- Concept (subtitle, wraps to two lines when long) -->
|
||||||
|
<text
|
||||||
|
v-for="(line, i) in subtitleLines"
|
||||||
|
:key="`subtitle-${i}`"
|
||||||
|
:x="WIDTH / 2"
|
||||||
|
:y="subtitleStartY + i * subtitleLineHeight"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
:font-size="subtitleFontSize"
|
||||||
|
fill="#64748b"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</text>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<text
|
<text
|
||||||
v-if="insufficientCriteria"
|
v-if="insufficientCriteria"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Radar } from '../types'
|
import type { Radar } from "../types"
|
||||||
import { MIN_SCORE, MAX_SCORE } from '../types'
|
import { MIN_SCORE, MAX_SCORE } from "../types"
|
||||||
import { useRadars } from '../storage'
|
import { useRadars } from "../storage"
|
||||||
|
|
||||||
const props = defineProps<{ radar: Radar }>()
|
const props = defineProps<{ radar: Radar }>()
|
||||||
const { update } = useRadars()
|
const { update } = useRadars()
|
||||||
@@ -24,16 +24,14 @@ function setScore(profileId: string, criterionId: string, value: number): void {
|
|||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold mb-2">Scores</h2>
|
<h2 class="text-lg font-semibold mb-2">Scores</h2>
|
||||||
<p class="text-xs text-base-content/60 mb-3">
|
<p class="text-xs text-base-content/60 mb-3">0 = worst on this Criterion, 5 = best.</p>
|
||||||
0 = worst on this Criterion, 5 = best.
|
|
||||||
</p>
|
|
||||||
<div class="overflow-x-auto rounded-lg border border-base-300 bg-base-100">
|
<div class="overflow-x-auto rounded-lg border border-base-300 bg-base-100">
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left">Profile \ Criterion</th>
|
<th class="text-left">Profile \ Criterion</th>
|
||||||
<th v-for="c in radar.criteria" :key="c.id" class="text-left">
|
<th v-for="c in radar.criteria" :key="c.id" class="text-left">
|
||||||
{{ c.name || 'Untitled' }}
|
{{ c.name || "Untitled" }}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -45,7 +43,7 @@ function setScore(profileId: string, criterionId: string, value: number): void {
|
|||||||
:style="{ background: p.color }"
|
:style="{ background: p.color }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{{ p.name || 'Untitled' }}
|
{{ p.name || "Untitled" }}
|
||||||
</td>
|
</td>
|
||||||
<td v-for="c in radar.criteria" :key="c.id">
|
<td v-for="c in radar.criteria" :key="c.id">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
@@ -54,9 +52,7 @@ function setScore(profileId: string, criterionId: string, value: number): void {
|
|||||||
:key="value"
|
:key="value"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-xs"
|
class="btn btn-xs"
|
||||||
:class="
|
:class="getScore(p.id, c.id) === value ? 'btn-primary' : 'btn-ghost'"
|
||||||
getScore(p.id, c.id) === value ? 'btn-primary' : 'btn-ghost'
|
|
||||||
"
|
|
||||||
@click="setScore(p.id, c.id, value)"
|
@click="setScore(p.id, c.id, value)"
|
||||||
:aria-label="`Score ${value} for ${p.name} on ${c.name}`"
|
:aria-label="`Score ${value} for ${p.name} on ${c.name}`"
|
||||||
>
|
>
|
||||||
|
|||||||
10
src/main.ts
10
src/main.ts
@@ -1,6 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from "vue"
|
||||||
import './style.css'
|
import "./style.css"
|
||||||
import App from './App.vue'
|
import App from "./App.vue"
|
||||||
import { router } from './router'
|
import { router } from "./router.ts"
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app')
|
createApp(App).use(router).mount("#app")
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
import RadarList from './views/RadarList.vue'
|
import RadarList from "./views/RadarList.vue"
|
||||||
import RadarEditor from './views/RadarEditor.vue'
|
import RadarEditor from "./views/RadarEditor.vue"
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'list', component: RadarList },
|
{ path: "/", name: "list", component: RadarList },
|
||||||
{ path: '/radar/:id', name: 'editor', component: RadarEditor, props: true },
|
{ path: "/radar/:id", name: "editor", component: RadarEditor, props: true },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from "vue"
|
||||||
import type { Radar, Criterion, Profile } from './types'
|
import type { Radar, Criterion, Profile } from "./types.ts"
|
||||||
import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from './types'
|
import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from "./types.ts"
|
||||||
|
|
||||||
const STORAGE_KEY = 'product-radar:radars'
|
const STORAGE_KEY = "product-radar:radars"
|
||||||
|
|
||||||
function loadAll(): Radar[] {
|
function loadAll(): Radar[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
return Array.isArray(parsed) ? parsed : []
|
if (!Array.isArray(parsed)) return []
|
||||||
|
// Default concept for radars saved before the field existed.
|
||||||
|
return parsed.map((r: Radar) => ({ ...r, concept: r.concept ?? "" }))
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -29,12 +31,16 @@ function uid(): string {
|
|||||||
return crypto.randomUUID()
|
return crypto.randomUUID()
|
||||||
}
|
}
|
||||||
|
|
||||||
function blankCriterion(name = ''): Criterion {
|
function blankCriterion(name = ""): Criterion {
|
||||||
return { id: uid(), name }
|
return { id: uid(), name }
|
||||||
}
|
}
|
||||||
|
|
||||||
function blankProfile(name = '', colorIndex = 0): Profile {
|
function blankProfile(name = "", colorIndex = 0): Profile {
|
||||||
return { id: uid(), name, color: PROFILE_PALETTE[colorIndex % PROFILE_PALETTE.length] }
|
return {
|
||||||
|
id: uid(),
|
||||||
|
name,
|
||||||
|
color: PROFILE_PALETTE[colorIndex % PROFILE_PALETTE.length],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureScores(radar: Radar): void {
|
function ensureScores(radar: Radar): void {
|
||||||
@@ -69,7 +75,7 @@ export function useRadars() {
|
|||||||
return radars.value.find((r) => r.id === id)
|
return radars.value.find((r) => r.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBlank(name = 'Untitled radar'): Radar {
|
function createBlank(name = "Untitled radar"): Radar {
|
||||||
const criteria: Criterion[] = []
|
const criteria: Criterion[] = []
|
||||||
for (let i = 0; i < MIN_CRITERIA; i++) criteria.push(blankCriterion(`Criterion ${i + 1}`))
|
for (let i = 0; i < MIN_CRITERIA; i++) criteria.push(blankCriterion(`Criterion ${i + 1}`))
|
||||||
|
|
||||||
@@ -80,6 +86,7 @@ export function useRadars() {
|
|||||||
const radar: Radar = {
|
const radar: Radar = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
name,
|
name,
|
||||||
|
concept: "",
|
||||||
criteria,
|
criteria,
|
||||||
profiles,
|
profiles,
|
||||||
scores: {},
|
scores: {},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
themes: light --default, dark --prefersdark;
|
themes:
|
||||||
|
light --default,
|
||||||
|
dark --prefersdark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
|
|||||||
18
src/types.ts
18
src/types.ts
@@ -12,6 +12,8 @@ export type Profile = {
|
|||||||
export type Radar = {
|
export type Radar = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
/** Short subtitle naming the product and its typical performances. May be empty. */
|
||||||
|
concept: string
|
||||||
criteria: Criterion[]
|
criteria: Criterion[]
|
||||||
profiles: Profile[]
|
profiles: Profile[]
|
||||||
/** scores[profileId][criterionId] = 0..5 */
|
/** scores[profileId][criterionId] = 0..5 */
|
||||||
@@ -28,12 +30,12 @@ export const MIN_SCORE = 0
|
|||||||
export const MAX_SCORE = 5
|
export const MAX_SCORE = 5
|
||||||
|
|
||||||
export const PROFILE_PALETTE = [
|
export const PROFILE_PALETTE = [
|
||||||
'#e11d48', // rose-600
|
"#e11d48", // rose-600
|
||||||
'#2563eb', // blue-600
|
"#2563eb", // blue-600
|
||||||
'#059669', // emerald-600
|
"#059669", // emerald-600
|
||||||
'#d97706', // amber-600
|
"#d97706", // amber-600
|
||||||
'#7c3aed', // violet-600
|
"#7c3aed", // violet-600
|
||||||
'#0891b2', // cyan-600
|
"#0891b2", // cyan-600
|
||||||
'#db2777', // pink-600
|
"#db2777", // pink-600
|
||||||
'#65a30d', // lime-600
|
"#65a30d", // lime-600
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promis
|
|||||||
source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"')
|
source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"')
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgBlob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' })
|
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" })
|
||||||
const svgUrl = URL.createObjectURL(svgBlob)
|
const svgUrl = URL.createObjectURL(svgBlob)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
img.onload = () => resolve()
|
img.onload = () => resolve()
|
||||||
img.onerror = (e) => reject(e instanceof Event ? new Error('SVG image failed to load') : e)
|
img.onerror = (e) => reject(e instanceof Event ? new Error("SVG image failed to load") : e)
|
||||||
img.src = svgUrl
|
img.src = svgUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -20,20 +20,20 @@ export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promis
|
|||||||
const w = vb.width || svgElement.clientWidth || 720
|
const w = vb.width || svgElement.clientWidth || 720
|
||||||
const h = vb.height || svgElement.clientHeight || 800
|
const h = vb.height || svgElement.clientHeight || 800
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement("canvas")
|
||||||
canvas.width = Math.round(w * scale)
|
canvas.width = Math.round(w * scale)
|
||||||
canvas.height = Math.round(h * scale)
|
canvas.height = Math.round(h * scale)
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext("2d")
|
||||||
if (!ctx) throw new Error('Could not get 2D context')
|
if (!ctx) throw new Error("Could not get 2D context")
|
||||||
ctx.fillStyle = '#ffffff'
|
ctx.fillStyle = "#ffffff"
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
return await new Promise<Blob>((resolve, reject) => {
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
canvas.toBlob((b) => {
|
canvas.toBlob((b) => {
|
||||||
if (b) resolve(b)
|
if (b) resolve(b)
|
||||||
else reject(new Error('canvas.toBlob returned null'))
|
else reject(new Error("canvas.toBlob returned null"))
|
||||||
}, 'image/png')
|
}, "image/png")
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
URL.revokeObjectURL(svgUrl)
|
URL.revokeObjectURL(svgUrl)
|
||||||
@@ -42,7 +42,7 @@ export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promis
|
|||||||
|
|
||||||
export function downloadBlob(blob: Blob, filename: string): void {
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = filename
|
a.download = filename
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
@@ -54,7 +54,7 @@ export function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
export async function copyBlobToClipboard(blob: Blob): Promise<void> {
|
export async function copyBlobToClipboard(blob: Blob): Promise<void> {
|
||||||
const ClipItem = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
|
const ClipItem = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
|
||||||
if (!navigator.clipboard || !ClipItem) {
|
if (!navigator.clipboard || !ClipItem) {
|
||||||
throw new Error('Clipboard image copy not supported in this browser')
|
throw new Error("Clipboard image copy not supported in this browser")
|
||||||
}
|
}
|
||||||
await navigator.clipboard.write([new ClipItem({ [blob.type]: blob })])
|
await navigator.clipboard.write([new ClipItem({ [blob.type]: blob })])
|
||||||
}
|
}
|
||||||
@@ -63,10 +63,10 @@ export function slugify(input: string): string {
|
|||||||
return (
|
return (
|
||||||
input
|
input
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFKD')
|
.normalize("NFKD")
|
||||||
.replace(/[̀-ͯ]/g, '')
|
.replace(/[̀-ͯ]/g, "")
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 60) || 'radar'
|
.slice(0, 60) || "radar"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watchEffect } from 'vue'
|
import { computed, ref, watchEffect } from "vue"
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from "vue-router"
|
||||||
import { useRadars } from '../storage'
|
import { useRadars } from "../storage"
|
||||||
import RadarChart from '../components/RadarChart.vue'
|
import RadarChart from "../components/RadarChart.vue"
|
||||||
import CriteriaEditor from '../components/CriteriaEditor.vue'
|
import CriteriaEditor from "../components/CriteriaEditor.vue"
|
||||||
import ProfilesEditor from '../components/ProfilesEditor.vue'
|
import ProfilesEditor from "../components/ProfilesEditor.vue"
|
||||||
import ScoreGrid from '../components/ScoreGrid.vue'
|
import ScoreGrid from "../components/ScoreGrid.vue"
|
||||||
import { svgToPngBlob, downloadBlob, copyBlobToClipboard, slugify } from '../utils/png'
|
import { svgToPngBlob, downloadBlob, copyBlobToClipboard, slugify } from "../utils/png"
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>()
|
const props = defineProps<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -16,7 +16,7 @@ const radar = computed(() => get(props.id))
|
|||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (!radar.value) {
|
if (!radar.value) {
|
||||||
router.replace({ name: 'list' })
|
router.replace({ name: "list" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -26,10 +26,16 @@ function setName(name: string): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartContainer = ref<HTMLElement | null>(null)
|
function setConcept(concept: string): void {
|
||||||
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
|
update(props.id, (r) => {
|
||||||
|
r.concept = concept
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function flash(kind: 'success' | 'error', text: string, ms = 2200): void {
|
const chartContainer = ref<HTMLElement | null>(null)
|
||||||
|
const toast = ref<{ kind: "success" | "error"; text: string } | null>(null)
|
||||||
|
|
||||||
|
function flash(kind: "success" | "error", text: string, ms = 2200): void {
|
||||||
toast.value = { kind, text }
|
toast.value = { kind, text }
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.value = null
|
toast.value = null
|
||||||
@@ -37,7 +43,7 @@ function flash(kind: 'success' | 'error', text: string, ms = 2200): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSvg(): SVGSVGElement | null {
|
function getSvg(): SVGSVGElement | null {
|
||||||
return chartContainer.value?.querySelector('svg') ?? null
|
return chartContainer.value?.querySelector("svg") ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadPng(): Promise<void> {
|
async function downloadPng(): Promise<void> {
|
||||||
@@ -46,10 +52,10 @@ async function downloadPng(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const blob = await svgToPngBlob(svg, 2)
|
const blob = await svgToPngBlob(svg, 2)
|
||||||
downloadBlob(blob, `${slugify(radar.value.name)}.png`)
|
downloadBlob(blob, `${slugify(radar.value.name)}.png`)
|
||||||
flash('success', 'PNG downloaded')
|
flash("success", "PNG downloaded")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
flash('error', 'Could not export PNG')
|
flash("error", "Could not export PNG")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +65,10 @@ async function copyPng(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const blob = await svgToPngBlob(svg, 2)
|
const blob = await svgToPngBlob(svg, 2)
|
||||||
await copyBlobToClipboard(blob)
|
await copyBlobToClipboard(blob)
|
||||||
flash('success', 'Copied to clipboard')
|
flash("success", "Copied to clipboard")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
flash('error', 'Clipboard copy not supported here — try Download')
|
flash("error", "Clipboard copy not supported here — try Download")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -70,19 +76,29 @@ async function copyPng(): Promise<void> {
|
|||||||
<template>
|
<template>
|
||||||
<section v-if="radar" class="max-w-6xl mx-auto px-6 py-8">
|
<section v-if="radar" class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
<div class="flex flex-wrap items-start gap-3 mb-6">
|
||||||
<button class="btn btn-ghost btn-sm" @click="router.push({ name: 'list' })">
|
<button class="btn btn-ghost btn-sm mt-1" @click="router.push({ name: 'list' })">
|
||||||
← All radars
|
← All radars
|
||||||
</button>
|
</button>
|
||||||
|
<div class="flex-1 min-w-[200px] flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-lg flex-1 min-w-[200px] font-semibold"
|
class="input input-bordered input-lg font-semibold"
|
||||||
:value="radar.name"
|
:value="radar.name"
|
||||||
@input="setName(($event.target as HTMLInputElement).value)"
|
@input="setName(($event.target as HTMLInputElement).value)"
|
||||||
placeholder="Untitled radar"
|
placeholder="Untitled radar"
|
||||||
aria-label="Radar name"
|
aria-label="Radar name"
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-2">
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:value="radar.concept"
|
||||||
|
@input="setConcept(($event.target as HTMLInputElement).value)"
|
||||||
|
placeholder="Concept — name the product and its typical performances"
|
||||||
|
aria-label="Radar concept"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
<button class="btn btn-outline" @click="copyPng">Copy PNG</button>
|
<button class="btn btn-outline" @click="copyPng">Copy PNG</button>
|
||||||
<button class="btn btn-primary" @click="downloadPng">Download PNG</button>
|
<button class="btn btn-primary" @click="downloadPng">Download PNG</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,10 +106,7 @@ async function copyPng(): Promise<void> {
|
|||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
<div v-if="toast" class="mb-4">
|
<div v-if="toast" class="mb-4">
|
||||||
<div
|
<div class="alert" :class="toast.kind === 'success' ? 'alert-success' : 'alert-error'">
|
||||||
class="alert"
|
|
||||||
:class="toast.kind === 'success' ? 'alert-success' : 'alert-error'"
|
|
||||||
>
|
|
||||||
<span>{{ toast.text }}</span>
|
<span>{{ toast.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from "vue"
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from "vue-router"
|
||||||
import { useRadars } from '../storage'
|
import { useRadars } from "../storage"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { radars, createBlank, remove } = useRadars()
|
const { radars, createBlank, remove } = useRadars()
|
||||||
|
|
||||||
const sorted = computed(() =>
|
const sorted = computed(() => [...radars.value].sort((a, b) => b.updatedAt - a.updatedAt))
|
||||||
[...radars.value].sort((a, b) => b.updatedAt - a.updatedAt),
|
|
||||||
)
|
|
||||||
|
|
||||||
function createAndOpen(): void {
|
function createAndOpen(): void {
|
||||||
const radar = createBlank()
|
const radar = createBlank()
|
||||||
router.push({ name: 'editor', params: { id: radar.id } })
|
router.push({ name: "editor", params: { id: radar.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(id: string): void {
|
function open(id: string): void {
|
||||||
router.push({ name: 'editor', params: { id } })
|
router.push({ name: "editor", params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(id: string, name: string): void {
|
function confirmDelete(id: string, name: string): void {
|
||||||
@@ -25,9 +23,9 @@ function confirmDelete(id: string, name: string): void {
|
|||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
return new Date(ts).toLocaleDateString(undefined, {
|
return new Date(ts).toLocaleDateString(undefined, {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -44,15 +42,10 @@ function formatDate(ts: number): string {
|
|||||||
<button class="btn btn-primary" @click="createAndOpen">+ New radar</button>
|
<button class="btn btn-primary" @click="createAndOpen">+ New radar</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="sorted.length === 0" class="card bg-base-100 border border-base-300 border-dashed">
|
||||||
v-if="sorted.length === 0"
|
|
||||||
class="card bg-base-100 border border-base-300 border-dashed"
|
|
||||||
>
|
|
||||||
<div class="card-body items-center text-center py-16">
|
<div class="card-body items-center text-center py-16">
|
||||||
<p class="text-base-content/70">No radars yet.</p>
|
<p class="text-base-content/70">No radars yet.</p>
|
||||||
<button class="btn btn-primary mt-3" @click="createAndOpen">
|
<button class="btn btn-primary mt-3" @click="createAndOpen">Create your first radar</button>
|
||||||
Create your first radar
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,15 +57,13 @@ function formatDate(ts: number): string {
|
|||||||
@click="open(r.id)"
|
@click="open(r.id)"
|
||||||
>
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-lg truncate">{{ r.name || 'Untitled radar' }}</h2>
|
<h2 class="card-title text-lg truncate">{{ r.name || "Untitled radar" }}</h2>
|
||||||
<p class="text-sm text-base-content/60">
|
<p class="text-sm text-base-content/60">
|
||||||
{{ r.criteria.length }} criteria · {{ r.profiles.length }} profile{{
|
{{ r.criteria.length }} criteria · {{ r.profiles.length }} profile{{
|
||||||
r.profiles.length === 1 ? '' : 's'
|
r.profiles.length === 1 ? "" : "s"
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-base-content/40 mt-1">
|
<p class="text-xs text-base-content/40 mt-1">Updated {{ formatDate(r.updatedAt) }}</p>
|
||||||
Updated {{ formatDate(r.updatedAt) }}
|
|
||||||
</p>
|
|
||||||
<div class="card-actions justify-end mt-2">
|
<div class="card-actions justify-end mt-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs text-error"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite"
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from "@vitejs/plugin-vue"
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user