Skip to content

Commit 87ddf4a

Browse files
committed
feat: add bar chart component for facet comparisons
1 parent fea11d6 commit 87ddf4a

4 files changed

Lines changed: 390 additions & 19 deletions

File tree

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

app/composables/useFacetSelection.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface FacetInfoWithLabels extends Omit<FacetInfo, 'id'> {
1313
id: ComparisonFacet
1414
label: string
1515
description: string
16+
chartable: boolean
1617
}
1718

1819
/**
@@ -24,58 +25,71 @@ export function useFacetSelection(queryParam = 'facets') {
2425
const { t } = useI18n()
2526

2627
const facetLabels = computed(
27-
(): Record<ComparisonFacet, { label: string; description: string }> => ({
28+
(): Record<ComparisonFacet, { label: string; description: string; chartable: boolean }> => ({
2829
downloads: {
2930
label: t(`compare.facets.items.downloads.label`),
3031
description: t(`compare.facets.items.downloads.description`),
32+
chartable: true,
3133
},
3234
totalLikes: {
3335
label: t(`compare.facets.items.totalLikes.label`),
3436
description: t(`compare.facets.items.totalLikes.description`),
37+
chartable: true,
3538
},
3639
packageSize: {
3740
label: t(`compare.facets.items.packageSize.label`),
3841
description: t(`compare.facets.items.packageSize.description`),
42+
chartable: true,
3943
},
4044
installSize: {
4145
label: t(`compare.facets.items.installSize.label`),
4246
description: t(`compare.facets.items.installSize.description`),
47+
chartable: true,
4348
},
4449
moduleFormat: {
4550
label: t(`compare.facets.items.moduleFormat.label`),
4651
description: t(`compare.facets.items.moduleFormat.description`),
52+
chartable: false,
4753
},
4854
types: {
4955
label: t(`compare.facets.items.types.label`),
5056
description: t(`compare.facets.items.types.description`),
57+
chartable: false,
5158
},
5259
engines: {
5360
label: t(`compare.facets.items.engines.label`),
5461
description: t(`compare.facets.items.engines.description`),
62+
chartable: false,
5563
},
5664
vulnerabilities: {
5765
label: t(`compare.facets.items.vulnerabilities.label`),
5866
description: t(`compare.facets.items.vulnerabilities.description`),
67+
chartable: false,
5968
},
6069
lastUpdated: {
6170
label: t(`compare.facets.items.lastUpdated.label`),
6271
description: t(`compare.facets.items.lastUpdated.description`),
72+
chartable: false,
6373
},
6474
license: {
6575
label: t(`compare.facets.items.license.label`),
6676
description: t(`compare.facets.items.license.description`),
77+
chartable: false,
6778
},
6879
dependencies: {
6980
label: t(`compare.facets.items.dependencies.label`),
7081
description: t(`compare.facets.items.dependencies.description`),
82+
chartable: true,
7183
},
7284
totalDependencies: {
7385
label: t(`compare.facets.items.totalDependencies.label`),
7486
description: t(`compare.facets.items.totalDependencies.description`),
87+
chartable: true,
7588
},
7689
deprecated: {
7790
label: t(`compare.facets.items.deprecated.label`),
7891
description: t(`compare.facets.items.deprecated.description`),
92+
chartable: false,
7993
},
8094
}),
8195
)
@@ -87,6 +101,7 @@ export function useFacetSelection(queryParam = 'facets') {
87101
...FACET_INFO[facet],
88102
label: facetLabels.value[facet].label,
89103
description: facetLabels.value[facet].description,
104+
chartable: facetLabels.value[facet].chartable,
90105
}
91106
}
92107

0 commit comments

Comments
 (0)