From e44f15aaa05523c0aa53d868df64b1793d1f9808 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Wed, 17 Jun 2026 18:02:02 +0200 Subject: [PATCH] feat: add product concept subtitle to radars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CONTEXT.md | 5 +++ src/components/RadarChart.vue | 58 +++++++++++++++++++++++++++++++++-- src/storage.ts | 5 ++- src/types.ts | 2 ++ src/views/RadarEditor.vue | 38 ++++++++++++++++------- 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 6ffd698..b2db68e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/src/components/RadarChart.vue b/src/components/RadarChart.vue index 83c0b97..131513a 100644 --- a/src/components/RadarChart.vue +++ b/src/components/RadarChart.vue @@ -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(() => { + 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) props.radar.criteria.length < 3) {{ titleText }} + + + {{ line }} + + ({ ...r, concept: r.concept ?? '' })) } catch { return [] } @@ -80,6 +82,7 @@ export function useRadars() { const radar: Radar = { id: uid(), name, + concept: '', criteria, profiles, scores: {}, diff --git a/src/types.ts b/src/types.ts index 90f3e88..f4ae0bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 */ diff --git a/src/views/RadarEditor.vue b/src/views/RadarEditor.vue index 75799de..18cf3e6 100644 --- a/src/views/RadarEditor.vue +++ b/src/views/RadarEditor.vue @@ -26,6 +26,12 @@ function setName(name: string): void { }) } +function setConcept(concept: string): void { + update(props.id, (r) => { + r.concept = concept + }) +} + const chartContainer = ref(null) const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null) @@ -70,19 +76,29 @@ async function copyPng(): Promise {