Skip to content

Commit 70465c0

Browse files
graphierosautofix-ci[bot]coderabbitai[bot]Adebesin-Cellclaude
authored andcommitted
feat: compare download charts with sparklines (#2273)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Adebesin Tolulope <adebesintolulope80@gmail.com> Co-authored-by: Alec Lloyd Probert <graphieros@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aa17d1d commit 70465c0

File tree

16 files changed

+867
-107
lines changed

16 files changed

+867
-107
lines changed
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
<script setup lang="ts">
2+
import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
3+
import { useCssVariables } from '~/composables/useColors'
4+
import {
5+
type VueUiSparklineConfig,
6+
type VueUiSparklineDatasetItem,
7+
type VueUiXyDatasetItem,
8+
} from 'vue-data-ui'
9+
import { getPalette, lightenColor } from 'vue-data-ui/utils'
10+
11+
import('vue-data-ui/style.css')
12+
13+
const props = defineProps<{
14+
dataset?: Array<
15+
VueUiXyDatasetItem & {
16+
color?: string
17+
series: number[]
18+
dashIndices?: number[]
19+
}
20+
>
21+
dates: number[]
22+
datetimeFormatterOptions: {
23+
year: string
24+
month: string
25+
day: string
26+
}
27+
showLastDatapointEstimation: boolean
28+
}>()
29+
30+
const { locale } = useI18n()
31+
const colorMode = useColorMode()
32+
const resolvedMode = shallowRef<'light' | 'dark'>('light')
33+
const rootEl = shallowRef<HTMLElement | null>(null)
34+
const palette = getPalette('')
35+
36+
const step = ref(0)
37+
38+
onMounted(() => {
39+
rootEl.value = document.documentElement
40+
})
41+
42+
watch(
43+
() => colorMode.value,
44+
value => {
45+
resolvedMode.value = value === 'dark' ? 'dark' : 'light'
46+
},
47+
{ flush: 'sync', immediate: true },
48+
)
49+
50+
const { colors } = useCssVariables(
51+
[
52+
'--bg',
53+
'--fg',
54+
'--bg-subtle',
55+
'--bg-elevated',
56+
'--border-hover',
57+
'--fg-subtle',
58+
'--border',
59+
'--border-subtle',
60+
],
61+
{
62+
element: rootEl,
63+
watchHtmlAttributes: true,
64+
watchResize: false, // set to true only if a var changes color on resize
65+
},
66+
)
67+
68+
const isDarkMode = computed(() => resolvedMode.value === 'dark')
69+
70+
const datasets = computed<VueUiSparklineDatasetItem[][]>(() => {
71+
return (props.dataset ?? []).map(unit => {
72+
return props.dates.map((period, i) => {
73+
return {
74+
period,
75+
value: unit.series[i] ?? 0,
76+
}
77+
})
78+
})
79+
})
80+
81+
const selectedIndex = ref<number | undefined | null>(null)
82+
83+
function hoverIndex({ index }: { index: number | undefined | null }) {
84+
if (typeof index === 'number') {
85+
selectedIndex.value = index
86+
}
87+
}
88+
89+
function resetHover() {
90+
selectedIndex.value = null
91+
step.value += 1 // required to reset all chart instances
92+
}
93+
94+
const configs = computed(() => {
95+
return (props.dataset || []).map<VueUiSparklineConfig>((unit, i) => {
96+
const lastIndex = unit.series.length - 1
97+
const dashIndices = props.showLastDatapointEstimation
98+
? Array.from(new Set([...(unit.dashIndices ?? []), lastIndex]))
99+
: unit.dashIndices
100+
101+
// Ensure we loop through available palette colours when the series count is higher than the avalable palette
102+
const fallbackColor = palette[i] ?? palette[i % palette.length] ?? palette[0]!
103+
const seriesColor = unit.color ?? fallbackColor
104+
const lightenedSeriesColor: string = unit.color
105+
? (lightenOklch(unit.color, 0.5) ?? seriesColor)
106+
: (lightenColor(seriesColor, 0.5) ?? seriesColor) // palette uses hex colours
107+
108+
return {
109+
a11y: {
110+
translations: {
111+
keyboardNavigation: $t(
112+
'package.trends.chart_assistive_text.keyboard_navigation_horizontal',
113+
),
114+
tableAvailable: $t('package.trends.chart_assistive_text.table_available'),
115+
tableCaption: $t('package.trends.chart_assistive_text.table_caption'),
116+
},
117+
},
118+
theme: isDarkMode.value ? 'dark' : '',
119+
temperatureColors: {
120+
show: isDarkMode.value,
121+
colors: [lightenedSeriesColor, seriesColor],
122+
},
123+
skeletonConfig: {
124+
style: {
125+
backgroundColor: 'transparent',
126+
dataLabel: {
127+
show: true,
128+
color: 'transparent',
129+
},
130+
area: {
131+
color: colors.value.borderHover,
132+
useGradient: false,
133+
opacity: 10,
134+
},
135+
line: {
136+
color: colors.value.borderHover,
137+
},
138+
},
139+
},
140+
skeletonDataset: Array.from({ length: unit.series.length }, () => 0),
141+
style: {
142+
backgroundColor: 'transparent',
143+
animation: { show: false },
144+
area: {
145+
color: colors.value.borderHover,
146+
useGradient: false,
147+
opacity: 10,
148+
},
149+
dataLabel: {
150+
offsetX: -12,
151+
fontSize: 24,
152+
bold: false,
153+
color: colors.value.fg,
154+
datetimeFormatter: {
155+
enable: true,
156+
locale: locale.value,
157+
useUTC: true,
158+
options: props.datetimeFormatterOptions,
159+
},
160+
},
161+
line: {
162+
color: seriesColor,
163+
dashIndices,
164+
dashArray: 3,
165+
},
166+
plot: {
167+
radius: 6,
168+
stroke: isDarkMode.value ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)',
169+
},
170+
title: {
171+
fontSize: 12,
172+
color: colors.value.fgSubtle,
173+
bold: false,
174+
},
175+
176+
verticalIndicator: {
177+
strokeDasharray: 0,
178+
color: colors.value.fgSubtle,
179+
},
180+
padding: {
181+
left: 0,
182+
right: 0,
183+
top: 0,
184+
bottom: 0,
185+
},
186+
},
187+
}
188+
})
189+
})
190+
</script>
191+
192+
<template>
193+
<div class="grid gap-8 sm:grid-cols-2">
194+
<ClientOnly v-for="(config, i) in configs" :key="`config_${i}`">
195+
<div @mouseleave="resetHover" @keydown.esc="resetHover" class="w-full max-w-[400px] mx-auto">
196+
<div class="flex gap-2 place-items-center">
197+
<div class="h-3 w-3">
198+
<svg viewBox="0 0 2 2" class="w-full">
199+
<rect
200+
x="0"
201+
y="0"
202+
width="2"
203+
height="2"
204+
rx="0.3"
205+
:fill="dataset?.[i]?.color ?? palette[i]"
206+
/>
207+
</svg>
208+
</div>
209+
{{ applyEllipsis(dataset?.[i]?.name ?? '', 28) }}
210+
</div>
211+
<VueUiSparkline
212+
:key="`${i}_${step}`"
213+
:config
214+
:dataset="datasets?.[i]"
215+
:selectedIndex
216+
@hoverIndex="hoverIndex"
217+
>
218+
<!-- Keyboard navigation hint -->
219+
<template #hint="{ isVisible }">
220+
<p v-if="isVisible" class="text-accent text-xs text-center mt-2" aria-hidden="true">
221+
{{ $t('package.downloads.sparkline_nav_hint') }}
222+
</p>
223+
</template>
224+
225+
<template #skeleton>
226+
<!-- This empty div overrides the default built-in scanning animation on load -->
227+
<div />
228+
</template>
229+
</VueUiSparkline>
230+
</div>
231+
232+
<template #fallback>
233+
<!-- Skeleton matching VueUiSparkline layout (title 24px + SVG aspect 500:80) -->
234+
<div class="max-w-xs">
235+
<!-- Title row: fontSize * 2 = 24px -->
236+
<div class="h-6 flex items-center ps-3">
237+
<SkeletonInline class="h-3 w-36" />
238+
</div>
239+
<!-- Chart area: matches SVG viewBox 500:80 -->
240+
<div class="aspect-[500/80] flex items-center">
241+
<!-- Data label (covers ~42% width, matching dataLabel.offsetX) -->
242+
<div class="w-[42%] flex items-center ps-0.5">
243+
<SkeletonInline class="h-7 w-24" />
244+
</div>
245+
<!-- Sparkline line placeholder -->
246+
<div class="flex-1 flex items-end pe-3">
247+
<SkeletonInline class="h-px w-full" />
248+
</div>
249+
</div>
250+
</div>
251+
</template>
252+
</ClientOnly>
253+
</div>
254+
</template>

app/components/Package/TrendsChart.vue

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,15 @@ watch(selectedMetric, value => {
16221622
if (!isMounted.value) return
16231623
loadMetric(value)
16241624
})
1625+
1626+
// Sparkline charts (a11y alternative display for multi series)
1627+
const chartLayout = usePermalink<'combined' | 'split'>('layout', 'combined')
1628+
const isSparklineLayout = computed({
1629+
get: () => chartLayout.value === 'split',
1630+
set: (v: boolean) => {
1631+
chartLayout.value = v ? 'split' : 'combined'
1632+
},
1633+
})
16251634
</script>
16261635

16271636
<template>
@@ -1630,6 +1639,26 @@ watch(selectedMetric, value => {
16301639
id="trends-chart"
16311640
:aria-busy="activeMetricState.pending ? 'true' : 'false'"
16321641
>
1642+
<TabRoot
1643+
v-if="isMultiPackageMode"
1644+
v-model="chartLayout"
1645+
id-prefix="chart-layout"
1646+
class="mt-4 mb-8"
1647+
>
1648+
<TabList :ariaLabel="$t('package.trends.chart_view_toggle')">
1649+
<TabItem value="combined" tab-id="combined-chart-layout-tab" icon="i-lucide:chart-line">
1650+
{{ $t('package.trends.chart_view_combined') }}
1651+
</TabItem>
1652+
<TabItem
1653+
value="split"
1654+
tab-id="split-chart-layout-tab"
1655+
icon="i-lucide:square-split-horizontal"
1656+
>
1657+
{{ $t('package.trends.chart_view_split') }}
1658+
</TabItem>
1659+
</TabList>
1660+
</TabRoot>
1661+
16331662
<div class="w-full mb-4 flex flex-col gap-3">
16341663
<div class="grid grid-cols-2 sm:flex sm:flex-row gap-3 sm:gap-2 sm:items-end">
16351664
<SelectField
@@ -1875,7 +1904,28 @@ watch(selectedMetric, value => {
18751904
"
18761905
>
18771906
<ClientOnly v-if="chartData.dataset">
1878-
<div :data-pending="pending" :data-minimap-visible="maxDatapoints > 6">
1907+
<div
1908+
v-if="isSparklineLayout"
1909+
id="split-chart-layout-panel"
1910+
:role="isMultiPackageMode ? 'tabpanel' : undefined"
1911+
:aria-labelledby="isMultiPackageMode ? 'split-chart-layout-tab' : undefined"
1912+
>
1913+
<ChartSplitSparkline
1914+
:dataset="normalisedDataset"
1915+
:dates="chartData.dates"
1916+
:datetimeFormatterOptions
1917+
:showLastDatapointEstimation="shouldRenderEstimationOverlay && !isEndDateOnPeriodEnd"
1918+
/>
1919+
</div>
1920+
1921+
<div
1922+
:data-pending="pending"
1923+
:data-minimap-visible="maxDatapoints > 6"
1924+
v-else
1925+
id="combined-chart-layout-panel"
1926+
:role="isMultiPackageMode ? 'tabpanel' : undefined"
1927+
:aria-labelledby="isMultiPackageMode ? 'combined-chart-layout-tab' : undefined"
1928+
>
18791929
<VueUiXy
18801930
:dataset="normalisedDataset"
18811931
:config="chartConfig"

0 commit comments

Comments
 (0)