Skip to content

Commit 9a01017

Browse files
committed
fix: data label collisions in compare downloads chart
1 parent bacf137 commit 9a01017

5 files changed

Lines changed: 477 additions & 55 deletions

File tree

app/components/Package/TrendsChart.vue

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { VueUiXy, type VueUiXyConfig } from 'vue-data-ui/vue-ui-xy'
2+
import { VueUiXy, type VueUiXyConfig, type VueUiXySvgSlotProps } from 'vue-data-ui/vue-ui-xy'
33
import { useDebounceFn, useElementSize, useTimeoutFn } from '@vueuse/core'
44
import { useColors } from '~/composables/useColors'
55
import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors'
@@ -25,6 +25,7 @@ import {
2525
getTrendsDatetimeFormatterOptions,
2626
} from '#shared/utils/trends-chart'
2727
import { downloadFileLink } from '~/utils/download'
28+
import { createLastDatapointLabelsSvg } from '#shared/utils/download-chart-last-label'
2829
2930
import('vue-data-ui/style.css')
3031
@@ -980,41 +981,32 @@ function drawEstimationLine(svg: Record<string, any>) {
980981
* - renders a text label slightly offset to the right of the point
981982
* - formats the value using the compact number formatter
982983
*
984+
* In case of label collisions for multiple series:
985+
* - labels are evenly distributed vertically
986+
* - an elbowed marker connects the last point to its label
987+
*
983988
* Return an empty string when no series data is available.
984989
*
985990
* @param svg - SVG context object provided by `VueUiXy` via the `#svg` slot
986991
* @returns A string containing SVG `<text>` elements, or an empty string when
987992
* no labels should be rendered.
988993
*/
989-
function drawLastDatapointLabel(svg: Record<string, any>) {
990-
const data = Array.isArray(svg?.data) ? svg.data : []
991-
if (!data.length) return ''
992-
993-
const dataLabels: string[] = []
994-
995-
for (const serie of data) {
996-
const lastPlot = serie.plots.at(-1)
997-
998-
dataLabels.push(`
999-
<text
1000-
text-anchor="start"
1001-
dominant-baseline="middle"
1002-
x="${lastPlot.x + 12}"
1003-
y="${lastPlot.y}"
1004-
font-size="24"
1005-
fill="${colors.value.fg}"
1006-
stroke="${colors.value.bg}"
1007-
stroke-width="1"
1008-
paint-order="stroke fill"
1009-
>
1010-
${compactNumberFormatter.value.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)}
1011-
</text>
1012-
`)
1013-
}
1014-
1015-
return dataLabels.join('\n')
994+
function drawLastDatapointLabel(svg: VueUiXySvgSlotProps['svg']) {
995+
return createLastDatapointLabelsSvg({
996+
series: Array.isArray(svg?.data) ? svg.data : [],
997+
drawingArea: svg.drawingArea,
998+
svgWidth: svg.width,
999+
fontSize: isMultiPackageMode.value ? 20 : 24,
1000+
labelOffset: isMultiPackageMode.value ? 24 : 16,
1001+
colors: {
1002+
foreground: colors.value.fg!,
1003+
background: colors.value.bg!,
1004+
fallbackSerieColor: colors.value.fg!,
1005+
},
1006+
formatValue: value => compactNumberFormatter.value.format(value),
1007+
isDarkMode: isDarkMode.value,
1008+
})
10161009
}
1017-
10181010
/**
10191011
* Build and return a legend to be injected during the SVG export only, since the custom legend is
10201012
* displayed as an independent div, content has to be injected within the chart's viewBox.

server/utils/embed-downloads-svg.ts

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createError } from 'h3'
22
import { createStaticVueUiXy } from 'vue-data-ui/ssr/vue-ui-xy'
33
import { mergeConfigs } from 'vue-data-ui/utils'
4-
import { generateWatermarkLogo } from '#shared/utils/trends-chart'
4+
import { generateWatermarkLogo, LOCALES_WITH_EXTRA_SPACE } from '#shared/utils/trends-chart'
55
import {
66
buildNormalisedTrendsDataset,
77
buildTrendsChartConfig,
@@ -11,6 +11,7 @@ import { resolveEmbedChartColors } from '#shared/utils/embed-chart-colors'
1111
import { OKLCH_NEUTRAL_FALLBACK } from '~/utils/colors'
1212
import { getEffectiveEndDateIso, isLastDayOfMonth, isLastDayOfYear } from '~/utils/date'
1313
import { fetchDownloadsEvolution } from '~~/server/utils/download-evolution'
14+
import { createLastDatapointLabelsSvg } from '#shared/utils/download-chart-last-label'
1415

1516
type FetchGranularity = 'day' | 'week' | 'month' | 'year'
1617
type ChartGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly'
@@ -254,7 +255,8 @@ export async function createDownloadsSvgResponse(query: QueryParameters): Promis
254255
height,
255256
padding: {
256257
left: 12,
257-
right: 120,
258+
right:
259+
packageNames.length > 1 ? (LOCALES_WITH_EXTRA_SPACE.includes(locale) ? 180 : 160) : 145,
258260
},
259261
legend: {
260262
show: true,
@@ -301,29 +303,17 @@ export async function createDownloadsSvgResponse(query: QueryParameters): Promis
301303
}),
302304
config,
303305
additionalSvgContent: ({ series, drawingArea }) => {
304-
const lastPlotValues = series
305-
.map(serie => {
306-
const lastPlot = (serie.plots || []).at(-1)
307-
308-
if (!lastPlot) return ''
309-
310-
return `
311-
<text
312-
x="${lastPlot.x + 8}"
313-
y="${lastPlot.y}"
314-
fill="${colors.fg}"
315-
stroke="${colors.bg}"
316-
stroke-width="1"
317-
paint-order="stroke fill"
318-
font-size="18"
319-
dominant-baseline="middle"
320-
text-anchor="start"
321-
>
322-
${compactNumberFormatter.format(Number(lastPlot.value ?? 0))}
323-
</text>
324-
`
325-
})
326-
.join('')
306+
const lastPlotValues = createLastDatapointLabelsSvg({
307+
series,
308+
drawingArea,
309+
colors: {
310+
foreground: colors.fg,
311+
background: colors.bg,
312+
fallbackSerieColor: colors.fg,
313+
},
314+
formatValue: value => compactNumberFormatter.format(value),
315+
isDarkMode,
316+
})
327317

328318
const logo = generateWatermarkLogo({
329319
x: 12,
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Utility to be used in vue-data-ui line charts (`VueUiXy`) using the `#svg` slot to display the last value as data label.
3+
* In case of mutliple series, if label collisions are detected, labels are evenly distributed vertically,
4+
* and linked to the last datapoint with an elbowed marker
5+
*/
6+
export function createLastDatapointLabelsSvg({
7+
series,
8+
drawingArea,
9+
colors,
10+
formatValue,
11+
isDarkMode,
12+
fontSize = 20,
13+
labelOffset = 24,
14+
labelHeight = 30,
15+
}: {
16+
series: LastDatapointLabelSerie[]
17+
drawingArea: {
18+
top: number
19+
height?: number
20+
bottom?: number
21+
}
22+
colors: LastDatapointLabelColors
23+
formatValue: (value: number) => string
24+
isDarkMode: boolean
25+
svgWidth?: number
26+
fontSize?: number
27+
labelOffset?: number
28+
labelHeight?: number
29+
}) {
30+
const drawingAreaTop = Number(drawingArea.top ?? 0)
31+
const drawingAreaHeight = Number(
32+
drawingArea.height ?? Number(drawingArea.bottom ?? 0) - drawingAreaTop,
33+
)
34+
const drawingAreaBottom = drawingAreaTop + drawingAreaHeight
35+
36+
const labels = series
37+
.map(serie => {
38+
const lastPlot = Array.isArray(serie.plots) ? serie.plots.at(-1) : null
39+
if (!lastPlot) return null
40+
41+
const value = Number(lastPlot.value ?? 0)
42+
const safeValue = Number.isFinite(value) ? value : 0
43+
const text = formatValue(safeValue)
44+
45+
return {
46+
x: Number(lastPlot.x ?? 0),
47+
y: Number(lastPlot.y ?? 0),
48+
value: safeValue,
49+
color: String(serie.color ?? colors.fallbackSerieColor),
50+
text,
51+
width: text.length * fontSize * 0.58,
52+
}
53+
})
54+
.filter(isLastDatapointLabel)
55+
56+
if (!labels.length) return ''
57+
58+
const hasCollision = labels.some((label, labelIndex) =>
59+
labels.some((otherLabel, otherLabelIndex) => {
60+
if (labelIndex === otherLabelIndex) return false
61+
62+
return (
63+
label.x + labelOffset < otherLabel.x + labelOffset + otherLabel.width &&
64+
label.x + labelOffset + label.width > otherLabel.x + labelOffset &&
65+
label.y - labelHeight / 2 < otherLabel.y + labelHeight / 2 &&
66+
label.y + labelHeight / 2 > otherLabel.y - labelHeight / 2
67+
)
68+
}),
69+
)
70+
71+
if (!hasCollision) {
72+
return labels
73+
.map(
74+
label => `
75+
<text
76+
text-anchor="start"
77+
dominant-baseline="middle"
78+
x="${label.x + labelOffset}"
79+
y="${label.y}"
80+
font-size="${fontSize}"
81+
fill="${colors.foreground}"
82+
stroke="${colors.background}"
83+
stroke-width="1"
84+
paint-order="stroke fill"
85+
>
86+
${label.text}
87+
</text>
88+
`,
89+
)
90+
.join('\n')
91+
}
92+
93+
const sortedLabels = [...labels].sort((firstLabel, secondLabel) => {
94+
return secondLabel.value - firstLabel.value
95+
})
96+
97+
const availableHeight = drawingAreaBottom - drawingAreaTop - labelHeight
98+
const verticalStep = availableHeight / (sortedLabels.length - 1)
99+
100+
const labelX = Math.max(...sortedLabels.map(label => label.x)) + labelOffset + 10
101+
102+
return sortedLabels
103+
.map((label, index) => {
104+
const labelY = drawingAreaTop + labelHeight / 2 + verticalStep * index
105+
const connectorStartX = label.x + 5
106+
const connectorEndX = labelX
107+
108+
return `
109+
<path
110+
d="M${connectorStartX},${label.y} ${connectorStartX + 6},${label.y} ${connectorEndX},${labelY} ${connectorEndX + 6},${labelY}"
111+
stroke="${label.color}"
112+
stroke-width="1"
113+
opacity="${isDarkMode ? '0.7' : '1'}"
114+
fill="none"
115+
/>
116+
<text
117+
text-anchor="start"
118+
dominant-baseline="middle"
119+
x="${connectorEndX + 12}"
120+
y="${labelY}"
121+
font-size="${fontSize}"
122+
fill="${colors.foreground}"
123+
stroke="${colors.background}"
124+
stroke-width="1"
125+
paint-order="stroke fill"
126+
>
127+
${label.text}
128+
</text>
129+
`
130+
})
131+
.join('\n')
132+
}
133+
134+
export type LastDatapointLabelColors = {
135+
foreground: string
136+
background: string
137+
fallbackSerieColor: string
138+
}
139+
140+
export type LastDatapointLabelSerie = {
141+
color?: string
142+
plots?: {
143+
x?: number | null
144+
y?: number | null
145+
value?: number | null
146+
}[]
147+
}
148+
149+
type LastDatapointLabel = {
150+
x: number
151+
y: number
152+
value: number
153+
color: string
154+
text: string
155+
width: number
156+
}
157+
158+
function isLastDatapointLabel(label: LastDatapointLabel | null): label is LastDatapointLabel {
159+
return label !== null
160+
}

shared/utils/trends-chart.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,25 @@ export function getTrendsDatetimeFormatterOptions(granularity: ChartTimeGranular
348348
}[granularity]
349349
}
350350

351+
// Some locales require more spacing for the last label value displayed on the chart, and for which some extra padding is reserved in the chart config.
352+
export const LOCALES_WITH_EXTRA_SPACE = [
353+
'bg-BG',
354+
'ru-RU',
355+
'cs-CZ',
356+
'de-AT',
357+
'de-DE',
358+
'id-ID',
359+
'it-IT',
360+
'ja-JP',
361+
'nb-NO',
362+
'nl-NL',
363+
'pl-PL',
364+
'pt-BR',
365+
'ro-RO',
366+
'sr-Latn-RS',
367+
'uk-UA',
368+
]
369+
351370
export function buildTrendsChartConfig(
352371
options: TrendChartConfigOptions & {
353372
dates: number[]
@@ -372,7 +391,11 @@ export function buildTrendsChartConfig(
372391
backgroundColor: options.colors.bg,
373392
padding: {
374393
bottom: options.displayedGranularity === 'yearly' ? 84 : 64,
375-
right: 145,
394+
right: options.isMultiPackageMode
395+
? LOCALES_WITH_EXTRA_SPACE.includes(options.locale)
396+
? 180
397+
: 160
398+
: 145,
376399
},
377400
userOptions: {
378401
buttons: {

0 commit comments

Comments
 (0)