-
-
Notifications
You must be signed in to change notification settings - Fork 427
feat: compare download charts with sparklines #2273
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
834aebb
f716beb
f0e5637
1407af3
509914e
d395997
08ace72
a58127f
22a4208
02eaa7d
d037a79
9ff2919
d7ba1fa
965a99c
19b6bba
8f07f9d
ebb9831
e60df0a
a3bf620
a72ddb6
7fb1a2a
102a96d
315eda2
d321354
8221712
8518b37
8187c56
cdec42f
c4a4d9c
d5872e2
a057d6d
2ced70f
dc4d902
ffb5ead
67db049
e698ad1
2127638
ecb8ca5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| <script setup lang="ts"> | ||
| import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' | ||
| import { useCssVariables } from '~/composables/useColors' | ||
| import { getPalette, type VueUiXyDatasetItem } from 'vue-data-ui' | ||
| import type { VueUiSparklineConfig, VueUiSparklineDatasetItem } from 'vue-data-ui' | ||
|
|
||
| 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 | ||
| } | ||
| }>() | ||
|
|
||
| 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 | ||
| resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' | ||
| }) | ||
|
|
||
| watch( | ||
| () => colorMode.value, | ||
| value => { | ||
| resolvedMode.value = value === 'dark' ? 'dark' : 'light' | ||
| }, | ||
| { flush: 'sync' }, | ||
| ) | ||
|
|
||
|
graphieros marked this conversation as resolved.
|
||
| 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) => { | ||
| 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' : '', | ||
| 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: unit.color ?? palette[i], | ||
| }, | ||
| 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: 5, | ||
| color: colors.value.fgSubtle, | ||
| }, | ||
| padding: { | ||
| left: 0, | ||
| right: 0, | ||
| top: 0, | ||
| bottom: 0, | ||
| }, | ||
| }, | ||
| } | ||
| }) | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <div> | ||
|
graphieros marked this conversation as resolved.
Outdated
|
||
| <div class="grid gap-8 sm:grid-cols-2"> | ||
| <ClientOnly v-for="(config, i) in configs" :key="`config_${i}`"> | ||
| <div @mouseleave="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> | ||
| </div> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1622,6 +1622,9 @@ watch(selectedMetric, value => { | |
| if (!isMounted.value) return | ||
| loadMetric(value) | ||
| }) | ||
|
|
||
| // Sparkline charts (a11y alternative display for multi series) | ||
| const isSparklineLayout = shallowRef(false) | ||
| </script> | ||
|
|
||
| <template> | ||
|
|
@@ -1630,6 +1633,51 @@ watch(selectedMetric, value => { | |
| id="trends-chart" | ||
| :aria-busy="activeMetricState.pending ? 'true' : 'false'" | ||
| > | ||
| <div | ||
| v-if="isMultiPackageMode" | ||
| class="inline-flex items-center gap-1 rounded-md border border-border-subtle bg-bg-subtle p-0.5 mt-4 mb-8" | ||
| role="tablist" | ||
| :aria-label="$t('package.trends.chart_view_toggle')" | ||
| > | ||
| <button | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a little nitpick here, can we use |
||
| id="combined-chart-layout-tab" | ||
| type="button" | ||
| role="tab" | ||
| :aria-selected="isSparklineLayout ? 'false' : 'true'" | ||
| aria-controls="combined-chart-layout-panel" | ||
| :tabindex="isSparklineLayout ? 0 : -1" | ||
|
graphieros marked this conversation as resolved.
Outdated
|
||
| class="flex items-center justify-center gap-x-2 rounded px-3 py-2 font-mono text-sm border border-solid transition-colors duration-150 focus-visible:outline-accent/70" | ||
| :class=" | ||
| isSparklineLayout | ||
| ? 'border-transparent text-fg-subtle hover:text-fg' | ||
| : 'bg-bg border-border shadow-sm text-fg' | ||
| " | ||
| @click="isSparklineLayout = false" | ||
| > | ||
| <span class="i-lucide:chart-line size-[1em]" aria-hidden="true" /> | ||
| <span>{{ $t('package.trends.chart_view_combined') }}</span> | ||
| </button> | ||
|
|
||
| <button | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ButtonBase does not exactly fit the bill out of the box here. |
||
| id="split-chart-layout-tab" | ||
| type="button" | ||
| role="tab" | ||
| :aria-selected="isSparklineLayout ? 'true' : 'false'" | ||
| aria-controls="split-chart-layout-panel" | ||
| :tabindex="!isSparklineLayout ? 0 : -1" | ||
| class="flex items-center justify-center gap-x-2 rounded px-3 py-2 font-mono text-sm border border-solid transition-colors duration-150 focus-visible:outline-accent/70" | ||
| :class=" | ||
| isSparklineLayout | ||
| ? 'bg-bg border-border shadow-sm text-fg' | ||
| : 'border-transparent text-fg-subtle hover:text-fg' | ||
| " | ||
| @click="isSparklineLayout = true" | ||
| > | ||
| <span class="i-lucide:square-split-horizontal size-[1em]" aria-hidden="true" /> | ||
| <span>{{ $t('package.trends.chart_view_split') }}</span> | ||
| </button> | ||
| </div> | ||
|
|
||
| <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 | ||
|
|
@@ -1875,7 +1923,27 @@ 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="tabpanel" | ||
| aria-labelledby="split-chart-layout-tab" | ||
| > | ||
| <ChartSplitSparkline | ||
| :dataset="normalisedDataset" | ||
| :dates="chartData.dates" | ||
| :datetimeFormatterOptions | ||
| /> | ||
| </div> | ||
|
|
||
| <div | ||
| :data-pending="pending" | ||
| :data-minimap-visible="maxDatapoints > 6" | ||
| v-else | ||
| id="combined-chart-layout-panel" | ||
| role="tabpanel" | ||
| aria-labelledby="combined-chart-layout-tab" | ||
| > | ||
|
graphieros marked this conversation as resolved.
|
||
| <VueUiXy | ||
| :dataset="normalisedDataset" | ||
| :config="chartConfig" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.