Skip to content

Commit bd73ea9

Browse files
graphieroscoderabbitai[bot]alexdln
authored
feat: add bar chart view to compare page (#1974)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Alex Savelyev <91429106+alexdln@users.noreply.github.com>
1 parent e374f75 commit bd73ea9

File tree

14 files changed

+901
-70
lines changed

14 files changed

+901
-70
lines changed
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
import { VueUiHorizontalBar } from 'vue-data-ui/vue-ui-horizontal-bar'
4+
import type {
5+
VueUiHorizontalBarConfig,
6+
VueUiHorizontalBarDatapoint,
7+
VueUiHorizontalBarDatasetItem,
8+
} from 'vue-data-ui'
9+
import { getFrameworkColor, isListedFramework } from '~/utils/frameworks'
10+
import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'
11+
import {
12+
loadFile,
13+
insertLineBreaks,
14+
sanitise,
15+
applyEllipsis,
16+
copyAltTextForCompareFacetBarChart,
17+
} from '~/utils/charts'
18+
19+
import('vue-data-ui/style.css')
20+
21+
const props = defineProps<{
22+
values: (FacetValue | null | undefined)[]
23+
packages: string[]
24+
label: string
25+
description: string
26+
facetLoading?: boolean
27+
}>()
28+
29+
const colorMode = useColorMode()
30+
const resolvedMode = shallowRef<'light' | 'dark'>('light')
31+
const rootEl = shallowRef<HTMLElement | null>(null)
32+
const { width } = useElementSize(rootEl)
33+
const { copy, copied } = useClipboard()
34+
35+
const mobileBreakpointWidth = 640
36+
const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth)
37+
38+
const chartKey = ref(0)
39+
40+
const { colors } = useCssVariables(
41+
[
42+
'--bg',
43+
'--fg',
44+
'--bg-subtle',
45+
'--bg-elevated',
46+
'--fg-subtle',
47+
'--fg-muted',
48+
'--border',
49+
'--border-subtle',
50+
],
51+
{
52+
element: rootEl,
53+
watchHtmlAttributes: true,
54+
watchResize: false,
55+
},
56+
)
57+
58+
const watermarkColors = computed(() => ({
59+
fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK,
60+
bg: colors.value.bg ?? OKLCH_NEUTRAL_FALLBACK,
61+
fgSubtle: colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK,
62+
}))
63+
64+
onMounted(async () => {
65+
rootEl.value = document.documentElement
66+
resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'
67+
})
68+
69+
watch(
70+
() => colorMode.value,
71+
value => {
72+
resolvedMode.value = value === 'dark' ? 'dark' : 'light'
73+
},
74+
{ flush: 'sync' },
75+
)
76+
77+
watch(
78+
() => props.packages,
79+
(newP, oldP) => {
80+
if (newP.length !== oldP.length) return
81+
chartKey.value += 1
82+
},
83+
)
84+
85+
const isDarkMode = computed(() => resolvedMode.value === 'dark')
86+
87+
const dataset = computed<VueUiHorizontalBarDatasetItem[]>(() => {
88+
if (props.facetLoading) return []
89+
return props.packages.map((name, index) => {
90+
const rawValue = props.values[index]?.raw
91+
return {
92+
name: insertLineBreaks(applyEllipsis(name)),
93+
value: typeof rawValue === 'number' ? rawValue : 0,
94+
color: isListedFramework(name) ? getFrameworkColor(name) : undefined,
95+
formattedValue: props.values[index]?.display,
96+
}
97+
})
98+
})
99+
100+
const skeletonDataset = computed(() =>
101+
props.packages.map((_pkg, i) => ({
102+
name: '_',
103+
value: i + 1,
104+
color: colors.value.border,
105+
})),
106+
)
107+
108+
function buildExportFilename(extension: string): string {
109+
const sanitizedPackages = props.packages.map(p => sanitise(p).slice(0, 10)).join('_')
110+
const comparisonLabel = sanitise($t('compare.packages.section_comparison'))
111+
const facetLabel = sanitise(props.label)
112+
return `${facetLabel}_${comparisonLabel}_${sanitizedPackages}.${extension}`
113+
}
114+
115+
const config = computed<VueUiHorizontalBarConfig>(() => {
116+
return {
117+
theme: isDarkMode.value ? 'dark' : '',
118+
userOptions: {
119+
buttons: {
120+
tooltip: false,
121+
pdf: false,
122+
fullscreen: false,
123+
sort: false,
124+
annotator: false,
125+
table: false,
126+
csv: false,
127+
altCopy: true,
128+
},
129+
buttonTitle: {
130+
img: $t('package.trends.download_file', { fileType: 'PNG' }),
131+
svg: $t('package.trends.download_file', { fileType: 'SVG' }),
132+
altCopy: $t('package.trends.copy_alt.button_label'),
133+
},
134+
callbacks: {
135+
img: args => {
136+
const imageUri = args?.imageUri
137+
if (!imageUri) return
138+
loadFile(imageUri, buildExportFilename('png'))
139+
},
140+
svg: args => {
141+
const blob = args?.blob
142+
if (!blob) return
143+
const url = URL.createObjectURL(blob)
144+
loadFile(url, buildExportFilename('svg'))
145+
URL.revokeObjectURL(url)
146+
},
147+
altCopy: ({ dataset: dst, config: cfg }) => {
148+
copyAltTextForCompareFacetBarChart({
149+
dataset: dst,
150+
config: {
151+
...cfg,
152+
facet: props.label,
153+
description: props.description,
154+
copy,
155+
$t,
156+
},
157+
})
158+
},
159+
},
160+
},
161+
skeletonDataset: skeletonDataset.value,
162+
skeletonConfig: {
163+
style: {
164+
chart: {
165+
backgroundColor: colors.value.bg,
166+
},
167+
},
168+
},
169+
style: {
170+
chart: {
171+
backgroundColor: colors.value.bg,
172+
height: 60 * props.packages.length,
173+
layout: {
174+
bars: {
175+
rowColor: isDarkMode.value ? colors.value.borderSubtle : colors.value.bgSubtle,
176+
rowRadius: 4,
177+
borderRadius: 4,
178+
dataLabels: {
179+
fontSize: isMobile.value ? 12 : 18,
180+
percentage: { show: false },
181+
offsetX: 12,
182+
bold: false,
183+
color: colors.value.fg,
184+
value: {
185+
formatter: ({ config }) => {
186+
return config?.datapoint?.formattedValue ?? '0'
187+
},
188+
},
189+
},
190+
nameLabels: {
191+
fontSize: isMobile.value ? 12 : 18,
192+
color: colors.value.fgSubtle,
193+
},
194+
underlayerColor: colors.value.bg,
195+
},
196+
highlighter: {
197+
opacity: isMobile.value ? 0 : 5,
198+
},
199+
},
200+
legend: {
201+
show: false,
202+
},
203+
title: {
204+
fontSize: 16,
205+
bold: false,
206+
text: props.label,
207+
color: colors.value.fg,
208+
subtitle: {
209+
text: props.description,
210+
fontSize: 12,
211+
color: colors.value.fgSubtle,
212+
},
213+
},
214+
tooltip: {
215+
show: !isMobile.value,
216+
borderColor: 'transparent',
217+
backdropFilter: false,
218+
backgroundColor: 'transparent',
219+
customFormat: ({ datapoint }) => {
220+
const name = datapoint?.name?.replace(/\n/g, '<br>')
221+
return `
222+
<div class="font-mono p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md">
223+
<div class="flex items-center gap-2">
224+
<div class="w-3 h-3">
225+
<svg viewBox="0 0 2 2" class="w-full h-full">
226+
<rect x="0" y="0" width="2" height="2" rx="0.3" fill="${datapoint?.color}" />
227+
</svg>
228+
</div>
229+
<span>${name}: ${(datapoint as VueUiHorizontalBarDatapoint).formattedValue ?? 0}</span>
230+
</div>
231+
</div>
232+
`
233+
},
234+
},
235+
},
236+
},
237+
}
238+
})
239+
</script>
240+
241+
<template>
242+
<div class="font-mono facet-bar">
243+
<ClientOnly v-if="dataset.length">
244+
<VueUiHorizontalBar :key="chartKey" :dataset :config class="[direction:ltr]">
245+
<template #svg="{ svg }">
246+
<!-- Inject npmx logo & tagline during SVG and PNG print -->
247+
<g
248+
v-if="svg.isPrintingSvg || svg.isPrintingImg"
249+
v-html="
250+
drawSmallNpmxLogoAndTaglineWatermark({
251+
svg,
252+
colors: watermarkColors,
253+
translateFn: $t,
254+
})
255+
"
256+
/>
257+
</template>
258+
259+
<template #menuIcon="{ isOpen }">
260+
<span v-if="isOpen" class="i-lucide:x w-6 h-6" aria-hidden="true" />
261+
<span v-else class="i-lucide:ellipsis-vertical w-6 h-6" aria-hidden="true" />
262+
</template>
263+
<template #optionCsv>
264+
<span class="text-fg-subtle font-mono pointer-events-none">CSV</span>
265+
</template>
266+
<template #optionImg>
267+
<span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
268+
</template>
269+
<template #optionSvg>
270+
<span class="text-fg-subtle font-mono pointer-events-none">SVG</span>
271+
</template>
272+
<template #optionAltCopy>
273+
<span
274+
class="w-6 h-6"
275+
:class="
276+
copied ? 'i-lucide:check text-accent' : 'i-lucide:person-standing text-fg-subtle'
277+
"
278+
style="pointer-events: none"
279+
aria-hidden="true"
280+
/>
281+
</template>
282+
</VueUiHorizontalBar>
283+
284+
<template #fallback>
285+
<div class="flex flex-col gap-2 justify-center items-center mb-2">
286+
<SkeletonInline class="h-4 w-16" />
287+
<SkeletonInline class="h-4 w-28" />
288+
</div>
289+
<div class="flex flex-col gap-1">
290+
<SkeletonInline class="h-7 w-full" v-for="pkg in packages" :key="pkg" />
291+
</div>
292+
</template>
293+
</ClientOnly>
294+
295+
<template v-else>
296+
<div class="flex flex-col gap-2 justify-center items-center mb-2">
297+
<SkeletonInline class="h-4 w-16" />
298+
<SkeletonInline class="h-4 w-28" />
299+
</div>
300+
<div class="flex flex-col gap-1">
301+
<SkeletonInline class="h-7 w-full" v-for="pkg in packages" :key="pkg" />
302+
</div>
303+
</template>
304+
</div>
305+
</template>
306+
307+
<style>
308+
.facet-bar .atom-subtitle {
309+
width: 80% !important;
310+
margin: 0 auto;
311+
height: 2rem;
312+
}
313+
</style>

app/components/Package/TrendsChart.vue

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
import { DATE_INPUT_MAX } from '~/utils/input'
2121
import { applyDataCorrection } from '~/utils/chart-data-correction'
2222
import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies'
23-
import { copyAltTextForTrendLineChart } from '~/utils/charts'
23+
import { copyAltTextForTrendLineChart, sanitise, loadFile } from '~/utils/charts'
2424
2525
import('vue-data-ui/style.css')
2626
@@ -1085,14 +1085,6 @@ const maxDatapoints = computed(() =>
10851085
Math.max(0, ...(chartData.value.dataset ?? []).map(d => d.series.length)),
10861086
)
10871087
1088-
const loadFile = (link: string, filename: string) => {
1089-
const a = document.createElement('a')
1090-
a.href = link
1091-
a.download = filename
1092-
a.click()
1093-
a.remove()
1094-
}
1095-
10961088
const datetimeFormatterOptions = computed(() => {
10971089
return {
10981090
daily: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' },
@@ -1113,12 +1105,6 @@ const tooltipDateFormatter = computed(() => {
11131105
})
11141106
})
11151107
1116-
const sanitise = (value: string) =>
1117-
value
1118-
.replace(/^@/, '')
1119-
.replace(/[\\/:"*?<>|]/g, '-')
1120-
.replace(/\//g, '-')
1121-
11221108
function buildExportFilename(extension: string): string {
11231109
const g = selectedGranularity.value
11241110
const range = `${startDate.value}_${endDate.value}`
@@ -1954,7 +1940,14 @@ watch(selectedMetric, value => {
19541940
<!-- Inject npmx logo & tagline during SVG and PNG print -->
19551941
<g
19561942
v-if="svg.isPrintingSvg || svg.isPrintingImg"
1957-
v-html="drawNpmxLogoAndTaglineWatermark(svg, watermarkColors, $t, 'bottom')"
1943+
v-html="
1944+
drawNpmxLogoAndTaglineWatermark({
1945+
svg,
1946+
colors: watermarkColors,
1947+
translateFn: $t,
1948+
positioning: 'bottom',
1949+
})
1950+
"
19581951
/>
19591952

19601953
<!-- Overlay covering the chart area to hide line resizing when switching granularities recalculates VueUiXy scaleMax when estimation lines are necessary -->

0 commit comments

Comments
 (0)