fix(chart): wrap long criterion labels to fit the canvas

Labels rendered as single-line SVG text and clipped at the canvas edge
(and in the PNG export). Word-wrap each label to the room available on
its side, stacking lines away from the chart.
This commit is contained in:
Julien Calixte
2026-06-19 11:33:45 +02:00
parent f42744b409
commit 4eafda5dfb

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed, onMounted, ref } from "vue"
import type { Radar } from "../types" import type { Radar } from "../types"
import { MAX_SCORE } from "../types" import { MAX_SCORE } from "../types"
@@ -110,7 +110,57 @@ const profileShapes = computed<ProfileShape[]>(() => {
type Label = { x: number; y: number; anchor: "start" | "middle" | "end"; text: string; dy: string } type Label = { x: number; y: number; anchor: "start" | "middle" | "end"; text: string; dy: string }
const criterionLabels = computed<Label[]>(() => { type CriterionLabel = {
x: number
y: number
anchor: "start" | "middle" | "end"
lines: string[]
firstDy: number
}
const LABEL_FONT_SIZE = 14
const LABEL_LINE_HEIGHT = 17
const LABEL_MARGIN = 8
// Becomes true once "Fredoka" is loaded so wrapping recomputes with accurate widths.
const fontReady = ref(false)
onMounted(() => {
const fonts = (document as Document & { fonts?: FontFaceSet }).fonts
if (fonts?.ready) fonts.ready.then(() => (fontReady.value = true))
})
let measureCtx: CanvasRenderingContext2D | null = null
function textWidth(text: string): number {
if (typeof document !== "undefined") {
measureCtx ??= document.createElement("canvas").getContext("2d")
if (measureCtx) {
measureCtx.font = `500 ${LABEL_FONT_SIZE}px "Fredoka", ui-sans-serif, system-ui, sans-serif`
return measureCtx.measureText(text).width
}
}
return text.length * LABEL_FONT_SIZE * 0.55
}
// Greedily word-wrap so each line fits maxWidth; long single words stay on their own line.
function wrapToWidth(text: string, maxWidth: number): string[] {
void fontReady.value // re-wrap once the web font finishes loading
const words = text.split(/\s+/).filter(Boolean)
if (words.length === 0) return [text]
const lines: string[] = []
let current = words[0]
for (let i = 1; i < words.length; i++) {
const candidate = `${current} ${words[i]}`
if (textWidth(candidate) <= maxWidth) current = candidate
else {
lines.push(current)
current = words[i]
}
}
lines.push(current)
return lines
}
const criterionLabels = computed<CriterionLabel[]>(() => {
const total = props.radar.criteria.length const total = props.radar.criteria.length
return props.radar.criteria.map((criterion, i) => { return props.radar.criteria.map((criterion, i) => {
const labelRadius = CHART_R.value + 28 const labelRadius = CHART_R.value + 28
@@ -121,11 +171,23 @@ const criterionLabels = computed<Label[]>(() => {
let anchor: "start" | "middle" | "end" = "middle" let anchor: "start" | "middle" | "end" = "middle"
if (cosA > 0.2) anchor = "start" if (cosA > 0.2) anchor = "start"
else if (cosA < -0.2) anchor = "end" else if (cosA < -0.2) anchor = "end"
// Horizontal room from the anchor point to the nearest SVG edge.
let maxWidth: number
if (anchor === "start") maxWidth = WIDTH - x - LABEL_MARGIN
else if (anchor === "end") maxWidth = x - LABEL_MARGIN
else maxWidth = 2 * Math.min(x - LABEL_MARGIN, WIDTH - x - LABEL_MARGIN)
const lines = wrapToWidth(criterion.name || "Untitled", maxWidth)
const extra = (lines.length - 1) * LABEL_LINE_HEIGHT
// Stack lines away from the chart: top labels grow upward, bottom downward, sides center.
const sinA = Math.sin(angle) const sinA = Math.sin(angle)
let dy = "0.35em" let firstDy: number
if (sinA < -0.5) dy = "0em" if (sinA < -0.5) firstDy = -extra
else if (sinA > 0.5) dy = "0.7em" else if (sinA > 0.5) firstDy = 10
return { x, y, anchor, text: criterion.name || "Untitled", dy } else firstDy = 5 - extra / 2
return { x, y, anchor, lines, firstDy }
}) })
}) })
@@ -272,19 +334,24 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
/> />
</g> </g>
<!-- Criterion labels --> <!-- Criterion labels (word-wrapped to stay inside the canvas) -->
<text <text
v-for="(l, i) in criterionLabels" v-for="(l, i) in criterionLabels"
:key="`label-${i}`" :key="`label-${i}`"
:x="l.x"
:y="l.y" :y="l.y"
:text-anchor="l.anchor" :text-anchor="l.anchor"
:dy="l.dy"
font-size="14" font-size="14"
font-weight="500" font-weight="500"
fill="#0f172a" fill="#0f172a"
> >
{{ l.text }} <tspan
v-for="(line, j) in l.lines"
:key="`label-${i}-${j}`"
:x="l.x"
:dy="j === 0 ? l.firstDy : LABEL_LINE_HEIGHT"
>
{{ line }}
</tspan>
</text> </text>
</g> </g>