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:
117
src/storage.ts
Normal file
117
src/storage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user