Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
834aebb
chore: add translations
graphieros Mar 25, 2026
f716beb
feat: create split parklines component
graphieros Mar 25, 2026
f0e5637
feat: implement split view for compare download charts
graphieros Mar 25, 2026
1407af3
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2026
509914e
fix: follow the rabbit
graphieros Mar 25, 2026
d395997
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2026
08ace72
fix: add missing translation for aria-label
graphieros Mar 25, 2026
a58127f
fix: follow the rabbit
graphieros Mar 25, 2026
22a4208
chore: bump vue-data-ui from 3.16.1 to 3.16.2
graphieros Mar 25, 2026
02eaa7d
feat: apply dashed estimation segments on compare sparklines
graphieros Mar 25, 2026
d037a79
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2026
9ff2919
fix: add missing prop in test
graphieros Mar 25, 2026
d7ba1fa
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2026
965a99c
Merge branch 'main' into 2270-line-charts-multiple-series---propose-a…
graphieros Mar 25, 2026
19b6bba
fix: use treeshaken import
graphieros Mar 26, 2026
8f07f9d
chore: bump vue-data-ui from 3.16.3 to 3.16.4
graphieros Mar 26, 2026
ebb9831
fix: unify imports
graphieros Mar 26, 2026
e60df0a
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 26, 2026
a3bf620
chore: bump vue-data-ui from 3.16.4 to 3.16.5
graphieros Mar 26, 2026
a72ddb6
chore: remove test vue-data-ui tgz file
graphieros Mar 26, 2026
7fb1a2a
fix: reset selectedIndex on esc
graphieros Mar 26, 2026
102a96d
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 26, 2026
315eda2
feat(ui): add reusable Tab component and wire URL persistence
Adebesin-Cell Mar 26, 2026
d321354
Merge branch 'main' into 2270-line-charts-multiple-series---propose-a…
graphieros Mar 26, 2026
8221712
test: add Tab component tests and a11y coverage
Adebesin-Cell Mar 26, 2026
8518b37
chore: remove comments from Tab components
Adebesin-Cell Mar 26, 2026
8187c56
fix: always persist chart layout tab state in URL
Adebesin-Cell Mar 26, 2026
cdec42f
feat: add Tab component + tests (#2282)
graphieros Mar 26, 2026
c4a4d9c
fix: resolve lint errors and type check failures
Adebesin-Cell Mar 26, 2026
d5872e2
fix: resolve lint errors and type check failures (#2285)
graphieros Mar 26, 2026
a057d6d
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 26, 2026
2ced70f
feat: add subtle gradient on sparklines in dark mode
graphieros Mar 27, 2026
dc4d902
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 27, 2026
ffb5ead
fix: use future proof series colour
graphieros Mar 27, 2026
67db049
chore: add comment
graphieros Mar 27, 2026
e698ad1
fix: apply suggested refactoring
graphieros Mar 27, 2026
2127638
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 27, 2026
ecb8ca5
fix: error message
graphieros Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions app/components/Chart/SplitSparkline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
import { useCssVariables } from '~/composables/useColors'
import {
type VueUiSparklineConfig,
type VueUiSparklineDatasetItem,
type VueUiXyDatasetItem,
} from 'vue-data-ui'
import { getPalette, lightenColor } from 'vue-data-ui/utils'

import('vue-data-ui/style.css')

const props = defineProps<{
dataset?: Array<
VueUiXyDatasetItem & {
color?: string
series: number[]
dashIndices?: number[]
}
>
dates: number[]
datetimeFormatterOptions: {
year: string
month: string
day: string
}
showLastDatapointEstimation: boolean
}>()

const { locale } = useI18n()
const colorMode = useColorMode()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef<HTMLElement | null>(null)
const palette = getPalette('')

const step = ref(0)

onMounted(() => {
rootEl.value = document.documentElement
})

watch(
() => colorMode.value,
value => {
resolvedMode.value = value === 'dark' ? 'dark' : 'light'
},
{ flush: 'sync', immediate: true },
)

const { colors } = useCssVariables(
[
'--bg',
'--fg',
'--bg-subtle',
'--bg-elevated',
'--border-hover',
'--fg-subtle',
'--border',
'--border-subtle',
],
{
element: rootEl,
watchHtmlAttributes: true,
watchResize: false, // set to true only if a var changes color on resize
},
)

const isDarkMode = computed(() => resolvedMode.value === 'dark')

const datasets = computed<VueUiSparklineDatasetItem[][]>(() => {
return (props.dataset ?? []).map(unit => {
return props.dates.map((period, i) => {
return {
period,
value: unit.series[i] ?? 0,
}
})
})
})

const selectedIndex = ref<number | undefined | null>(null)

function hoverIndex({ index }: { index: number | undefined | null }) {
if (typeof index === 'number') {
selectedIndex.value = index
}
}

function resetHover() {
selectedIndex.value = null
step.value += 1 // required to reset all chart instances
}

const configs = computed(() => {
return (props.dataset || []).map<VueUiSparklineConfig>((unit, i) => {
const lastIndex = unit.series.length - 1
const dashIndices = props.showLastDatapointEstimation
? Array.from(new Set([...(unit.dashIndices ?? []), lastIndex]))
: unit.dashIndices

// Ensure we loop through available palette colours when the series count is higher than the avalable palette
const fallbackColor = palette[i] ?? palette[i % palette.length] ?? palette[0]!
const seriesColor = unit.color ?? fallbackColor
const lightenedSeriesColor: string = unit.color
? (lightenOklch(unit.color, 0.5) ?? seriesColor)
: (lightenColor(seriesColor, 0.5) ?? seriesColor) // palette uses hex colours

return {
a11y: {
translations: {
keyboardNavigation: $t(
'package.trends.chart_assistive_text.keyboard_navigation_horizontal',
),
tableAvailable: $t('package.trends.chart_assistive_text.table_available'),
tableCaption: $t('package.trends.chart_assistive_text.table_caption'),
},
},
theme: isDarkMode.value ? 'dark' : '',
temperatureColors: {
show: isDarkMode.value,
colors: [lightenedSeriesColor, seriesColor],
},
skeletonConfig: {
style: {
backgroundColor: 'transparent',
dataLabel: {
show: true,
color: 'transparent',
},
area: {
color: colors.value.borderHover,
useGradient: false,
opacity: 10,
},
line: {
color: colors.value.borderHover,
},
},
},
skeletonDataset: Array.from({ length: unit.series.length }, () => 0),
style: {
backgroundColor: 'transparent',
animation: { show: false },
area: {
color: colors.value.borderHover,
useGradient: false,
opacity: 10,
},
dataLabel: {
offsetX: -12,
fontSize: 24,
bold: false,
color: colors.value.fg,
datetimeFormatter: {
enable: true,
locale: locale.value,
useUTC: true,
options: props.datetimeFormatterOptions,
},
},
line: {
color: seriesColor,
dashIndices,
dashArray: 3,
},
plot: {
radius: 6,
stroke: isDarkMode.value ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)',
},
title: {
fontSize: 12,
color: colors.value.fgSubtle,
bold: false,
},

verticalIndicator: {
strokeDasharray: 0,
color: colors.value.fgSubtle,
},
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
}
})
})
</script>

<template>
<div class="grid gap-8 sm:grid-cols-2">
<ClientOnly v-for="(config, i) in configs" :key="`config_${i}`">
<div @mouseleave="resetHover" @keydown.esc="resetHover" class="w-full max-w-[400px] mx-auto">
<div class="flex gap-2 place-items-center">
<div class="h-3 w-3">
<svg viewBox="0 0 2 2" class="w-full">
<rect
x="0"
y="0"
width="2"
height="2"
rx="0.3"
:fill="dataset?.[i]?.color ?? palette[i]"
/>
</svg>
</div>
{{ applyEllipsis(dataset?.[i]?.name ?? '', 28) }}
</div>
<VueUiSparkline
:key="`${i}_${step}`"
:config
:dataset="datasets?.[i]"
:selectedIndex
@hoverIndex="hoverIndex"
>
<!-- Keyboard navigation hint -->
<template #hint="{ isVisible }">
<p v-if="isVisible" class="text-accent text-xs text-center mt-2" aria-hidden="true">
{{ $t('package.downloads.sparkline_nav_hint') }}
</p>
</template>

<template #skeleton>
<!-- This empty div overrides the default built-in scanning animation on load -->
<div />
</template>
</VueUiSparkline>
</div>

<template #fallback>
<!-- Skeleton matching VueUiSparkline layout (title 24px + SVG aspect 500:80) -->
<div class="max-w-xs">
<!-- Title row: fontSize * 2 = 24px -->
<div class="h-6 flex items-center ps-3">
<SkeletonInline class="h-3 w-36" />
</div>
<!-- Chart area: matches SVG viewBox 500:80 -->
<div class="aspect-[500/80] flex items-center">
<!-- Data label (covers ~42% width, matching dataLabel.offsetX) -->
<div class="w-[42%] flex items-center ps-0.5">
<SkeletonInline class="h-7 w-24" />
</div>
<!-- Sparkline line placeholder -->
<div class="flex-1 flex items-end pe-3">
<SkeletonInline class="h-px w-full" />
</div>
</div>
</div>
</template>
</ClientOnly>
</div>
</template>
52 changes: 51 additions & 1 deletion app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,15 @@ watch(selectedMetric, value => {
if (!isMounted.value) return
loadMetric(value)
})

// Sparkline charts (a11y alternative display for multi series)
const chartLayout = usePermalink<'combined' | 'split'>('layout', 'combined')
const isSparklineLayout = computed({
get: () => chartLayout.value === 'split',
set: (v: boolean) => {
chartLayout.value = v ? 'split' : 'combined'
},
})
</script>

<template>
Expand All @@ -1630,6 +1639,26 @@ watch(selectedMetric, value => {
id="trends-chart"
:aria-busy="activeMetricState.pending ? 'true' : 'false'"
>
<TabRoot
v-if="isMultiPackageMode"
v-model="chartLayout"
id-prefix="chart-layout"
class="mt-4 mb-8"
>
<TabList :ariaLabel="$t('package.trends.chart_view_toggle')">
<TabItem value="combined" tab-id="combined-chart-layout-tab" icon="i-lucide:chart-line">
{{ $t('package.trends.chart_view_combined') }}
</TabItem>
<TabItem
value="split"
tab-id="split-chart-layout-tab"
icon="i-lucide:square-split-horizontal"
>
{{ $t('package.trends.chart_view_split') }}
</TabItem>
</TabList>
</TabRoot>

<div class="w-full mb-4 flex flex-col gap-3">
<div class="grid grid-cols-2 sm:flex sm:flex-row gap-3 sm:gap-2 sm:items-end">
<SelectField
Expand Down Expand Up @@ -1875,7 +1904,28 @@ watch(selectedMetric, value => {
"
>
<ClientOnly v-if="chartData.dataset">
<div :data-pending="pending" :data-minimap-visible="maxDatapoints > 6">
<div
v-if="isSparklineLayout"
id="split-chart-layout-panel"
:role="isMultiPackageMode ? 'tabpanel' : undefined"
:aria-labelledby="isMultiPackageMode ? 'split-chart-layout-tab' : undefined"
>
<ChartSplitSparkline
:dataset="normalisedDataset"
:dates="chartData.dates"
:datetimeFormatterOptions
:showLastDatapointEstimation="shouldRenderEstimationOverlay && !isEndDateOnPeriodEnd"
/>
</div>

<div
:data-pending="pending"
:data-minimap-visible="maxDatapoints > 6"
v-else
id="combined-chart-layout-panel"
:role="isMultiPackageMode ? 'tabpanel' : undefined"
:aria-labelledby="isMultiPackageMode ? 'combined-chart-layout-tab' : undefined"
>
<VueUiXy
:dataset="normalisedDataset"
:config="chartConfig"
Expand Down
Loading
Loading