feat: implement radar app — list, editor, chart, scoring, PNG export

- 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
This commit is contained in:
Julien Calixte
2026-06-16 22:06:11 +02:00
parent a8b83b79ce
commit 636fba170f
14 changed files with 1294 additions and 22 deletions

View File

@@ -2,17 +2,17 @@
</script>
<template>
<main class="min-h-screen flex items-center justify-center bg-base-200 p-6">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body items-center text-center">
<h1 class="card-title text-2xl">product-radar</h1>
<p class="text-base-content/70">
Compare up to 5 Profiles across 37 Criteria on a 05 scale.
</p>
<div class="card-actions mt-4">
<button class="btn btn-primary" disabled>New Radar coming soon</button>
</div>
<div class="min-h-screen bg-base-200 flex flex-col">
<header class="bg-base-100 border-b border-base-300">
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<router-link to="/" class="text-xl font-semibold tracking-tight">
product-radar
</router-link>
<span class="text-xs text-base-content/50">browser-local · 05 scale</span>
</div>
</div>
</main>
</header>
<main class="flex-1">
<router-view />
</main>
</div>
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { Radar } from '../types'
import { MIN_CRITERIA, MAX_CRITERIA } from '../types'
import { useRadars } from '../storage'
const props = defineProps<{ radar: Radar }>()
const { update, blankCriterion } = useRadars()
function addCriterion(): void {
update(props.radar.id, (r) => {
if (r.criteria.length >= MAX_CRITERIA) return
r.criteria.push(blankCriterion(`Criterion ${r.criteria.length + 1}`))
})
}
function removeCriterion(criterionId: string): void {
update(props.radar.id, (r) => {
if (r.criteria.length <= MIN_CRITERIA) return
r.criteria = r.criteria.filter((c) => c.id !== criterionId)
})
}
function renameCriterion(criterionId: string, name: string): void {
update(props.radar.id, (r) => {
const c = r.criteria.find((x) => x.id === criterionId)
if (c) c.name = name
})
}
</script>
<template>
<section>
<div class="flex items-baseline justify-between mb-2">
<h2 class="text-lg font-semibold">Criteria</h2>
<span class="text-xs text-base-content/50">
{{ radar.criteria.length }} / {{ MAX_CRITERIA }} · min {{ MIN_CRITERIA }}
</span>
</div>
<p class="text-xs text-base-content/60 mb-3">
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not
<em>Price</em>).
</p>
<ul class="flex flex-col gap-2">
<li
v-for="c in radar.criteria"
:key="c.id"
class="flex items-center gap-2"
>
<input
type="text"
class="input input-bordered input-sm flex-1"
:value="c.name"
@input="renameCriterion(c.id, ($event.target as HTMLInputElement).value)"
placeholder="Criterion name"
/>
<button
class="btn btn-ghost btn-sm text-error"
:disabled="radar.criteria.length <= MIN_CRITERIA"
@click="removeCriterion(c.id)"
:title="
radar.criteria.length <= MIN_CRITERIA
? `Minimum ${MIN_CRITERIA} criteria`
: 'Remove'
"
>
</button>
</li>
</ul>
<button
class="btn btn-outline btn-sm mt-3"
:disabled="radar.criteria.length >= MAX_CRITERIA"
@click="addCriterion"
>
+ Add criterion
</button>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { Radar } from '../types'
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from '../types'
import { useRadars } from '../storage'
const props = defineProps<{ radar: Radar }>()
const { update, blankProfile } = useRadars()
function addProfile(): void {
update(props.radar.id, (r) => {
if (r.profiles.length >= MAX_PROFILES) return
r.profiles.push(blankProfile(`Profile ${r.profiles.length + 1}`, r.profiles.length))
})
}
function removeProfile(profileId: string): void {
update(props.radar.id, (r) => {
if (r.profiles.length <= MIN_PROFILES) return
r.profiles = r.profiles.filter((p) => p.id !== profileId)
})
}
function renameProfile(profileId: string, name: string): void {
update(props.radar.id, (r) => {
const p = r.profiles.find((x) => x.id === profileId)
if (p) p.name = name
})
}
function setColor(profileId: string, color: string): void {
update(props.radar.id, (r) => {
const p = r.profiles.find((x) => x.id === profileId)
if (p) p.color = color
})
}
</script>
<template>
<section>
<div class="flex items-baseline justify-between mb-2">
<h2 class="text-lg font-semibold">Profiles</h2>
<span class="text-xs text-base-content/50">
{{ radar.profiles.length }} / {{ MAX_PROFILES }} · min {{ MIN_PROFILES }}
</span>
</div>
<p class="text-xs text-base-content/60 mb-3">
Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>,
<em>Acme Corp</em>).
</p>
<ul class="flex flex-col gap-3">
<li
v-for="p in radar.profiles"
:key="p.id"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100"
>
<div class="flex items-center gap-2">
<span
class="inline-block w-4 h-4 rounded"
:style="{ background: p.color }"
aria-hidden="true"
/>
<input
type="text"
class="input input-bordered input-sm flex-1"
:value="p.name"
@input="renameProfile(p.id, ($event.target as HTMLInputElement).value)"
placeholder="Profile name"
/>
<button
class="btn btn-ghost btn-sm text-error"
:disabled="radar.profiles.length <= MIN_PROFILES"
@click="removeProfile(p.id)"
:title="
radar.profiles.length <= MIN_PROFILES
? `Minimum ${MIN_PROFILES} profile`
: 'Remove'
"
>
</button>
</div>
<div class="flex flex-wrap gap-1">
<button
v-for="color in PROFILE_PALETTE"
:key="color"
type="button"
class="w-6 h-6 rounded transition border-2"
:style="{ background: color }"
:class="
p.color === color
? 'border-base-content scale-110'
: 'border-transparent hover:scale-105'
"
:aria-label="`Set color ${color}`"
@click="setColor(p.id, color)"
/>
</div>
</li>
</ul>
<button
class="btn btn-outline btn-sm mt-3"
:disabled="radar.profiles.length >= MAX_PROFILES"
@click="addProfile"
>
+ Add profile
</button>
</section>
</template>

View File

@@ -0,0 +1,249 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Radar } from '../types'
import { MAX_SCORE } from '../types'
const props = defineProps<{
radar: Radar
/** If true, the SVG renders Radar name (top) and legend (bottom). */
withChrome?: boolean
}>()
const WIDTH = 720
const HEIGHT = computed(() => (props.withChrome === false ? 600 : 800))
const TITLE_H = computed(() => (props.withChrome === false ? 0 : 60))
const LEGEND_H = computed(() => (props.withChrome === false ? 0 : 120))
const CHART_TOP = computed(() => TITLE_H.value)
const CHART_BOTTOM = computed(() => HEIGHT.value - LEGEND_H.value)
const CHART_CX = WIDTH / 2
const CHART_CY = computed(() => (CHART_TOP.value + CHART_BOTTOM.value) / 2)
const CHART_R = computed(() =>
Math.min((CHART_BOTTOM.value - CHART_TOP.value) / 2 - 70, WIDTH / 2 - 100),
)
type Pt = { x: number; y: number }
function angleFor(i: number, total: number): number {
return -Math.PI / 2 + (i * 2 * Math.PI) / total
}
function pointOnAxis(i: number, total: number, ratio: number): Pt {
const angle = angleFor(i, total)
return {
x: CHART_CX + Math.cos(angle) * CHART_R.value * ratio,
y: CHART_CY.value + Math.sin(angle) * CHART_R.value * ratio,
}
}
const axisPoints = computed<Pt[]>(() =>
props.radar.criteria.map((_, i) => pointOnAxis(i, props.radar.criteria.length, 1)),
)
const ringPaths = computed<string[]>(() => {
const total = props.radar.criteria.length
if (total < 3) return []
return [0.2, 0.4, 0.6, 0.8, 1].map((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'
})
})
type ProfileShape = {
id: string
name: string
color: string
path: string
vertices: Pt[]
}
const profileShapes = computed<ProfileShape[]>(() => {
const total = props.radar.criteria.length
if (total < 3) return []
return props.radar.profiles.map((profile) => {
const vertices = props.radar.criteria.map((criterion, i) => {
const score = props.radar.scores[profile.id]?.[criterion.id] ?? 0
return pointOnAxis(i, total, score / MAX_SCORE)
})
const path =
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 }
})
})
type Label = { x: number; y: number; anchor: 'start' | 'middle' | 'end'; text: string; dy: string }
const criterionLabels = computed<Label[]>(() => {
const total = props.radar.criteria.length
return props.radar.criteria.map((criterion, i) => {
const labelRadius = CHART_R.value + 28
const angle = angleFor(i, total)
const x = CHART_CX + Math.cos(angle) * labelRadius
const y = CHART_CY.value + Math.sin(angle) * labelRadius
const cosA = Math.cos(angle)
let anchor: 'start' | 'middle' | 'end' = 'middle'
if (cosA > 0.2) anchor = 'start'
else if (cosA < -0.2) anchor = 'end'
const sinA = Math.sin(angle)
let dy = '0.35em'
if (sinA < -0.5) dy = '0em'
else if (sinA > 0.5) dy = '0.7em'
return { x, y, anchor, text: criterion.name || 'Untitled', dy }
})
})
const scoreRingLabels = computed<Label[]>(() => {
if (props.radar.criteria.length < 3) return []
return [1, 2, 3, 4, 5].map((score) => {
const ratio = score / MAX_SCORE
const x = CHART_CX + 4
const y = CHART_CY.value - CHART_R.value * ratio
return { x, y, anchor: 'start' as const, text: String(score), dy: '0.35em' }
})
})
const titleText = computed(() => props.radar.name || 'Untitled radar')
const legendItems = computed(() => {
// 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 }))
})
const legendLayout = computed(() => {
const count = legendItems.value.length
if (count === 0) return { rows: [] as Array<typeof legendItems.value> }
const perRow = count <= 3 ? count : Math.ceil(count / 2)
const rows: Array<typeof legendItems.value> = []
for (let i = 0; i < count; i += perRow) {
rows.push(legendItems.value.slice(i, i + perRow))
}
return { rows }
})
const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
</script>
<template>
<svg
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
xmlns="http://www.w3.org/2000/svg"
role="img"
:aria-label="`Radar chart: ${titleText}`"
class="w-full h-auto"
style="font-family: 'Fredoka', ui-sans-serif, system-ui, sans-serif"
>
<rect :width="WIDTH" :height="HEIGHT" fill="#ffffff" />
<!-- Title -->
<text
v-if="withChrome !== false"
:x="WIDTH / 2"
:y="TITLE_H / 2 + 4"
text-anchor="middle"
dominant-baseline="middle"
font-size="28"
font-weight="600"
fill="#0f172a"
>
{{ titleText }}
</text>
<!-- Empty state -->
<text
v-if="insufficientCriteria"
:x="CHART_CX"
:y="CHART_CY"
text-anchor="middle"
dominant-baseline="middle"
font-size="18"
fill="#94a3b8"
>
Add at least 3 criteria to see the chart
</text>
<g v-else>
<!-- Rings -->
<path
v-for="(d, i) in ringPaths"
:key="`ring-${i}`"
:d="d"
fill="none"
stroke="#e2e8f0"
stroke-width="1"
/>
<!-- Axes -->
<line
v-for="(p, i) in axisPoints"
:key="`axis-${i}`"
:x1="CHART_CX"
:y1="CHART_CY"
:x2="p.x"
:y2="p.y"
stroke="#e2e8f0"
stroke-width="1"
/>
<!-- Score ring labels -->
<text
v-for="(l, i) in scoreRingLabels"
:key="`score-${i}`"
:x="l.x"
:y="l.y"
:text-anchor="l.anchor"
:dy="l.dy"
font-size="11"
fill="#94a3b8"
>
{{ l.text }}
</text>
<!-- Profile shapes -->
<g v-for="shape in profileShapes" :key="shape.id">
<path
:d="shape.path"
:fill="shape.color"
fill-opacity="0.18"
:stroke="shape.color"
stroke-width="2"
stroke-linejoin="round"
/>
<circle
v-for="(v, i) in shape.vertices"
:key="`${shape.id}-v-${i}`"
:cx="v.x"
:cy="v.y"
r="3.5"
:fill="shape.color"
/>
</g>
<!-- Criterion labels -->
<text
v-for="(l, i) in criterionLabels"
:key="`label-${i}`"
:x="l.x"
:y="l.y"
:text-anchor="l.anchor"
:dy="l.dy"
font-size="14"
font-weight="500"
fill="#0f172a"
>
{{ l.text }}
</text>
</g>
<!-- Legend -->
<g v-if="withChrome !== false" :transform="`translate(0, ${CHART_BOTTOM + 24})`">
<g
v-for="(row, rowIdx) in legendLayout.rows"
:key="`row-${rowIdx}`"
:transform="`translate(${WIDTH / 2 - (row.length * 140) / 2}, ${rowIdx * 28})`"
>
<g v-for="(item, i) in row" :key="item.id" :transform="`translate(${i * 140}, 0)`">
<rect width="14" height="14" rx="3" :fill="item.color" />
<text x="22" y="11" font-size="14" fill="#0f172a">{{ item.name }}</text>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Radar } from '../types'
import { MIN_SCORE, MAX_SCORE } from '../types'
import { useRadars } from '../storage'
const props = defineProps<{ radar: Radar }>()
const { update } = useRadars()
const SCALE: number[] = []
for (let i = MIN_SCORE; i <= MAX_SCORE; i++) SCALE.push(i)
function getScore(profileId: string, criterionId: string): number {
return props.radar.scores[profileId]?.[criterionId] ?? 0
}
function setScore(profileId: string, criterionId: string, value: number): void {
update(props.radar.id, (r) => {
if (!r.scores[profileId]) r.scores[profileId] = {}
r.scores[profileId][criterionId] = value
})
}
</script>
<template>
<section>
<h2 class="text-lg font-semibold mb-2">Scores</h2>
<p class="text-xs text-base-content/60 mb-3">
0 = worst on this Criterion, 5 = best.
</p>
<div class="overflow-x-auto rounded-lg border border-base-300 bg-base-100">
<table class="table table-sm">
<thead>
<tr>
<th class="text-left">Profile \ Criterion</th>
<th v-for="c in radar.criteria" :key="c.id" class="text-left">
{{ c.name || 'Untitled' }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="p in radar.profiles" :key="p.id">
<td class="font-medium whitespace-nowrap">
<span
class="inline-block w-3 h-3 rounded mr-2 align-middle"
:style="{ background: p.color }"
aria-hidden="true"
/>
{{ p.name || 'Untitled' }}
</td>
<td v-for="c in radar.criteria" :key="c.id">
<div class="flex gap-1">
<button
v-for="value in SCALE"
:key="value"
type="button"
class="btn btn-xs"
:class="
getScore(p.id, c.id) === value ? 'btn-primary' : 'btn-ghost'
"
@click="setScore(p.id, c.id, value)"
:aria-label="`Score ${value} for ${p.name} on ${c.name}`"
>
{{ value }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>

View File

@@ -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')

11
src/router.ts Normal file
View File

@@ -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 },
],
})

117
src/storage.ts Normal file
View File

@@ -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<Radar[]>(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,
}
}

39
src/types.ts Normal file
View File

@@ -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<string, Record<string, number>>
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

72
src/utils/png.ts Normal file
View File

@@ -0,0 +1,72 @@
export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promise<Blob> {
const serializer = new XMLSerializer()
let source = serializer.serializeToString(svgElement)
if (!source.match(/^<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 svgUrl = URL.createObjectURL(svgBlob)
try {
const img = new Image()
await new Promise<void>((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<Blob>((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<void> {
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'
)
}

120
src/views/RadarEditor.vue Normal file
View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { useRadars } from '../storage'
import RadarChart from '../components/RadarChart.vue'
import CriteriaEditor from '../components/CriteriaEditor.vue'
import ProfilesEditor from '../components/ProfilesEditor.vue'
import ScoreGrid from '../components/ScoreGrid.vue'
import { svgToPngBlob, downloadBlob, copyBlobToClipboard, slugify } from '../utils/png'
const props = defineProps<{ id: string }>()
const router = useRouter()
const { get, update } = useRadars()
const radar = computed(() => get(props.id))
watchEffect(() => {
if (!radar.value) {
router.replace({ name: 'list' })
}
})
function setName(name: string): void {
update(props.id, (r) => {
r.name = name
})
}
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 }
setTimeout(() => {
toast.value = null
}, ms)
}
function getSvg(): SVGSVGElement | null {
return chartContainer.value?.querySelector('svg') ?? null
}
async function downloadPng(): Promise<void> {
const svg = getSvg()
if (!svg || !radar.value) return
try {
const blob = await svgToPngBlob(svg, 2)
downloadBlob(blob, `${slugify(radar.value.name)}.png`)
flash('success', 'PNG downloaded')
} catch (err) {
console.error(err)
flash('error', 'Could not export PNG')
}
}
async function copyPng(): Promise<void> {
const svg = getSvg()
if (!svg) return
try {
const blob = await svgToPngBlob(svg, 2)
await copyBlobToClipboard(blob)
flash('success', 'Copied to clipboard')
} catch (err) {
console.error(err)
flash('error', 'Clipboard copy not supported here — try Download')
}
}
</script>
<template>
<section v-if="radar" class="max-w-6xl mx-auto px-6 py-8">
<!-- Top bar -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<button class="btn btn-ghost btn-sm" @click="router.push({ name: 'list' })">
All radars
</button>
<input
type="text"
class="input input-bordered input-lg flex-1 min-w-[200px] font-semibold"
:value="radar.name"
@input="setName(($event.target as HTMLInputElement).value)"
placeholder="Untitled radar"
aria-label="Radar name"
/>
<div class="flex gap-2">
<button class="btn btn-outline" @click="copyPng">Copy PNG</button>
<button class="btn btn-primary" @click="downloadPng">Download PNG</button>
</div>
</div>
<!-- Toast -->
<div v-if="toast" class="mb-4">
<div
class="alert"
:class="toast.kind === 'success' ? 'alert-success' : 'alert-error'"
>
<span>{{ toast.text }}</span>
</div>
</div>
<!-- Layout: chart left, editors right -->
<div class="grid lg:grid-cols-[1fr_360px] gap-6">
<div
ref="chartContainer"
class="card bg-base-100 border border-base-300 shadow-sm p-4 flex items-center justify-center"
>
<RadarChart :radar="radar" />
</div>
<div class="flex flex-col gap-6">
<CriteriaEditor :radar="radar" />
<ProfilesEditor :radar="radar" />
</div>
</div>
<!-- Score grid below, full width -->
<div class="mt-8">
<ScoreGrid :radar="radar" />
</div>
</section>
</template>

88
src/views/RadarList.vue Normal file
View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRadars } from '../storage'
const router = useRouter()
const { radars, createBlank, remove } = useRadars()
const sorted = computed(() =>
[...radars.value].sort((a, b) => b.updatedAt - a.updatedAt),
)
function createAndOpen(): void {
const radar = createBlank()
router.push({ name: 'editor', params: { id: radar.id } })
}
function open(id: string): void {
router.push({ name: 'editor', params: { id } })
}
function confirmDelete(id: string, name: string): void {
if (confirm(`Delete "${name}"? This cannot be undone.`)) remove(id)
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</script>
<template>
<section class="max-w-5xl mx-auto px-6 py-10">
<div class="flex items-end justify-between mb-8">
<div>
<h1 class="text-3xl font-semibold">Your radars</h1>
<p class="text-base-content/60 mt-1">
Compare up to 5 Profiles across 37 Criteria on a 05 scale.
</p>
</div>
<button class="btn btn-primary" @click="createAndOpen">+ New radar</button>
</div>
<div
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">
<p class="text-base-content/70">No radars yet.</p>
<button class="btn btn-primary mt-3" @click="createAndOpen">
Create your first radar
</button>
</div>
</div>
<div v-else class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="r in sorted"
:key="r.id"
class="card bg-base-100 shadow-sm hover:shadow-md transition cursor-pointer border border-base-300"
@click="open(r.id)"
>
<div class="card-body">
<h2 class="card-title text-lg truncate">{{ r.name || 'Untitled radar' }}</h2>
<p class="text-sm text-base-content/60">
{{ r.criteria.length }} criteria · {{ r.profiles.length }} profile{{
r.profiles.length === 1 ? '' : 's'
}}
</p>
<p class="text-xs text-base-content/40 mt-1">
Updated {{ formatDate(r.updatedAt) }}
</p>
<div class="card-actions justify-end mt-2">
<button
class="btn btn-ghost btn-xs text-error"
@click.stop="confirmDelete(r.id, r.name)"
>
Delete
</button>
</div>
</div>
</div>
</div>
</section>
</template>