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:
@@ -24,6 +24,11 @@ A single integer in **{0, 1, 2, 3, 4, 5}** assigned to one (Criterion, Profile)
|
||||
|
||||
- **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
|
||||
|
||||
### Higher is better
|
||||
|
||||
@@ -14,7 +14,47 @@ const props = withDefaults(
|
||||
|
||||
const WIDTH = 720
|
||||
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 CHART_TOP = computed(() => TITLE_H.value)
|
||||
const CHART_BOTTOM = computed(() => HEIGHT.value - LEGEND_H.value)
|
||||
@@ -140,7 +180,7 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
|
||||
<text
|
||||
v-if="withChrome !== false"
|
||||
:x="WIDTH / 2"
|
||||
:y="TITLE_H / 2 + 4"
|
||||
:y="titleY"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-size="28"
|
||||
@@ -150,6 +190,20 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
|
||||
{{ titleText }}
|
||||
</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 -->
|
||||
<text
|
||||
v-if="insufficientCriteria"
|
||||
|
||||
@@ -9,7 +9,9 @@ function loadAll(): Radar[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
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 {
|
||||
return []
|
||||
}
|
||||
@@ -80,6 +82,7 @@ export function useRadars() {
|
||||
const radar: Radar = {
|
||||
id: uid(),
|
||||
name,
|
||||
concept: '',
|
||||
criteria,
|
||||
profiles,
|
||||
scores: {},
|
||||
|
||||
@@ -12,6 +12,8 @@ export type Profile = {
|
||||
export type Radar = {
|
||||
id: string
|
||||
name: string
|
||||
/** Short subtitle naming the product and its typical performances. May be empty. */
|
||||
concept: string
|
||||
criteria: Criterion[]
|
||||
profiles: Profile[]
|
||||
/** scores[profileId][criterionId] = 0..5 */
|
||||
|
||||
@@ -26,6 +26,12 @@ function setName(name: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function setConcept(concept: string): void {
|
||||
update(props.id, (r) => {
|
||||
r.concept = concept
|
||||
})
|
||||
}
|
||||
|
||||
const chartContainer = ref<HTMLElement | null>(null)
|
||||
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
@@ -70,19 +76,29 @@ async function copyPng(): Promise<void> {
|
||||
<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' })">
|
||||
<div class="flex flex-wrap items-start gap-3 mb-6">
|
||||
<button class="btn btn-ghost btn-sm mt-1" @click="router.push({ name: 'list' })">
|
||||
← All radars
|
||||
</button>
|
||||
<div class="flex-1 min-w-[200px] flex flex-col gap-2">
|
||||
<input
|
||||
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"
|
||||
@input="setName(($event.target as HTMLInputElement).value)"
|
||||
placeholder="Untitled radar"
|
||||
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-primary" @click="downloadPng">Download PNG</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user