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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user