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).
|
- **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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user