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:
Julien Calixte
2026-06-17 18:02:02 +02:00
parent 8e751e77b7
commit e44f15aaa0
5 changed files with 94 additions and 14 deletions

View File

@@ -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). - **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

View File

@@ -14,7 +14,47 @@ 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)
@@ -140,7 +180,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 +190,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"

View File

@@ -9,7 +9,9 @@ function loadAll(): Radar[] {
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 []
} }
@@ -80,6 +82,7 @@ export function useRadars() {
const radar: Radar = { const radar: Radar = {
id: uid(), id: uid(),
name, name,
concept: '',
criteria, criteria,
profiles, profiles,
scores: {}, scores: {},

View File

@@ -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 */

View File

@@ -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 chartContainer = ref<HTMLElement | null>(null)
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null) const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
@@ -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>
<input <div class="flex-1 min-w-[200px] flex flex-col gap-2">
type="text" <input
class="input input-bordered input-lg flex-1 min-w-[200px] font-semibold" type="text"
:value="radar.name" class="input input-bordered input-lg font-semibold"
@input="setName(($event.target as HTMLInputElement).value)" :value="radar.name"
placeholder="Untitled radar" @input="setName(($event.target as HTMLInputElement).value)"
aria-label="Radar name" 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-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>